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

A sensitive value is referenced by the name of the environment variable that holds it, not by value — the runtime reads it from the environment at use time, so a file that is edited, versioned, or displayed never contains the credential itself. The reference takes one of two shapes, by where it sits:
  • A scalar value written !ENV ${VAR} is the convention wherever a secret rides as a plain string leaf — a manifest value, a Studio secret field, and a preset’s baked fixed_kwargs. The runtime keeps the marker verbatim and resolves it from the environment at use time; the !ENV ${VAR:default} form falls back to default when the variable is absent. The Studio SecretRefField and MCP-tab secret flow write this marker for a pasted secret automatically (see Settings profiles).
  • A secret_env field carries the env var name where the binding is a config object rather than a scalar — a hook or webhook verifier binding names secret_env, and an OAuth connector provider a client_secret_env.
Either way the credential stays out of every file that is 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. Every config write runs inside the provider’s exclusive_write() span, held from the first read through the persist. A provider must block until the write is exclusive across every process sharing the store and within the holding process itself, so two spans over one store are never held at once. It must release the span when the holding process dies, and must raise rather than let a write proceed unserialized. A release the provider cannot complete raises from the span’s exit; the writes made inside the span stand, the write’s reload and broadcast still run, and the store may still hold the span until the holding process dies — the response’s span_release_error and an ERROR log carry the provider’s error. The span is entered and exited on worker threads, and the exiting thread may differ from the entering one, so a provider must not bind the span to the thread that entered it. The file provider takes a POSIX record lock on .config-write.lock (mode 0600) in the config directory, plus a process-local lock, since a record lock does not exclude its own process; it is not supported on Windows.
  • 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.

Deployment values and stored values

