Latest commit

History

217 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

@smooai/config — One schema. Every language. Typed everywhere.

npmSmoo AIlicense

TypeScriptPythonRustGo.NETKotlinSwift

Features · Install · Quick start · Platform


Define your config once with Zod (or Valibot, ArkType, Effect), and read it with full type inference everywhere — public config, server-only secrets, and live feature flags. Rename a key and every call site is a compile error, not a 3 AM page. Native clients in TypeScript, Python, Rust, Go, and .NET all read from the same source of truth.

📣 The CLI moved. Use smoo config from the smooth repo for all operator commands (login, get, set, list, push, pull, diff, init, etc.). The standalone smooai-config CLI that used to live in this repo is deprecated and being deleted (SMOODEV-1411). The runtime library @smooai/config (TypeScript / Python / Rust / Go / .NET, consumed via secretConfig.get(...) / publicConfig.get(...) / featureFlag.get(...)) is unchanged — only the operator CLI surface moved to Rust in the smooth repo. (smoo … is the th binary's platform namespace; th smoo … and the old th config … spelling also work.)

✨ Features

  • Three tiers, one schema — public config, secrets, and feature flags defined once with Zod/Valibot/ArkType/Effect, validated everywhere they're read.
  • Strongly-typed keysdefineConfig() gives you PublicConfigKeys, SecretConfigKeys, and FeatureFlagKeys with full inference. Mis-typed keys fail at compile time, not at runtime.
  • Any environment, any key — the same API for development, staging, and production. Override per-stage without touching code.
  • Zero-latency cold starts — values are baked into the bundle as env vars (Next.js, Vite) or resolved in-memory from a local runtime (server). No network round-trip on the hot path.
  • Browser, server, framework-native — the same typed keys read cleanly from React client components, Server Components, Next.js, Vite, or plain Node.
  • Live feature flags — toggled from the dashboard without a redeploy, but still typed.
  • Native clients in every language — TypeScript, Python, Rust, Go, and .NET (C#) server SDKs all read from the same source of truth, plus Kotlin and Swift mobile SDKs for the public-only app surface (ADR-074 mobile runtime mode).

Languages / SDKs

Pick the SDK that matches your service. Every server client reads the same schema, the same encrypted bundle, and the same config API — so a key renamed in one language ripples through all of them. The two mobile SDKs (📱) speak a deliberately narrower, public-only surface — no secrets ever ship to a device.

SDKOne-linerREADME
TypeScriptPrimary SDK. Schema definition, Next.js / Vite plugins, server runtime, React hooks.README.md (this file)
PythonPydantic-validated schemas, sync ConfigClient, LocalConfigManager + ConfigManager, baked runtime.python/README.md
GoNative struct schemas, thread-safe ConfigClient / ConfigManager, baked-blob runtime.go/config/README.md
RustJsonSchema-derived schemas, async ConfigClient, sync ConfigManager, baked-blob runtime.rust/config/README.md
.NETRoslyn source-generated typed keys, OAuth2 SmooConfigClient, AES-GCM SmooConfigRuntime. Thinner surface than the other server SDKs — see the capability notes.dotnet/README.md
Kotlin 📱Mobile runtime mode (ADR-074): baked public bundle + live flag/limit evaluation, offline-safe, no secrets on device. Ships via JitPack commit pin — not on Maven Central yet.kotlin/ · spec
Swift 📱Mobile runtime mode (ADR-074): same surface as Kotlin (publicValue, evaluateFlag, evaluateLimit). Consumed via SPM pinned to a revision — no version tag yet.swift/ · spec

The five server SDKs release in version lockstep — v6.11.3 on npm, PyPI, crates.io, and NuGet, with the matching v6.11.3 git tag for the Go module, all cut from the same commit (verified against each registry 2026-08-20). The mobile SDKs version separately (commit-pinned; see below).

📦 Install

pnpm add @smooai/config

🚀 Quick Start (TypeScript)

1. Define your configuration schema

Use defineConfig() with any StandardSchema-compliant library (Zod, Valibot, ArkType, Effect Schema) or the built-in StringSchema, BooleanSchema, and NumberSchema helpers:

// .smooai-config/config.tsimport{defineConfig,StringSchema,BooleanSchema,NumberSchema}from'@smooai/config';import{z}from'zod';constconfig=defineConfig({publicConfigSchema: {apiBaseUrl: z.string().url(),maxRetries: NumberSchema,enableDebug: BooleanSchema,},secretConfigSchema: {databaseUrl: z.string().url(),apiKey: StringSchema,},featureFlagSchema: {enableNewUi: BooleanSchema,betaFeatures: BooleanSchema,},});exportdefaultconfig;// Extract typed key objects for use throughout your appexportconst{ FeatureFlagKeys, PublicConfigKeys, SecretConfigKeys }=config;

defineConfig() automatically maps camelCase keys to UPPER_SNAKE_CASE:

FeatureFlagKeys.ENABLE_NEW_UI;// "ENABLE_NEW_UI"PublicConfigKeys.API_BASE_URL;// "API_BASE_URL"SecretConfigKeys.DATABASE_URL;// "DATABASE_URL"

2. Add to tsconfig.json

{
"compilerOptions": { ... },
"include": ["src/**/*", ".smooai-config/**/*.ts"]
}

📖 Next.js Integration

Inject config into next.config.ts

Use withSmooConfig() to inject feature flags and public config as NEXT_PUBLIC_ environment variables, with per-stage overrides:

// next.config.tsimport{withSmooConfig}from'@smooai/config/nextjs/withSmooConfig';constnextConfig=withSmooConfig({default: {featureFlags: {enableNewUi: false,betaFeatures: false},publicConfig: {apiBaseUrl: 'https://api.smooai.com',maxRetries: 3},},development: {featureFlags: {enableNewUi: true},publicConfig: {apiBaseUrl: 'http://localhost:3000'},},});exportdefaultnextConfig;

This sets environment variables like NEXT_PUBLIC_FEATURE_FLAG_ENABLE_NEW_UI=true and NEXT_PUBLIC_CONFIG_API_BASE_URL=http://localhost:3000 based on the current stage.

Read config in React client components

import{getClientFeatureFlag,getClientPublicConfig}from'@smooai/config/client';functionMyComponent(){constisNewUi=getClientFeatureFlag('enableNewUi');constapiUrl=getClientPublicConfig('apiBaseUrl');if(!isNewUi)return<LegacyUI/>;return<NewUIapiUrl={apiUrl}/>;}

These functions check NEXT_PUBLIC_FEATURE_FLAG_* and NEXT_PUBLIC_CONFIG_* env vars automatically — no provider needed, no loading state.

Server Components + Client hydration (zero loading flash)

For apps that need runtime config from a config server, use getConfig on the server and SmooConfigProvider to hydrate client components:

// app/layout.tsx (Server Component)import{getConfig,SmooConfigProvider}from'@smooai/config/nextjs';exportdefaultasyncfunctionRootLayout({ children }: {children: React.ReactNode}){constconfig=awaitgetConfig({environment: 'production',fetchOptions: {next: {revalidate: 60}},});return(<html><body><SmooConfigProviderinitialValues={config}baseUrl={process.env.SMOOAI_CONFIG_API_URL}apiKey={process.env.SMOOAI_CONFIG_API_KEY}orgId={process.env.SMOOAI_CONFIG_ORG_ID}environment="production">{children}</SmooConfigProvider></body></html>);}
// Any client component — values available synchronously (pre-seeded from SSR)import{usePublicConfig,useFeatureFlag}from'@smooai/config/nextjs';functionDashboard(){const{value: apiUrl}=usePublicConfig<string>('API_BASE_URL');const{value: enableNewUi}=useFeatureFlag<boolean>('ENABLE_NEW_UI');return(<div>
API: {apiUrl}, New UI: {String(enableNewUi)}</div>);}

📖 Vite Integration

Vite plugin

// vite.config.tsimport{defineConfig}from'vite';import{smooConfigPlugin}from'@smooai/config/vite/smooConfigPlugin';exportdefaultdefineConfig({plugins: [smooConfigPlugin({featureFlags: {enableNewUi: true,betaFeatures: false},publicConfig: {apiBaseUrl: 'http://localhost:3000'},}),],});

Then read values the same way as Next.js — getClientFeatureFlag and getClientPublicConfig from @smooai/config/client check VITE_FEATURE_FLAG_* and VITE_CONFIG_* automatically.

Preload config (optional)

For runtime config from a config server, start fetching before React mounts:

// main.tsximport{preloadConfig,ConfigProvider}from'@smooai/config/vite';import{createRoot}from'react-dom/client';preloadConfig({environment: 'production'});createRoot(document.getElementById('root')!).render(<ConfigProviderbaseUrl="https://config.smooai.dev"apiKey="your-public-key"orgId="your-org-id"environment="production"><App/></ConfigProvider>,);

📖 Server-Side Config Access

For Node.js server code, use buildConfigObject to get sync and async accessors with full type safety:

importbuildConfigObjectfrom'@smooai/config/platform/server';importconfig,{PublicConfigKeys,SecretConfigKeys,FeatureFlagKeys}from'./.smooai-config/config';constconfigObj=buildConfigObject(config);// Sync access (uses worker threads)constdbUrl=configObj.secretConfig.getSync(SecretConfigKeys.DATABASE_URL);constapiUrl=configObj.publicConfig.getSync(PublicConfigKeys.API_BASE_URL);constisNewUi=configObj.featureFlag.getSync(FeatureFlagKeys.ENABLE_NEW_UI);// Async accessconstapiKey=awaitconfigObj.secretConfig.getAsync(SecretConfigKeys.API_KEY);

How .getSync() works (and how to ship it in any bundled compute)

Sync accessors run an async config read to completion on the caller thread via synckit — a Node Worker pool + Atomics.wait on a SharedArrayBuffer. createSyncFn only accepts a file:// URL, so the worker body has to live on disk. The SDK resolves it in two stages:

  1. Sidecar filesync-worker.mjs sitting next to the compiled SDK entry (i.e. resolved via new URL('./sync-worker.mjs', import.meta.url) from dist/server/index.mjs). This is the normal case for plain Node resolution with no bundling — node_modules/@smooai/config/dist/server/sync-worker.mjs is already there. It's also the preferred case when bundlers copy the sidecar into the deploy output. Zero /tmp writes.

  2. Extract-to-/tmp fallback — if the sidecar isn't on disk at that path (e.g. a bundler inlined the SDK entry into a single file and didn't copy the sidecar), the SDK writes an embedded copy of the worker source to mkdtempSync()/sync-worker.mjs once per process and hands that path to synckit. One ~1-2 MiB write at cold start, amortised across every sync read for the lifetime of the process. Works anywhere with a writable temp dir.

Both paths are transparent — your code is identical either way. Which path you land on depends on how your compute is packaged.

Plain Node (no bundling)

Zero config. The SDK resolves node_modules/@smooai/config/dist/server/sync-worker.mjs directly — path (1) every time.

Any bundled compute (Lambda, Cloud Run, ECS, container, Worker, etc.)

