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 withTAI_CONFIG_MODE
(file by default). The factory loads the selected provider by dynamic import and
builds its manager:
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. Asecret_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
fileprovider takes a lock, thek8sprovider 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 Profiles tab: a saved profile's env band, with its secret-marked key masked.
!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.
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.
The plugin model
Config providers are one member of a broader pattern. A plugin is any separately-shipped package that registers through thetai42_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 (default → DEFAULT). 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 theTAI_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:
- the value written to the config store (manifest env / init);
- the field’s own environment variable;
- the field’s own
.enventry; - a file secret for the field (
secrets_dir); - the matching
TAI_DEFAULT_*environment variable; - the matching
TAI_DEFAULT_*.enventry; - the class-level default.
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-inlocalhost. 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
200with an empty list; a read of a named entity returns404, byte-identical to a genuine miss. - Writes refuse with a named reason. A mutation returns
501with the JSON body{"error": …, "code": "<feature>-not-configured"}— thecodeis machine-readable so a caller can branch on it, and the message names the env var that turns the feature on. - Readiness skips it.
/readyomits an unconfigured feature entirely: an OFF feature never fails a readiness probe and is never probed. - Introspection labels it.
GET /api/system/kindslists the feature withstate: "off"and a detail naming the variable to set; the boot summary shows the same row. - Boot logs one line. Startup emits a single
INFOline 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.
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 sharedTAI_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 thedefault 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 theseTAI_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:
- Carry the old key. Set
CONNECTORS_KEK_PREVIOUSto the current key’s value and setCONNECTORS_KEKto the new key. Every stored blob still decrypts (via the ring), and every new/refreshed token is written under the new key. - Converge the store. Run
tai connectors reencrypt-tokens(routePOST /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 infailedand named — never silently dropped. - Retire the old key. Once the sweep reports
failed: 0, clearCONNECTORS_KEK_PREVIOUS. The old key is no longer needed and its exposure window closes.
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.
tai config.