A server takes its env from two places: the deployment’s environment — the process environment it starts with, such as a Compose environment: entry, a Kubernetes pod env value, or an exported shell variable — and the stored env, which the admin edits through the app. In file mode the stored env is the .env file in the config directory. Each tai entry point first loads the .env file that python-dotenv’s search finds — upward from the folder of the starting tai code, or from the working directory when run interactively or under a debugger — into the process environment, so its keys are deployment values; it never loads the config directory’s stored .env this way: when the search lands on that file, it logs a WARNING naming it and skips it, and the server applies the stored env itself. A .env that names its own folder as the config directory while the process environment sets none is refused at startup, naming the file: the config directory is a deployment value, set in the environment, never in the stored env. Settings also read the .env file in the working directory as a source of their own (see the resolution order), but in file mode never the stored .env, even when the working directory is the config directory, as in the shipped image (/app): a stored key reaches a setting only through the process environment the server applies the stored env to. The FASTMCP_* settings of the FastMCP framework the server runs on follow the same rule in every process that runs the app — the tai serve workers, tai backend and the stdio server: FastMCP reads them when it loads, and the process reads them again, without the stored .env, once it has applied the stored env. Every other tai process — the tai serve parent, tai metrics and the client commands — runs no app and keeps FastMCP’s own read at load, which takes the .env that FASTMCP_ENV_FILE names (the working directory’s by default), so in file mode it can read the stored .env. An embedded app leaves FastMCP’s settings as the host process set them. FastMCP’s settings are a reader of the applied env like the server’s own settings, so a FASTMCP_* setting the deployment sets is the deployment’s under every name FastMCP reads for it: a deployment that sets FASTMCP_DOCKET_URL owns FASTMCP_DOCKET__URL and the docket’s JSON name FASTMCP_DOCKET too. The deployment’s value wins. A key that any app process of the deployment — a serve worker or a backend runtime — starts with set to a non-empty value belongs to the deployment, for the whole deployment: the stored env is one for every process, so Studio shows the key locked, no save sets it, and no process on the same worker bus applies a stored copy of it, including a process whose own environment does not carry the key. While two skeleton majors share one Redis and bus namespace, this holds per major: each judges by its own major’s workers (two fleets). A process learns what the other processes’ environments set from the bus it reaches (see below): a process with no bus, or one that a stored copy of the bus Redis URL or of TAI_BUS_NAMESPACE points at another bus, learns none of their names, judges by its own environment alone, and runs apart from the fleet. A process the deployment did not give the value runs what it would run with no stored copy: the TAI_DEFAULT_* fallback when one is set, otherwise the field’s code default. At boot and at every reload, the deployment’s value wins over a stored value for the same key: the stored copy is ignored, and each apply logs one WARNING naming the ignored keys (names only, never values). A stored empty value for such a key is skipped the same way, so the deployment’s value stays. Env names are read case-insensitively, so a key belongs to the deployment under every spelling of its name. A settings field that the deployment sets under any name that sets it belongs to the deployment under all of them: its accepted env names, the JSON name of a nested group that holds it (HOOKS_REDIS sets every field of the hooks Redis connection, HOOKS_REDIS_URL among them), and, in a group that reads nested names, its delimited names (TAI_RATE_LIMIT_REDIS__REDIS_URL sets TAI_RATE_LIMIT_REDIS_URL). A deployment that sets a group’s JSON name owns every field of the group. A TAI_DEFAULT_* fallback is not one of a field’s names: a deployment that sets only the fallback owns that fallback name (a save that sets it is refused), and the field stays the admin’s. The field’s own name can still be stored, and the stored value then wins over the fallback. Every other key belongs to the admin, and the app stores it. A save cannot set a deployment value. A write that sets a deployment-owned key to a new value is refused with a 400 that names the keys, and nothing is written. This holds on every env write: an env edit (POST /api/config/env, tai config env set, its MCP tool, the Studio editor), a profile apply, a secret env write, a marketplace install or revert, and a backup import. It holds on a profile save too, so a profile that could never be applied is never stored. A write may remove a stored copy of such a key, since the copy has no effect, or carry it with its stored value unchanged. Neither counts as a change: it rolls no recycle, and it is allowed for the bus URL too. GET /api/config/env (tai config env get) names the stored keys the deployment sets under deployment_keys. tai config profile diff lists the deployment-owned keys a profile sets to a new value under refused_keys. To change a deployment value, change it in the deployment’s environment and restart the process. One spelling per name. The stored env sets each name under one spelling. A write whose resulting stored env would set one name under two spellings (TAI_LOG_LEVEL and tai_log_level) is refused with a 400 naming both, since a process reads only one of them and two processes could read different ones. To rename a key’s spelling, remove the old spelling in the same write. A stored env that carries two spellings of one name is refused at boot and at reload, naming both. To recover, keep one spelling of each such name: while a process still runs, an env write that removes the other spelling (sets it to an empty value) is accepted; otherwise edit the stored env directly (the config store’s .env or the Kubernetes Secret) and start the process again. How every process learns what the deployment owns. Each process on the worker bus publishes, on its row of the fleet census, the names its own environment sets and a description of every settings name it reads: the names and how they nest, never values. A save, a read of the env or the settings schema, a profile diff, and a reload each read the census once and judge ownership by the names of every worker in it, whatever the worker’s state, read through the descriptions of every worker in it. So a name only one worker’s settings read (a plugin installed on one worker, a worker on another image of the same skeleton major) is owned the same on every worker of that major, and they all own the same fields. The census holds the workers of one skeleton major only, so the workers of another major running on the same bus namespace (two fleets) judge ownership by their own major’s names and descriptions. A worker on the census whose description the bus no longer holds fails the operation, naming the worker. When the bus cannot answer, the operation judges by the fleet’s names and descriptions as the worker last read them and logs a warning; it is never refused for that. A worker that joins the bus, receives a join announcement, or reconnects reads the census and, when it cannot, fails that step loudly and retries on its next connect. A server with no bus is a deployment of one process. At boot a process applies the stored env, reads the census on the worker bus that env configures, and withdraws every stored copy of a key another worker’s deployment sets before it reads the manifest or builds anything but the bus, so no such copy is ever bound or built into a resource; the withdraw logs a WARNING naming the keys. A process that reaches the bus only through a stored copy of a key that locates it (the bus Redis URL, TAI_BUS_REDIS_URL or TAI_DEFAULT_REDIS_URL, or TAI_BUS_NAMESPACE) while another worker on that same bus has its deployment set that key cannot stay on that bus and cannot run without it: it refuses to start, naming the keys. Set them in that process’s environment too, or in no worker’s. A stored copy that points the process at another bus is not refused: the census of that bus holds none of the fleet’s workers, so the process never learns that a worker’s deployment sets the key, and it runs on that other bus, apart from the fleet. When the bus cannot answer at boot, the process judges by its own environment, logs a warning, and corrects itself when it joins; a join, a join announcement or a reconnect whose withdraw itself moves the bus settings off the bus the process is a member of (a key that locates it was a withdrawn copy) ends the process with an ERROR naming the keys, and the supervisor’s restart meets the boot refusal. When it joins, it withdraws every applied stored key that another worker’s deployment sets, then announces its join, and every other worker withdraws the stored keys the newcomer’s names make owned. A withdrawn key returns to that process’s own deployment value, or is dropped when its environment does not carry the key, and the withdraw logs a WARNING naming it. A worker that withdraws a key on joining, on a join announcement, or on a reconnect rebuilds its serving generation, and a backend runtime turns its pool over. A recycle-class key it still runs on is recycled: the worker that joined rolls that recycle over the workers that reported such a key, one at a time, and recycles itself last when its own withdraw left one and the roll converged, under the supervisor, as every recycle. A worker that does not confirm the announcement is sent a config reload once it is ready, and restarted unless its reply confirms it clean or states a refused build (see How a recycle runs below). A worker that is away from the bus — between its exit and its return in a recycle, or scaled down — publishes no names while it is away, so a save may then set a key only that worker’s environment sets; when it returns, that stored value is ignored, listed under deployment_keys, and can be removed. Where a value comes from. GET /api/config/settings-schema reports each field’s current value and its value_source: The value is the one the worker that answered the request runs: for a deployment field its own environment’s value, or, when only another worker’s environment sets the field, its TAI_DEFAULT_* fallback or the field’s code default. Workers publish the names they own, never values, so the schema never shows another worker’s value. Studio shows a deployment field read-only, marked Set by the deployment — see Settings and environment. Run under a supervisor. The server must run under a supervisor that starts a process again when it exits — a Compose restart policy, Kubernetes, or systemd. A recycle-class change relies on it: the affected processes exit gracefully, and the supervisor starts them again under the new env. Without a supervisor, an exited process stays down, and the roll reports the target where it stopped.

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 (except the launcher-set excluded band, which is carried; see the reload classes below), so a profile is a whole environment you switch to, not a patch you layer on. The profile’s secret marks are written with it, so a key it marks secret stays masked once applied. 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 applies the stored env — the config store’s .env or the Kubernetes Secret — to its environment at boot, before the manifest resolves, by the same rule as a reload: a key the deployment sets keeps the deployment’s value. 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 deployment’s environment 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. Every env write — a profile apply, an env edit (tai config env set, the Studio editor), a backup import, and the marketplace and MCP-secret writes — diffs the env it would store against the stored one and classifies each changed key by its class; a key re-sent unchanged is not a change:
  • 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 the supervisor that starts the affected process again. Every env write rolls that recycle across the fleet (see How a recycle runs below). A recycle-class value changed in the stored env outside these writes (a hand edit of .env, a kubectl edit of the config Secret) is named by the next reload — each worker’s awaiting_respawn — and that reload (a config reload, or the fleet soft restart tai fleet reload-config) rolls the same recycle over the workers that name it, all but those whose restart it withholds (see How a recycle runs below). A worker compares each recycle-class value with the one it bound — at boot, or when the plugin that declares the setting was loaded — so a plugin loaded after boot with its value already in place awaits nothing. A stored recycle-class value a worker withdraws when another worker’s deployment turns out to set the key is rolled as a recycle by the worker that joined (see How every process learns what the deployment owns above). The response of an env write or a profile apply names under awaiting_respawn the workers (name and keys) still awaiting a respawn after its roll — withheld workers included — empty when no worker is left owing one, and when the roll was handed off (roll_deferred, below).
  • excluded — deployment / boot identity: the config mode, the transport, the manifest path, the readiness sentinel, the backend liveness settings (TAI_BACKEND_LIVENESS_PATH, TAI_BACKEND_LIVENESS_INTERVAL, TAI_BACKEND_LIVENESS_GRACE), the launcher’s server options (bind, workers, graceful-shutdown timeout). 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. A stored env that carries an excluded-band key is refused at boot and at reload, naming the key.
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 changes a key-material key (a new value, or a key that was not stored) 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 stored env that counts 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. The snapshot holds the stored env that counted before the apply — the stored keys the deployment does not set — so a revert never sets a deployment value. Names beginning @ are reserved for these pipeline snapshots; an operator cannot create or overwrite one, and they are hidden from the profile list. How a recycle runs. These rules hold on every env write — a profile apply, an env edit, a backup import — and the roll they describe follows every reload that names a recycle-class key: a config reload, the fleet soft restart, a worker joining the bus. A refusal names the keys, and nothing is written:
  • The reload’s report decides whom the roll restarts. A worker that reports a recycle-class key off the value it bound is restarted; a worker other than the writer whose state the report leaves unknown (its reload did not confirm, it has no row, its reply carries no awaiting_respawn list, or the bus was unreachable) is asked once it is ready, and restarted unless its reply confirms it clean or states a refused build; a worker that confirmed clean is left running. A worker whose reload was refused at its epoch build (the build discarded before its swap) keeps serving its current config, and its failed reply carries {"status": "discarded", "awaiting_respawn": [...]}; when it reports keys, its restart is withheld, since the stored config did not build on it and a restart may fail the same way. It is left running, never asked, named in awaiting_respawn, and its own ERROR line says to fix the stored config, or the cause the build error names, before restarting it. A discard that itself failed is no refused build: it leaves the worker’s state unknown. A worker asked once ready that states a refused build with keys is a withheld row of the roll, which does not stop the roll. The writer, the worker that took the change, is never asked at its own roll: its own reload row is its statement, and when its own state is unknown it needs its own restart. A reload that names its targets decides over those targets only. The roll restarts the workers one at a time: for each it waits the drain the worker announces on its census row as its stop begins (its runtime’s stop phase plus the app’s shutdown bounds, see What a leaving worker announces), plus the start-and-rejoin step (TAI_TOOL_RUNS_SHUTDOWN_DRAIN_SECONDS).
  • The recycle rebuilds the affected pooled resource, and the response of every env write — a profile apply, tai config env set, a backup restore, a plugin install — carries the recycle rows naming which workers recycled. The roll runs inside the change, unless the change hands it off (below), so the response answers once the roll ends. The worker that took the write, only when it needs its own restart and the roll converged, recycles itself after replying (its self-deferred row in the recycle rows), through whichever door the write came, when that door runs under no bound: the HTTP response, or a tool call (MCP tools/call, tai tools run, an agent, a preset, a hook run in that worker’s process). The exit is graceful, so the reply finishes before the worker stops and its supervisor starts it again. A background tool run (POST /api/tool-runs) runs on the serve worker that took it, so that serve worker recycles once the run has ended. A change under a bound hands its roll off instead, its writer’s restart included (below).
  • A change made under a bound fixed before it started hands its roll off instead, since one worker’s drain alone can outlast the bound. Two bounds count: a backend job’s budget (every tool a tai backend worker runs as a job: a tool queued on the backend with an async_task branch or run there with a sync_task branch, a schedule fire, a hook dispatched through the backend, a callback’s tool), and the turn budget of a live caller’s call (MCP tools/call, tai tools run, an agent run, a conversation turn, a preset run through any of these) when TAI_TURN_TIMEOUT_SECONDS is set. Such a change hands its roll to the worker it runs for — the tai backend worker that ran the job, or the worker serving the call — and answers at once: roll_deferred is true, roll_deferred_to names that worker, recycle carries no rows and awaiting_respawn is empty. That worker carries the roll out on its own, outside every bound: it waits each worker’s drain and step as above and, when it needs its own restart, restarts itself last, once the roll converged. The roll’s outcome is in that worker’s log, and the restarts show on tai fleet workers as generation bumps. A hand-off that fails fails the change loudly, naming the worker: the change is persisted and reloaded, and the error says the roll did not start (the worker refused it, or is already leaving) or, when no answer came, that whether it started is unknown. A change under a bound hands off even when the only restart is that worker’s own, so its exit never waits inside the bound. Every response that reports a roll carries the two fields; roll_deferred is false and roll_deferred_to null when the roll ran inside the change.
  • A worker joining the bus starts the same roll when a join withdraws a stored recycle-class value the workers still run on: it restarts the workers that reported such a key, leaves running those whose reload was refused at their epoch build (their restart withheld), and asks the workers whose state the announcement left unknown once they are ready, one at a time. It recycles itself last when its own withdraw left one and the roll converged. A roll that stops is logged as an error with its partial report, and the workers it did not reach are named as awaiting a respawn.
  • A change that sets a key the deployment’s environment sets is refused, as described in Deployment values and stored values.
  • A change to the bus URL is refused on every deployment — the fleet census rides the bus, so recycling the bus out from under a live apply is intrinsically unobservable. Removing a stored copy of a bus URL the deployment sets is no change, so it is allowed.
Owned settings prefixes refuse unknown names. The settings layer reads only the variables a settings group declares, so a mistyped, renamed, or removed setting would otherwise be ignored without a word. A settings group can declare its env prefix owned — ACCESS_CONTROL_, INTERACTIONS_, and CHANNELS_ are — and then every variable under that prefix must name a registered setting. An unknown name (say ACCESS_CONTROL_ENABEL) refuses boot, naming the variable and where it was read (the process environment, the .env file, or the stored env), and an env write or a profile that sets one is refused with a 400. A config reload re-checks the stored env it applies — so a name added to the store by hand is refused there, and the running configuration keeps serving. Deleting an unknown name is always allowed — an env write that empties it, or a profile that omits it — since that is the fix. Inside a Kubernetes pod the container runtime injects link variables for every Service in the namespace (<NAME>_SERVICE_HOST, <NAME>_PORT, …), and a Service named like an owned prefix (channels, access-control) yields names under it: those are not refused from the process environment where the Service’s <NAME>_SERVICE_HOST and <NAME>_PORT are both present; a lone one is refused like any unknown name. To switch the link variables off, set enableServiceLinks: false on the pod. 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:

Model providers

The platform’s model layers are swappable the same way. Three selectors pick which provider implementation backs each model type, and each defaults to a built-in provider: Each provider’s own connection details ride the model’s env namespace, resolved at use time: LLM_* for the chat model, EMBEDDING_* for the embedding model, and CLASSIFIER_* for the classifier (CLASSIFIER_MODEL, CLASSIFIER_BASE_URL, CLASSIFIER_API_KEY, CLASSIFIER_TIMEOUT). An unset credential falls through to the provider’s own resolution and its loud error when none is available. A chat-model or embedding-model setting the selected provider cannot carry (for example LLM_TOP_K with openai, or EMBEDDING_TIMEOUT with google) is refused when the model is first built, with an error naming the setting; no setting is silently dropped. The same setting given twice (for example under its flat name and the provider’s own name) is refused naming both. One case is caught only at the first call: an OpenAI or xAI chat model with a built-in tool bound (a gpt-6 model with any tool bound) switches to the Responses call, and a setting only the Chat Completions call takes then fails that call, before any request is sent. The full field table for every namespace is in the settings reference.

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, timeouts and the connect retry (_PG_MIN_CONNECTIONS, _PG_MAX_CONNECTIONS, _PG_CONNECT_TIMEOUT, _PG_STATEMENT_TIMEOUT_SECONDS, _PG_RETRY_ATTEMPTS) tune each database the same way; see Connection timeouts and retries. 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. The host has no default either: a configured database with no TAI_DATABASE_<NAME>_PG_HOST raises when its first connection is built.

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 .env file of steps 3 and 6 is the one in the working directory. In file mode it is not read when it is the config directory’s stored .env; the server applies that file’s keys to the process environment, so they resolve at steps 2 and 5. 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 fails at first use when 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, RQ_REDIS_MAX_CONNECTIONS, CELERY_BROKER_URL, CELERY_RESULT_BACKEND, CELERY_REDBEAT_REDIS_URL, and CELERY_REDBEAT_REDIS_MAX_CONNECTIONS 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. Each worker plugin’s Redis takes the shared connection rows under its own prefix (ARQ_, RQ_, CELERY_REDBEAT_): RETRY_ATTEMPTS, RETRY_ON_TIMEOUT, SOCKET_TIMEOUT, and SOCKET_CONNECT_TIMEOUT. They never fall back. ARQ_SOCKET_CONNECT_TIMEOUT defaults to 1 second, and the arq worker’s startup connect is retried by ARQ_RETRY_ATTEMPTS. 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. Not every feature is OFF in this sense. Hooks, conversations, and sub-MCP registrations run in memory. Access control, on by default, refuses to boot with no identity provider and denies every keyed request without its Postgres or Redis. The checkpoint store and the LLM store raise at first use, and so does starting a connector OAuth flow without its Redis. Infrastructure by feature lists every feature and what it does when its service is missing.

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. A plugin can turn an OFF feature into a boot refusal. A plugin that lists a feature’s kind under requires in its tai-plugin.yml refuses every boot and reload that loads it while that feature is off, and the refusal names the variable to set. For every other plugin the feature stays the plain OFF state above.

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 at use time from the process environment, which the server fills from the stored env, or from the working directory’s .env file. 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 and retries

Postgres connection tuning is per named database: TAI_DATABASE_<NAME>_PG_CONNECT_TIMEOUT (default 10), TAI_DATABASE_<NAME>_PG_STATEMENT_TIMEOUT_SECONDS (default 60) and TAI_DATABASE_<NAME>_PG_RETRY_ATTEMPTS (default 0) — for example TAI_DATABASE_DEFAULT_PG_CONNECT_TIMEOUT. Each Redis-backed feature carries its own tuning under its env prefix: <PREFIX>SOCKET_TIMEOUT, <PREFIX>SOCKET_CONNECT_TIMEOUT, <PREFIX>RETRY_ATTEMPTS and <PREFIX>RETRY_ON_TIMEOUT — for example ACCESS_CONTROL_SOCKET_TIMEOUT, TAI_TOOL_RUNS_RETRY_ATTEMPTS.
  • Postgres. _PG_RETRY_ATTEMPTS re-attempts establishing a connection when a pool is built: after a failed connect it waits and tries again, with an exponential backoff that starts at 16 ms and doubles to a 512 ms cap. A statement is never re-run. With n retries, building a pool waits at most (n + 1) × _PG_CONNECT_TIMEOUT plus the backoff.
  • Redis. <PREFIX>RETRY_ATTEMPTS re-tries a failed connect and the command, writes included, with the same backoff. A subscription whose connection drops is reconnected and re-subscribed inside the same retry, so a drop shorter than the retry raises no error to the subscriber; a message published while it was disconnected is not delivered, as Redis pub/sub keeps no backlog. So each subscriber catches up after such a reconnect: the celery Redis result backend reads the stored state of every task it still awaits, so a result written meanwhile still arrives; the worker bus runs its resync (presence resyncing, the resync, then ready); and a turn waiting on a conversation thread retries the thread at once. A fleet operation’s replies have no stored state, so when the publisher’s reply subscription reconnects this way it logs a WARNING, keeps collecting the replies sent after the reconnect, and every worker left without a final reply carries the cause in its verdict (reply collection transport error: the reply subscription reconnected …). When the celery result backend’s retry runs out, the wait raises the connection error, and the next wait, on any task, connects again, re-subscribes every task still awaited and reads their stored state. <PREFIX>RETRY_ON_TIMEOUT alone (with RETRY_ATTEMPTS at 0) re-tries a timed-out command once, at once.
  • Every retry is logged. Each retried failure logs one WARNING naming the connection — Redis connection <SETTING> failed (attempt 1 of 3): …; retrying in … or Postgres connection '<pool>' failed (attempt 1 of 3): … — and when the attempts run out the last error is raised; a Postgres one carries the note Postgres connection '<pool>' failed after 3 attempt(s). With the defaults (0) a connection makes one attempt and fails at once.
  • A connection with its own connection string takes its retry and timeouts from its own namespace, never from the base one: the model-provider store and checkpointer (LLM_PROVIDER_STORE_* / LLM_PROVIDER_CHECKPOINT_*: RETRY_ATTEMPTS, RETRY_ON_TIMEOUT, SOCKET_TIMEOUT, SOCKET_CONNECT_TIMEOUT, PG_RETRY_ATTEMPTS), the celery Redis result backend (CELERY_RESULT_BACKEND_*) and the celery Redis broker (CELERY_BROKER_*, read and write sides alike). With them unset, that connection makes one attempt. The celery broker never retries a read kombu polls: a worker’s task and fanout reads, and a control command’s reply read in any process. A drop there raises at once: a worker’s consumer restart reconnects, and a control call fails with the connection error, so a retry of that call reconnects.
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.