The rule is universal: if your build inlines the SDK entry into a single output file, you need to ship sync-worker.mjs next to that output (or accept path (2)'s /tmp write once per cold start).

The source path is always:

node_modules/@smooai/config/dist/server/sync-worker.mjs

The destination is alongside whichever file ends up being your runtime's import.meta.url anchor — typically the bundled handler .mjs / .js.

Recipes for common setups:

esbuild — explicit copy plugin

// build.tsimport{build}from'esbuild';import{copy}from'esbuild-plugin-copy';awaitbuild({entryPoints: ['src/handler.ts'],outdir: 'dist',bundle: true,format: 'esm',platform: 'node',plugins: [copy({assets: {from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs',to: 'dist/sync-worker.mjs',},}),],});

tsup — onSuccess hook

// tsup.config.tsexportdefaultdefineConfig({entry: ['src/handler.ts'],format: ['esm'],onSuccess: 'cp node_modules/@smooai/config/dist/server/sync-worker.mjs dist/sync-worker.mjs',});

Serverless Framework — package.include

package:
patterns:
- 'node_modules/@smooai/config/dist/server/sync-worker.mjs'

Or copy into the handler dir as a build step and include from there.

AWS SAM — CodeUri + build script

Add a Makefile / build script that copies sync-worker.mjs into the BuildArtifactPath alongside your handler.

SST (AWS) — per-function or via $transform

// sst.config.ts — per functionnewsst.aws.Function('Api',{handler: 'src/api.handler',copyFiles: [{from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs'}],});// Or at the stack level via $transform (every Function gets it automatically)$transform(sst.aws.Function,(fn)=>{fn.copyFiles=[...(fn.copyFiles??[]),{from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs'}];});

Docker container (ECS, Cloud Run, anywhere)

# After your main build step, ensure the sidecar is next to the bundled entry.COPY --from=build /app/dist/server.mjs /app/
COPY --from=build /app/node_modules/@smooai/config/dist/server/sync-worker.mjs /app/
CMD ["node", "server.mjs"]

If your build step keeps node_modules in the final image, no extra copy is needed — the SDK resolves the sidecar from node_modules/ path (1) directly.

When the sidecar truly can't be shipped

Path (2) — the /tmp extraction — is the safety net. One ~1-2 MiB write at cold start, then synckit re-uses the file for the rest of the process lifetime. Lambda's 512 MiB–10 GiB /tmp easily absorbs this; containers with an ephemeral /tmp work the same way. You can ignore this whole section and .getSync() will still work — you're just paying one filesystem write per cold start.

Edge runtimes (Vercel Edge, Cloudflare Workers)

These runtimes don't expose Node's worker_threads at all, so .getSync() is a no-go there by design. Use .get() (async) everywhere that needs to run on the edge. The error surface makes this explicit if you try.

📖 React Hooks (framework-agnostic)

For any React app using the runtime config client:

import{ConfigProvider,usePublicConfig,useFeatureFlag}from'@smooai/config/react';functionApp(){return(<ConfigProviderbaseUrl="https://config.smooai.dev"apiKey="your-api-key"orgId="your-org-id"environment="production"><MyComponent/></ConfigProvider>);}functionMyComponent(){const{value: apiUrl, isLoading, error }=usePublicConfig<string>('API_BASE_URL');const{value: enableNewUi}=useFeatureFlag<boolean>('ENABLE_NEW_UI');if(isLoading)return<div>Loading...</div>;if(error)return<div>Error: {error.message}</div>;return(<div>
API URL: {apiUrl}, New UI: {String(enableNewUi)}</div>);}

📖 SDK Runtime Client

All language implementations include a runtime client for fetching configuration values from the Smoo AI config server with local caching.

Environment Variables

Authentication is OAuth2 client_credentials against {authUrl}/token — the client exchanges (CLIENT_ID, CLIENT_SECRET) for a JWT and uses that JWT as the Bearer token on every config call. TokenProvider caches the JWT in memory and refreshes 60s before expiry.

VariableDescriptionRequired
SMOOAI_CONFIG_API_URLBase URL of the config APIYes
SMOOAI_CONFIG_AUTH_URLOAuth issuer base URL (defaults to https://auth.smoo.ai)No
SMOOAI_CONFIG_CLIENT_IDOAuth client IDYes
SMOOAI_CONFIG_CLIENT_SECRETOAuth client secret (legacy SMOOAI_CONFIG_API_KEY is accepted as a fallback)Yes
SMOOAI_CONFIG_ORG_IDOrganization IDYes
SMOOAI_CONFIG_ENVDefault environment name (defaults to "development")No

Migration note (v5 / SMOODEV-974): the TypeScript ConfigClient previously sent SMOOAI_CONFIG_API_KEY directly as the Bearer token, which the backend rejected with 401 because it expects a JWT. The SDK now mints a JWT via the OAuth client_credentials grant before each call — matching the .NET client, the in-package bootstrap, and the CLI. You must set SMOOAI_CONFIG_CLIENT_ID in addition to SMOOAI_CONFIG_API_KEY / SMOOAI_CONFIG_CLIENT_SECRET for the runtime SDK to work. The legacy SMOOAI_CONFIG_API_KEY env var continues to function as the OAuth client secret.

TypeScript Client

import{ConfigClient}from'@smooai/config/platform/client';// Zero-config (reads from env vars — needs CLIENT_ID + CLIENT_SECRET/API_KEY + ORG_ID)constclient=newConfigClient();// Or explicitconstclient=newConfigClient({baseUrl: 'https://config.smooai.dev',authUrl: 'https://auth.smooai.dev',clientId: 'your-client-id',clientSecret: 'your-client-secret',orgId: 'your-org-id',environment: 'production',});constapiUrl=awaitclient.getValue('API_BASE_URL');constallValues=awaitclient.getAllValues();client.invalidateCache();

📖 Container / Runtime Mode (EKS / ECS)

The baked blob tier is the blessed path for Lambda, but it is the wrong default for long-lived containers: when the per-build blob key isn't delivered to the pod, resolution silently falls through to the (absent) file tier and returns undefined for a required secret. That caused a real outage — a container got undefined for STRIPE_API_KEY, new Stripe(undefined) threw at module load, the process exited 0 before listen(), and the pod CrashLooped with the root cause buried (SMOODEV-1478).

Container mode makes the HTTP config API the first-class path for containers, authenticated with an OAuth2 client_credentials (M2M) token, and fails loud: a required value that doesn't resolve throws a typed error instead of returning undefined.

Containers use container mode, not the baked blob. See docs/Container-Runtime-Mode.md for the full env contract, a complete ExternalSecret (External Secrets Operator) recipe, and a readiness-probe example.

import{initContainerConfig,ConfigKeyUnresolvedError}from'@smooai/config/container';importschemafrom'../.smooai-config/config';// Validates the container env, mints a token, and does an initial fetch —// startup fails LOUD here (throws), not on first read.constconfig=awaitinitContainerConfig({ schema });// Fail-loud: a required secret that doesn't resolve throws// ConfigKeyUnresolvedError instead of returning undefined.conststripeKey=awaitconfig.secretConfig.get('stripeApiKey');// Kubernetes readiness probe — never throws.app.get('/healthz/config',(_req,res)=>{consth=config.health();// { status: 'healthy' } | { status: 'unhealthy', reason }res.status(h.status==='healthy' ? 200 : 503).json(h);});

Env contract (identical in every SDK): SMOOAI_CONFIG_API_URL, SMOOAI_CONFIG_CLIENT_ID, SMOOAI_CONFIG_CLIENT_SECRET, SMOOAI_CONFIG_ORG_ID, SMOOAI_CONFIG_ENV (all required), plus optional SMOOAI_CONFIG_AUTH_URL and SMOOAI_CONFIG_MODE=container (to force the mode). All schema-declared keys are treated as required by default; opt specific keys out with initContainerConfig({ optionalKeys: ['...'] }).

📖 Configuration Tiers

TierPurposeExamples
PublicClient-visible settingsAPI URLs, feature toggles, UI config
SecretServer-side onlyDatabase URLs, API keys, JWT secrets
Feature FlagsRuntime togglesA/B tests, gradual rollouts, beta access

Security: B2M Key Restrictions

OperationB2M (Public Key)M2M (Secret Key)
Read public valuesYesYes
Read feature flagsYesYes
Read secret valuesNo (filtered)Yes
Write config valuesNo (403)Yes
Delete config valuesNo (403)Yes

Browser-to-Machine (B2M) keys are designed for browser clients. Secret-tier values are automatically filtered. B2M keys are read-only for public and feature flag tiers.

Machine-to-Machine (M2M) keys have full access to all tiers and write operations.

📖 Multi-Language Support

@smooai/config has native server implementations in Python, Rust, Go, and .NET (C#) alongside the primary TypeScript package, plus mobile SDKs in Kotlin and Swift (a deliberately different, public-only surface — see below). Every server client reads the same encrypted bundle, the same schema, and the same config API. See the per-SDK READMEs linked above for full usage docs — the snippets below are five-line orientation only.

Python — see python/README.md

pip install smooai-config
# or: uv add smooai-config
fromsmooai_config.clientimportConfigClientwithConfigClient() asclient: # reads SMOOAI_CONFIG_* env varsvalue=client.get_value("API_URL", environment="production")
cargo add smooai-config
use smooai_config::ConfigClient;letmut client = ConfigClient::from_env();let value = client.get_value("API_URL",None).await?;
go get github.com/SmooAI/config/go/config
import"github.com/SmooAI/config/go/config"client:=config.NewConfigClientFromEnv()
deferclient.Close()
value, _:=client.GetValue("API_URL", "production")

.NET — see dotnet/README.md

dotnet add package SmooAI.Config
usingSmooAI.Config;usingSmooAI.Config.Runtime;varruntime=SmooConfigRuntime.Load();// reads SMOO_CONFIG_KEY_FILE + SMOO_CONFIG_KEYusingvarclient=newSmooConfigClient(options);varapiUrl=awaitPublic.ApiUrl.ResolveAsync(runtime,client);

Kotlin (mobile) — see docs/Mobile-Runtime-Mode-Spec.md

Ships via JitPack pinned to a commit (Maven Central under ai.smoo is the planned follow-up — the in-repo Gradle version is a dev default, so don't pin a version, pin a SHA):

// settings.gradle.kts / build.gradle.kts
repositories { maven("https://jitpack.io") }
dependencies { implementation("com.github.SmooAI:config:<commit-sha>") }
importai.smoo.config.SmooConfigimportai.smoo.config.SmooConfigOptionsval config =SmooConfig(SmooConfigOptions(environment ="production", engine = engine, bundledConfigFile = bakedBundle))
val apiUrl = config.publicValue("API_BASE_URL") // baked bundle → refreshed cache, offline-safeval newUi = config.evaluateFlag("ENABLE_NEW_UI", default =false) // http → disk cache → default

Swift (mobile) — see docs/Mobile-Runtime-Mode-Spec.md

Consumed via Swift Package Manager pinned to a revision (there is no SPM version tag yet; the root Package.swift exists so SPM can resolve the repo URL directly):

// Package.swift
.package(url:"https://github.com/SmooAI/config", revision:"<commit-sha>")
import SmooAIConfig
letconfig=SmooConfig(options:SmooConfigOptions(environment:"production", bundledConfigURL: bakedBundleURL))letapiUrl= config.publicValue(forKey:"API_BASE_URL")letnewUi=await config.evaluateFlag("ENABLE_NEW_UI", default:false)

Mobile binaries are attacker-owned territory, so the mobile SDKs speak only the public app-config surface (/config/app/...): a baked public bundle (plaintext — it never contains secrets) plus live feature-flag / limit evaluation with an offline disk cache. There is no secret tier, no M2M credential, and no schema/LocalConfigManager surface on device — by design, per ADR-074.

SDK capability notes

Honest asymmetries between the SDKs, so you can pick with your eyes open:

CapabilityTSPythonRustGo.NETKotlin / Swift
Encrypted baked bundle + config API reads📱 public-only bundle
Local config-file workflow (LocalConfigManager in Py/Rust/Go/.NET; the file tier in TS)
Cloud-region resolution
Deferred values / merge_replace_arrays semantics
Shared schema-validation conformance fixture (test-fixtures/schema-validation-cases.json)
Live feature flags✅ (+ limits)

The .NET SDK reached parity with the other four server SDKs in 6.11.x: LocalConfigManager, cloud-region resolution, deferred values and MergeReplaceArrays all landed, and its schema validator is held to the same test-fixtures/schema-validation-cases.json corpus. Cross-language schema-validation parity is now a TS/Python/Rust/Go/.NET guarantee. Kotlin and Swift stay outside it by design — there is no schema surface on device (ADR-074).

📖 Development

Prerequisites

  • Node.js 22+, pnpm 10+
  • Python 3.13+ with uv (for the Python package)
  • Rust toolchain (for the Rust package)
  • Go 1.22+ (for the Go package)

Commands

pnpm install # Install dependencies
pnpm build # Build all packages (TS, Python, Rust, Go)
pnpm test# Run all tests (Vitest, pytest, cargo test, go test)
pnpm lint # Lint all code (oxlint, ruff, clippy, go vet)
pnpm format # Format all code (oxfmt, ruff, cargo fmt, gofmt)
pnpm typecheck # Type check (tsc, basedpyright, cargo check)
pnpm check-all # Full CI parity check

Schema Libraries

Supports Zod, Valibot, ArkType, Effect Schema, and built-in schema types. See SCHEMA_USAGE.md for examples with each library.

🧩 Part of Smoo AI

@smooai/config is built and open-sourced by Smoo AI — the AI-powered business platform with AI built into every product: CRM, customer support, campaigns, field service, observability, and developer tools.

🤝 Contributing

Contributions are welcome. This project uses changesets to manage versions and releases.

  1. Fork the repository.
  2. Create your branch (git checkout -b amazing-feature).
  3. Make your changes.
  4. Add a changeset: pnpm changeset.
  5. Commit and push.
  6. Open a pull request.

📄 License

MIT © SmooAI. See LICENSE.

Contact

Brent Rager

Smoo GitHub: github.com/SmooAI


Built by Smoo AI — AI built into every product.

About

Type-safe multi-language configuration management — schema validation, three-tier config (public, secrets, feature flags), and runtime clients for TypeScript, Python, Rust, and Go.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Latest commit

History

217 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

@smooai/config — One schema. Every language. Typed everywhere.

npmSmoo AIlicense

TypeScriptPythonRustGo.NETKotlinSwift

Features · Install · Quick start · Platform


Define your config once with Zod (or Valibot, ArkType, Effect), and read it with full type inference everywhere — public config, server-only secrets, and live feature flags. Rename a key and every call site is a compile error, not a 3 AM page. Native clients in TypeScript, Python, Rust, Go, and .NET all read from the same source of truth.

📣 The CLI moved. Use smoo config from the smooth repo for all operator commands (login, get, set, list, push, pull, diff, init, etc.). The standalone smooai-config CLI that used to live in this repo is deprecated and being deleted (SMOODEV-1411). The runtime library @smooai/config (TypeScript / Python / Rust / Go / .NET, consumed via secretConfig.get(...) / publicConfig.get(...) / featureFlag.get(...)) is unchanged — only the operator CLI surface moved to Rust in the smooth repo. (smoo … is the th binary's platform namespace; th smoo … and the old th config … spelling also work.)

✨ Features

  • Three tiers, one schema — public config, secrets, and feature flags defined once with Zod/Valibot/ArkType/Effect, validated everywhere they're read.
  • Strongly-typed keysdefineConfig() gives you PublicConfigKeys, SecretConfigKeys, and FeatureFlagKeys with full inference. Mis-typed keys fail at compile time, not at runtime.
  • Any environment, any key — the same API for development, staging, and production. Override per-stage without touching code.
  • Zero-latency cold starts — values are baked into the bundle as env vars (Next.js, Vite) or resolved in-memory from a local runtime (server). No network round-trip on the hot path.
  • Browser, server, framework-native — the same typed keys read cleanly from React client components, Server Components, Next.js, Vite, or plain Node.
  • Live feature flags — toggled from the dashboard without a redeploy, but still typed.
  • Native clients in every language — TypeScript, Python, Rust, Go, and .NET (C#) server SDKs all read from the same source of truth, plus Kotlin and Swift mobile SDKs for the public-only app surface (ADR-074 mobile runtime mode).

Languages / SDKs

Pick the SDK that matches your service. Every server client reads the same schema, the same encrypted bundle, and the same config API — so a key renamed in one language ripples through all of them. The two mobile SDKs (📱) speak a deliberately narrower, public-only surface — no secrets ever ship to a device.

SDKOne-linerREADME
TypeScriptPrimary SDK. Schema definition, Next.js / Vite plugins, server runtime, React hooks.README.md (this file)
PythonPydantic-validated schemas, sync ConfigClient, LocalConfigManager + ConfigManager, baked runtime.python/README.md
GoNative struct schemas, thread-safe ConfigClient / ConfigManager, baked-blob runtime.go/config/README.md
RustJsonSchema-derived schemas, async ConfigClient, sync ConfigManager, baked-blob runtime.rust/config/README.md
.NETRoslyn source-generated typed keys, OAuth2 SmooConfigClient, AES-GCM SmooConfigRuntime. Thinner surface than the other server SDKs — see the capability notes.dotnet/README.md
Kotlin 📱Mobile runtime mode (ADR-074): baked public bundle + live flag/limit evaluation, offline-safe, no secrets on device. Ships via JitPack commit pin — not on Maven Central yet.kotlin/ · spec
Swift 📱Mobile runtime mode (ADR-074): same surface as Kotlin (publicValue, evaluateFlag, evaluateLimit). Consumed via SPM pinned to a revision — no version tag yet.swift/ · spec

The five server SDKs release in version lockstep — v6.11.3 on npm, PyPI, crates.io, and NuGet, with the matching v6.11.3 git tag for the Go module, all cut from the same commit (verified against each registry 2026-08-20). The mobile SDKs version separately (commit-pinned; see below).

📦 Install

pnpm add @smooai/config

🚀 Quick Start (TypeScript)

1. Define your configuration schema

Use defineConfig() with any StandardSchema-compliant library (Zod, Valibot, ArkType, Effect Schema) or the built-in StringSchema, BooleanSchema, and NumberSchema helpers:

// .smooai-config/config.tsimport{defineConfig,StringSchema,BooleanSchema,NumberSchema}from'@smooai/config';import{z}from'zod';constconfig=defineConfig({publicConfigSchema: {apiBaseUrl: z.string().url(),maxRetries: NumberSchema,enableDebug: BooleanSchema,},secretConfigSchema: {databaseUrl: z.string().url(),apiKey: StringSchema,},featureFlagSchema: {enableNewUi: BooleanSchema,betaFeatures: BooleanSchema,},});exportdefaultconfig;// Extract typed key objects for use throughout your appexportconst{ FeatureFlagKeys, PublicConfigKeys, SecretConfigKeys }=config;

defineConfig() automatically maps camelCase keys to UPPER_SNAKE_CASE:

FeatureFlagKeys.ENABLE_NEW_UI;// "ENABLE_NEW_UI"PublicConfigKeys.API_BASE_URL;// "API_BASE_URL"SecretConfigKeys.DATABASE_URL;// "DATABASE_URL"

2. Add to tsconfig.json

{
"compilerOptions": { ... },
"include": ["src/**/*", ".smooai-config/**/*.ts"]
}

📖 Next.js Integration

Inject config into next.config.ts

Use withSmooConfig() to inject feature flags and public config as NEXT_PUBLIC_ environment variables, with per-stage overrides:

// next.config.tsimport{withSmooConfig}from'@smooai/config/nextjs/withSmooConfig';constnextConfig=withSmooConfig({default: {featureFlags: {enableNewUi: false,betaFeatures: false},publicConfig: {apiBaseUrl: 'https://api.smooai.com',maxRetries: 3},},development: {featureFlags: {enableNewUi: true},publicConfig: {apiBaseUrl: 'http://localhost:3000'},},});exportdefaultnextConfig;

This sets environment variables like NEXT_PUBLIC_FEATURE_FLAG_ENABLE_NEW_UI=true and NEXT_PUBLIC_CONFIG_API_BASE_URL=http://localhost:3000 based on the current stage.

Read config in React client components

import{getClientFeatureFlag,getClientPublicConfig}from'@smooai/config/client';functionMyComponent(){constisNewUi=getClientFeatureFlag('enableNewUi');constapiUrl=getClientPublicConfig('apiBaseUrl');if(!isNewUi)return<LegacyUI/>;return<NewUIapiUrl={apiUrl}/>;}

These functions check NEXT_PUBLIC_FEATURE_FLAG_* and NEXT_PUBLIC_CONFIG_* env vars automatically — no provider needed, no loading state.

Server Components + Client hydration (zero loading flash)

For apps that need runtime config from a config server, use getConfig on the server and SmooConfigProvider to hydrate client components:

// app/layout.tsx (Server Component)import{getConfig,SmooConfigProvider}from'@smooai/config/nextjs';exportdefaultasyncfunctionRootLayout({ children }: {children: React.ReactNode}){constconfig=awaitgetConfig({environment: 'production',fetchOptions: {next: {revalidate: 60}},});return(<html><body><SmooConfigProviderinitialValues={config}baseUrl={process.env.SMOOAI_CONFIG_API_URL}apiKey={process.env.SMOOAI_CONFIG_API_KEY}orgId={process.env.SMOOAI_CONFIG_ORG_ID}environment="production">{children}</SmooConfigProvider></body></html>);}
// Any client component — values available synchronously (pre-seeded from SSR)import{usePublicConfig,useFeatureFlag}from'@smooai/config/nextjs';functionDashboard(){const{value: apiUrl}=usePublicConfig<string>('API_BASE_URL');const{value: enableNewUi}=useFeatureFlag<boolean>('ENABLE_NEW_UI');return(<div>
API: {apiUrl}, New UI: {String(enableNewUi)}</div>);}

📖 Vite Integration

Vite plugin

// vite.config.tsimport{defineConfig}from'vite';import{smooConfigPlugin}from'@smooai/config/vite/smooConfigPlugin';exportdefaultdefineConfig({plugins: [smooConfigPlugin({featureFlags: {enableNewUi: true,betaFeatures: false},publicConfig: {apiBaseUrl: 'http://localhost:3000'},}),],});

Then read values the same way as Next.js — getClientFeatureFlag and getClientPublicConfig from @smooai/config/client check VITE_FEATURE_FLAG_* and VITE_CONFIG_* automatically.

Preload config (optional)

For runtime config from a config server, start fetching before React mounts:

// main.tsximport{preloadConfig,ConfigProvider}from'@smooai/config/vite';import{createRoot}from'react-dom/client';preloadConfig({environment: 'production'});createRoot(document.getElementById('root')!).render(<ConfigProviderbaseUrl="https://config.smooai.dev"apiKey="your-public-key"orgId="your-org-id"environment="production"><App/></ConfigProvider>,);

📖 Server-Side Config Access

For Node.js server code, use buildConfigObject to get sync and async accessors with full type safety:

importbuildConfigObjectfrom'@smooai/config/platform/server';importconfig,{PublicConfigKeys,SecretConfigKeys,FeatureFlagKeys}from'./.smooai-config/config';constconfigObj=buildConfigObject(config);// Sync access (uses worker threads)constdbUrl=configObj.secretConfig.getSync(SecretConfigKeys.DATABASE_URL);constapiUrl=configObj.publicConfig.getSync(PublicConfigKeys.API_BASE_URL);constisNewUi=configObj.featureFlag.getSync(FeatureFlagKeys.ENABLE_NEW_UI);// Async accessconstapiKey=awaitconfigObj.secretConfig.getAsync(SecretConfigKeys.API_KEY);

How .getSync() works (and how to ship it in any bundled compute)

Sync accessors run an async config read to completion on the caller thread via synckit — a Node Worker pool + Atomics.wait on a SharedArrayBuffer. createSyncFn only accepts a file:// URL, so the worker body has to live on disk. The SDK resolves it in two stages:

  1. Sidecar filesync-worker.mjs sitting next to the compiled SDK entry (i.e. resolved via new URL('./sync-worker.mjs', import.meta.url) from dist/server/index.mjs). This is the normal case for plain Node resolution with no bundling — node_modules/@smooai/config/dist/server/sync-worker.mjs is already there. It's also the preferred case when bundlers copy the sidecar into the deploy output. Zero /tmp writes.

  2. Extract-to-/tmp fallback — if the sidecar isn't on disk at that path (e.g. a bundler inlined the SDK entry into a single file and didn't copy the sidecar), the SDK writes an embedded copy of the worker source to mkdtempSync()/sync-worker.mjs once per process and hands that path to synckit. One ~1-2 MiB write at cold start, amortised across every sync read for the lifetime of the process. Works anywhere with a writable temp dir.

Both paths are transparent — your code is identical either way. Which path you land on depends on how your compute is packaged.

Plain Node (no bundling)

Zero config. The SDK resolves node_modules/@smooai/config/dist/server/sync-worker.mjs directly — path (1) every time.

Any bundled compute (Lambda, Cloud Run, ECS, container, Worker, etc.)

The rule is universal: if your build inlines the SDK entry into a single output file, you need to ship sync-worker.mjs next to that output (or accept path (2)'s /tmp write once per cold start).

The source path is always:

node_modules/@smooai/config/dist/server/sync-worker.mjs

The destination is alongside whichever file ends up being your runtime's import.meta.url anchor — typically the bundled handler .mjs / .js.

Recipes for common setups:

esbuild — explicit copy plugin

// build.tsimport{build}from'esbuild';import{copy}from'esbuild-plugin-copy';awaitbuild({entryPoints: ['src/handler.ts'],outdir: 'dist',bundle: true,format: 'esm',platform: 'node',plugins: [copy({assets: {from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs',to: 'dist/sync-worker.mjs',},}),],});

tsup — onSuccess hook

// tsup.config.tsexportdefaultdefineConfig({entry: ['src/handler.ts'],format: ['esm'],onSuccess: 'cp node_modules/@smooai/config/dist/server/sync-worker.mjs dist/sync-worker.mjs',});

Serverless Framework — package.include

package:
patterns:
- 'node_modules/@smooai/config/dist/server/sync-worker.mjs'

Or copy into the handler dir as a build step and include from there.

AWS SAM — CodeUri + build script

Add a Makefile / build script that copies sync-worker.mjs into the BuildArtifactPath alongside your handler.

SST (AWS) — per-function or via $transform

// sst.config.ts — per functionnewsst.aws.Function('Api',{handler: 'src/api.handler',copyFiles: [{from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs'}],});// Or at the stack level via $transform (every Function gets it automatically)$transform(sst.aws.Function,(fn)=>{fn.copyFiles=[...(fn.copyFiles??[]),{from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs'}];});

Docker container (ECS, Cloud Run, anywhere)

# After your main build step, ensure the sidecar is next to the bundled entry.COPY --from=build /app/dist/server.mjs /app/
COPY --from=build /app/node_modules/@smooai/config/dist/server/sync-worker.mjs /app/
CMD ["node", "server.mjs"]

If your build step keeps node_modules in the final image, no extra copy is needed — the SDK resolves the sidecar from node_modules/ path (1) directly.

When the sidecar truly can't be shipped

Path (2) — the /tmp extraction — is the safety net. One ~1-2 MiB write at cold start, then synckit re-uses the file for the rest of the process lifetime. Lambda's 512 MiB–10 GiB /tmp easily absorbs this; containers with an ephemeral /tmp work the same way. You can ignore this whole section and .getSync() will still work — you're just paying one filesystem write per cold start.

Edge runtimes (Vercel Edge, Cloudflare Workers)

These runtimes don't expose Node's worker_threads at all, so .getSync() is a no-go there by design. Use .get() (async) everywhere that needs to run on the edge. The error surface makes this explicit if you try.

📖 React Hooks (framework-agnostic)

For any React app using the runtime config client:

import{ConfigProvider,usePublicConfig,useFeatureFlag}from'@smooai/config/react';functionApp(){return(<ConfigProviderbaseUrl="https://config.smooai.dev"apiKey="your-api-key"orgId="your-org-id"environment="production"><MyComponent/></ConfigProvider>);}functionMyComponent(){const{value: apiUrl, isLoading, error }=usePublicConfig<string>('API_BASE_URL');const{value: enableNewUi}=useFeatureFlag<boolean>('ENABLE_NEW_UI');if(isLoading)return<div>Loading...</div>;if(error)return<div>Error: {error.message}</div>;return(<div>
API URL: {apiUrl}, New UI: {String(enableNewUi)}</div>);}

📖 SDK Runtime Client

All language implementations include a runtime client for fetching configuration values from the Smoo AI config server with local caching.

Environment Variables

Authentication is OAuth2 client_credentials against {authUrl}/token — the client exchanges (CLIENT_ID, CLIENT_SECRET) for a JWT and uses that JWT as the Bearer token on every config call. TokenProvider caches the JWT in memory and refreshes 60s before expiry.

VariableDescriptionRequired
SMOOAI_CONFIG_API_URLBase URL of the config APIYes
SMOOAI_CONFIG_AUTH_URLOAuth issuer base URL (defaults to https://auth.smoo.ai)No
SMOOAI_CONFIG_CLIENT_IDOAuth client IDYes
SMOOAI_CONFIG_CLIENT_SECRETOAuth client secret (legacy SMOOAI_CONFIG_API_KEY is accepted as a fallback)Yes
SMOOAI_CONFIG_ORG_IDOrganization IDYes
SMOOAI_CONFIG_ENVDefault environment name (defaults to "development")No

Migration note (v5 / SMOODEV-974): the TypeScript ConfigClient previously sent SMOOAI_CONFIG_API_KEY directly as the Bearer token, which the backend rejected with 401 because it expects a JWT. The SDK now mints a JWT via the OAuth client_credentials grant before each call — matching the .NET client, the in-package bootstrap, and the CLI. You must set SMOOAI_CONFIG_CLIENT_ID in addition to SMOOAI_CONFIG_API_KEY / SMOOAI_CONFIG_CLIENT_SECRET for the runtime SDK to work. The legacy SMOOAI_CONFIG_API_KEY env var continues to function as the OAuth client secret.

TypeScript Client

import{ConfigClient}from'@smooai/config/platform/client';// Zero-config (reads from env vars — needs CLIENT_ID + CLIENT_SECRET/API_KEY + ORG_ID)constclient=newConfigClient();// Or explicitconstclient=newConfigClient({baseUrl: 'https://config.smooai.dev',authUrl: 'https://auth.smooai.dev',clientId: 'your-client-id',clientSecret: 'your-client-secret',orgId: 'your-org-id',environment: 'production',});constapiUrl=awaitclient.getValue('API_BASE_URL');constallValues=awaitclient.getAllValues();client.invalidateCache();

📖 Container / Runtime Mode (EKS / ECS)

The baked blob tier is the blessed path for Lambda, but it is the wrong default for long-lived containers: when the per-build blob key isn't delivered to the pod, resolution silently falls through to the (absent) file tier and returns undefined for a required secret. That caused a real outage — a container got undefined for STRIPE_API_KEY, new Stripe(undefined) threw at module load, the process exited 0 before listen(), and the pod CrashLooped with the root cause buried (SMOODEV-1478).

Container mode makes the HTTP config API the first-class path for containers, authenticated with an OAuth2 client_credentials (M2M) token, and fails loud: a required value that doesn't resolve throws a typed error instead of returning undefined.

Containers use container mode, not the baked blob. See docs/Container-Runtime-Mode.md for the full env contract, a complete ExternalSecret (External Secrets Operator) recipe, and a readiness-probe example.

import{initContainerConfig,ConfigKeyUnresolvedError}from'@smooai/config/container';importschemafrom'../.smooai-config/config';// Validates the container env, mints a token, and does an initial fetch —// startup fails LOUD here (throws), not on first read.constconfig=awaitinitContainerConfig({ schema });// Fail-loud: a required secret that doesn't resolve throws// ConfigKeyUnresolvedError instead of returning undefined.conststripeKey=awaitconfig.secretConfig.get('stripeApiKey');// Kubernetes readiness probe — never throws.app.get('/healthz/config',(_req,res)=>{consth=config.health();// { status: 'healthy' } | { status: 'unhealthy', reason }res.status(h.status==='healthy' ? 200 : 503).json(h);});

Env contract (identical in every SDK): SMOOAI_CONFIG_API_URL, SMOOAI_CONFIG_CLIENT_ID, SMOOAI_CONFIG_CLIENT_SECRET, SMOOAI_CONFIG_ORG_ID, SMOOAI_CONFIG_ENV (all required), plus optional SMOOAI_CONFIG_AUTH_URL and SMOOAI_CONFIG_MODE=container (to force the mode). All schema-declared keys are treated as required by default; opt specific keys out with initContainerConfig({ optionalKeys: ['...'] }).

📖 Configuration Tiers

TierPurposeExamples
PublicClient-visible settingsAPI URLs, feature toggles, UI config
SecretServer-side onlyDatabase URLs, API keys, JWT secrets
Feature FlagsRuntime togglesA/B tests, gradual rollouts, beta access

Security: B2M Key Restrictions

OperationB2M (Public Key)M2M (Secret Key)
Read public valuesYesYes
Read feature flagsYesYes
Read secret valuesNo (filtered)Yes
Write config valuesNo (403)Yes
Delete config valuesNo (403)Yes

Browser-to-Machine (B2M) keys are designed for browser clients. Secret-tier values are automatically filtered. B2M keys are read-only for public and feature flag tiers.

Machine-to-Machine (M2M) keys have full access to all tiers and write operations.

📖 Multi-Language Support

@smooai/config has native server implementations in Python, Rust, Go, and .NET (C#) alongside the primary TypeScript package, plus mobile SDKs in Kotlin and Swift (a deliberately different, public-only surface — see below). Every server client reads the same encrypted bundle, the same schema, and the same config API. See the per-SDK READMEs linked above for full usage docs — the snippets below are five-line orientation only.

Python — see python/README.md

pip install smooai-config
# or: uv add smooai-config
fromsmooai_config.clientimportConfigClientwithConfigClient() asclient: # reads SMOOAI_CONFIG_* env varsvalue=client.get_value("API_URL", environment="production")
cargo add smooai-config
use smooai_config::ConfigClient;letmut client = ConfigClient::from_env();let value = client.get_value("API_URL",None).await?;
go get github.com/SmooAI/config/go/config
import"github.com/SmooAI/config/go/config"client:=config.NewConfigClientFromEnv()
deferclient.Close()
value, _:=client.GetValue("API_URL", "production")

.NET — see dotnet/README.md

dotnet add package SmooAI.Config
usingSmooAI.Config;usingSmooAI.Config.Runtime;varruntime=SmooConfigRuntime.Load();// reads SMOO_CONFIG_KEY_FILE + SMOO_CONFIG_KEYusingvarclient=newSmooConfigClient(options);varapiUrl=awaitPublic.ApiUrl.ResolveAsync(runtime,client);

Kotlin (mobile) — see docs/Mobile-Runtime-Mode-Spec.md

Ships via JitPack pinned to a commit (Maven Central under ai.smoo is the planned follow-up — the in-repo Gradle version is a dev default, so don't pin a version, pin a SHA):

// settings.gradle.kts / build.gradle.kts
repositories { maven("https://jitpack.io") }
dependencies { implementation("com.github.SmooAI:config:<commit-sha>") }
importai.smoo.config.SmooConfigimportai.smoo.config.SmooConfigOptionsval config =SmooConfig(SmooConfigOptions(environment ="production", engine = engine, bundledConfigFile = bakedBundle))
val apiUrl = config.publicValue("API_BASE_URL") // baked bundle → refreshed cache, offline-safeval newUi = config.evaluateFlag("ENABLE_NEW_UI", default =false) // http → disk cache → default

Swift (mobile) — see docs/Mobile-Runtime-Mode-Spec.md

Consumed via Swift Package Manager pinned to a revision (there is no SPM version tag yet; the root Package.swift exists so SPM can resolve the repo URL directly):

// Package.swift
.package(url:"https://github.com/SmooAI/config", revision:"<commit-sha>")
import SmooAIConfig
letconfig=SmooConfig(options:SmooConfigOptions(environment:"production", bundledConfigURL: bakedBundleURL))letapiUrl= config.publicValue(forKey:"API_BASE_URL")letnewUi=await config.evaluateFlag("ENABLE_NEW_UI", default:false)

Mobile binaries are attacker-owned territory, so the mobile SDKs speak only the public app-config surface (/config/app/...): a baked public bundle (plaintext — it never contains secrets) plus live feature-flag / limit evaluation with an offline disk cache. There is no secret tier, no M2M credential, and no schema/LocalConfigManager surface on device — by design, per ADR-074.

SDK capability notes

Honest asymmetries between the SDKs, so you can pick with your eyes open:

CapabilityTSPythonRustGo.NETKotlin / Swift
Encrypted baked bundle + config API reads📱 public-only bundle
Local config-file workflow (LocalConfigManager in Py/Rust/Go/.NET; the file tier in TS)
Cloud-region resolution
Deferred values / merge_replace_arrays semantics
Shared schema-validation conformance fixture (test-fixtures/schema-validation-cases.json)
Live feature flags✅ (+ limits)

The .NET SDK reached parity with the other four server SDKs in 6.11.x: LocalConfigManager, cloud-region resolution, deferred values and MergeReplaceArrays all landed, and its schema validator is held to the same test-fixtures/schema-validation-cases.json corpus. Cross-language schema-validation parity is now a TS/Python/Rust/Go/.NET guarantee. Kotlin and Swift stay outside it by design — there is no schema surface on device (ADR-074).

📖 Development

Prerequisites

  • Node.js 22+, pnpm 10+
  • Python 3.13+ with uv (for the Python package)
  • Rust toolchain (for the Rust package)
  • Go 1.22+ (for the Go package)

Commands

pnpm install # Install dependencies
pnpm build # Build all packages (TS, Python, Rust, Go)
pnpm test# Run all tests (Vitest, pytest, cargo test, go test)
pnpm lint # Lint all code (oxlint, ruff, clippy, go vet)
pnpm format # Format all code (oxfmt, ruff, cargo fmt, gofmt)
pnpm typecheck # Type check (tsc, basedpyright, cargo check)
pnpm check-all # Full CI parity check

Schema Libraries

Supports Zod, Valibot, ArkType, Effect Schema, and built-in schema types. See SCHEMA_USAGE.md for examples with each library.

🧩 Part of Smoo AI

@smooai/config is built and open-sourced by Smoo AI — the AI-powered business platform with AI built into every product: CRM, customer support, campaigns, field service, observability, and developer tools.

🤝 Contributing

Contributions are welcome. This project uses changesets to manage versions and releases.

  1. Fork the repository.
  2. Create your branch (git checkout -b amazing-feature).
  3. Make your changes.
  4. Add a changeset: pnpm changeset.
  5. Commit and push.
  6. Open a pull request.

📄 License

MIT © SmooAI. See LICENSE.

Contact

Brent Rager

Smoo GitHub: github.com/SmooAI


Built by Smoo AI — AI built into every product.

About

Type-safe multi-language configuration management — schema validation, three-tier config (public, secrets, feature flags), and runtime clients for TypeScript, Python, Rust, and Go.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

217 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

@smooai/config — One schema. Every language. Typed everywhere.

npmSmoo AIlicense

TypeScriptPythonRustGo.NETKotlinSwift

Features · Install · Quick start · Platform


Define your config once with Zod (or Valibot, ArkType, Effect), and read it with full type inference everywhere — public config, server-only secrets, and live feature flags. Rename a key and every call site is a compile error, not a 3 AM page. Native clients in TypeScript, Python, Rust, Go, and .NET all read from the same source of truth.

📣 The CLI moved. Use smoo config from the smooth repo for all operator commands (login, get, set, list, push, pull, diff, init, etc.). The standalone smooai-config CLI that used to live in this repo is deprecated and being deleted (SMOODEV-1411). The runtime library @smooai/config (TypeScript / Python / Rust / Go / .NET, consumed via secretConfig.get(...) / publicConfig.get(...) / featureFlag.get(...)) is unchanged — only the operator CLI surface moved to Rust in the smooth repo. (smoo … is the th binary's platform namespace; th smoo … and the old th config … spelling also work.)

✨ Features

  • Three tiers, one schema — public config, secrets, and feature flags defined once with Zod/Valibot/ArkType/Effect, validated everywhere they're read.
  • Strongly-typed keysdefineConfig() gives you PublicConfigKeys, SecretConfigKeys, and FeatureFlagKeys with full inference. Mis-typed keys fail at compile time, not at runtime.
  • Any environment, any key — the same API for development, staging, and production. Override per-stage without touching code.
  • Zero-latency cold starts — values are baked into the bundle as env vars (Next.js, Vite) or resolved in-memory from a local runtime (server). No network round-trip on the hot path.
  • Browser, server, framework-native — the same typed keys read cleanly from React client components, Server Components, Next.js, Vite, or plain Node.
  • Live feature flags — toggled from the dashboard without a redeploy, but still typed.
  • Native clients in every language — TypeScript, Python, Rust, Go, and .NET (C#) server SDKs all read from the same source of truth, plus Kotlin and Swift mobile SDKs for the public-only app surface (ADR-074 mobile runtime mode).

Languages / SDKs

Pick the SDK that matches your service. Every server client reads the same schema, the same encrypted bundle, and the same config API — so a key renamed in one language ripples through all of them. The two mobile SDKs (📱) speak a deliberately narrower, public-only surface — no secrets ever ship to a device.

SDKOne-linerREADME
TypeScriptPrimary SDK. Schema definition, Next.js / Vite plugins, server runtime, React hooks.README.md (this file)
PythonPydantic-validated schemas, sync ConfigClient, LocalConfigManager + ConfigManager, baked runtime.python/README.md
GoNative struct schemas, thread-safe ConfigClient / ConfigManager, baked-blob runtime.go/config/README.md
RustJsonSchema-derived schemas, async ConfigClient, sync ConfigManager, baked-blob runtime.rust/config/README.md
.NETRoslyn source-generated typed keys, OAuth2 SmooConfigClient, AES-GCM SmooConfigRuntime. Thinner surface than the other server SDKs — see the capability notes.dotnet/README.md
Kotlin 📱Mobile runtime mode (ADR-074): baked public bundle + live flag/limit evaluation, offline-safe, no secrets on device. Ships via JitPack commit pin — not on Maven Central yet.kotlin/ · spec
Swift 📱Mobile runtime mode (ADR-074): same surface as Kotlin (publicValue, evaluateFlag, evaluateLimit). Consumed via SPM pinned to a revision — no version tag yet.swift/ · spec

The five server SDKs release in version lockstep — v6.11.3 on npm, PyPI, crates.io, and NuGet, with the matching v6.11.3 git tag for the Go module, all cut from the same commit (verified against each registry 2026-08-20). The mobile SDKs version separately (commit-pinned; see below).

📦 Install

pnpm add @smooai/config

🚀 Quick Start (TypeScript)

1. Define your configuration schema

Use defineConfig() with any StandardSchema-compliant library (Zod, Valibot, ArkType, Effect Schema) or the built-in StringSchema, BooleanSchema, and NumberSchema helpers:

// .smooai-config/config.tsimport{defineConfig,StringSchema,BooleanSchema,NumberSchema}from'@smooai/config';import{z}from'zod';constconfig=defineConfig({publicConfigSchema: {apiBaseUrl: z.string().url(),maxRetries: NumberSchema,enableDebug: BooleanSchema,},secretConfigSchema: {databaseUrl: z.string().url(),apiKey: StringSchema,},featureFlagSchema: {enableNewUi: BooleanSchema,betaFeatures: BooleanSchema,},});exportdefaultconfig;// Extract typed key objects for use throughout your appexportconst{ FeatureFlagKeys, PublicConfigKeys, SecretConfigKeys }=config;

defineConfig() automatically maps camelCase keys to UPPER_SNAKE_CASE:

FeatureFlagKeys.ENABLE_NEW_UI;// "ENABLE_NEW_UI"PublicConfigKeys.API_BASE_URL;// "API_BASE_URL"SecretConfigKeys.DATABASE_URL;// "DATABASE_URL"

2. Add to tsconfig.json

{
"compilerOptions": { ... },
"include": ["src/**/*", ".smooai-config/**/*.ts"]
}

📖 Next.js Integration

Inject config into next.config.ts

Use withSmooConfig() to inject feature flags and public config as NEXT_PUBLIC_ environment variables, with per-stage overrides:

// next.config.tsimport{withSmooConfig}from'@smooai/config/nextjs/withSmooConfig';constnextConfig=withSmooConfig({default: {featureFlags: {enableNewUi: false,betaFeatures: false},publicConfig: {apiBaseUrl: 'https://api.smooai.com',maxRetries: 3},},development: {featureFlags: {enableNewUi: true},publicConfig: {apiBaseUrl: 'http://localhost:3000'},},});exportdefaultnextConfig;

This sets environment variables like NEXT_PUBLIC_FEATURE_FLAG_ENABLE_NEW_UI=true and NEXT_PUBLIC_CONFIG_API_BASE_URL=http://localhost:3000 based on the current stage.

Read config in React client components

import{getClientFeatureFlag,getClientPublicConfig}from'@smooai/config/client';functionMyComponent(){constisNewUi=getClientFeatureFlag('enableNewUi');constapiUrl=getClientPublicConfig('apiBaseUrl');if(!isNewUi)return<LegacyUI/>;return<NewUIapiUrl={apiUrl}/>;}

These functions check NEXT_PUBLIC_FEATURE_FLAG_* and NEXT_PUBLIC_CONFIG_* env vars automatically — no provider needed, no loading state.

Server Components + Client hydration (zero loading flash)

For apps that need runtime config from a config server, use getConfig on the server and SmooConfigProvider to hydrate client components:

// app/layout.tsx (Server Component)import{getConfig,SmooConfigProvider}from'@smooai/config/nextjs';exportdefaultasyncfunctionRootLayout({ children }: {children: React.ReactNode}){constconfig=awaitgetConfig({environment: 'production',fetchOptions: {next: {revalidate: 60}},});return(<html><body><SmooConfigProviderinitialValues={config}baseUrl={process.env.SMOOAI_CONFIG_API_URL}apiKey={process.env.SMOOAI_CONFIG_API_KEY}orgId={process.env.SMOOAI_CONFIG_ORG_ID}environment="production">{children}</SmooConfigProvider></body></html>);}
// Any client component — values available synchronously (pre-seeded from SSR)import{usePublicConfig,useFeatureFlag}from'@smooai/config/nextjs';functionDashboard(){const{value: apiUrl}=usePublicConfig<string>('API_BASE_URL');const{value: enableNewUi}=useFeatureFlag<boolean>('ENABLE_NEW_UI');return(<div>
API: {apiUrl}, New UI: {String(enableNewUi)}</div>);}

📖 Vite Integration

Vite plugin

// vite.config.tsimport{defineConfig}from'vite';import{smooConfigPlugin}from'@smooai/config/vite/smooConfigPlugin';exportdefaultdefineConfig({plugins: [smooConfigPlugin({featureFlags: {enableNewUi: true,betaFeatures: false},publicConfig: {apiBaseUrl: 'http://localhost:3000'},}),],});

Then read values the same way as Next.js — getClientFeatureFlag and getClientPublicConfig from @smooai/config/client check VITE_FEATURE_FLAG_* and VITE_CONFIG_* automatically.

Preload config (optional)

For runtime config from a config server, start fetching before React mounts:

// main.tsximport{preloadConfig,ConfigProvider}from'@smooai/config/vite';import{createRoot}from'react-dom/client';preloadConfig({environment: 'production'});createRoot(document.getElementById('root')!).render(<ConfigProviderbaseUrl="https://config.smooai.dev"apiKey="your-public-key"orgId="your-org-id"environment="production"><App/></ConfigProvider>,);

📖 Server-Side Config Access

For Node.js server code, use buildConfigObject to get sync and async accessors with full type safety:

importbuildConfigObjectfrom'@smooai/config/platform/server';importconfig,{PublicConfigKeys,SecretConfigKeys,FeatureFlagKeys}from'./.smooai-config/config';constconfigObj=buildConfigObject(config);// Sync access (uses worker threads)constdbUrl=configObj.secretConfig.getSync(SecretConfigKeys.DATABASE_URL);constapiUrl=configObj.publicConfig.getSync(PublicConfigKeys.API_BASE_URL);constisNewUi=configObj.featureFlag.getSync(FeatureFlagKeys.ENABLE_NEW_UI);// Async accessconstapiKey=awaitconfigObj.secretConfig.getAsync(SecretConfigKeys.API_KEY);

How .getSync() works (and how to ship it in any bundled compute)

Sync accessors run an async config read to completion on the caller thread via synckit — a Node Worker pool + Atomics.wait on a SharedArrayBuffer. createSyncFn only accepts a file:// URL, so the worker body has to live on disk. The SDK resolves it in two stages:

  1. Sidecar filesync-worker.mjs sitting next to the compiled SDK entry (i.e. resolved via new URL('./sync-worker.mjs', import.meta.url) from dist/server/index.mjs). This is the normal case for plain Node resolution with no bundling — node_modules/@smooai/config/dist/server/sync-worker.mjs is already there. It's also the preferred case when bundlers copy the sidecar into the deploy output. Zero /tmp writes.

  2. Extract-to-/tmp fallback — if the sidecar isn't on disk at that path (e.g. a bundler inlined the SDK entry into a single file and didn't copy the sidecar), the SDK writes an embedded copy of the worker source to mkdtempSync()/sync-worker.mjs once per process and hands that path to synckit. One ~1-2 MiB write at cold start, amortised across every sync read for the lifetime of the process. Works anywhere with a writable temp dir.

Both paths are transparent — your code is identical either way. Which path you land on depends on how your compute is packaged.

Plain Node (no bundling)

Zero config. The SDK resolves node_modules/@smooai/config/dist/server/sync-worker.mjs directly — path (1) every time.

Any bundled compute (Lambda, Cloud Run, ECS, container, Worker, etc.)

The rule is universal: if your build inlines the SDK entry into a single output file, you need to ship sync-worker.mjs next to that output (or accept path (2)'s /tmp write once per cold start).

The source path is always:

node_modules/@smooai/config/dist/server/sync-worker.mjs

The destination is alongside whichever file ends up being your runtime's import.meta.url anchor — typically the bundled handler .mjs / .js.

Recipes for common setups:

esbuild — explicit copy plugin

// build.tsimport{build}from'esbuild';import{copy}from'esbuild-plugin-copy';awaitbuild({entryPoints: ['src/handler.ts'],outdir: 'dist',bundle: true,format: 'esm',platform: 'node',plugins: [copy({assets: {from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs',to: 'dist/sync-worker.mjs',},}),],});

tsup — onSuccess hook

// tsup.config.tsexportdefaultdefineConfig({entry: ['src/handler.ts'],format: ['esm'],onSuccess: 'cp node_modules/@smooai/config/dist/server/sync-worker.mjs dist/sync-worker.mjs',});

Serverless Framework — package.include

package:
patterns:
- 'node_modules/@smooai/config/dist/server/sync-worker.mjs'

Or copy into the handler dir as a build step and include from there.

AWS SAM — CodeUri + build script

Add a Makefile / build script that copies sync-worker.mjs into the BuildArtifactPath alongside your handler.

SST (AWS) — per-function or via $transform

// sst.config.ts — per functionnewsst.aws.Function('Api',{handler: 'src/api.handler',copyFiles: [{from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs'}],});// Or at the stack level via $transform (every Function gets it automatically)$transform(sst.aws.Function,(fn)=>{fn.copyFiles=[...(fn.copyFiles??[]),{from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs'}];});

Docker container (ECS, Cloud Run, anywhere)

# After your main build step, ensure the sidecar is next to the bundled entry.COPY --from=build /app/dist/server.mjs /app/
COPY --from=build /app/node_modules/@smooai/config/dist/server/sync-worker.mjs /app/
CMD ["node", "server.mjs"]

If your build step keeps node_modules in the final image, no extra copy is needed — the SDK resolves the sidecar from node_modules/ path (1) directly.

When the sidecar truly can't be shipped

Path (2) — the /tmp extraction — is the safety net. One ~1-2 MiB write at cold start, then synckit re-uses the file for the rest of the process lifetime. Lambda's 512 MiB–10 GiB /tmp easily absorbs this; containers with an ephemeral /tmp work the same way. You can ignore this whole section and .getSync() will still work — you're just paying one filesystem write per cold start.

Edge runtimes (Vercel Edge, Cloudflare Workers)

These runtimes don't expose Node's worker_threads at all, so .getSync() is a no-go there by design. Use .get() (async) everywhere that needs to run on the edge. The error surface makes this explicit if you try.

📖 React Hooks (framework-agnostic)

For any React app using the runtime config client:

import{ConfigProvider,usePublicConfig,useFeatureFlag}from'@smooai/config/react';functionApp(){return(<ConfigProviderbaseUrl="https://config.smooai.dev"apiKey="your-api-key"orgId="your-org-id"environment="production"><MyComponent/></ConfigProvider>);}functionMyComponent(){const{value: apiUrl, isLoading, error }=usePublicConfig<string>('API_BASE_URL');const{value: enableNewUi}=useFeatureFlag<boolean>('ENABLE_NEW_UI');if(isLoading)return<div>Loading...</div>;if(error)return<div>Error: {error.message}</div>;return(<div>
API URL: {apiUrl}, New UI: {String(enableNewUi)}</div>);}

📖 SDK Runtime Client

All language implementations include a runtime client for fetching configuration values from the Smoo AI config server with local caching.

Environment Variables

Authentication is OAuth2 client_credentials against {authUrl}/token — the client exchanges (CLIENT_ID, CLIENT_SECRET) for a JWT and uses that JWT as the Bearer token on every config call. TokenProvider caches the JWT in memory and refreshes 60s before expiry.

VariableDescriptionRequired
SMOOAI_CONFIG_API_URLBase URL of the config APIYes
SMOOAI_CONFIG_AUTH_URLOAuth issuer base URL (defaults to https://auth.smoo.ai)No
SMOOAI_CONFIG_CLIENT_IDOAuth client IDYes
SMOOAI_CONFIG_CLIENT_SECRETOAuth client secret (legacy SMOOAI_CONFIG_API_KEY is accepted as a fallback)Yes
SMOOAI_CONFIG_ORG_IDOrganization IDYes
SMOOAI_CONFIG_ENVDefault environment name (defaults to "development")No

Migration note (v5 / SMOODEV-974): the TypeScript ConfigClient previously sent SMOOAI_CONFIG_API_KEY directly as the Bearer token, which the backend rejected with 401 because it expects a JWT. The SDK now mints a JWT via the OAuth client_credentials grant before each call — matching the .NET client, the in-package bootstrap, and the CLI. You must set SMOOAI_CONFIG_CLIENT_ID in addition to SMOOAI_CONFIG_API_KEY / SMOOAI_CONFIG_CLIENT_SECRET for the runtime SDK to work. The legacy SMOOAI_CONFIG_API_KEY env var continues to function as the OAuth client secret.

TypeScript Client

import{ConfigClient}from'@smooai/config/platform/client';// Zero-config (reads from env vars — needs CLIENT_ID + CLIENT_SECRET/API_KEY + ORG_ID)constclient=newConfigClient();// Or explicitconstclient=newConfigClient({baseUrl: 'https://config.smooai.dev',authUrl: 'https://auth.smooai.dev',clientId: 'your-client-id',clientSecret: 'your-client-secret',orgId: 'your-org-id',environment: 'production',});constapiUrl=awaitclient.getValue('API_BASE_URL');constallValues=awaitclient.getAllValues();client.invalidateCache();

📖 Container / Runtime Mode (EKS / ECS)

The baked blob tier is the blessed path for Lambda, but it is the wrong default for long-lived containers: when the per-build blob key isn't delivered to the pod, resolution silently falls through to the (absent) file tier and returns undefined for a required secret. That caused a real outage — a container got undefined for STRIPE_API_KEY, new Stripe(undefined) threw at module load, the process exited 0 before listen(), and the pod CrashLooped with the root cause buried (SMOODEV-1478).

Container mode makes the HTTP config API the first-class path for containers, authenticated with an OAuth2 client_credentials (M2M) token, and fails loud: a required value that doesn't resolve throws a typed error instead of returning undefined.

Containers use container mode, not the baked blob. See docs/Container-Runtime-Mode.md for the full env contract, a complete ExternalSecret (External Secrets Operator) recipe, and a readiness-probe example.

import{initContainerConfig,ConfigKeyUnresolvedError}from'@smooai/config/container';importschemafrom'../.smooai-config/config';// Validates the container env, mints a token, and does an initial fetch —// startup fails LOUD here (throws), not on first read.constconfig=awaitinitContainerConfig({ schema });// Fail-loud: a required secret that doesn't resolve throws// ConfigKeyUnresolvedError instead of returning undefined.conststripeKey=awaitconfig.secretConfig.get('stripeApiKey');// Kubernetes readiness probe — never throws.app.get('/healthz/config',(_req,res)=>{consth=config.health();// { status: 'healthy' } | { status: 'unhealthy', reason }res.status(h.status==='healthy' ? 200 : 503).json(h);});

Env contract (identical in every SDK): SMOOAI_CONFIG_API_URL, SMOOAI_CONFIG_CLIENT_ID, SMOOAI_CONFIG_CLIENT_SECRET, SMOOAI_CONFIG_ORG_ID, SMOOAI_CONFIG_ENV (all required), plus optional SMOOAI_CONFIG_AUTH_URL and SMOOAI_CONFIG_MODE=container (to force the mode). All schema-declared keys are treated as required by default; opt specific keys out with initContainerConfig({ optionalKeys: ['...'] }).

📖 Configuration Tiers

TierPurposeExamples
PublicClient-visible settingsAPI URLs, feature toggles, UI config
SecretServer-side onlyDatabase URLs, API keys, JWT secrets
Feature FlagsRuntime togglesA/B tests, gradual rollouts, beta access

Security: B2M Key Restrictions

OperationB2M (Public Key)M2M (Secret Key)
Read public valuesYesYes
Read feature flagsYesYes
Read secret valuesNo (filtered)Yes
Write config valuesNo (403)Yes
Delete config valuesNo (403)Yes

Browser-to-Machine (B2M) keys are designed for browser clients. Secret-tier values are automatically filtered. B2M keys are read-only for public and feature flag tiers.

Machine-to-Machine (M2M) keys have full access to all tiers and write operations.

📖 Multi-Language Support

@smooai/config has native server implementations in Python, Rust, Go, and .NET (C#) alongside the primary TypeScript package, plus mobile SDKs in Kotlin and Swift (a deliberately different, public-only surface — see below). Every server client reads the same encrypted bundle, the same schema, and the same config API. See the per-SDK READMEs linked above for full usage docs — the snippets below are five-line orientation only.

Python — see python/README.md

pip install smooai-config
# or: uv add smooai-config
fromsmooai_config.clientimportConfigClientwithConfigClient() asclient: # reads SMOOAI_CONFIG_* env varsvalue=client.get_value("API_URL", environment="production")
cargo add smooai-config
use smooai_config::ConfigClient;letmut client = ConfigClient::from_env();let value = client.get_value("API_URL",None).await?;
go get github.com/SmooAI/config/go/config
import"github.com/SmooAI/config/go/config"client:=config.NewConfigClientFromEnv()
deferclient.Close()
value, _:=client.GetValue("API_URL", "production")

.NET — see dotnet/README.md

dotnet add package SmooAI.Config
usingSmooAI.Config;usingSmooAI.Config.Runtime;varruntime=SmooConfigRuntime.Load();// reads SMOO_CONFIG_KEY_FILE + SMOO_CONFIG_KEYusingvarclient=newSmooConfigClient(options);varapiUrl=awaitPublic.ApiUrl.ResolveAsync(runtime,client);

Kotlin (mobile) — see docs/Mobile-Runtime-Mode-Spec.md

Ships via JitPack pinned to a commit (Maven Central under ai.smoo is the planned follow-up — the in-repo Gradle version is a dev default, so don't pin a version, pin a SHA):

// settings.gradle.kts / build.gradle.kts
repositories { maven("https://jitpack.io") }
dependencies { implementation("com.github.SmooAI:config:<commit-sha>") }
importai.smoo.config.SmooConfigimportai.smoo.config.SmooConfigOptionsval config =SmooConfig(SmooConfigOptions(environment ="production", engine = engine, bundledConfigFile = bakedBundle))
val apiUrl = config.publicValue("API_BASE_URL") // baked bundle → refreshed cache, offline-safeval newUi = config.evaluateFlag("ENABLE_NEW_UI", default =false) // http → disk cache → default

Swift (mobile) — see docs/Mobile-Runtime-Mode-Spec.md

Consumed via Swift Package Manager pinned to a revision (there is no SPM version tag yet; the root Package.swift exists so SPM can resolve the repo URL directly):

// Package.swift
.package(url:"https://github.com/SmooAI/config", revision:"<commit-sha>")
import SmooAIConfig
letconfig=SmooConfig(options:SmooConfigOptions(environment:"production", bundledConfigURL: bakedBundleURL))letapiUrl= config.publicValue(forKey:"API_BASE_URL")letnewUi=await config.evaluateFlag("ENABLE_NEW_UI", default:false)

Mobile binaries are attacker-owned territory, so the mobile SDKs speak only the public app-config surface (/config/app/...): a baked public bundle (plaintext — it never contains secrets) plus live feature-flag / limit evaluation with an offline disk cache. There is no secret tier, no M2M credential, and no schema/LocalConfigManager surface on device — by design, per ADR-074.

SDK capability notes

Honest asymmetries between the SDKs, so you can pick with your eyes open:

CapabilityTSPythonRustGo.NETKotlin / Swift
Encrypted baked bundle + config API reads📱 public-only bundle
Local config-file workflow (LocalConfigManager in Py/Rust/Go/.NET; the file tier in TS)
Cloud-region resolution
Deferred values / merge_replace_arrays semantics
Shared schema-validation conformance fixture (test-fixtures/schema-validation-cases.json)
Live feature flags✅ (+ limits)

The .NET SDK reached parity with the other four server SDKs in 6.11.x: LocalConfigManager, cloud-region resolution, deferred values and MergeReplaceArrays all landed, and its schema validator is held to the same test-fixtures/schema-validation-cases.json corpus. Cross-language schema-validation parity is now a TS/Python/Rust/Go/.NET guarantee. Kotlin and Swift stay outside it by design — there is no schema surface on device (ADR-074).

📖 Development

Prerequisites

  • Node.js 22+, pnpm 10+
  • Python 3.13+ with uv (for the Python package)
  • Rust toolchain (for the Rust package)
  • Go 1.22+ (for the Go package)

Commands

pnpm install # Install dependencies
pnpm build # Build all packages (TS, Python, Rust, Go)
pnpm test# Run all tests (Vitest, pytest, cargo test, go test)
pnpm lint # Lint all code (oxlint, ruff, clippy, go vet)
pnpm format # Format all code (oxfmt, ruff, cargo fmt, gofmt)
pnpm typecheck # Type check (tsc, basedpyright, cargo check)
pnpm check-all # Full CI parity check

Schema Libraries

Supports Zod, Valibot, ArkType, Effect Schema, and built-in schema types. See SCHEMA_USAGE.md for examples with each library.

🧩 Part of Smoo AI

@smooai/config is built and open-sourced by Smoo AI — the AI-powered business platform with AI built into every product: CRM, customer support, campaigns, field service, observability, and developer tools.

🤝 Contributing

Contributions are welcome. This project uses changesets to manage versions and releases.

  1. Fork the repository.
  2. Create your branch (git checkout -b amazing-feature).
  3. Make your changes.
  4. Add a changeset: pnpm changeset.
  5. Commit and push.
  6. Open a pull request.

📄 License

MIT © SmooAI. See LICENSE.

Contact

Brent Rager

Smoo GitHub: github.com/SmooAI


Built by Smoo AI — AI built into every product.

About

Type-safe multi-language configuration management — schema validation, three-tier config (public, secrets, feature flags), and runtime clients for TypeScript, Python, Rust, and Go.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

217 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

@smooai/config — One schema. Every language. Typed everywhere.

npmSmoo AIlicense

TypeScriptPythonRustGo.NETKotlinSwift

Features · Install · Quick start · Platform


Define your config once with Zod (or Valibot, ArkType, Effect), and read it with full type inference everywhere — public config, server-only secrets, and live feature flags. Rename a key and every call site is a compile error, not a 3 AM page. Native clients in TypeScript, Python, Rust, Go, and .NET all read from the same source of truth.

📣 The CLI moved. Use smoo config from the smooth repo for all operator commands (login, get, set, list, push, pull, diff, init, etc.). The standalone smooai-config CLI that used to live in this repo is deprecated and being deleted (SMOODEV-1411). The runtime library @smooai/config (TypeScript / Python / Rust / Go / .NET, consumed via secretConfig.get(...) / publicConfig.get(...) / featureFlag.get(...)) is unchanged — only the operator CLI surface moved to Rust in the smooth repo. (smoo … is the th binary's platform namespace; th smoo … and the old th config … spelling also work.)

✨ Features

  • Three tiers, one schema — public config, secrets, and feature flags defined once with Zod/Valibot/ArkType/Effect, validated everywhere they're read.
  • Strongly-typed keysdefineConfig() gives you PublicConfigKeys, SecretConfigKeys, and FeatureFlagKeys with full inference. Mis-typed keys fail at compile time, not at runtime.
  • Any environment, any key — the same API for development, staging, and production. Override per-stage without touching code.
  • Zero-latency cold starts — values are baked into the bundle as env vars (Next.js, Vite) or resolved in-memory from a local runtime (server). No network round-trip on the hot path.
  • Browser, server, framework-native — the same typed keys read cleanly from React client components, Server Components, Next.js, Vite, or plain Node.
  • Live feature flags — toggled from the dashboard without a redeploy, but still typed.
  • Native clients in every language — TypeScript, Python, Rust, Go, and .NET (C#) server SDKs all read from the same source of truth, plus Kotlin and Swift mobile SDKs for the public-only app surface (ADR-074 mobile runtime mode).

Languages / SDKs

Pick the SDK that matches your service. Every server client reads the same schema, the same encrypted bundle, and the same config API — so a key renamed in one language ripples through all of them. The two mobile SDKs (📱) speak a deliberately narrower, public-only surface — no secrets ever ship to a device.

SDKOne-linerREADME
TypeScriptPrimary SDK. Schema definition, Next.js / Vite plugins, server runtime, React hooks.README.md (this file)
PythonPydantic-validated schemas, sync ConfigClient, LocalConfigManager + ConfigManager, baked runtime.python/README.md
GoNative struct schemas, thread-safe ConfigClient / ConfigManager, baked-blob runtime.go/config/README.md
RustJsonSchema-derived schemas, async ConfigClient, sync ConfigManager, baked-blob runtime.rust/config/README.md
.NETRoslyn source-generated typed keys, OAuth2 SmooConfigClient, AES-GCM SmooConfigRuntime. Thinner surface than the other server SDKs — see the capability notes.dotnet/README.md
Kotlin 📱Mobile runtime mode (ADR-074): baked public bundle + live flag/limit evaluation, offline-safe, no secrets on device. Ships via JitPack commit pin — not on Maven Central yet.kotlin/ · spec
Swift 📱Mobile runtime mode (ADR-074): same surface as Kotlin (publicValue, evaluateFlag, evaluateLimit). Consumed via SPM pinned to a revision — no version tag yet.swift/ · spec

The five server SDKs release in version lockstep — v6.11.3 on npm, PyPI, crates.io, and NuGet, with the matching v6.11.3 git tag for the Go module, all cut from the same commit (verified against each registry 2026-08-20). The mobile SDKs version separately (commit-pinned; see below).

📦 Install

pnpm add @smooai/config

🚀 Quick Start (TypeScript)

1. Define your configuration schema

Use defineConfig() with any StandardSchema-compliant library (Zod, Valibot, ArkType, Effect Schema) or the built-in StringSchema, BooleanSchema, and NumberSchema helpers:

// .smooai-config/config.tsimport{defineConfig,StringSchema,BooleanSchema,NumberSchema}from'@smooai/config';import{z}from'zod';constconfig=defineConfig({publicConfigSchema: {apiBaseUrl: z.string().url(),maxRetries: NumberSchema,enableDebug: BooleanSchema,},secretConfigSchema: {databaseUrl: z.string().url(),apiKey: StringSchema,},featureFlagSchema: {enableNewUi: BooleanSchema,betaFeatures: BooleanSchema,},});exportdefaultconfig;// Extract typed key objects for use throughout your appexportconst{ FeatureFlagKeys, PublicConfigKeys, SecretConfigKeys }=config;

defineConfig() automatically maps camelCase keys to UPPER_SNAKE_CASE:

FeatureFlagKeys.ENABLE_NEW_UI;// "ENABLE_NEW_UI"PublicConfigKeys.API_BASE_URL;// "API_BASE_URL"SecretConfigKeys.DATABASE_URL;// "DATABASE_URL"

2. Add to tsconfig.json

{
"compilerOptions": { ... },
"include": ["src/**/*", ".smooai-config/**/*.ts"]
}

📖 Next.js Integration

Inject config into next.config.ts

Use withSmooConfig() to inject feature flags and public config as NEXT_PUBLIC_ environment variables, with per-stage overrides:

// next.config.tsimport{withSmooConfig}from'@smooai/config/nextjs/withSmooConfig';constnextConfig=withSmooConfig({default: {featureFlags: {enableNewUi: false,betaFeatures: false},publicConfig: {apiBaseUrl: 'https://api.smooai.com',maxRetries: 3},},development: {featureFlags: {enableNewUi: true},publicConfig: {apiBaseUrl: 'http://localhost:3000'},},});exportdefaultnextConfig;

This sets environment variables like NEXT_PUBLIC_FEATURE_FLAG_ENABLE_NEW_UI=true and NEXT_PUBLIC_CONFIG_API_BASE_URL=http://localhost:3000 based on the current stage.

Read config in React client components

import{getClientFeatureFlag,getClientPublicConfig}from'@smooai/config/client';functionMyComponent(){constisNewUi=getClientFeatureFlag('enableNewUi');constapiUrl=getClientPublicConfig('apiBaseUrl');if(!isNewUi)return<LegacyUI/>;return<NewUIapiUrl={apiUrl}/>;}

These functions check NEXT_PUBLIC_FEATURE_FLAG_* and NEXT_PUBLIC_CONFIG_* env vars automatically — no provider needed, no loading state.

Server Components + Client hydration (zero loading flash)

For apps that need runtime config from a config server, use getConfig on the server and SmooConfigProvider to hydrate client components:

// app/layout.tsx (Server Component)import{getConfig,SmooConfigProvider}from'@smooai/config/nextjs';exportdefaultasyncfunctionRootLayout({ children }: {children: React.ReactNode}){constconfig=awaitgetConfig({environment: 'production',fetchOptions: {next: {revalidate: 60}},});return(<html><body><SmooConfigProviderinitialValues={config}baseUrl={process.env.SMOOAI_CONFIG_API_URL}apiKey={process.env.SMOOAI_CONFIG_API_KEY}orgId={process.env.SMOOAI_CONFIG_ORG_ID}environment="production">{children}</SmooConfigProvider></body></html>);}
// Any client component — values available synchronously (pre-seeded from SSR)import{usePublicConfig,useFeatureFlag}from'@smooai/config/nextjs';functionDashboard(){const{value: apiUrl}=usePublicConfig<string>('API_BASE_URL');const{value: enableNewUi}=useFeatureFlag<boolean>('ENABLE_NEW_UI');return(<div>
API: {apiUrl}, New UI: {String(enableNewUi)}</div>);}

📖 Vite Integration

Vite plugin

// vite.config.tsimport{defineConfig}from'vite';import{smooConfigPlugin}from'@smooai/config/vite/smooConfigPlugin';exportdefaultdefineConfig({plugins: [smooConfigPlugin({featureFlags: {enableNewUi: true,betaFeatures: false},publicConfig: {apiBaseUrl: 'http://localhost:3000'},}),],});

Then read values the same way as Next.js — getClientFeatureFlag and getClientPublicConfig from @smooai/config/client check VITE_FEATURE_FLAG_* and VITE_CONFIG_* automatically.

Preload config (optional)

For runtime config from a config server, start fetching before React mounts:

// main.tsximport{preloadConfig,ConfigProvider}from'@smooai/config/vite';import{createRoot}from'react-dom/client';preloadConfig({environment: 'production'});createRoot(document.getElementById('root')!).render(<ConfigProviderbaseUrl="https://config.smooai.dev"apiKey="your-public-key"orgId="your-org-id"environment="production"><App/></ConfigProvider>,);

📖 Server-Side Config Access

For Node.js server code, use buildConfigObject to get sync and async accessors with full type safety:

importbuildConfigObjectfrom'@smooai/config/platform/server';importconfig,{PublicConfigKeys,SecretConfigKeys,FeatureFlagKeys}from'./.smooai-config/config';constconfigObj=buildConfigObject(config);// Sync access (uses worker threads)constdbUrl=configObj.secretConfig.getSync(SecretConfigKeys.DATABASE_URL);constapiUrl=configObj.publicConfig.getSync(PublicConfigKeys.API_BASE_URL);constisNewUi=configObj.featureFlag.getSync(FeatureFlagKeys.ENABLE_NEW_UI);// Async accessconstapiKey=awaitconfigObj.secretConfig.getAsync(SecretConfigKeys.API_KEY);

How .getSync() works (and how to ship it in any bundled compute)

Sync accessors run an async config read to completion on the caller thread via synckit — a Node Worker pool + Atomics.wait on a SharedArrayBuffer. createSyncFn only accepts a file:// URL, so the worker body has to live on disk. The SDK resolves it in two stages:

  1. Sidecar filesync-worker.mjs sitting next to the compiled SDK entry (i.e. resolved via new URL('./sync-worker.mjs', import.meta.url) from dist/server/index.mjs). This is the normal case for plain Node resolution with no bundling — node_modules/@smooai/config/dist/server/sync-worker.mjs is already there. It's also the preferred case when bundlers copy the sidecar into the deploy output. Zero /tmp writes.

  2. Extract-to-/tmp fallback — if the sidecar isn't on disk at that path (e.g. a bundler inlined the SDK entry into a single file and didn't copy the sidecar), the SDK writes an embedded copy of the worker source to mkdtempSync()/sync-worker.mjs once per process and hands that path to synckit. One ~1-2 MiB write at cold start, amortised across every sync read for the lifetime of the process. Works anywhere with a writable temp dir.

Both paths are transparent — your code is identical either way. Which path you land on depends on how your compute is packaged.

Plain Node (no bundling)

Zero config. The SDK resolves node_modules/@smooai/config/dist/server/sync-worker.mjs directly — path (1) every time.

Any bundled compute (Lambda, Cloud Run, ECS, container, Worker, etc.)

The rule is universal: if your build inlines the SDK entry into a single output file, you need to ship sync-worker.mjs next to that output (or accept path (2)'s /tmp write once per cold start).

The source path is always:

node_modules/@smooai/config/dist/server/sync-worker.mjs

The destination is alongside whichever file ends up being your runtime's import.meta.url anchor — typically the bundled handler .mjs / .js.

Recipes for common setups:

esbuild — explicit copy plugin

// build.tsimport{build}from'esbuild';import{copy}from'esbuild-plugin-copy';awaitbuild({entryPoints: ['src/handler.ts'],outdir: 'dist',bundle: true,format: 'esm',platform: 'node',plugins: [copy({assets: {from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs',to: 'dist/sync-worker.mjs',},}),],});

tsup — onSuccess hook

// tsup.config.tsexportdefaultdefineConfig({entry: ['src/handler.ts'],format: ['esm'],onSuccess: 'cp node_modules/@smooai/config/dist/server/sync-worker.mjs dist/sync-worker.mjs',});

Serverless Framework — package.include

package:
patterns:
- 'node_modules/@smooai/config/dist/server/sync-worker.mjs'

Or copy into the handler dir as a build step and include from there.

AWS SAM — CodeUri + build script

Add a Makefile / build script that copies sync-worker.mjs into the BuildArtifactPath alongside your handler.

SST (AWS) — per-function or via $transform

// sst.config.ts — per functionnewsst.aws.Function('Api',{handler: 'src/api.handler',copyFiles: [{from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs'}],});// Or at the stack level via $transform (every Function gets it automatically)$transform(sst.aws.Function,(fn)=>{fn.copyFiles=[...(fn.copyFiles??[]),{from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs'}];});

Docker container (ECS, Cloud Run, anywhere)

# After your main build step, ensure the sidecar is next to the bundled entry.COPY --from=build /app/dist/server.mjs /app/
COPY --from=build /app/node_modules/@smooai/config/dist/server/sync-worker.mjs /app/
CMD ["node", "server.mjs"]

If your build step keeps node_modules in the final image, no extra copy is needed — the SDK resolves the sidecar from node_modules/ path (1) directly.

When the sidecar truly can't be shipped

Path (2) — the /tmp extraction — is the safety net. One ~1-2 MiB write at cold start, then synckit re-uses the file for the rest of the process lifetime. Lambda's 512 MiB–10 GiB /tmp easily absorbs this; containers with an ephemeral /tmp work the same way. You can ignore this whole section and .getSync() will still work — you're just paying one filesystem write per cold start.

Edge runtimes (Vercel Edge, Cloudflare Workers)

These runtimes don't expose Node's worker_threads at all, so .getSync() is a no-go there by design. Use .get() (async) everywhere that needs to run on the edge. The error surface makes this explicit if you try.

📖 React Hooks (framework-agnostic)

For any React app using the runtime config client:

import{ConfigProvider,usePublicConfig,useFeatureFlag}from'@smooai/config/react';functionApp(){return(<ConfigProviderbaseUrl="https://config.smooai.dev"apiKey="your-api-key"orgId="your-org-id"environment="production"><MyComponent/></ConfigProvider>);}functionMyComponent(){const{value: apiUrl, isLoading, error }=usePublicConfig<string>('API_BASE_URL');const{value: enableNewUi}=useFeatureFlag<boolean>('ENABLE_NEW_UI');if(isLoading)return<div>Loading...</div>;if(error)return<div>Error: {error.message}</div>;return(<div>
API URL: {apiUrl}, New UI: {String(enableNewUi)}</div>);}

📖 SDK Runtime Client

All language implementations include a runtime client for fetching configuration values from the Smoo AI config server with local caching.

Environment Variables

Authentication is OAuth2 client_credentials against {authUrl}/token — the client exchanges (CLIENT_ID, CLIENT_SECRET) for a JWT and uses that JWT as the Bearer token on every config call. TokenProvider caches the JWT in memory and refreshes 60s before expiry.

VariableDescriptionRequired
SMOOAI_CONFIG_API_URLBase URL of the config APIYes
SMOOAI_CONFIG_AUTH_URLOAuth issuer base URL (defaults to https://auth.smoo.ai)No
SMOOAI_CONFIG_CLIENT_IDOAuth client IDYes
SMOOAI_CONFIG_CLIENT_SECRETOAuth client secret (legacy SMOOAI_CONFIG_API_KEY is accepted as a fallback)Yes
SMOOAI_CONFIG_ORG_IDOrganization IDYes
SMOOAI_CONFIG_ENVDefault environment name (defaults to "development")No

Migration note (v5 / SMOODEV-974): the TypeScript ConfigClient previously sent SMOOAI_CONFIG_API_KEY directly as the Bearer token, which the backend rejected with 401 because it expects a JWT. The SDK now mints a JWT via the OAuth client_credentials grant before each call — matching the .NET client, the in-package bootstrap, and the CLI. You must set SMOOAI_CONFIG_CLIENT_ID in addition to SMOOAI_CONFIG_API_KEY / SMOOAI_CONFIG_CLIENT_SECRET for the runtime SDK to work. The legacy SMOOAI_CONFIG_API_KEY env var continues to function as the OAuth client secret.

TypeScript Client

import{ConfigClient}from'@smooai/config/platform/client';// Zero-config (reads from env vars — needs CLIENT_ID + CLIENT_SECRET/API_KEY + ORG_ID)constclient=newConfigClient();// Or explicitconstclient=newConfigClient({baseUrl: 'https://config.smooai.dev',authUrl: 'https://auth.smooai.dev',clientId: 'your-client-id',clientSecret: 'your-client-secret',orgId: 'your-org-id',environment: 'production',});constapiUrl=awaitclient.getValue('API_BASE_URL');constallValues=awaitclient.getAllValues();client.invalidateCache();

📖 Container / Runtime Mode (EKS / ECS)

The baked blob tier is the blessed path for Lambda, but it is the wrong default for long-lived containers: when the per-build blob key isn't delivered to the pod, resolution silently falls through to the (absent) file tier and returns undefined for a required secret. That caused a real outage — a container got undefined for STRIPE_API_KEY, new Stripe(undefined) threw at module load, the process exited 0 before listen(), and the pod CrashLooped with the root cause buried (SMOODEV-1478).

Container mode makes the HTTP config API the first-class path for containers, authenticated with an OAuth2 client_credentials (M2M) token, and fails loud: a required value that doesn't resolve throws a typed error instead of returning undefined.

Containers use container mode, not the baked blob. See docs/Container-Runtime-Mode.md for the full env contract, a complete ExternalSecret (External Secrets Operator) recipe, and a readiness-probe example.

import{initContainerConfig,ConfigKeyUnresolvedError}from'@smooai/config/container';importschemafrom'../.smooai-config/config';// Validates the container env, mints a token, and does an initial fetch —// startup fails LOUD here (throws), not on first read.constconfig=awaitinitContainerConfig({ schema });// Fail-loud: a required secret that doesn't resolve throws// ConfigKeyUnresolvedError instead of returning undefined.conststripeKey=awaitconfig.secretConfig.get('stripeApiKey');// Kubernetes readiness probe — never throws.app.get('/healthz/config',(_req,res)=>{consth=config.health();// { status: 'healthy' } | { status: 'unhealthy', reason }res.status(h.status==='healthy' ? 200 : 503).json(h);});

Env contract (identical in every SDK): SMOOAI_CONFIG_API_URL, SMOOAI_CONFIG_CLIENT_ID, SMOOAI_CONFIG_CLIENT_SECRET, SMOOAI_CONFIG_ORG_ID, SMOOAI_CONFIG_ENV (all required), plus optional SMOOAI_CONFIG_AUTH_URL and SMOOAI_CONFIG_MODE=container (to force the mode). All schema-declared keys are treated as required by default; opt specific keys out with initContainerConfig({ optionalKeys: ['...'] }).

📖 Configuration Tiers

TierPurposeExamples
PublicClient-visible settingsAPI URLs, feature toggles, UI config
SecretServer-side onlyDatabase URLs, API keys, JWT secrets
Feature FlagsRuntime togglesA/B tests, gradual rollouts, beta access

Security: B2M Key Restrictions

OperationB2M (Public Key)M2M (Secret Key)
Read public valuesYesYes
Read feature flagsYesYes
Read secret valuesNo (filtered)Yes
Write config valuesNo (403)Yes
Delete config valuesNo (403)Yes

Browser-to-Machine (B2M) keys are designed for browser clients. Secret-tier values are automatically filtered. B2M keys are read-only for public and feature flag tiers.

Machine-to-Machine (M2M) keys have full access to all tiers and write operations.

📖 Multi-Language Support

@smooai/config has native server implementations in Python, Rust, Go, and .NET (C#) alongside the primary TypeScript package, plus mobile SDKs in Kotlin and Swift (a deliberately different, public-only surface — see below). Every server client reads the same encrypted bundle, the same schema, and the same config API. See the per-SDK READMEs linked above for full usage docs — the snippets below are five-line orientation only.

Python — see python/README.md

pip install smooai-config
# or: uv add smooai-config
fromsmooai_config.clientimportConfigClientwithConfigClient() asclient: # reads SMOOAI_CONFIG_* env varsvalue=client.get_value("API_URL", environment="production")
cargo add smooai-config
use smooai_config::ConfigClient;letmut client = ConfigClient::from_env();let value = client.get_value("API_URL",None).await?;
go get github.com/SmooAI/config/go/config
import"github.com/SmooAI/config/go/config"client:=config.NewConfigClientFromEnv()
deferclient.Close()
value, _:=client.GetValue("API_URL", "production")

.NET — see dotnet/README.md

dotnet add package SmooAI.Config
usingSmooAI.Config;usingSmooAI.Config.Runtime;varruntime=SmooConfigRuntime.Load();// reads SMOO_CONFIG_KEY_FILE + SMOO_CONFIG_KEYusingvarclient=newSmooConfigClient(options);varapiUrl=awaitPublic.ApiUrl.ResolveAsync(runtime,client);

Kotlin (mobile) — see docs/Mobile-Runtime-Mode-Spec.md

Ships via JitPack pinned to a commit (Maven Central under ai.smoo is the planned follow-up — the in-repo Gradle version is a dev default, so don't pin a version, pin a SHA):

// settings.gradle.kts / build.gradle.kts
repositories { maven("https://jitpack.io") }
dependencies { implementation("com.github.SmooAI:config:<commit-sha>") }
importai.smoo.config.SmooConfigimportai.smoo.config.SmooConfigOptionsval config =SmooConfig(SmooConfigOptions(environment ="production", engine = engine, bundledConfigFile = bakedBundle))
val apiUrl = config.publicValue("API_BASE_URL") // baked bundle → refreshed cache, offline-safeval newUi = config.evaluateFlag("ENABLE_NEW_UI", default =false) // http → disk cache → default

Swift (mobile) — see docs/Mobile-Runtime-Mode-Spec.md

Consumed via Swift Package Manager pinned to a revision (there is no SPM version tag yet; the root Package.swift exists so SPM can resolve the repo URL directly):

// Package.swift
.package(url:"https://github.com/SmooAI/config", revision:"<commit-sha>")
import SmooAIConfig
letconfig=SmooConfig(options:SmooConfigOptions(environment:"production", bundledConfigURL: bakedBundleURL))letapiUrl= config.publicValue(forKey:"API_BASE_URL")letnewUi=await config.evaluateFlag("ENABLE_NEW_UI", default:false)

Mobile binaries are attacker-owned territory, so the mobile SDKs speak only the public app-config surface (/config/app/...): a baked public bundle (plaintext — it never contains secrets) plus live feature-flag / limit evaluation with an offline disk cache. There is no secret tier, no M2M credential, and no schema/LocalConfigManager surface on device — by design, per ADR-074.

SDK capability notes

Honest asymmetries between the SDKs, so you can pick with your eyes open:

CapabilityTSPythonRustGo.NETKotlin / Swift
Encrypted baked bundle + config API reads📱 public-only bundle
Local config-file workflow (LocalConfigManager in Py/Rust/Go/.NET; the file tier in TS)
Cloud-region resolution
Deferred values / merge_replace_arrays semantics
Shared schema-validation conformance fixture (test-fixtures/schema-validation-cases.json)
Live feature flags✅ (+ limits)

The .NET SDK reached parity with the other four server SDKs in 6.11.x: LocalConfigManager, cloud-region resolution, deferred values and MergeReplaceArrays all landed, and its schema validator is held to the same test-fixtures/schema-validation-cases.json corpus. Cross-language schema-validation parity is now a TS/Python/Rust/Go/.NET guarantee. Kotlin and Swift stay outside it by design — there is no schema surface on device (ADR-074).

📖 Development

Prerequisites

  • Node.js 22+, pnpm 10+
  • Python 3.13+ with uv (for the Python package)
  • Rust toolchain (for the Rust package)
  • Go 1.22+ (for the Go package)

Commands

pnpm install # Install dependencies
pnpm build # Build all packages (TS, Python, Rust, Go)
pnpm test# Run all tests (Vitest, pytest, cargo test, go test)
pnpm lint # Lint all code (oxlint, ruff, clippy, go vet)
pnpm format # Format all code (oxfmt, ruff, cargo fmt, gofmt)
pnpm typecheck # Type check (tsc, basedpyright, cargo check)
pnpm check-all # Full CI parity check

Schema Libraries

Supports Zod, Valibot, ArkType, Effect Schema, and built-in schema types. See SCHEMA_USAGE.md for examples with each library.

🧩 Part of Smoo AI

@smooai/config is built and open-sourced by Smoo AI — the AI-powered business platform with AI built into every product: CRM, customer support, campaigns, field service, observability, and developer tools.

🤝 Contributing

Contributions are welcome. This project uses changesets to manage versions and releases.

  1. Fork the repository.
  2. Create your branch (git checkout -b amazing-feature).
  3. Make your changes.
  4. Add a changeset: pnpm changeset.
  5. Commit and push.
  6. Open a pull request.

📄 License

MIT © SmooAI. See LICENSE.

Contact

Brent Rager

Smoo GitHub: github.com/SmooAI


Built by Smoo AI — AI built into every product.

About

Type-safe multi-language configuration management — schema validation, three-tier config (public, secrets, feature flags), and runtime clients for TypeScript, Python, Rust, and Go.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Latest commit

History

217 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

@smooai/config — One schema. Every language. Typed everywhere.

npmSmoo AIlicense

TypeScriptPythonRustGo.NETKotlinSwift

Features · Install · Quick start · Platform


Define your config once with Zod (or Valibot, ArkType, Effect), and read it with full type inference everywhere — public config, server-only secrets, and live feature flags. Rename a key and every call site is a compile error, not a 3 AM page. Native clients in TypeScript, Python, Rust, Go, and .NET all read from the same source of truth.

📣 The CLI moved. Use smoo config from the smooth repo for all operator commands (login, get, set, list, push, pull, diff, init, etc.). The standalone smooai-config CLI that used to live in this repo is deprecated and being deleted (SMOODEV-1411). The runtime library @smooai/config (TypeScript / Python / Rust / Go / .NET, consumed via secretConfig.get(...) / publicConfig.get(...) / featureFlag.get(...)) is unchanged — only the operator CLI surface moved to Rust in the smooth repo. (smoo … is the th binary's platform namespace; th smoo … and the old th config … spelling also work.)

✨ Features

  • Three tiers, one schema — public config, secrets, and feature flags defined once with Zod/Valibot/ArkType/Effect, validated everywhere they're read.
  • Strongly-typed keysdefineConfig() gives you PublicConfigKeys, SecretConfigKeys, and FeatureFlagKeys with full inference. Mis-typed keys fail at compile time, not at runtime.
  • Any environment, any key — the same API for development, staging, and production. Override per-stage without touching code.
  • Zero-latency cold starts — values are baked into the bundle as env vars (Next.js, Vite) or resolved in-memory from a local runtime (server). No network round-trip on the hot path.
  • Browser, server, framework-native — the same typed keys read cleanly from React client components, Server Components, Next.js, Vite, or plain Node.
  • Live feature flags — toggled from the dashboard without a redeploy, but still typed.
  • Native clients in every language — TypeScript, Python, Rust, Go, and .NET (C#) server SDKs all read from the same source of truth, plus Kotlin and Swift mobile SDKs for the public-only app surface (ADR-074 mobile runtime mode).

Languages / SDKs

Pick the SDK that matches your service. Every server client reads the same schema, the same encrypted bundle, and the same config API — so a key renamed in one language ripples through all of them. The two mobile SDKs (📱) speak a deliberately narrower, public-only surface — no secrets ever ship to a device.

SDKOne-linerREADME
TypeScriptPrimary SDK. Schema definition, Next.js / Vite plugins, server runtime, React hooks.README.md (this file)
PythonPydantic-validated schemas, sync ConfigClient, LocalConfigManager + ConfigManager, baked runtime.python/README.md
GoNative struct schemas, thread-safe ConfigClient / ConfigManager, baked-blob runtime.go/config/README.md
RustJsonSchema-derived schemas, async ConfigClient, sync ConfigManager, baked-blob runtime.rust/config/README.md
.NETRoslyn source-generated typed keys, OAuth2 SmooConfigClient, AES-GCM SmooConfigRuntime. Thinner surface than the other server SDKs — see the capability notes.dotnet/README.md
Kotlin 📱Mobile runtime mode (ADR-074): baked public bundle + live flag/limit evaluation, offline-safe, no secrets on device. Ships via JitPack commit pin — not on Maven Central yet.kotlin/ · spec
Swift 📱Mobile runtime mode (ADR-074): same surface as Kotlin (publicValue, evaluateFlag, evaluateLimit). Consumed via SPM pinned to a revision — no version tag yet.swift/ · spec

The five server SDKs release in version lockstep — v6.11.3 on npm, PyPI, crates.io, and NuGet, with the matching v6.11.3 git tag for the Go module, all cut from the same commit (verified against each registry 2026-08-20). The mobile SDKs version separately (commit-pinned; see below).

📦 Install

pnpm add @smooai/config

🚀 Quick Start (TypeScript)

1. Define your configuration schema

Use defineConfig() with any StandardSchema-compliant library (Zod, Valibot, ArkType, Effect Schema) or the built-in StringSchema, BooleanSchema, and NumberSchema helpers:

// .smooai-config/config.tsimport{defineConfig,StringSchema,BooleanSchema,NumberSchema}from'@smooai/config';import{z}from'zod';constconfig=defineConfig({publicConfigSchema: {apiBaseUrl: z.string().url(),maxRetries: NumberSchema,enableDebug: BooleanSchema,},secretConfigSchema: {databaseUrl: z.string().url(),apiKey: StringSchema,},featureFlagSchema: {enableNewUi: BooleanSchema,betaFeatures: BooleanSchema,},});exportdefaultconfig;// Extract typed key objects for use throughout your appexportconst{ FeatureFlagKeys, PublicConfigKeys, SecretConfigKeys }=config;

defineConfig() automatically maps camelCase keys to UPPER_SNAKE_CASE:

FeatureFlagKeys.ENABLE_NEW_UI;// "ENABLE_NEW_UI"PublicConfigKeys.API_BASE_URL;// "API_BASE_URL"SecretConfigKeys.DATABASE_URL;// "DATABASE_URL"

2. Add to tsconfig.json

{
"compilerOptions": { ... },
"include": ["src/**/*", ".smooai-config/**/*.ts"]
}

📖 Next.js Integration

Inject config into next.config.ts

Use withSmooConfig() to inject feature flags and public config as NEXT_PUBLIC_ environment variables, with per-stage overrides:

// next.config.tsimport{withSmooConfig}from'@smooai/config/nextjs/withSmooConfig';constnextConfig=withSmooConfig({default: {featureFlags: {enableNewUi: false,betaFeatures: false},publicConfig: {apiBaseUrl: 'https://api.smooai.com',maxRetries: 3},},development: {featureFlags: {enableNewUi: true},publicConfig: {apiBaseUrl: 'http://localhost:3000'},},});exportdefaultnextConfig;

This sets environment variables like NEXT_PUBLIC_FEATURE_FLAG_ENABLE_NEW_UI=true and NEXT_PUBLIC_CONFIG_API_BASE_URL=http://localhost:3000 based on the current stage.

Read config in React client components

import{getClientFeatureFlag,getClientPublicConfig}from'@smooai/config/client';functionMyComponent(){constisNewUi=getClientFeatureFlag('enableNewUi');constapiUrl=getClientPublicConfig('apiBaseUrl');if(!isNewUi)return<LegacyUI/>;return<NewUIapiUrl={apiUrl}/>;}

These functions check NEXT_PUBLIC_FEATURE_FLAG_* and NEXT_PUBLIC_CONFIG_* env vars automatically — no provider needed, no loading state.

Server Components + Client hydration (zero loading flash)

For apps that need runtime config from a config server, use getConfig on the server and SmooConfigProvider to hydrate client components:

// app/layout.tsx (Server Component)import{getConfig,SmooConfigProvider}from'@smooai/config/nextjs';exportdefaultasyncfunctionRootLayout({ children }: {children: React.ReactNode}){constconfig=awaitgetConfig({environment: 'production',fetchOptions: {next: {revalidate: 60}},});return(<html><body><SmooConfigProviderinitialValues={config}baseUrl={process.env.SMOOAI_CONFIG_API_URL}apiKey={process.env.SMOOAI_CONFIG_API_KEY}orgId={process.env.SMOOAI_CONFIG_ORG_ID}environment="production">{children}</SmooConfigProvider></body></html>);}
// Any client component — values available synchronously (pre-seeded from SSR)import{usePublicConfig,useFeatureFlag}from'@smooai/config/nextjs';functionDashboard(){const{value: apiUrl}=usePublicConfig<string>('API_BASE_URL');const{value: enableNewUi}=useFeatureFlag<boolean>('ENABLE_NEW_UI');return(<div>
API: {apiUrl}, New UI: {String(enableNewUi)}</div>);}

📖 Vite Integration

Vite plugin

// vite.config.tsimport{defineConfig}from'vite';import{smooConfigPlugin}from'@smooai/config/vite/smooConfigPlugin';exportdefaultdefineConfig({plugins: [smooConfigPlugin({featureFlags: {enableNewUi: true,betaFeatures: false},publicConfig: {apiBaseUrl: 'http://localhost:3000'},}),],});

Then read values the same way as Next.js — getClientFeatureFlag and getClientPublicConfig from @smooai/config/client check VITE_FEATURE_FLAG_* and VITE_CONFIG_* automatically.

Preload config (optional)

For runtime config from a config server, start fetching before React mounts:

// main.tsximport{preloadConfig,ConfigProvider}from'@smooai/config/vite';import{createRoot}from'react-dom/client';preloadConfig({environment: 'production'});createRoot(document.getElementById('root')!).render(<ConfigProviderbaseUrl="https://config.smooai.dev"apiKey="your-public-key"orgId="your-org-id"environment="production"><App/></ConfigProvider>,);

📖 Server-Side Config Access

For Node.js server code, use buildConfigObject to get sync and async accessors with full type safety:

importbuildConfigObjectfrom'@smooai/config/platform/server';importconfig,{PublicConfigKeys,SecretConfigKeys,FeatureFlagKeys}from'./.smooai-config/config';constconfigObj=buildConfigObject(config);// Sync access (uses worker threads)constdbUrl=configObj.secretConfig.getSync(SecretConfigKeys.DATABASE_URL);constapiUrl=configObj.publicConfig.getSync(PublicConfigKeys.API_BASE_URL);constisNewUi=configObj.featureFlag.getSync(FeatureFlagKeys.ENABLE_NEW_UI);// Async accessconstapiKey=awaitconfigObj.secretConfig.getAsync(SecretConfigKeys.API_KEY);

How .getSync() works (and how to ship it in any bundled compute)

Sync accessors run an async config read to completion on the caller thread via synckit — a Node Worker pool + Atomics.wait on a SharedArrayBuffer. createSyncFn only accepts a file:// URL, so the worker body has to live on disk. The SDK resolves it in two stages:

  1. Sidecar filesync-worker.mjs sitting next to the compiled SDK entry (i.e. resolved via new URL('./sync-worker.mjs', import.meta.url) from dist/server/index.mjs). This is the normal case for plain Node resolution with no bundling — node_modules/@smooai/config/dist/server/sync-worker.mjs is already there. It's also the preferred case when bundlers copy the sidecar into the deploy output. Zero /tmp writes.

  2. Extract-to-/tmp fallback — if the sidecar isn't on disk at that path (e.g. a bundler inlined the SDK entry into a single file and didn't copy the sidecar), the SDK writes an embedded copy of the worker source to mkdtempSync()/sync-worker.mjs once per process and hands that path to synckit. One ~1-2 MiB write at cold start, amortised across every sync read for the lifetime of the process. Works anywhere with a writable temp dir.

Both paths are transparent — your code is identical either way. Which path you land on depends on how your compute is packaged.

Plain Node (no bundling)

Zero config. The SDK resolves node_modules/@smooai/config/dist/server/sync-worker.mjs directly — path (1) every time.

Any bundled compute (Lambda, Cloud Run, ECS, container, Worker, etc.)

The rule is universal: if your build inlines the SDK entry into a single output file, you need to ship sync-worker.mjs next to that output (or accept path (2)'s /tmp write once per cold start).

The source path is always:

node_modules/@smooai/config/dist/server/sync-worker.mjs

The destination is alongside whichever file ends up being your runtime's import.meta.url anchor — typically the bundled handler .mjs / .js.

Recipes for common setups:

esbuild — explicit copy plugin

// build.tsimport{build}from'esbuild';import{copy}from'esbuild-plugin-copy';awaitbuild({entryPoints: ['src/handler.ts'],outdir: 'dist',bundle: true,format: 'esm',platform: 'node',plugins: [copy({assets: {from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs',to: 'dist/sync-worker.mjs',},}),],});

tsup — onSuccess hook

// tsup.config.tsexportdefaultdefineConfig({entry: ['src/handler.ts'],format: ['esm'],onSuccess: 'cp node_modules/@smooai/config/dist/server/sync-worker.mjs dist/sync-worker.mjs',});

Serverless Framework — package.include

package:
patterns:
- 'node_modules/@smooai/config/dist/server/sync-worker.mjs'

Or copy into the handler dir as a build step and include from there.

AWS SAM — CodeUri + build script

Add a Makefile / build script that copies sync-worker.mjs into the BuildArtifactPath alongside your handler.

SST (AWS) — per-function or via $transform

// sst.config.ts — per functionnewsst.aws.Function('Api',{handler: 'src/api.handler',copyFiles: [{from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs'}],});// Or at the stack level via $transform (every Function gets it automatically)$transform(sst.aws.Function,(fn)=>{fn.copyFiles=[...(fn.copyFiles??[]),{from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs'}];});

Docker container (ECS, Cloud Run, anywhere)

# After your main build step, ensure the sidecar is next to the bundled entry.COPY --from=build /app/dist/server.mjs /app/
COPY --from=build /app/node_modules/@smooai/config/dist/server/sync-worker.mjs /app/
CMD ["node", "server.mjs"]

If your build step keeps node_modules in the final image, no extra copy is needed — the SDK resolves the sidecar from node_modules/ path (1) directly.

When the sidecar truly can't be shipped

Path (2) — the /tmp extraction — is the safety net. One ~1-2 MiB write at cold start, then synckit re-uses the file for the rest of the process lifetime. Lambda's 512 MiB–10 GiB /tmp easily absorbs this; containers with an ephemeral /tmp work the same way. You can ignore this whole section and .getSync() will still work — you're just paying one filesystem write per cold start.

Edge runtimes (Vercel Edge, Cloudflare Workers)

These runtimes don't expose Node's worker_threads at all, so .getSync() is a no-go there by design. Use .get() (async) everywhere that needs to run on the edge. The error surface makes this explicit if you try.

📖 React Hooks (framework-agnostic)

For any React app using the runtime config client:

import{ConfigProvider,usePublicConfig,useFeatureFlag}from'@smooai/config/react';functionApp(){return(<ConfigProviderbaseUrl="https://config.smooai.dev"apiKey="your-api-key"orgId="your-org-id"environment="production"><MyComponent/></ConfigProvider>);}functionMyComponent(){const{value: apiUrl, isLoading, error }=usePublicConfig<string>('API_BASE_URL');const{value: enableNewUi}=useFeatureFlag<boolean>('ENABLE_NEW_UI');if(isLoading)return<div>Loading...</div>;if(error)return<div>Error: {error.message}</div>;return(<div>
API URL: {apiUrl}, New UI: {String(enableNewUi)}</div>);}

📖 SDK Runtime Client

All language implementations include a runtime client for fetching configuration values from the Smoo AI config server with local caching.

Environment Variables

Authentication is OAuth2 client_credentials against {authUrl}/token — the client exchanges (CLIENT_ID, CLIENT_SECRET) for a JWT and uses that JWT as the Bearer token on every config call. TokenProvider caches the JWT in memory and refreshes 60s before expiry.

VariableDescriptionRequired
SMOOAI_CONFIG_API_URLBase URL of the config APIYes
SMOOAI_CONFIG_AUTH_URLOAuth issuer base URL (defaults to https://auth.smoo.ai)No
SMOOAI_CONFIG_CLIENT_IDOAuth client IDYes
SMOOAI_CONFIG_CLIENT_SECRETOAuth client secret (legacy SMOOAI_CONFIG_API_KEY is accepted as a fallback)Yes
SMOOAI_CONFIG_ORG_IDOrganization IDYes
SMOOAI_CONFIG_ENVDefault environment name (defaults to "development")No

Migration note (v5 / SMOODEV-974): the TypeScript ConfigClient previously sent SMOOAI_CONFIG_API_KEY directly as the Bearer token, which the backend rejected with 401 because it expects a JWT. The SDK now mints a JWT via the OAuth client_credentials grant before each call — matching the .NET client, the in-package bootstrap, and the CLI. You must set SMOOAI_CONFIG_CLIENT_ID in addition to SMOOAI_CONFIG_API_KEY / SMOOAI_CONFIG_CLIENT_SECRET for the runtime SDK to work. The legacy SMOOAI_CONFIG_API_KEY env var continues to function as the OAuth client secret.

TypeScript Client

import{ConfigClient}from'@smooai/config/platform/client';// Zero-config (reads from env vars — needs CLIENT_ID + CLIENT_SECRET/API_KEY + ORG_ID)constclient=newConfigClient();// Or explicitconstclient=newConfigClient({baseUrl: 'https://config.smooai.dev',authUrl: 'https://auth.smooai.dev',clientId: 'your-client-id',clientSecret: 'your-client-secret',orgId: 'your-org-id',environment: 'production',});constapiUrl=awaitclient.getValue('API_BASE_URL');constallValues=awaitclient.getAllValues();client.invalidateCache();

📖 Container / Runtime Mode (EKS / ECS)

The baked blob tier is the blessed path for Lambda, but it is the wrong default for long-lived containers: when the per-build blob key isn't delivered to the pod, resolution silently falls through to the (absent) file tier and returns undefined for a required secret. That caused a real outage — a container got undefined for STRIPE_API_KEY, new Stripe(undefined) threw at module load, the process exited 0 before listen(), and the pod CrashLooped with the root cause buried (SMOODEV-1478).

Container mode makes the HTTP config API the first-class path for containers, authenticated with an OAuth2 client_credentials (M2M) token, and fails loud: a required value that doesn't resolve throws a typed error instead of returning undefined.

Containers use container mode, not the baked blob. See docs/Container-Runtime-Mode.md for the full env contract, a complete ExternalSecret (External Secrets Operator) recipe, and a readiness-probe example.

import{initContainerConfig,ConfigKeyUnresolvedError}from'@smooai/config/container';importschemafrom'../.smooai-config/config';// Validates the container env, mints a token, and does an initial fetch —// startup fails LOUD here (throws), not on first read.constconfig=awaitinitContainerConfig({ schema });// Fail-loud: a required secret that doesn't resolve throws// ConfigKeyUnresolvedError instead of returning undefined.conststripeKey=awaitconfig.secretConfig.get('stripeApiKey');// Kubernetes readiness probe — never throws.app.get('/healthz/config',(_req,res)=>{consth=config.health();// { status: 'healthy' } | { status: 'unhealthy', reason }res.status(h.status==='healthy' ? 200 : 503).json(h);});

Env contract (identical in every SDK): SMOOAI_CONFIG_API_URL, SMOOAI_CONFIG_CLIENT_ID, SMOOAI_CONFIG_CLIENT_SECRET, SMOOAI_CONFIG_ORG_ID, SMOOAI_CONFIG_ENV (all required), plus optional SMOOAI_CONFIG_AUTH_URL and SMOOAI_CONFIG_MODE=container (to force the mode). All schema-declared keys are treated as required by default; opt specific keys out with initContainerConfig({ optionalKeys: ['...'] }).

📖 Configuration Tiers

TierPurposeExamples
PublicClient-visible settingsAPI URLs, feature toggles, UI config
SecretServer-side onlyDatabase URLs, API keys, JWT secrets
Feature FlagsRuntime togglesA/B tests, gradual rollouts, beta access

Security: B2M Key Restrictions

OperationB2M (Public Key)M2M (Secret Key)
Read public valuesYesYes
Read feature flagsYesYes
Read secret valuesNo (filtered)Yes
Write config valuesNo (403)Yes
Delete config valuesNo (403)Yes

Browser-to-Machine (B2M) keys are designed for browser clients. Secret-tier values are automatically filtered. B2M keys are read-only for public and feature flag tiers.

Machine-to-Machine (M2M) keys have full access to all tiers and write operations.

📖 Multi-Language Support

@smooai/config has native server implementations in Python, Rust, Go, and .NET (C#) alongside the primary TypeScript package, plus mobile SDKs in Kotlin and Swift (a deliberately different, public-only surface — see below). Every server client reads the same encrypted bundle, the same schema, and the same config API. See the per-SDK READMEs linked above for full usage docs — the snippets below are five-line orientation only.

Python — see python/README.md

pip install smooai-config
# or: uv add smooai-config
fromsmooai_config.clientimportConfigClientwithConfigClient() asclient: # reads SMOOAI_CONFIG_* env varsvalue=client.get_value("API_URL", environment="production")
cargo add smooai-config
use smooai_config::ConfigClient;letmut client = ConfigClient::from_env();let value = client.get_value("API_URL",None).await?;
go get github.com/SmooAI/config/go/config
import"github.com/SmooAI/config/go/config"client:=config.NewConfigClientFromEnv()
deferclient.Close()
value, _:=client.GetValue("API_URL", "production")

.NET — see dotnet/README.md

dotnet add package SmooAI.Config
usingSmooAI.Config;usingSmooAI.Config.Runtime;varruntime=SmooConfigRuntime.Load();// reads SMOO_CONFIG_KEY_FILE + SMOO_CONFIG_KEYusingvarclient=newSmooConfigClient(options);varapiUrl=awaitPublic.ApiUrl.ResolveAsync(runtime,client);

Kotlin (mobile) — see docs/Mobile-Runtime-Mode-Spec.md

Ships via JitPack pinned to a commit (Maven Central under ai.smoo is the planned follow-up — the in-repo Gradle version is a dev default, so don't pin a version, pin a SHA):

// settings.gradle.kts / build.gradle.kts
repositories { maven("https://jitpack.io") }
dependencies { implementation("com.github.SmooAI:config:<commit-sha>") }
importai.smoo.config.SmooConfigimportai.smoo.config.SmooConfigOptionsval config =SmooConfig(SmooConfigOptions(environment ="production", engine = engine, bundledConfigFile = bakedBundle))
val apiUrl = config.publicValue("API_BASE_URL") // baked bundle → refreshed cache, offline-safeval newUi = config.evaluateFlag("ENABLE_NEW_UI", default =false) // http → disk cache → default

Swift (mobile) — see docs/Mobile-Runtime-Mode-Spec.md

Consumed via Swift Package Manager pinned to a revision (there is no SPM version tag yet; the root Package.swift exists so SPM can resolve the repo URL directly):

// Package.swift
.package(url:"https://github.com/SmooAI/config", revision:"<commit-sha>")
import SmooAIConfig
letconfig=SmooConfig(options:SmooConfigOptions(environment:"production", bundledConfigURL: bakedBundleURL))letapiUrl= config.publicValue(forKey:"API_BASE_URL")letnewUi=await config.evaluateFlag("ENABLE_NEW_UI", default:false)

Mobile binaries are attacker-owned territory, so the mobile SDKs speak only the public app-config surface (/config/app/...): a baked public bundle (plaintext — it never contains secrets) plus live feature-flag / limit evaluation with an offline disk cache. There is no secret tier, no M2M credential, and no schema/LocalConfigManager surface on device — by design, per ADR-074.

SDK capability notes

Honest asymmetries between the SDKs, so you can pick with your eyes open:

CapabilityTSPythonRustGo.NETKotlin / Swift
Encrypted baked bundle + config API reads📱 public-only bundle
Local config-file workflow (LocalConfigManager in Py/Rust/Go/.NET; the file tier in TS)
Cloud-region resolution
Deferred values / merge_replace_arrays semantics
Shared schema-validation conformance fixture (test-fixtures/schema-validation-cases.json)
Live feature flags✅ (+ limits)

The .NET SDK reached parity with the other four server SDKs in 6.11.x: LocalConfigManager, cloud-region resolution, deferred values and MergeReplaceArrays all landed, and its schema validator is held to the same test-fixtures/schema-validation-cases.json corpus. Cross-language schema-validation parity is now a TS/Python/Rust/Go/.NET guarantee. Kotlin and Swift stay outside it by design — there is no schema surface on device (ADR-074).

📖 Development

Prerequisites

  • Node.js 22+, pnpm 10+
  • Python 3.13+ with uv (for the Python package)
  • Rust toolchain (for the Rust package)
  • Go 1.22+ (for the Go package)

Commands

pnpm install # Install dependencies
pnpm build # Build all packages (TS, Python, Rust, Go)
pnpm test# Run all tests (Vitest, pytest, cargo test, go test)
pnpm lint # Lint all code (oxlint, ruff, clippy, go vet)
pnpm format # Format all code (oxfmt, ruff, cargo fmt, gofmt)
pnpm typecheck # Type check (tsc, basedpyright, cargo check)
pnpm check-all # Full CI parity check

Schema Libraries

Supports Zod, Valibot, ArkType, Effect Schema, and built-in schema types. See SCHEMA_USAGE.md for examples with each library.

🧩 Part of Smoo AI

@smooai/config is built and open-sourced by Smoo AI — the AI-powered business platform with AI built into every product: CRM, customer support, campaigns, field service, observability, and developer tools.

🤝 Contributing

Contributions are welcome. This project uses changesets to manage versions and releases.

  1. Fork the repository.
  2. Create your branch (git checkout -b amazing-feature).
  3. Make your changes.
  4. Add a changeset: pnpm changeset.
  5. Commit and push.
  6. Open a pull request.

📄 License

MIT © SmooAI. See LICENSE.

Contact

Brent Rager

Smoo GitHub: github.com/SmooAI


Built by Smoo AI — AI built into every product.

About

Type-safe multi-language configuration management — schema validation, three-tier config (public, secrets, feature flags), and runtime clients for TypeScript, Python, Rust, and Go.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

217 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

@smooai/config — One schema. Every language. Typed everywhere.

npmSmoo AIlicense

TypeScriptPythonRustGo.NETKotlinSwift

Features · Install · Quick start · Platform


Define your config once with Zod (or Valibot, ArkType, Effect), and read it with full type inference everywhere — public config, server-only secrets, and live feature flags. Rename a key and every call site is a compile error, not a 3 AM page. Native clients in TypeScript, Python, Rust, Go, and .NET all read from the same source of truth.

📣 The CLI moved. Use smoo config from the smooth repo for all operator commands (login, get, set, list, push, pull, diff, init, etc.). The standalone smooai-config CLI that used to live in this repo is deprecated and being deleted (SMOODEV-1411). The runtime library @smooai/config (TypeScript / Python / Rust / Go / .NET, consumed via secretConfig.get(...) / publicConfig.get(...) / featureFlag.get(...)) is unchanged — only the operator CLI surface moved to Rust in the smooth repo. (smoo … is the th binary's platform namespace; th smoo … and the old th config … spelling also work.)

✨ Features

  • Three tiers, one schema — public config, secrets, and feature flags defined once with Zod/Valibot/ArkType/Effect, validated everywhere they're read.
  • Strongly-typed keysdefineConfig() gives you PublicConfigKeys, SecretConfigKeys, and FeatureFlagKeys with full inference. Mis-typed keys fail at compile time, not at runtime.
  • Any environment, any key — the same API for development, staging, and production. Override per-stage without touching code.
  • Zero-latency cold starts — values are baked into the bundle as env vars (Next.js, Vite) or resolved in-memory from a local runtime (server). No network round-trip on the hot path.
  • Browser, server, framework-native — the same typed keys read cleanly from React client components, Server Components, Next.js, Vite, or plain Node.
  • Live feature flags — toggled from the dashboard without a redeploy, but still typed.
  • Native clients in every language — TypeScript, Python, Rust, Go, and .NET (C#) server SDKs all read from the same source of truth, plus Kotlin and Swift mobile SDKs for the public-only app surface (ADR-074 mobile runtime mode).

Languages / SDKs

Pick the SDK that matches your service. Every server client reads the same schema, the same encrypted bundle, and the same config API — so a key renamed in one language ripples through all of them. The two mobile SDKs (📱) speak a deliberately narrower, public-only surface — no secrets ever ship to a device.

SDKOne-linerREADME
TypeScriptPrimary SDK. Schema definition, Next.js / Vite plugins, server runtime, React hooks.README.md (this file)
PythonPydantic-validated schemas, sync ConfigClient, LocalConfigManager + ConfigManager, baked runtime.python/README.md
GoNative struct schemas, thread-safe ConfigClient / ConfigManager, baked-blob runtime.go/config/README.md
RustJsonSchema-derived schemas, async ConfigClient, sync ConfigManager, baked-blob runtime.rust/config/README.md
.NETRoslyn source-generated typed keys, OAuth2 SmooConfigClient, AES-GCM SmooConfigRuntime. Thinner surface than the other server SDKs — see the capability notes.dotnet/README.md
Kotlin 📱Mobile runtime mode (ADR-074): baked public bundle + live flag/limit evaluation, offline-safe, no secrets on device. Ships via JitPack commit pin — not on Maven Central yet.kotlin/ · spec
Swift 📱Mobile runtime mode (ADR-074): same surface as Kotlin (publicValue, evaluateFlag, evaluateLimit). Consumed via SPM pinned to a revision — no version tag yet.swift/ · spec

The five server SDKs release in version lockstep — v6.11.3 on npm, PyPI, crates.io, and NuGet, with the matching v6.11.3 git tag for the Go module, all cut from the same commit (verified against each registry 2026-08-20). The mobile SDKs version separately (commit-pinned; see below).

📦 Install

pnpm add @smooai/config

🚀 Quick Start (TypeScript)

1. Define your configuration schema

Use defineConfig() with any StandardSchema-compliant library (Zod, Valibot, ArkType, Effect Schema) or the built-in StringSchema, BooleanSchema, and NumberSchema helpers:

// .smooai-config/config.tsimport{defineConfig,StringSchema,BooleanSchema,NumberSchema}from'@smooai/config';import{z}from'zod';constconfig=defineConfig({publicConfigSchema: {apiBaseUrl: z.string().url(),maxRetries: NumberSchema,enableDebug: BooleanSchema,},secretConfigSchema: {databaseUrl: z.string().url(),apiKey: StringSchema,},featureFlagSchema: {enableNewUi: BooleanSchema,betaFeatures: BooleanSchema,},});exportdefaultconfig;// Extract typed key objects for use throughout your appexportconst{ FeatureFlagKeys, PublicConfigKeys, SecretConfigKeys }=config;

defineConfig() automatically maps camelCase keys to UPPER_SNAKE_CASE:

FeatureFlagKeys.ENABLE_NEW_UI;// "ENABLE_NEW_UI"PublicConfigKeys.API_BASE_URL;// "API_BASE_URL"SecretConfigKeys.DATABASE_URL;// "DATABASE_URL"

2. Add to tsconfig.json

{
"compilerOptions": { ... },
"include": ["src/**/*", ".smooai-config/**/*.ts"]
}

📖 Next.js Integration

Inject config into next.config.ts

Use withSmooConfig() to inject feature flags and public config as NEXT_PUBLIC_ environment variables, with per-stage overrides:

// next.config.tsimport{withSmooConfig}from'@smooai/config/nextjs/withSmooConfig';constnextConfig=withSmooConfig({default: {featureFlags: {enableNewUi: false,betaFeatures: false},publicConfig: {apiBaseUrl: 'https://api.smooai.com',maxRetries: 3},},development: {featureFlags: {enableNewUi: true},publicConfig: {apiBaseUrl: 'http://localhost:3000'},},});exportdefaultnextConfig;

This sets environment variables like NEXT_PUBLIC_FEATURE_FLAG_ENABLE_NEW_UI=true and NEXT_PUBLIC_CONFIG_API_BASE_URL=http://localhost:3000 based on the current stage.

Read config in React client components

import{getClientFeatureFlag,getClientPublicConfig}from'@smooai/config/client';functionMyComponent(){constisNewUi=getClientFeatureFlag('enableNewUi');constapiUrl=getClientPublicConfig('apiBaseUrl');if(!isNewUi)return<LegacyUI/>;return<NewUIapiUrl={apiUrl}/>;}

These functions check NEXT_PUBLIC_FEATURE_FLAG_* and NEXT_PUBLIC_CONFIG_* env vars automatically — no provider needed, no loading state.

Server Components + Client hydration (zero loading flash)

For apps that need runtime config from a config server, use getConfig on the server and SmooConfigProvider to hydrate client components:

// app/layout.tsx (Server Component)import{getConfig,SmooConfigProvider}from'@smooai/config/nextjs';exportdefaultasyncfunctionRootLayout({ children }: {children: React.ReactNode}){constconfig=awaitgetConfig({environment: 'production',fetchOptions: {next: {revalidate: 60}},});return(<html><body><SmooConfigProviderinitialValues={config}baseUrl={process.env.SMOOAI_CONFIG_API_URL}apiKey={process.env.SMOOAI_CONFIG_API_KEY}orgId={process.env.SMOOAI_CONFIG_ORG_ID}environment="production">{children}</SmooConfigProvider></body></html>);}
// Any client component — values available synchronously (pre-seeded from SSR)import{usePublicConfig,useFeatureFlag}from'@smooai/config/nextjs';functionDashboard(){const{value: apiUrl}=usePublicConfig<string>('API_BASE_URL');const{value: enableNewUi}=useFeatureFlag<boolean>('ENABLE_NEW_UI');return(<div>
API: {apiUrl}, New UI: {String(enableNewUi)}</div>);}

📖 Vite Integration

Vite plugin

// vite.config.tsimport{defineConfig}from'vite';import{smooConfigPlugin}from'@smooai/config/vite/smooConfigPlugin';exportdefaultdefineConfig({plugins: [smooConfigPlugin({featureFlags: {enableNewUi: true,betaFeatures: false},publicConfig: {apiBaseUrl: 'http://localhost:3000'},}),],});

Then read values the same way as Next.js — getClientFeatureFlag and getClientPublicConfig from @smooai/config/client check VITE_FEATURE_FLAG_* and VITE_CONFIG_* automatically.

Preload config (optional)

For runtime config from a config server, start fetching before React mounts:

// main.tsximport{preloadConfig,ConfigProvider}from'@smooai/config/vite';import{createRoot}from'react-dom/client';preloadConfig({environment: 'production'});createRoot(document.getElementById('root')!).render(<ConfigProviderbaseUrl="https://config.smooai.dev"apiKey="your-public-key"orgId="your-org-id"environment="production"><App/></ConfigProvider>,);

📖 Server-Side Config Access

For Node.js server code, use buildConfigObject to get sync and async accessors with full type safety:

importbuildConfigObjectfrom'@smooai/config/platform/server';importconfig,{PublicConfigKeys,SecretConfigKeys,FeatureFlagKeys}from'./.smooai-config/config';constconfigObj=buildConfigObject(config);// Sync access (uses worker threads)constdbUrl=configObj.secretConfig.getSync(SecretConfigKeys.DATABASE_URL);constapiUrl=configObj.publicConfig.getSync(PublicConfigKeys.API_BASE_URL);constisNewUi=configObj.featureFlag.getSync(FeatureFlagKeys.ENABLE_NEW_UI);// Async accessconstapiKey=awaitconfigObj.secretConfig.getAsync(SecretConfigKeys.API_KEY);

How .getSync() works (and how to ship it in any bundled compute)

Sync accessors run an async config read to completion on the caller thread via synckit — a Node Worker pool + Atomics.wait on a SharedArrayBuffer. createSyncFn only accepts a file:// URL, so the worker body has to live on disk. The SDK resolves it in two stages:

  1. Sidecar filesync-worker.mjs sitting next to the compiled SDK entry (i.e. resolved via new URL('./sync-worker.mjs', import.meta.url) from dist/server/index.mjs). This is the normal case for plain Node resolution with no bundling — node_modules/@smooai/config/dist/server/sync-worker.mjs is already there. It's also the preferred case when bundlers copy the sidecar into the deploy output. Zero /tmp writes.

  2. Extract-to-/tmp fallback — if the sidecar isn't on disk at that path (e.g. a bundler inlined the SDK entry into a single file and didn't copy the sidecar), the SDK writes an embedded copy of the worker source to mkdtempSync()/sync-worker.mjs once per process and hands that path to synckit. One ~1-2 MiB write at cold start, amortised across every sync read for the lifetime of the process. Works anywhere with a writable temp dir.

Both paths are transparent — your code is identical either way. Which path you land on depends on how your compute is packaged.

Plain Node (no bundling)

Zero config. The SDK resolves node_modules/@smooai/config/dist/server/sync-worker.mjs directly — path (1) every time.

Any bundled compute (Lambda, Cloud Run, ECS, container, Worker, etc.)

The rule is universal: if your build inlines the SDK entry into a single output file, you need to ship sync-worker.mjs next to that output (or accept path (2)'s /tmp write once per cold start).

The source path is always:

node_modules/@smooai/config/dist/server/sync-worker.mjs

The destination is alongside whichever file ends up being your runtime's import.meta.url anchor — typically the bundled handler .mjs / .js.

Recipes for common setups:

esbuild — explicit copy plugin

// build.tsimport{build}from'esbuild';import{copy}from'esbuild-plugin-copy';awaitbuild({entryPoints: ['src/handler.ts'],outdir: 'dist',bundle: true,format: 'esm',platform: 'node',plugins: [copy({assets: {from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs',to: 'dist/sync-worker.mjs',},}),],});

tsup — onSuccess hook

// tsup.config.tsexportdefaultdefineConfig({entry: ['src/handler.ts'],format: ['esm'],onSuccess: 'cp node_modules/@smooai/config/dist/server/sync-worker.mjs dist/sync-worker.mjs',});

Serverless Framework — package.include

package:
patterns:
- 'node_modules/@smooai/config/dist/server/sync-worker.mjs'

Or copy into the handler dir as a build step and include from there.

AWS SAM — CodeUri + build script

Add a Makefile / build script that copies sync-worker.mjs into the BuildArtifactPath alongside your handler.

SST (AWS) — per-function or via $transform

// sst.config.ts — per functionnewsst.aws.Function('Api',{handler: 'src/api.handler',copyFiles: [{from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs'}],});// Or at the stack level via $transform (every Function gets it automatically)$transform(sst.aws.Function,(fn)=>{fn.copyFiles=[...(fn.copyFiles??[]),{from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs'}];});

Docker container (ECS, Cloud Run, anywhere)

# After your main build step, ensure the sidecar is next to the bundled entry.COPY --from=build /app/dist/server.mjs /app/
COPY --from=build /app/node_modules/@smooai/config/dist/server/sync-worker.mjs /app/
CMD ["node", "server.mjs"]

If your build step keeps node_modules in the final image, no extra copy is needed — the SDK resolves the sidecar from node_modules/ path (1) directly.

When the sidecar truly can't be shipped

Path (2) — the /tmp extraction — is the safety net. One ~1-2 MiB write at cold start, then synckit re-uses the file for the rest of the process lifetime. Lambda's 512 MiB–10 GiB /tmp easily absorbs this; containers with an ephemeral /tmp work the same way. You can ignore this whole section and .getSync() will still work — you're just paying one filesystem write per cold start.

Edge runtimes (Vercel Edge, Cloudflare Workers)

These runtimes don't expose Node's worker_threads at all, so .getSync() is a no-go there by design. Use .get() (async) everywhere that needs to run on the edge. The error surface makes this explicit if you try.

📖 React Hooks (framework-agnostic)

For any React app using the runtime config client:

import{ConfigProvider,usePublicConfig,useFeatureFlag}from'@smooai/config/react';functionApp(){return(<ConfigProviderbaseUrl="https://config.smooai.dev"apiKey="your-api-key"orgId="your-org-id"environment="production"><MyComponent/></ConfigProvider>);}functionMyComponent(){const{value: apiUrl, isLoading, error }=usePublicConfig<string>('API_BASE_URL');const{value: enableNewUi}=useFeatureFlag<boolean>('ENABLE_NEW_UI');if(isLoading)return<div>Loading...</div>;if(error)return<div>Error: {error.message}</div>;return(<div>
API URL: {apiUrl}, New UI: {String(enableNewUi)}</div>);}

📖 SDK Runtime Client

All language implementations include a runtime client for fetching configuration values from the Smoo AI config server with local caching.

Environment Variables

Authentication is OAuth2 client_credentials against {authUrl}/token — the client exchanges (CLIENT_ID, CLIENT_SECRET) for a JWT and uses that JWT as the Bearer token on every config call. TokenProvider caches the JWT in memory and refreshes 60s before expiry.

VariableDescriptionRequired
SMOOAI_CONFIG_API_URLBase URL of the config APIYes
SMOOAI_CONFIG_AUTH_URLOAuth issuer base URL (defaults to https://auth.smoo.ai)No
SMOOAI_CONFIG_CLIENT_IDOAuth client IDYes
SMOOAI_CONFIG_CLIENT_SECRETOAuth client secret (legacy SMOOAI_CONFIG_API_KEY is accepted as a fallback)Yes
SMOOAI_CONFIG_ORG_IDOrganization IDYes
SMOOAI_CONFIG_ENVDefault environment name (defaults to "development")No

Migration note (v5 / SMOODEV-974): the TypeScript ConfigClient previously sent SMOOAI_CONFIG_API_KEY directly as the Bearer token, which the backend rejected with 401 because it expects a JWT. The SDK now mints a JWT via the OAuth client_credentials grant before each call — matching the .NET client, the in-package bootstrap, and the CLI. You must set SMOOAI_CONFIG_CLIENT_ID in addition to SMOOAI_CONFIG_API_KEY / SMOOAI_CONFIG_CLIENT_SECRET for the runtime SDK to work. The legacy SMOOAI_CONFIG_API_KEY env var continues to function as the OAuth client secret.

TypeScript Client

import{ConfigClient}from'@smooai/config/platform/client';// Zero-config (reads from env vars — needs CLIENT_ID + CLIENT_SECRET/API_KEY + ORG_ID)constclient=newConfigClient();// Or explicitconstclient=newConfigClient({baseUrl: 'https://config.smooai.dev',authUrl: 'https://auth.smooai.dev',clientId: 'your-client-id',clientSecret: 'your-client-secret',orgId: 'your-org-id',environment: 'production',});constapiUrl=awaitclient.getValue('API_BASE_URL');constallValues=awaitclient.getAllValues();client.invalidateCache();

📖 Container / Runtime Mode (EKS / ECS)

The baked blob tier is the blessed path for Lambda, but it is the wrong default for long-lived containers: when the per-build blob key isn't delivered to the pod, resolution silently falls through to the (absent) file tier and returns undefined for a required secret. That caused a real outage — a container got undefined for STRIPE_API_KEY, new Stripe(undefined) threw at module load, the process exited 0 before listen(), and the pod CrashLooped with the root cause buried (SMOODEV-1478).

Container mode makes the HTTP config API the first-class path for containers, authenticated with an OAuth2 client_credentials (M2M) token, and fails loud: a required value that doesn't resolve throws a typed error instead of returning undefined.

Containers use container mode, not the baked blob. See docs/Container-Runtime-Mode.md for the full env contract, a complete ExternalSecret (External Secrets Operator) recipe, and a readiness-probe example.

import{initContainerConfig,ConfigKeyUnresolvedError}from'@smooai/config/container';importschemafrom'../.smooai-config/config';// Validates the container env, mints a token, and does an initial fetch —// startup fails LOUD here (throws), not on first read.constconfig=awaitinitContainerConfig({ schema });// Fail-loud: a required secret that doesn't resolve throws// ConfigKeyUnresolvedError instead of returning undefined.conststripeKey=awaitconfig.secretConfig.get('stripeApiKey');// Kubernetes readiness probe — never throws.app.get('/healthz/config',(_req,res)=>{consth=config.health();// { status: 'healthy' } | { status: 'unhealthy', reason }res.status(h.status==='healthy' ? 200 : 503).json(h);});

Env contract (identical in every SDK): SMOOAI_CONFIG_API_URL, SMOOAI_CONFIG_CLIENT_ID, SMOOAI_CONFIG_CLIENT_SECRET, SMOOAI_CONFIG_ORG_ID, SMOOAI_CONFIG_ENV (all required), plus optional SMOOAI_CONFIG_AUTH_URL and SMOOAI_CONFIG_MODE=container (to force the mode). All schema-declared keys are treated as required by default; opt specific keys out with initContainerConfig({ optionalKeys: ['...'] }).

📖 Configuration Tiers

TierPurposeExamples
PublicClient-visible settingsAPI URLs, feature toggles, UI config
SecretServer-side onlyDatabase URLs, API keys, JWT secrets
Feature FlagsRuntime togglesA/B tests, gradual rollouts, beta access

Security: B2M Key Restrictions

OperationB2M (Public Key)M2M (Secret Key)
Read public valuesYesYes
Read feature flagsYesYes
Read secret valuesNo (filtered)Yes
Write config valuesNo (403)Yes
Delete config valuesNo (403)Yes

Browser-to-Machine (B2M) keys are designed for browser clients. Secret-tier values are automatically filtered. B2M keys are read-only for public and feature flag tiers.

Machine-to-Machine (M2M) keys have full access to all tiers and write operations.

📖 Multi-Language Support

@smooai/config has native server implementations in Python, Rust, Go, and .NET (C#) alongside the primary TypeScript package, plus mobile SDKs in Kotlin and Swift (a deliberately different, public-only surface — see below). Every server client reads the same encrypted bundle, the same schema, and the same config API. See the per-SDK READMEs linked above for full usage docs — the snippets below are five-line orientation only.

Python — see python/README.md

pip install smooai-config
# or: uv add smooai-config
fromsmooai_config.clientimportConfigClientwithConfigClient() asclient: # reads SMOOAI_CONFIG_* env varsvalue=client.get_value("API_URL", environment="production")
cargo add smooai-config
use smooai_config::ConfigClient;letmut client = ConfigClient::from_env();let value = client.get_value("API_URL",None).await?;
go get github.com/SmooAI/config/go/config
import"github.com/SmooAI/config/go/config"client:=config.NewConfigClientFromEnv()
deferclient.Close()
value, _:=client.GetValue("API_URL", "production")

.NET — see dotnet/README.md

dotnet add package SmooAI.Config
usingSmooAI.Config;usingSmooAI.Config.Runtime;varruntime=SmooConfigRuntime.Load();// reads SMOO_CONFIG_KEY_FILE + SMOO_CONFIG_KEYusingvarclient=newSmooConfigClient(options);varapiUrl=awaitPublic.ApiUrl.ResolveAsync(runtime,client);

Kotlin (mobile) — see docs/Mobile-Runtime-Mode-Spec.md

Ships via JitPack pinned to a commit (Maven Central under ai.smoo is the planned follow-up — the in-repo Gradle version is a dev default, so don't pin a version, pin a SHA):

// settings.gradle.kts / build.gradle.kts
repositories { maven("https://jitpack.io") }
dependencies { implementation("com.github.SmooAI:config:<commit-sha>") }
importai.smoo.config.SmooConfigimportai.smoo.config.SmooConfigOptionsval config =SmooConfig(SmooConfigOptions(environment ="production", engine = engine, bundledConfigFile = bakedBundle))
val apiUrl = config.publicValue("API_BASE_URL") // baked bundle → refreshed cache, offline-safeval newUi = config.evaluateFlag("ENABLE_NEW_UI", default =false) // http → disk cache → default

Swift (mobile) — see docs/Mobile-Runtime-Mode-Spec.md

Consumed via Swift Package Manager pinned to a revision (there is no SPM version tag yet; the root Package.swift exists so SPM can resolve the repo URL directly):

// Package.swift
.package(url:"https://github.com/SmooAI/config", revision:"<commit-sha>")
import SmooAIConfig
letconfig=SmooConfig(options:SmooConfigOptions(environment:"production", bundledConfigURL: bakedBundleURL))letapiUrl= config.publicValue(forKey:"API_BASE_URL")letnewUi=await config.evaluateFlag("ENABLE_NEW_UI", default:false)

Mobile binaries are attacker-owned territory, so the mobile SDKs speak only the public app-config surface (/config/app/...): a baked public bundle (plaintext — it never contains secrets) plus live feature-flag / limit evaluation with an offline disk cache. There is no secret tier, no M2M credential, and no schema/LocalConfigManager surface on device — by design, per ADR-074.

SDK capability notes

Honest asymmetries between the SDKs, so you can pick with your eyes open:

CapabilityTSPythonRustGo.NETKotlin / Swift
Encrypted baked bundle + config API reads📱 public-only bundle
Local config-file workflow (LocalConfigManager in Py/Rust/Go/.NET; the file tier in TS)
Cloud-region resolution
Deferred values / merge_replace_arrays semantics
Shared schema-validation conformance fixture (test-fixtures/schema-validation-cases.json)
Live feature flags✅ (+ limits)

The .NET SDK reached parity with the other four server SDKs in 6.11.x: LocalConfigManager, cloud-region resolution, deferred values and MergeReplaceArrays all landed, and its schema validator is held to the same test-fixtures/schema-validation-cases.json corpus. Cross-language schema-validation parity is now a TS/Python/Rust/Go/.NET guarantee. Kotlin and Swift stay outside it by design — there is no schema surface on device (ADR-074).

📖 Development

Prerequisites

  • Node.js 22+, pnpm 10+
  • Python 3.13+ with uv (for the Python package)
  • Rust toolchain (for the Rust package)
  • Go 1.22+ (for the Go package)

Commands

pnpm install # Install dependencies
pnpm build # Build all packages (TS, Python, Rust, Go)
pnpm test# Run all tests (Vitest, pytest, cargo test, go test)
pnpm lint # Lint all code (oxlint, ruff, clippy, go vet)
pnpm format # Format all code (oxfmt, ruff, cargo fmt, gofmt)
pnpm typecheck # Type check (tsc, basedpyright, cargo check)
pnpm check-all # Full CI parity check

Schema Libraries

Supports Zod, Valibot, ArkType, Effect Schema, and built-in schema types. See SCHEMA_USAGE.md for examples with each library.

🧩 Part of Smoo AI

@smooai/config is built and open-sourced by Smoo AI — the AI-powered business platform with AI built into every product: CRM, customer support, campaigns, field service, observability, and developer tools.

🤝 Contributing

Contributions are welcome. This project uses changesets to manage versions and releases.

  1. Fork the repository.
  2. Create your branch (git checkout -b amazing-feature).
  3. Make your changes.
  4. Add a changeset: pnpm changeset.
  5. Commit and push.
  6. Open a pull request.

📄 License

MIT © SmooAI. See LICENSE.

Contact

Brent Rager

Smoo GitHub: github.com/SmooAI


Built by Smoo AI — AI built into every product.

About

Type-safe multi-language configuration management — schema validation, three-tier config (public, secrets, feature flags), and runtime clients for TypeScript, Python, Rust, and Go.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

217 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

@smooai/config — One schema. Every language. Typed everywhere.

npmSmoo AIlicense

TypeScriptPythonRustGo.NETKotlinSwift

Features · Install · Quick start · Platform


Define your config once with Zod (or Valibot, ArkType, Effect), and read it with full type inference everywhere — public config, server-only secrets, and live feature flags. Rename a key and every call site is a compile error, not a 3 AM page. Native clients in TypeScript, Python, Rust, Go, and .NET all read from the same source of truth.

📣 The CLI moved. Use smoo config from the smooth repo for all operator commands (login, get, set, list, push, pull, diff, init, etc.). The standalone smooai-config CLI that used to live in this repo is deprecated and being deleted (SMOODEV-1411). The runtime library @smooai/config (TypeScript / Python / Rust / Go / .NET, consumed via secretConfig.get(...) / publicConfig.get(...) / featureFlag.get(...)) is unchanged — only the operator CLI surface moved to Rust in the smooth repo. (smoo … is the th binary's platform namespace; th smoo … and the old th config … spelling also work.)

✨ Features

  • Three tiers, one schema — public config, secrets, and feature flags defined once with Zod/Valibot/ArkType/Effect, validated everywhere they're read.
  • Strongly-typed keysdefineConfig() gives you PublicConfigKeys, SecretConfigKeys, and FeatureFlagKeys with full inference. Mis-typed keys fail at compile time, not at runtime.
  • Any environment, any key — the same API for development, staging, and production. Override per-stage without touching code.
  • Zero-latency cold starts — values are baked into the bundle as env vars (Next.js, Vite) or resolved in-memory from a local runtime (server). No network round-trip on the hot path.
  • Browser, server, framework-native — the same typed keys read cleanly from React client components, Server Components, Next.js, Vite, or plain Node.
  • Live feature flags — toggled from the dashboard without a redeploy, but still typed.
  • Native clients in every language — TypeScript, Python, Rust, Go, and .NET (C#) server SDKs all read from the same source of truth, plus Kotlin and Swift mobile SDKs for the public-only app surface (ADR-074 mobile runtime mode).

Languages / SDKs

Pick the SDK that matches your service. Every server client reads the same schema, the same encrypted bundle, and the same config API — so a key renamed in one language ripples through all of them. The two mobile SDKs (📱) speak a deliberately narrower, public-only surface — no secrets ever ship to a device.

SDKOne-linerREADME
TypeScriptPrimary SDK. Schema definition, Next.js / Vite plugins, server runtime, React hooks.README.md (this file)
PythonPydantic-validated schemas, sync ConfigClient, LocalConfigManager + ConfigManager, baked runtime.python/README.md
GoNative struct schemas, thread-safe ConfigClient / ConfigManager, baked-blob runtime.go/config/README.md
RustJsonSchema-derived schemas, async ConfigClient, sync ConfigManager, baked-blob runtime.rust/config/README.md
.NETRoslyn source-generated typed keys, OAuth2 SmooConfigClient, AES-GCM SmooConfigRuntime. Thinner surface than the other server SDKs — see the capability notes.dotnet/README.md
Kotlin 📱Mobile runtime mode (ADR-074): baked public bundle + live flag/limit evaluation, offline-safe, no secrets on device. Ships via JitPack commit pin — not on Maven Central yet.kotlin/ · spec
Swift 📱Mobile runtime mode (ADR-074): same surface as Kotlin (publicValue, evaluateFlag, evaluateLimit). Consumed via SPM pinned to a revision — no version tag yet.swift/ · spec

The five server SDKs release in version lockstep — v6.11.3 on npm, PyPI, crates.io, and NuGet, with the matching v6.11.3 git tag for the Go module, all cut from the same commit (verified against each registry 2026-08-20). The mobile SDKs version separately (commit-pinned; see below).

📦 Install

pnpm add @smooai/config

🚀 Quick Start (TypeScript)

1. Define your configuration schema

Use defineConfig() with any StandardSchema-compliant library (Zod, Valibot, ArkType, Effect Schema) or the built-in StringSchema, BooleanSchema, and NumberSchema helpers:

// .smooai-config/config.tsimport{defineConfig,StringSchema,BooleanSchema,NumberSchema}from'@smooai/config';import{z}from'zod';constconfig=defineConfig({publicConfigSchema: {apiBaseUrl: z.string().url(),maxRetries: NumberSchema,enableDebug: BooleanSchema,},secretConfigSchema: {databaseUrl: z.string().url(),apiKey: StringSchema,},featureFlagSchema: {enableNewUi: BooleanSchema,betaFeatures: BooleanSchema,},});exportdefaultconfig;// Extract typed key objects for use throughout your appexportconst{ FeatureFlagKeys, PublicConfigKeys, SecretConfigKeys }=config;

defineConfig() automatically maps camelCase keys to UPPER_SNAKE_CASE:

FeatureFlagKeys.ENABLE_NEW_UI;// "ENABLE_NEW_UI"PublicConfigKeys.API_BASE_URL;// "API_BASE_URL"SecretConfigKeys.DATABASE_URL;// "DATABASE_URL"

2. Add to tsconfig.json

{
"compilerOptions": { ... },
"include": ["src/**/*", ".smooai-config/**/*.ts"]
}

📖 Next.js Integration

Inject config into next.config.ts

Use withSmooConfig() to inject feature flags and public config as NEXT_PUBLIC_ environment variables, with per-stage overrides:

// next.config.tsimport{withSmooConfig}from'@smooai/config/nextjs/withSmooConfig';constnextConfig=withSmooConfig({default: {featureFlags: {enableNewUi: false,betaFeatures: false},publicConfig: {apiBaseUrl: 'https://api.smooai.com',maxRetries: 3},},development: {featureFlags: {enableNewUi: true},publicConfig: {apiBaseUrl: 'http://localhost:3000'},},});exportdefaultnextConfig;

This sets environment variables like NEXT_PUBLIC_FEATURE_FLAG_ENABLE_NEW_UI=true and NEXT_PUBLIC_CONFIG_API_BASE_URL=http://localhost:3000 based on the current stage.

Read config in React client components

import{getClientFeatureFlag,getClientPublicConfig}from'@smooai/config/client';functionMyComponent(){constisNewUi=getClientFeatureFlag('enableNewUi');constapiUrl=getClientPublicConfig('apiBaseUrl');if(!isNewUi)return<LegacyUI/>;return<NewUIapiUrl={apiUrl}/>;}

These functions check NEXT_PUBLIC_FEATURE_FLAG_* and NEXT_PUBLIC_CONFIG_* env vars automatically — no provider needed, no loading state.

Server Components + Client hydration (zero loading flash)

For apps that need runtime config from a config server, use getConfig on the server and SmooConfigProvider to hydrate client components:

// app/layout.tsx (Server Component)import{getConfig,SmooConfigProvider}from'@smooai/config/nextjs';exportdefaultasyncfunctionRootLayout({ children }: {children: React.ReactNode}){constconfig=awaitgetConfig({environment: 'production',fetchOptions: {next: {revalidate: 60}},});return(<html><body><SmooConfigProviderinitialValues={config}baseUrl={process.env.SMOOAI_CONFIG_API_URL}apiKey={process.env.SMOOAI_CONFIG_API_KEY}orgId={process.env.SMOOAI_CONFIG_ORG_ID}environment="production">{children}</SmooConfigProvider></body></html>);}
// Any client component — values available synchronously (pre-seeded from SSR)import{usePublicConfig,useFeatureFlag}from'@smooai/config/nextjs';functionDashboard(){const{value: apiUrl}=usePublicConfig<string>('API_BASE_URL');const{value: enableNewUi}=useFeatureFlag<boolean>('ENABLE_NEW_UI');return(<div>
API: {apiUrl}, New UI: {String(enableNewUi)}</div>);}

📖 Vite Integration

Vite plugin

// vite.config.tsimport{defineConfig}from'vite';import{smooConfigPlugin}from'@smooai/config/vite/smooConfigPlugin';exportdefaultdefineConfig({plugins: [smooConfigPlugin({featureFlags: {enableNewUi: true,betaFeatures: false},publicConfig: {apiBaseUrl: 'http://localhost:3000'},}),],});

Then read values the same way as Next.js — getClientFeatureFlag and getClientPublicConfig from @smooai/config/client check VITE_FEATURE_FLAG_* and VITE_CONFIG_* automatically.

Preload config (optional)

For runtime config from a config server, start fetching before React mounts:

// main.tsximport{preloadConfig,ConfigProvider}from'@smooai/config/vite';import{createRoot}from'react-dom/client';preloadConfig({environment: 'production'});createRoot(document.getElementById('root')!).render(<ConfigProviderbaseUrl="https://config.smooai.dev"apiKey="your-public-key"orgId="your-org-id"environment="production"><App/></ConfigProvider>,);

📖 Server-Side Config Access

For Node.js server code, use buildConfigObject to get sync and async accessors with full type safety:

importbuildConfigObjectfrom'@smooai/config/platform/server';importconfig,{PublicConfigKeys,SecretConfigKeys,FeatureFlagKeys}from'./.smooai-config/config';constconfigObj=buildConfigObject(config);// Sync access (uses worker threads)constdbUrl=configObj.secretConfig.getSync(SecretConfigKeys.DATABASE_URL);constapiUrl=configObj.publicConfig.getSync(PublicConfigKeys.API_BASE_URL);constisNewUi=configObj.featureFlag.getSync(FeatureFlagKeys.ENABLE_NEW_UI);// Async accessconstapiKey=awaitconfigObj.secretConfig.getAsync(SecretConfigKeys.API_KEY);

How .getSync() works (and how to ship it in any bundled compute)

Sync accessors run an async config read to completion on the caller thread via synckit — a Node Worker pool + Atomics.wait on a SharedArrayBuffer. createSyncFn only accepts a file:// URL, so the worker body has to live on disk. The SDK resolves it in two stages:

  1. Sidecar filesync-worker.mjs sitting next to the compiled SDK entry (i.e. resolved via new URL('./sync-worker.mjs', import.meta.url) from dist/server/index.mjs). This is the normal case for plain Node resolution with no bundling — node_modules/@smooai/config/dist/server/sync-worker.mjs is already there. It's also the preferred case when bundlers copy the sidecar into the deploy output. Zero /tmp writes.

  2. Extract-to-/tmp fallback — if the sidecar isn't on disk at that path (e.g. a bundler inlined the SDK entry into a single file and didn't copy the sidecar), the SDK writes an embedded copy of the worker source to mkdtempSync()/sync-worker.mjs once per process and hands that path to synckit. One ~1-2 MiB write at cold start, amortised across every sync read for the lifetime of the process. Works anywhere with a writable temp dir.

Both paths are transparent — your code is identical either way. Which path you land on depends on how your compute is packaged.

Plain Node (no bundling)

Zero config. The SDK resolves node_modules/@smooai/config/dist/server/sync-worker.mjs directly — path (1) every time.

Any bundled compute (Lambda, Cloud Run, ECS, container, Worker, etc.)

The rule is universal: if your build inlines the SDK entry into a single output file, you need to ship sync-worker.mjs next to that output (or accept path (2)'s /tmp write once per cold start).

The source path is always:

node_modules/@smooai/config/dist/server/sync-worker.mjs

The destination is alongside whichever file ends up being your runtime's import.meta.url anchor — typically the bundled handler .mjs / .js.

Recipes for common setups:

esbuild — explicit copy plugin

// build.tsimport{build}from'esbuild';import{copy}from'esbuild-plugin-copy';awaitbuild({entryPoints: ['src/handler.ts'],outdir: 'dist',bundle: true,format: 'esm',platform: 'node',plugins: [copy({assets: {from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs',to: 'dist/sync-worker.mjs',},}),],});

tsup — onSuccess hook

// tsup.config.tsexportdefaultdefineConfig({entry: ['src/handler.ts'],format: ['esm'],onSuccess: 'cp node_modules/@smooai/config/dist/server/sync-worker.mjs dist/sync-worker.mjs',});

Serverless Framework — package.include

package:
patterns:
- 'node_modules/@smooai/config/dist/server/sync-worker.mjs'

Or copy into the handler dir as a build step and include from there.

AWS SAM — CodeUri + build script

Add a Makefile / build script that copies sync-worker.mjs into the BuildArtifactPath alongside your handler.

SST (AWS) — per-function or via $transform

// sst.config.ts — per functionnewsst.aws.Function('Api',{handler: 'src/api.handler',copyFiles: [{from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs'}],});// Or at the stack level via $transform (every Function gets it automatically)$transform(sst.aws.Function,(fn)=>{fn.copyFiles=[...(fn.copyFiles??[]),{from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs'}];});

Docker container (ECS, Cloud Run, anywhere)

# After your main build step, ensure the sidecar is next to the bundled entry.COPY --from=build /app/dist/server.mjs /app/
COPY --from=build /app/node_modules/@smooai/config/dist/server/sync-worker.mjs /app/
CMD ["node", "server.mjs"]

If your build step keeps node_modules in the final image, no extra copy is needed — the SDK resolves the sidecar from node_modules/ path (1) directly.

When the sidecar truly can't be shipped

Path (2) — the /tmp extraction — is the safety net. One ~1-2 MiB write at cold start, then synckit re-uses the file for the rest of the process lifetime. Lambda's 512 MiB–10 GiB /tmp easily absorbs this; containers with an ephemeral /tmp work the same way. You can ignore this whole section and .getSync() will still work — you're just paying one filesystem write per cold start.

Edge runtimes (Vercel Edge, Cloudflare Workers)

These runtimes don't expose Node's worker_threads at all, so .getSync() is a no-go there by design. Use .get() (async) everywhere that needs to run on the edge. The error surface makes this explicit if you try.

📖 React Hooks (framework-agnostic)

For any React app using the runtime config client:

import{ConfigProvider,usePublicConfig,useFeatureFlag}from'@smooai/config/react';functionApp(){return(<ConfigProviderbaseUrl="https://config.smooai.dev"apiKey="your-api-key"orgId="your-org-id"environment="production"><MyComponent/></ConfigProvider>);}functionMyComponent(){const{value: apiUrl, isLoading, error }=usePublicConfig<string>('API_BASE_URL');const{value: enableNewUi}=useFeatureFlag<boolean>('ENABLE_NEW_UI');if(isLoading)return<div>Loading...</div>;if(error)return<div>Error: {error.message}</div>;return(<div>
API URL: {apiUrl}, New UI: {String(enableNewUi)}</div>);}

📖 SDK Runtime Client

All language implementations include a runtime client for fetching configuration values from the Smoo AI config server with local caching.

Environment Variables

Authentication is OAuth2 client_credentials against {authUrl}/token — the client exchanges (CLIENT_ID, CLIENT_SECRET) for a JWT and uses that JWT as the Bearer token on every config call. TokenProvider caches the JWT in memory and refreshes 60s before expiry.

VariableDescriptionRequired
SMOOAI_CONFIG_API_URLBase URL of the config APIYes
SMOOAI_CONFIG_AUTH_URLOAuth issuer base URL (defaults to https://auth.smoo.ai)No
SMOOAI_CONFIG_CLIENT_IDOAuth client IDYes
SMOOAI_CONFIG_CLIENT_SECRETOAuth client secret (legacy SMOOAI_CONFIG_API_KEY is accepted as a fallback)Yes
SMOOAI_CONFIG_ORG_IDOrganization IDYes
SMOOAI_CONFIG_ENVDefault environment name (defaults to "development")No

Migration note (v5 / SMOODEV-974): the TypeScript ConfigClient previously sent SMOOAI_CONFIG_API_KEY directly as the Bearer token, which the backend rejected with 401 because it expects a JWT. The SDK now mints a JWT via the OAuth client_credentials grant before each call — matching the .NET client, the in-package bootstrap, and the CLI. You must set SMOOAI_CONFIG_CLIENT_ID in addition to SMOOAI_CONFIG_API_KEY / SMOOAI_CONFIG_CLIENT_SECRET for the runtime SDK to work. The legacy SMOOAI_CONFIG_API_KEY env var continues to function as the OAuth client secret.

TypeScript Client

import{ConfigClient}from'@smooai/config/platform/client';// Zero-config (reads from env vars — needs CLIENT_ID + CLIENT_SECRET/API_KEY + ORG_ID)constclient=newConfigClient();// Or explicitconstclient=newConfigClient({baseUrl: 'https://config.smooai.dev',authUrl: 'https://auth.smooai.dev',clientId: 'your-client-id',clientSecret: 'your-client-secret',orgId: 'your-org-id',environment: 'production',});constapiUrl=awaitclient.getValue('API_BASE_URL');constallValues=awaitclient.getAllValues();client.invalidateCache();

📖 Container / Runtime Mode (EKS / ECS)

The baked blob tier is the blessed path for Lambda, but it is the wrong default for long-lived containers: when the per-build blob key isn't delivered to the pod, resolution silently falls through to the (absent) file tier and returns undefined for a required secret. That caused a real outage — a container got undefined for STRIPE_API_KEY, new Stripe(undefined) threw at module load, the process exited 0 before listen(), and the pod CrashLooped with the root cause buried (SMOODEV-1478).

Container mode makes the HTTP config API the first-class path for containers, authenticated with an OAuth2 client_credentials (M2M) token, and fails loud: a required value that doesn't resolve throws a typed error instead of returning undefined.

Containers use container mode, not the baked blob. See docs/Container-Runtime-Mode.md for the full env contract, a complete ExternalSecret (External Secrets Operator) recipe, and a readiness-probe example.

import{initContainerConfig,ConfigKeyUnresolvedError}from'@smooai/config/container';importschemafrom'../.smooai-config/config';// Validates the container env, mints a token, and does an initial fetch —// startup fails LOUD here (throws), not on first read.constconfig=awaitinitContainerConfig({ schema });// Fail-loud: a required secret that doesn't resolve throws// ConfigKeyUnresolvedError instead of returning undefined.conststripeKey=awaitconfig.secretConfig.get('stripeApiKey');// Kubernetes readiness probe — never throws.app.get('/healthz/config',(_req,res)=>{consth=config.health();// { status: 'healthy' } | { status: 'unhealthy', reason }res.status(h.status==='healthy' ? 200 : 503).json(h);});

Env contract (identical in every SDK): SMOOAI_CONFIG_API_URL, SMOOAI_CONFIG_CLIENT_ID, SMOOAI_CONFIG_CLIENT_SECRET, SMOOAI_CONFIG_ORG_ID, SMOOAI_CONFIG_ENV (all required), plus optional SMOOAI_CONFIG_AUTH_URL and SMOOAI_CONFIG_MODE=container (to force the mode). All schema-declared keys are treated as required by default; opt specific keys out with initContainerConfig({ optionalKeys: ['...'] }).

📖 Configuration Tiers

TierPurposeExamples
PublicClient-visible settingsAPI URLs, feature toggles, UI config
SecretServer-side onlyDatabase URLs, API keys, JWT secrets
Feature FlagsRuntime togglesA/B tests, gradual rollouts, beta access

Security: B2M Key Restrictions

OperationB2M (Public Key)M2M (Secret Key)
Read public valuesYesYes
Read feature flagsYesYes
Read secret valuesNo (filtered)Yes
Write config valuesNo (403)Yes
Delete config valuesNo (403)Yes

Browser-to-Machine (B2M) keys are designed for browser clients. Secret-tier values are automatically filtered. B2M keys are read-only for public and feature flag tiers.

Machine-to-Machine (M2M) keys have full access to all tiers and write operations.

📖 Multi-Language Support

@smooai/config has native server implementations in Python, Rust, Go, and .NET (C#) alongside the primary TypeScript package, plus mobile SDKs in Kotlin and Swift (a deliberately different, public-only surface — see below). Every server client reads the same encrypted bundle, the same schema, and the same config API. See the per-SDK READMEs linked above for full usage docs — the snippets below are five-line orientation only.

Python — see python/README.md

pip install smooai-config
# or: uv add smooai-config
fromsmooai_config.clientimportConfigClientwithConfigClient() asclient: # reads SMOOAI_CONFIG_* env varsvalue=client.get_value("API_URL", environment="production")
cargo add smooai-config
use smooai_config::ConfigClient;letmut client = ConfigClient::from_env();let value = client.get_value("API_URL",None).await?;
go get github.com/SmooAI/config/go/config
import"github.com/SmooAI/config/go/config"client:=config.NewConfigClientFromEnv()
deferclient.Close()
value, _:=client.GetValue("API_URL", "production")

.NET — see dotnet/README.md

dotnet add package SmooAI.Config
usingSmooAI.Config;usingSmooAI.Config.Runtime;varruntime=SmooConfigRuntime.Load();// reads SMOO_CONFIG_KEY_FILE + SMOO_CONFIG_KEYusingvarclient=newSmooConfigClient(options);varapiUrl=awaitPublic.ApiUrl.ResolveAsync(runtime,client);

Kotlin (mobile) — see docs/Mobile-Runtime-Mode-Spec.md

Ships via JitPack pinned to a commit (Maven Central under ai.smoo is the planned follow-up — the in-repo Gradle version is a dev default, so don't pin a version, pin a SHA):

// settings.gradle.kts / build.gradle.kts
repositories { maven("https://jitpack.io") }
dependencies { implementation("com.github.SmooAI:config:<commit-sha>") }
importai.smoo.config.SmooConfigimportai.smoo.config.SmooConfigOptionsval config =SmooConfig(SmooConfigOptions(environment ="production", engine = engine, bundledConfigFile = bakedBundle))
val apiUrl = config.publicValue("API_BASE_URL") // baked bundle → refreshed cache, offline-safeval newUi = config.evaluateFlag("ENABLE_NEW_UI", default =false) // http → disk cache → default

Swift (mobile) — see docs/Mobile-Runtime-Mode-Spec.md

Consumed via Swift Package Manager pinned to a revision (there is no SPM version tag yet; the root Package.swift exists so SPM can resolve the repo URL directly):

// Package.swift
.package(url:"https://github.com/SmooAI/config", revision:"<commit-sha>")
import SmooAIConfig
letconfig=SmooConfig(options:SmooConfigOptions(environment:"production", bundledConfigURL: bakedBundleURL))letapiUrl= config.publicValue(forKey:"API_BASE_URL")letnewUi=await config.evaluateFlag("ENABLE_NEW_UI", default:false)

Mobile binaries are attacker-owned territory, so the mobile SDKs speak only the public app-config surface (/config/app/...): a baked public bundle (plaintext — it never contains secrets) plus live feature-flag / limit evaluation with an offline disk cache. There is no secret tier, no M2M credential, and no schema/LocalConfigManager surface on device — by design, per ADR-074.

SDK capability notes

Honest asymmetries between the SDKs, so you can pick with your eyes open:

CapabilityTSPythonRustGo.NETKotlin / Swift
Encrypted baked bundle + config API reads📱 public-only bundle
Local config-file workflow (LocalConfigManager in Py/Rust/Go/.NET; the file tier in TS)
Cloud-region resolution
Deferred values / merge_replace_arrays semantics
Shared schema-validation conformance fixture (test-fixtures/schema-validation-cases.json)
Live feature flags✅ (+ limits)

The .NET SDK reached parity with the other four server SDKs in 6.11.x: LocalConfigManager, cloud-region resolution, deferred values and MergeReplaceArrays all landed, and its schema validator is held to the same test-fixtures/schema-validation-cases.json corpus. Cross-language schema-validation parity is now a TS/Python/Rust/Go/.NET guarantee. Kotlin and Swift stay outside it by design — there is no schema surface on device (ADR-074).

📖 Development

Prerequisites

  • Node.js 22+, pnpm 10+
  • Python 3.13+ with uv (for the Python package)
  • Rust toolchain (for the Rust package)
  • Go 1.22+ (for the Go package)

Commands

pnpm install # Install dependencies
pnpm build # Build all packages (TS, Python, Rust, Go)
pnpm test# Run all tests (Vitest, pytest, cargo test, go test)
pnpm lint # Lint all code (oxlint, ruff, clippy, go vet)
pnpm format # Format all code (oxfmt, ruff, cargo fmt, gofmt)
pnpm typecheck # Type check (tsc, basedpyright, cargo check)
pnpm check-all # Full CI parity check

Schema Libraries

Supports Zod, Valibot, ArkType, Effect Schema, and built-in schema types. See SCHEMA_USAGE.md for examples with each library.

🧩 Part of Smoo AI

@smooai/config is built and open-sourced by Smoo AI — the AI-powered business platform with AI built into every product: CRM, customer support, campaigns, field service, observability, and developer tools.

🤝 Contributing

Contributions are welcome. This project uses changesets to manage versions and releases.

  1. Fork the repository.
  2. Create your branch (git checkout -b amazing-feature).
  3. Make your changes.
  4. Add a changeset: pnpm changeset.
  5. Commit and push.
  6. Open a pull request.

📄 License

MIT © SmooAI. See LICENSE.

Contact

Brent Rager

Smoo GitHub: github.com/SmooAI


Built by Smoo AI — AI built into every product.

About

Type-safe multi-language configuration management — schema validation, three-tier config (public, secrets, feature flags), and runtime clients for TypeScript, Python, Rust, and Go.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Latest commit

History

217 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

@smooai/config — One schema. Every language. Typed everywhere.

npmSmoo AIlicense

TypeScriptPythonRustGo.NETKotlinSwift

Features · Install · Quick start · Platform


Define your config once with Zod (or Valibot, ArkType, Effect), and read it with full type inference everywhere — public config, server-only secrets, and live feature flags. Rename a key and every call site is a compile error, not a 3 AM page. Native clients in TypeScript, Python, Rust, Go, and .NET all read from the same source of truth.

📣 The CLI moved. Use smoo config from the smooth repo for all operator commands (login, get, set, list, push, pull, diff, init, etc.). The standalone smooai-config CLI that used to live in this repo is deprecated and being deleted (SMOODEV-1411). The runtime library @smooai/config (TypeScript / Python / Rust / Go / .NET, consumed via secretConfig.get(...) / publicConfig.get(...) / featureFlag.get(...)) is unchanged — only the operator CLI surface moved to Rust in the smooth repo. (smoo … is the th binary's platform namespace; th smoo … and the old th config … spelling also work.)

✨ Features

  • Three tiers, one schema — public config, secrets, and feature flags defined once with Zod/Valibot/ArkType/Effect, validated everywhere they're read.
  • Strongly-typed keysdefineConfig() gives you PublicConfigKeys, SecretConfigKeys, and FeatureFlagKeys with full inference. Mis-typed keys fail at compile time, not at runtime.
  • Any environment, any key — the same API for development, staging, and production. Override per-stage without touching code.
  • Zero-latency cold starts — values are baked into the bundle as env vars (Next.js, Vite) or resolved in-memory from a local runtime (server). No network round-trip on the hot path.
  • Browser, server, framework-native — the same typed keys read cleanly from React client components, Server Components, Next.js, Vite, or plain Node.
  • Live feature flags — toggled from the dashboard without a redeploy, but still typed.
  • Native clients in every language — TypeScript, Python, Rust, Go, and .NET (C#) server SDKs all read from the same source of truth, plus Kotlin and Swift mobile SDKs for the public-only app surface (ADR-074 mobile runtime mode).

Languages / SDKs

Pick the SDK that matches your service. Every server client reads the same schema, the same encrypted bundle, and the same config API — so a key renamed in one language ripples through all of them. The two mobile SDKs (📱) speak a deliberately narrower, public-only surface — no secrets ever ship to a device.

SDKOne-linerREADME
TypeScriptPrimary SDK. Schema definition, Next.js / Vite plugins, server runtime, React hooks.README.md (this file)
PythonPydantic-validated schemas, sync ConfigClient, LocalConfigManager + ConfigManager, baked runtime.python/README.md
GoNative struct schemas, thread-safe ConfigClient / ConfigManager, baked-blob runtime.go/config/README.md
RustJsonSchema-derived schemas, async ConfigClient, sync ConfigManager, baked-blob runtime.rust/config/README.md
.NETRoslyn source-generated typed keys, OAuth2 SmooConfigClient, AES-GCM SmooConfigRuntime. Thinner surface than the other server SDKs — see the capability notes.dotnet/README.md
Kotlin 📱Mobile runtime mode (ADR-074): baked public bundle + live flag/limit evaluation, offline-safe, no secrets on device. Ships via JitPack commit pin — not on Maven Central yet.kotlin/ · spec
Swift 📱Mobile runtime mode (ADR-074): same surface as Kotlin (publicValue, evaluateFlag, evaluateLimit). Consumed via SPM pinned to a revision — no version tag yet.swift/ · spec

The five server SDKs release in version lockstep — v6.11.3 on npm, PyPI, crates.io, and NuGet, with the matching v6.11.3 git tag for the Go module, all cut from the same commit (verified against each registry 2026-08-20). The mobile SDKs version separately (commit-pinned; see below).

📦 Install

pnpm add @smooai/config

🚀 Quick Start (TypeScript)

1. Define your configuration schema

Use defineConfig() with any StandardSchema-compliant library (Zod, Valibot, ArkType, Effect Schema) or the built-in StringSchema, BooleanSchema, and NumberSchema helpers:

// .smooai-config/config.tsimport{defineConfig,StringSchema,BooleanSchema,NumberSchema}from'@smooai/config';import{z}from'zod';constconfig=defineConfig({publicConfigSchema: {apiBaseUrl: z.string().url(),maxRetries: NumberSchema,enableDebug: BooleanSchema,},secretConfigSchema: {databaseUrl: z.string().url(),apiKey: StringSchema,},featureFlagSchema: {enableNewUi: BooleanSchema,betaFeatures: BooleanSchema,},});exportdefaultconfig;// Extract typed key objects for use throughout your appexportconst{ FeatureFlagKeys, PublicConfigKeys, SecretConfigKeys }=config;

defineConfig() automatically maps camelCase keys to UPPER_SNAKE_CASE:

FeatureFlagKeys.ENABLE_NEW_UI;// "ENABLE_NEW_UI"PublicConfigKeys.API_BASE_URL;// "API_BASE_URL"SecretConfigKeys.DATABASE_URL;// "DATABASE_URL"

2. Add to tsconfig.json

{
"compilerOptions": { ... },
"include": ["src/**/*", ".smooai-config/**/*.ts"]
}

📖 Next.js Integration

Inject config into next.config.ts

Use withSmooConfig() to inject feature flags and public config as NEXT_PUBLIC_ environment variables, with per-stage overrides:

// next.config.tsimport{withSmooConfig}from'@smooai/config/nextjs/withSmooConfig';constnextConfig=withSmooConfig({default: {featureFlags: {enableNewUi: false,betaFeatures: false},publicConfig: {apiBaseUrl: 'https://api.smooai.com',maxRetries: 3},},development: {featureFlags: {enableNewUi: true},publicConfig: {apiBaseUrl: 'http://localhost:3000'},},});exportdefaultnextConfig;

This sets environment variables like NEXT_PUBLIC_FEATURE_FLAG_ENABLE_NEW_UI=true and NEXT_PUBLIC_CONFIG_API_BASE_URL=http://localhost:3000 based on the current stage.

Read config in React client components

import{getClientFeatureFlag,getClientPublicConfig}from'@smooai/config/client';functionMyComponent(){constisNewUi=getClientFeatureFlag('enableNewUi');constapiUrl=getClientPublicConfig('apiBaseUrl');if(!isNewUi)return<LegacyUI/>;return<NewUIapiUrl={apiUrl}/>;}

These functions check NEXT_PUBLIC_FEATURE_FLAG_* and NEXT_PUBLIC_CONFIG_* env vars automatically — no provider needed, no loading state.

Server Components + Client hydration (zero loading flash)

For apps that need runtime config from a config server, use getConfig on the server and SmooConfigProvider to hydrate client components:

// app/layout.tsx (Server Component)import{getConfig,SmooConfigProvider}from'@smooai/config/nextjs';exportdefaultasyncfunctionRootLayout({ children }: {children: React.ReactNode}){constconfig=awaitgetConfig({environment: 'production',fetchOptions: {next: {revalidate: 60}},});return(<html><body><SmooConfigProviderinitialValues={config}baseUrl={process.env.SMOOAI_CONFIG_API_URL}apiKey={process.env.SMOOAI_CONFIG_API_KEY}orgId={process.env.SMOOAI_CONFIG_ORG_ID}environment="production">{children}</SmooConfigProvider></body></html>);}
// Any client component — values available synchronously (pre-seeded from SSR)import{usePublicConfig,useFeatureFlag}from'@smooai/config/nextjs';functionDashboard(){const{value: apiUrl}=usePublicConfig<string>('API_BASE_URL');const{value: enableNewUi}=useFeatureFlag<boolean>('ENABLE_NEW_UI');return(<div>
API: {apiUrl}, New UI: {String(enableNewUi)}</div>);}

📖 Vite Integration

Vite plugin

// vite.config.tsimport{defineConfig}from'vite';import{smooConfigPlugin}from'@smooai/config/vite/smooConfigPlugin';exportdefaultdefineConfig({plugins: [smooConfigPlugin({featureFlags: {enableNewUi: true,betaFeatures: false},publicConfig: {apiBaseUrl: 'http://localhost:3000'},}),],});

Then read values the same way as Next.js — getClientFeatureFlag and getClientPublicConfig from @smooai/config/client check VITE_FEATURE_FLAG_* and VITE_CONFIG_* automatically.

Preload config (optional)

For runtime config from a config server, start fetching before React mounts:

// main.tsximport{preloadConfig,ConfigProvider}from'@smooai/config/vite';import{createRoot}from'react-dom/client';preloadConfig({environment: 'production'});createRoot(document.getElementById('root')!).render(<ConfigProviderbaseUrl="https://config.smooai.dev"apiKey="your-public-key"orgId="your-org-id"environment="production"><App/></ConfigProvider>,);

📖 Server-Side Config Access

For Node.js server code, use buildConfigObject to get sync and async accessors with full type safety:

importbuildConfigObjectfrom'@smooai/config/platform/server';importconfig,{PublicConfigKeys,SecretConfigKeys,FeatureFlagKeys}from'./.smooai-config/config';constconfigObj=buildConfigObject(config);// Sync access (uses worker threads)constdbUrl=configObj.secretConfig.getSync(SecretConfigKeys.DATABASE_URL);constapiUrl=configObj.publicConfig.getSync(PublicConfigKeys.API_BASE_URL);constisNewUi=configObj.featureFlag.getSync(FeatureFlagKeys.ENABLE_NEW_UI);// Async accessconstapiKey=awaitconfigObj.secretConfig.getAsync(SecretConfigKeys.API_KEY);

How .getSync() works (and how to ship it in any bundled compute)

Sync accessors run an async config read to completion on the caller thread via synckit — a Node Worker pool + Atomics.wait on a SharedArrayBuffer. createSyncFn only accepts a file:// URL, so the worker body has to live on disk. The SDK resolves it in two stages:

  1. Sidecar filesync-worker.mjs sitting next to the compiled SDK entry (i.e. resolved via new URL('./sync-worker.mjs', import.meta.url) from dist/server/index.mjs). This is the normal case for plain Node resolution with no bundling — node_modules/@smooai/config/dist/server/sync-worker.mjs is already there. It's also the preferred case when bundlers copy the sidecar into the deploy output. Zero /tmp writes.

  2. Extract-to-/tmp fallback — if the sidecar isn't on disk at that path (e.g. a bundler inlined the SDK entry into a single file and didn't copy the sidecar), the SDK writes an embedded copy of the worker source to mkdtempSync()/sync-worker.mjs once per process and hands that path to synckit. One ~1-2 MiB write at cold start, amortised across every sync read for the lifetime of the process. Works anywhere with a writable temp dir.

Both paths are transparent — your code is identical either way. Which path you land on depends on how your compute is packaged.

Plain Node (no bundling)

Zero config. The SDK resolves node_modules/@smooai/config/dist/server/sync-worker.mjs directly — path (1) every time.

Any bundled compute (Lambda, Cloud Run, ECS, container, Worker, etc.)

The rule is universal: if your build inlines the SDK entry into a single output file, you need to ship sync-worker.mjs next to that output (or accept path (2)'s /tmp write once per cold start).

The source path is always:

node_modules/@smooai/config/dist/server/sync-worker.mjs

The destination is alongside whichever file ends up being your runtime's import.meta.url anchor — typically the bundled handler .mjs / .js.

Recipes for common setups:

esbuild — explicit copy plugin

// build.tsimport{build}from'esbuild';import{copy}from'esbuild-plugin-copy';awaitbuild({entryPoints: ['src/handler.ts'],outdir: 'dist',bundle: true,format: 'esm',platform: 'node',plugins: [copy({assets: {from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs',to: 'dist/sync-worker.mjs',},}),],});

tsup — onSuccess hook

// tsup.config.tsexportdefaultdefineConfig({entry: ['src/handler.ts'],format: ['esm'],onSuccess: 'cp node_modules/@smooai/config/dist/server/sync-worker.mjs dist/sync-worker.mjs',});

Serverless Framework — package.include

package:
patterns:
- 'node_modules/@smooai/config/dist/server/sync-worker.mjs'

Or copy into the handler dir as a build step and include from there.

AWS SAM — CodeUri + build script

Add a Makefile / build script that copies sync-worker.mjs into the BuildArtifactPath alongside your handler.

SST (AWS) — per-function or via $transform

// sst.config.ts — per functionnewsst.aws.Function('Api',{handler: 'src/api.handler',copyFiles: [{from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs'}],});// Or at the stack level via $transform (every Function gets it automatically)$transform(sst.aws.Function,(fn)=>{fn.copyFiles=[...(fn.copyFiles??[]),{from: 'node_modules/@smooai/config/dist/server/sync-worker.mjs'}];});

Docker container (ECS, Cloud Run, anywhere)

# After your main build step, ensure the sidecar is next to the bundled entry.COPY --from=build /app/dist/server.mjs /app/
COPY --from=build /app/node_modules/@smooai/config/dist/server/sync-worker.mjs /app/
CMD ["node", "server.mjs"]

If your build step keeps node_modules in the final image, no extra copy is needed — the SDK resolves the sidecar from node_modules/ path (1) directly.

When the sidecar truly can't be shipped

Path (2) — the /tmp extraction — is the safety net. One ~1-2 MiB write at cold start, then synckit re-uses the file for the rest of the process lifetime. Lambda's 512 MiB–10 GiB /tmp easily absorbs this; containers with an ephemeral /tmp work the same way. You can ignore this whole section and .getSync() will still work — you're just paying one filesystem write per cold start.

Edge runtimes (Vercel Edge, Cloudflare Workers)

These runtimes don't expose Node's worker_threads at all, so .getSync() is a no-go there by design. Use .get() (async) everywhere that needs to run on the edge. The error surface makes this explicit if you try.

📖 React Hooks (framework-agnostic)

For any React app using the runtime config client:

import{ConfigProvider,usePublicConfig,useFeatureFlag}from'@smooai/config/react';functionApp(){return(<ConfigProviderbaseUrl="https://config.smooai.dev"apiKey="your-api-key"orgId="your-org-id"environment="production"><MyComponent/></ConfigProvider>);}functionMyComponent(){const{value: apiUrl, isLoading, error }=usePublicConfig<string>('API_BASE_URL');const{value: enableNewUi}=useFeatureFlag<boolean>('ENABLE_NEW_UI');if(isLoading)return<div>Loading...</div>;if(error)return<div>Error: {error.message}</div>;return(<div>
API URL: {apiUrl}, New UI: {String(enableNewUi)}</div>);}

📖 SDK Runtime Client

All language implementations include a runtime client for fetching configuration values from the Smoo AI config server with local caching.

Environment Variables

Authentication is OAuth2 client_credentials against {authUrl}/token — the client exchanges (CLIENT_ID, CLIENT_SECRET) for a JWT and uses that JWT as the Bearer token on every config call. TokenProvider caches the JWT in memory and refreshes 60s before expiry.

VariableDescriptionRequired
SMOOAI_CONFIG_API_URLBase URL of the config APIYes
SMOOAI_CONFIG_AUTH_URLOAuth issuer base URL (defaults to https://auth.smoo.ai)No
SMOOAI_CONFIG_CLIENT_IDOAuth client IDYes
SMOOAI_CONFIG_CLIENT_SECRETOAuth client secret (legacy SMOOAI_CONFIG_API_KEY is accepted as a fallback)Yes
SMOOAI_CONFIG_ORG_IDOrganization IDYes
SMOOAI_CONFIG_ENVDefault environment name (defaults to "development")No

Migration note (v5 / SMOODEV-974): the TypeScript ConfigClient previously sent SMOOAI_CONFIG_API_KEY directly as the Bearer token, which the backend rejected with 401 because it expects a JWT. The SDK now mints a JWT via the OAuth client_credentials grant before each call — matching the .NET client, the in-package bootstrap, and the CLI. You must set SMOOAI_CONFIG_CLIENT_ID in addition to SMOOAI_CONFIG_API_KEY / SMOOAI_CONFIG_CLIENT_SECRET for the runtime SDK to work. The legacy SMOOAI_CONFIG_API_KEY env var continues to function as the OAuth client secret.

TypeScript Client

import{ConfigClient}from'@smooai/config/platform/client';// Zero-config (reads from env vars — needs CLIENT_ID + CLIENT_SECRET/API_KEY + ORG_ID)constclient=newConfigClient();// Or explicitconstclient=newConfigClient({baseUrl: 'https://config.smooai.dev',authUrl: 'https://auth.smooai.dev',clientId: 'your-client-id',clientSecret: 'your-client-secret',orgId: 'your-org-id',environment: 'production',});constapiUrl=awaitclient.getValue('API_BASE_URL');constallValues=awaitclient.getAllValues();client.invalidateCache();

📖 Container / Runtime Mode (EKS / ECS)

The baked blob tier is the blessed path for Lambda, but it is the wrong default for long-lived containers: when the per-build blob key isn't delivered to the pod, resolution silently falls through to the (absent) file tier and returns undefined for a required secret. That caused a real outage — a container got undefined for STRIPE_API_KEY, new Stripe(undefined) threw at module load, the process exited 0 before listen(), and the pod CrashLooped with the root cause buried (SMOODEV-1478).

Container mode makes the HTTP config API the first-class path for containers, authenticated with an OAuth2 client_credentials (M2M) token, and fails loud: a required value that doesn't resolve throws a typed error instead of returning undefined.

Containers use container mode, not the baked blob. See docs/Container-Runtime-Mode.md for the full env contract, a complete ExternalSecret (External Secrets Operator) recipe, and a readiness-probe example.

import{initContainerConfig,ConfigKeyUnresolvedError}from'@smooai/config/container';importschemafrom'../.smooai-config/config';// Validates the container env, mints a token, and does an initial fetch —// startup fails LOUD here (throws), not on first read.constconfig=awaitinitContainerConfig({ schema });// Fail-loud: a required secret that doesn't resolve throws// ConfigKeyUnresolvedError instead of returning undefined.conststripeKey=awaitconfig.secretConfig.get('stripeApiKey');// Kubernetes readiness probe — never throws.app.get('/healthz/config',(_req,res)=>{consth=config.health();// { status: 'healthy' } | { status: 'unhealthy', reason }res.status(h.status==='healthy' ? 200 : 503).json(h);});

Env contract (identical in every SDK): SMOOAI_CONFIG_API_URL, SMOOAI_CONFIG_CLIENT_ID, SMOOAI_CONFIG_CLIENT_SECRET, SMOOAI_CONFIG_ORG_ID, SMOOAI_CONFIG_ENV (all required), plus optional SMOOAI_CONFIG_AUTH_URL and SMOOAI_CONFIG_MODE=container (to force the mode). All schema-declared keys are treated as required by default; opt specific keys out with initContainerConfig({ optionalKeys: ['...'] }).

📖 Configuration Tiers

TierPurposeExamples
PublicClient-visible settingsAPI URLs, feature toggles, UI config
SecretServer-side onlyDatabase URLs, API keys, JWT secrets
Feature FlagsRuntime togglesA/B tests, gradual rollouts, beta access

Security: B2M Key Restrictions

OperationB2M (Public Key)M2M (Secret Key)
Read public valuesYesYes
Read feature flagsYesYes
Read secret valuesNo (filtered)Yes
Write config valuesNo (403)Yes
Delete config valuesNo (403)Yes

Browser-to-Machine (B2M) keys are designed for browser clients. Secret-tier values are automatically filtered. B2M keys are read-only for public and feature flag tiers.

Machine-to-Machine (M2M) keys have full access to all tiers and write operations.

📖 Multi-Language Support

@smooai/config has native server implementations in Python, Rust, Go, and .NET (C#) alongside the primary TypeScript package, plus mobile SDKs in Kotlin and Swift (a deliberately different, public-only surface — see below). Every server client reads the same encrypted bundle, the same schema, and the same config API. See the per-SDK READMEs linked above for full usage docs — the snippets below are five-line orientation only.

Python — see python/README.md

pip install smooai-config
# or: uv add smooai-config
fromsmooai_config.clientimportConfigClientwithConfigClient() asclient: # reads SMOOAI_CONFIG_* env varsvalue=client.get_value("API_URL", environment="production")
cargo add smooai-config
use smooai_config::ConfigClient;letmut client = ConfigClient::from_env();let value = client.get_value("API_URL",None).await?;
go get github.com/SmooAI/config/go/config
import"github.com/SmooAI/config/go/config"client:=config.NewConfigClientFromEnv()
deferclient.Close()
value, _:=client.GetValue("API_URL", "production")

.NET — see dotnet/README.md

dotnet add package SmooAI.Config
usingSmooAI.Config;usingSmooAI.Config.Runtime;varruntime=SmooConfigRuntime.Load();// reads SMOO_CONFIG_KEY_FILE + SMOO_CONFIG_KEYusingvarclient=newSmooConfigClient(options);varapiUrl=awaitPublic.ApiUrl.ResolveAsync(runtime,client);

Kotlin (mobile) — see docs/Mobile-Runtime-Mode-Spec.md

Ships via JitPack pinned to a commit (Maven Central under ai.smoo is the planned follow-up — the in-repo Gradle version is a dev default, so don't pin a version, pin a SHA):

// settings.gradle.kts / build.gradle.kts
repositories { maven("https://jitpack.io") }
dependencies { implementation("com.github.SmooAI:config:<commit-sha>") }
importai.smoo.config.SmooConfigimportai.smoo.config.SmooConfigOptionsval config =SmooConfig(SmooConfigOptions(environment ="production", engine = engine, bundledConfigFile = bakedBundle))
val apiUrl = config.publicValue("API_BASE_URL") // baked bundle → refreshed cache, offline-safeval newUi = config.evaluateFlag("ENABLE_NEW_UI", default =false) // http → disk cache → default

Swift (mobile) — see docs/Mobile-Runtime-Mode-Spec.md

Consumed via Swift Package Manager pinned to a revision (there is no SPM version tag yet; the root Package.swift exists so SPM can resolve the repo URL directly):

// Package.swift
.package(url:"https://github.com/SmooAI/config", revision:"<commit-sha>")
import SmooAIConfig
letconfig=SmooConfig(options:SmooConfigOptions(environment:"production", bundledConfigURL: bakedBundleURL))letapiUrl= config.publicValue(forKey:"API_BASE_URL")letnewUi=await config.evaluateFlag("ENABLE_NEW_UI", default:false)

Mobile binaries are attacker-owned territory, so the mobile SDKs speak only the public app-config surface (/config/app/...): a baked public bundle (plaintext — it never contains secrets) plus live feature-flag / limit evaluation with an offline disk cache. There is no secret tier, no M2M credential, and no schema/LocalConfigManager surface on device — by design, per ADR-074.

SDK capability notes

Honest asymmetries between the SDKs, so you can pick with your eyes open:

CapabilityTSPythonRustGo.NETKotlin / Swift
Encrypted baked bundle + config API reads📱 public-only bundle
Local config-file workflow (LocalConfigManager in Py/Rust/Go/.NET; the file tier in TS)
Cloud-region resolution
Deferred values / merge_replace_arrays semantics
Shared schema-validation conformance fixture (test-fixtures/schema-validation-cases.json)
Live feature flags✅ (+ limits)

The .NET SDK reached parity with the other four server SDKs in 6.11.x: LocalConfigManager, cloud-region resolution, deferred values and MergeReplaceArrays all landed, and its schema validator is held to the same test-fixtures/schema-validation-cases.json corpus. Cross-language schema-validation parity is now a TS/Python/Rust/Go/.NET guarantee. Kotlin and Swift stay outside it by design — there is no schema surface on device (ADR-074).

📖 Development

Prerequisites

  • Node.js 22+, pnpm 10+
  • Python 3.13+ with uv (for the Python package)
  • Rust toolchain (for the Rust package)
  • Go 1.22+ (for the Go package)

Commands

pnpm install # Install dependencies
pnpm build # Build all packages (TS, Python, Rust, Go)
pnpm test# Run all tests (Vitest, pytest, cargo test, go test)
pnpm lint # Lint all code (oxlint, ruff, clippy, go vet)
pnpm format # Format all code (oxfmt, ruff, cargo fmt, gofmt)
pnpm typecheck # Type check (tsc, basedpyright, cargo check)
pnpm check-all # Full CI parity check

Schema Libraries

Supports Zod, Valibot, ArkType, Effect Schema, and built-in schema types. See SCHEMA_USAGE.md for examples with each library.

🧩 Part of Smoo AI

@smooai/config is built and open-sourced by Smoo AI — the AI-powered business platform with AI built into every product: CRM, customer support, campaigns, field service, observability, and developer tools.

🤝 Contributing

Contributions are welcome. This project uses changesets to manage versions and releases.

  1. Fork the repository.
  2. Create your branch (git checkout -b amazing-feature).
  3. Make your changes.
  4. Add a changeset: pnpm changeset.
  5. Commit and push.
  6. Open a pull request.

📄 License

MIT © SmooAI. See LICENSE.

Contact

Brent Rager

Smoo GitHub: github.com/SmooAI


Built by Smoo AI — AI built into every product.

About

Type-safe multi-language configuration management — schema validation, three-tier config (public, secrets, feature flags), and runtime clients for TypeScript, Python, Rust, and Go.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages