Skip to main content
Configuration and secrets back a server’s environment and settings. A built-in file provider ships by default, and the store is swappable behind one contract, so a server can read its config from a file, from Kubernetes, or from another backend without any change to the code that consumes it.

The provider seam

Config is a pluggable-provider feature. The skeleton ships the selection seam — a config mode, its settings, and a factory that maps a mode to a provider module — and the default file provider. The active mode is chosen with TAI_CONFIG_MODE (file by default). The factory loads the selected provider by dynamic import and builds its manager:
Other providers ship as separately installed plugins that expose the same build_config_manager() convention. The factory needs nothing but the mode and the module; the consuming code never knows which provider is behind the manager.

Secrets are referenced, not embedded

Sensitive values are referenced by the name of the environment variable that holds them, not by value. A secret_env field carries an env var name; the runtime reads the secret from the environment at use time. So a manifest, a hook, or a webhook verifier binding names secret_env and never contains the credential itself — keeping secrets out of files that are edited, versioned, or displayed.

How a config change is written

Every change to the manifest or env — a tool reload, a manifest replace, a live env edit — runs one mutation pipeline: validate the resulting config, persist it transactionally, reload the calling process, then broadcast on the worker bus. Persisting before reloading is why the persisted config and the live config cannot drift apart through a supported path — the running state is always what is on disk. Two guarantees hold across every provider:
  • Transactional writes, in both config modes. A write holds exclusive access across the whole read-modify-write span, so a concurrent writer cannot interleave and lose an update — the file provider takes a lock, the k8s provider retries on an optimistic-concurrency conflict.
  • Comment preservation. A manifest edit keeps the surrounding comments, key order, and formatting of the untouched document. An automated edit does not reflow a file a human maintains by hand.

Settings profiles

A settings profile is a named, versioned snapshot of the whole profile-managed env band — its env map, the per-key secret marks, and a description — kept in the same versioned-document store as presets. Applying a profile replaces the stored env with its band in one shot: a key the profile does not name is dropped (save the carried deployment band, below), so a profile is a whole environment you switch to, not a patch you layer on. Drive it from the CLI:
The Studio Profiles tab showing a saved settings profile whose secret-marked key is masked.The Studio Profiles tab showing a saved settings profile whose secret-marked key is masked.

The Profiles tab: a saved profile's env band, with its secret-marked key masked.

An apply runs the same mutation pipeline as every other config change — validate, persist, reload, broadcast — so the whole fleet converges and the response carries the fleet report. Only !ENV references follow the profile. A profile switches the profile-managed env band, not the manifest document, so what a profile can switch inside the manifest is exactly its !ENV references. A manifest value written as an !ENV ${KEY} reference resolves against the effective env at reload — switch to a profile that changes KEY and the value resolves differently. An inline (literal) manifest value is baked into the manifest document, not the env band, so no profile touches it and it is not switchable. This is why the Studio SecretRefField / MCP-tab secret flow writes an !ENV reference automatically: a pasted secret is stored under a generated env key and the manifest gets the marker, never the plaintext — so a secret is referenced, not embedded, stays masked, and stays profile-switchable. Stored env applies at boot, before anything resolves. Every serving process bridges the stored env — the config store’s .env or the Kubernetes Secret — into its environment at boot, before the manifest resolves, at the same precedence as a reload: a stored value overrides the container env. So an !ENV ${KEY} marker resolves to its stored value on a cold boot, not only after a later reload. A marker whose variable is set in neither the container env nor the stored env resolves to a sentinel, and boot emits one loud warning naming the dangling variables. Reload classes. Every setting carries a reload class, surfaced in the settings reference. When a profile is applied the pipeline diffs the proposed env against the stored one and routes each changed key by its class:
  • hot — re-read in place on the next serving generation. The common case; no resource is torn down.
  • recycle — the pooled resource behind the key (a Redis or broker connection, the bus) must be torn down and rebuilt, which needs a supervisor that can respawn the affected process (see per-shape support below).
  • excluded — deployment / boot identity: the config mode, the transport, the manifest path, the readiness sentinel. These are set by the launcher and have no live-reload path, so a profile that carries one is refused — at save and at apply — naming the key. The refusal reads the profile’s declared keys, never the effective env, so the applier legitimately carrying the whole excluded band across the replace is not itself rejected.
Key material is refused, not carried. Key material — KEKs and signing keys such as CONNECTORS_KEK, the KeyMaterial type — is flagged apart from the reload classes. A profile (or env edit, or backup import) that names a key-material key is refused — at save and at apply — naming the key and pointing at its rotation path, because bulk-setting one would silently invalidate every secret the old key secured. Key material is rotated only through its own controlled path, never set through a profile. Like the excluded refusal, this reads the profile’s declared keys, never the effective env, so an applier legitimately carrying the stored key material across the replace is not itself rejected. For CONNECTORS_KEK that controlled path is Rotating CONNECTORS_KEK: rotate the key with a previous-key decrypt ring plus a re-encrypt sweep, so old ciphertext is never bricked. @previous revert. Every apply first snapshots the current stored env into a reserved @previous profile before it writes, so the last-known-good environment is always one apply away — tai config profile apply @previous reverts a bad change. Names beginning @ are reserved for these pipeline snapshots; an operator cannot create or overwrite one, and they are hidden from the profile list. Per-shape recycle support. Whether a recycle-class change can be carried depends on the deployment shape, which the server reads from a supervision marker set in lockstep with the config mode:
  • On a supervised shape (Kubernetes, Compose, the e2e harness) a recycle rebuilds the affected pooled resource and the applier reports which origins recycled.
  • On a bare shape — no supervisor — a recycle-class diff is refused wholesale: nothing can respawn the process, so the change could never converge, and saying so upfront beats a half-applied surface.
  • Even on a supervised shape some keys are pinned by the deployment itself. A Compose anchor key (x-tai-app-env) or a Kubernetes pod-env value is re-injected on every respawn, so a profile-carried change to it would silently revert on the next restart. Those immovable anchor keys are refused upfront, named, rather than applied and then quietly undone. The bus URL is refused on every shape — the fleet census rides the bus, so recycling the bus out from under a live apply is intrinsically unobservable.
Stateful MCP sessions break on apply. An apply builds a fresh serving generation (an epoch) under the proposed env and swaps it in atomically; the old epoch is retired. Retiring closes the old generation’s FastMCP session manager, so the new epoch serves a fresh session-id space with no handoff: a stateful MCP client’s session is terminated and it must re-initialize. In-flight HTTP requests and SSE streams already admitted on the old epoch drain against it — only the MCP session identity is severed, and a client treats it as a normal reconnect.

The plugin model

Config providers are one member of a broader pattern. A plugin is any separately-shipped package that registers through the tai42_app contract handle — connectors, storage backends, config providers, backends, monitoring, and webhook verifiers. A plugin is loaded because the manifest names its module, and no plugin imports the skeleton, which is what keeps the core provider-free and the ecosystem open-ended. Change environment values and read the active mode from the CLI:

Named databases

Every Postgres-backed component connects through a named database in one central registry, not a private per-store connection namespace. A database <NAME> is declared once under TAI_DATABASE_<NAME>_PG_*, and every component bound to it — its migration chain, its boot-gate guard, and all of its stores — resolves to that same connection, so a runner and its guard can never target different databases. The registry ships one database, default, under TAI_DATABASE_DEFAULT_PG_*; add more by declaring another name. <NAME> is the registry name uppercased (defaultDEFAULT). Pool sizes and timeouts (_PG_MIN_CONNECTIONS, _PG_MAX_CONNECTIONS, _PG_CONNECT_TIMEOUT, _PG_STATEMENT_TIMEOUT_SECONDS) tune each database the same way. A database is configured when its password resolves to a non-empty value. An unconfigured database leaves every component bound to it cleanly OFF — there is no baked-in localhost.

Admin identity

Migrations run as a DDL-privileged role, separate from the runtime role the stores query with. Give a database its own migrator identity with: When the admin pair is unset, the migrator identity is that database’s own runtime user and password — one identity for both, the common single-role case. Setting exactly one of the pair is rejected loudly at startup, naming both TAI_DATABASE_<NAME>_PG_ADMIN_USER and TAI_DATABASE_<NAME>_PG_ADMIN_PASSWORD — never a silent pairing of one admin field with the runtime one. Set the admin pair only when DDL runs as a role distinct from day-to-day queries.

Component bindings

Each migration component — the core (skeleton) and every installed plugin that owns tables — binds to one named database: <COMPONENT_SLUG> is the component name uppercased with every character outside [A-Z0-9] turned into _: the core skeleton reads TAI_DB_BINDING_SKELETON, the accounts plugin tai42-accounts-postgres reads TAI_DB_BINDING_TAI42_ACCOUNTS_POSTGRES. Left unset, a component binds to default, so a single-database deployment sets only TAI_DATABASE_DEFAULT_PG_* and every component shares it. Point a component at its own database by binding it to another declared name.

Default connection namespace

An optional default namespace supplies the one Redis connection every Redis-backed feature falls back to, per field. Set the TAI_DEFAULT_REDIS_* variables once and any feature that leaves its own Redis URL unset reads it from there; a feature that sets its own URL always wins. Leaving them unset changes nothing — each feature resolves exactly as it does without the namespace. Only connection-identity fields fall back — the Redis URL and its connection cap. Behavior knobs (socket and connect timeouts, retries, decode_responses) never fall back and stay per-feature, so a blanket default can never reshape how one feature talks to a backend it shares an address with. This is the full public surface — nothing outside this list falls back. Resolution order. Each field resolves independently, taking the first source that sets it:
  1. the value written to the config store (manifest env / init);
  2. the field’s own environment variable;
  3. the field’s own .env entry;
  4. a file secret for the field (secrets_dir);
  5. the matching TAI_DEFAULT_* environment variable;
  6. the matching TAI_DEFAULT_* .env entry;
  7. the class-level default.
The default namespace sits below every store-specific source and above the built-in class default, so a specific value always beats the default and the default always beats the code-level fallback. Setting TAI_DEFAULT_REDIS_URL configures every Redis-gated feature — that reach is the point. A shared Redis URL is a deliberate act with fleet-wide effect: the presence-gated features that run in-memory until a Redis URL appears — conversations, hooks, sub-MCP, the worker bus, and their health and readiness probes — all become Redis-backed at once, and the four channel correlation stores (Slack, Telegram, Twilio, WhatsApp), each of which refuses to start unconfigured, become configured. Setting the default is how you turn those on together; if that is not what you want, leave TAI_DEFAULT_REDIS_URL unset and give each feature its own URL. Workers. The arq, rq, and celery worker plugins take the same fallback: ARQ_REDIS_URL, ARQ_REDIS_MAX_CONNECTIONS, RQ_REDIS_URL, CELERY_BROKER_URL, CELERY_RESULT_BACKEND, and CELERY_REDBEAT_REDIS_URL fall back to TAI_DEFAULT_REDIS_URL / TAI_DEFAULT_REDIS_MAX_CONNECTIONS when unset — so an unset celery broker runs on Redis rather than its AMQP class default once the default namespace is set. Startup visibility. A store that fills any field from the default namespace logs one INFO line at startup naming those fields — field names only, never values — so a value arriving from TAI_DEFAULT_* rather than the store’s own namespace is visible in the boot log instead of silent. Prior art. This is the shape of Rails’ database.yml default: anchor — per-key layered defaults that a specific environment overrides one key at a time — a long-canonical pattern for connection config.

No hidden defaults: absent means OFF

There is no baked-in localhost. A Postgres-backed feature is on only when the named database its component binds to is configured; a Redis-backed feature is on only when its own Redis URL — or the shared TAI_DEFAULT_REDIS_URL — resolves. Absent either way, the feature is cleanly OFF, never a silent connection to a local backend. Turning a feature on is therefore a deliberate act: configure its bound database (TAI_DATABASE_<NAME>_PG_PASSWORD), or give the Redis feature a URL.

What OFF looks like

An OFF feature is a reported state, not an error — nothing 500s, nothing probes a backend that is not there. Every surface answers honestly:
  • Reads answer empty. A collection read returns 200 with an empty list; a read of a named entity returns 404, byte-identical to a genuine miss.
  • Writes refuse with a named reason. A mutation returns 501 with the JSON body {"error": …, "code": "<feature>-not-configured"} — the code is machine-readable so a caller can branch on it, and the message names the env var that turns the feature on.
  • Readiness skips it. /ready omits an unconfigured feature entirely: an OFF feature never fails a readiness probe and is never probed.
  • Introspection labels it. GET /api/system/kinds lists the feature with state: "off" and a detail naming the variable to set; the boot summary shows the same row.
  • Boot logs one line. Startup emits a single INFO line per gated feature naming what is off and which env prefix turns it on — no traceback, no repetition.
  • Public doors stay uniform. An unauthenticated door keeps answering with its own standard 404, so an OFF feature opens no new oracle.
Rate limiting is the one loud case: with no Redis behind it, every public door passes through unthrottled and boot emits one WARNING — never a boot refusal — naming TAI_RATE_LIMIT_REDIS_URL.

OFF behavior per feature

Each feature turns on the moment its connection resolves — a Redis feature from its own variable or the shared TAI_DEFAULT_REDIS_URL, a Postgres feature from the named database its component binds to. The table below is generated from the skeleton’s gated-feature registry — the same list GET /api/system/kinds reports — so it never drifts from the code; the Kinds row column names the kind each feature reports and the off state it carries when unconfigured. Access control is the one gate outside this table: with ACCESS_CONTROL_ENABLE off (its Redis live context otherwise wired through ACCESS_CONTROL_REDIS_URL and its policy store on the named database the core skeleton component binds to), no gate runs on any request and the whole ecosystem is auth-free — its OFF state surfaces through the identity kind row, not a gated-feature row.

Local-dev recipe

For a laptop stack pointed at one local Redis and one local Postgres, configure the default database and the shared Redis namespace once and leave every binding unset — every component binds to default and every Redis feature falls back, so they turn on together:

Settings reference

Every setting below is read from the environment (or the managed .env store) at use time. These are hand-maintained here — there is no generated reference for the skeleton, toolbox, and agents settings, so this page is their canonical home.

Core environment

Worker bus

The worker bus is app-owned internal infrastructure — a plain-Redis fan-out primitive, not a plugin. It is configured entirely through these TAI_BUS_* variables; no manifest field selects a bus.

Connector token encryption

Connector OAuth token blobs are encrypted at rest. Supply the key by env var name — the value is never embedded in a manifest.

Rotating CONNECTORS_KEK

Rotating the current KEK on a live deployment does not brick stored tokens, because decryption trial-decrypts a key ring — the current CONNECTORS_KEK first, then each key in CONNECTORS_KEK_PREVIOUS. Encryption always uses the current key, so the store converges onto the new key. To rotate:
  1. Carry the old key. Set CONNECTORS_KEK_PREVIOUS to the current key’s value and set CONNECTORS_KEK to the new key. Every stored blob still decrypts (via the ring), and every new/refreshed token is written under the new key.
  2. Converge the store. Run tai connectors reencrypt-tokens (route POST /api/connectors/tokens/reencrypt). It scans every stored blob — expired sessions included — re-encrypts each one still under a previous key with the current key, and reports {scanned, reencrypted, skipped, failed, failed_connection_ids, cas_retries}. It is idempotent (a blob already under the current key is skipped) and compare-and-set safe (a concurrent token refresh is never clobbered). A blob no configured key can open is counted in failed and named — never silently dropped.
  3. Retire the old key. Once the sweep reports failed: 0, clear CONNECTORS_KEK_PREVIOUS. The old key is no longer needed and its exposure window closes.
This is the controlled rotation path the key-material refusal points at (see Settings profiles above, “Key material is refused, not carried”): a KEK is rotated through these steps, never by carrying it in a settings profile.

Proxy pool (tai42-toolbox)

Timeouts and limits

Guardrails on downstream calls, concurrency, and payload sizes. All have safe defaults; raise a timeout only when a legitimately longer operation needs it. Connection timeouts. Postgres connection tuning is per named database: TAI_DATABASE_<NAME>_PG_CONNECT_TIMEOUT (default 10) and TAI_DATABASE_<NAME>_PG_STATEMENT_TIMEOUT_SECONDS (default 60) — for example TAI_DATABASE_DEFAULT_PG_CONNECT_TIMEOUT. Each Redis-backed feature carries its own tuning under its env prefix: <PREFIX>SOCKET_TIMEOUT and <PREFIX>SOCKET_CONNECT_TIMEOUT — for example ACCESS_CONTROL_SOCKET_TIMEOUT, TAI_TOOL_RUNS_SOCKET_CONNECT_TIMEOUT.
A specific config provider’s setup — for example which secret store and config source it reads — lives in that provider package’s own repository, reachable from the Plugins section. The platform documents the provider contract, not any one provider.
See the CLI reference for tai config.