- SETTINGS_CATALOG: 28 env-mapped settings with type, default, required/nullable, strict options, preferred (reserved), encrypted flags, per-key bool word sets; routing split into CONFIG_SETTING_FIELDS (Config build) and SETTING_EXPORT_KEYS - Store.get_or_create_settings / get_all_settings / upsert_settings: global+profile scopes, atomic strict validation, canonical forms, created/updated timestamps, env/db/default source reporting; encrypted values sealed at rest, rotation-aware, plaintext-boot refusal covers the table - boot_settings in boot() and default_runner: Config second pass (env wins, CLI --no-opencode wins) plus env export for peripheral and applied keys; applied keys printed at boot - settings.md contract with full catalog and exclusion rationale; 16 SettingsTests incl. catalog self-check and doc drift guard
9.5 KiB
settings.md
Vault-backed settings: one catalog, three functions, env always wins.
Every TAI_* knob that behaves like a setting lives in the settings
table and is readable and overridable through
get_or_create_settings / upsert_settings / get_all_settings.
SettingsTests.test_settings_doc_names_every_key fails when this file
drifts from the code.
Precedence
For every catalog key, the effective value resolves in this order:
- env: a non-empty
${ENV_VAR}(empty counts as unset, matching every existing reader) - db, own scope: the profile row when one is stored
- db, global scope: the shared fallback row
- catalog default
Two run-scoped inputs outrank even env: CLI --theme beats the theme
setting, and CLI --no-opencode beats the stored opencode flag. A
stored model pin counts as explicit, so per-directory model defaults
do not override it.
Boot applies vault settings in boot() (and in default_runner for
workers) through boot_settings: Config fields first, then the
peripheral keys are exported into os.environ so the existing
readers, which all run later, pick them up unchanged. Applied keys are
printed (settings from vault: ...) and also exported for nested
processes. A second boot pass is idempotent: exported env wins, so
nothing is applied twice.
Row shape
Every function returns the same row dicts:
| Field | Meaning |
|---|---|
key, env, desc |
catalog identity and one-line docs |
type |
string, bool, or float |
default |
python value used when nothing is set (may be null) |
required |
null and empty are rejected; a required key must always resolve |
nullable |
whether null (unset) is a legal stored value |
options |
strict list the value must be one of, or null |
preferred |
suggestions that still allow free input, or null (reserved: no v1 entry uses it) |
encrypted |
sealed at rest with the vault key when true |
stored |
decrypted canonical string in the vault, or null |
value |
effective typed value after precedence |
source |
where value came from: env, db, or default |
scope, created, updated |
owning scope and ISO UTC timestamps |
Canonical stored forms: bool as 1/0, float as str(float),
strings raw. Env bools accept 1/true/yes/on and 0/false/no/off
(case-insensitive, surrounding whitespace ignored); anything else
falls back to the default, exactly like the readers. Env floats use
plain decimals (12, 2.5); anything else falls back to the default.
Three bool keys extend the env word sets (see catalog): those legacy
spellings stay readable from env but are never written by upsert.
API
get_or_create_settings(keys=None, scope="global"): ensure rows exist (all 28 whenkeysis None, a subset or single key otherwise) and return their merged rows. Unknown keys and bad scopes raiseValueError, as does a required key that resolves to nothing.get_all_settings(scope="global"): the same merge, read-only: creates no rows.upsert_settings(items, scope="global"): validate a{key: value}mapping and store it atomically; anything invalid raisesValueErrorand writes nothing.Noneclears a nullable key back to null. Returns fresh rows for the written keys.
Scopes are global or a profile name (PROFILE_RE). A profile
literally named global shares the global scope.
Encryption
Keys marked encrypted are sealed with seal.lock on write and opened
on read; plaintext vaults store them as-is, same as vault secrets. At
rest they are indistinguishable from other sealed blobs (tai1$...).
Passphrase rotation re-seals every sealed settings value and leaves
plaintext values alone. Booting a vault that holds sealed settings
without the passphrase refuses to start, like the other sealed
tables. Encrypted settings are config, not vault secrets: they are
not covered by secret redaction, exactly like their env equivalents.
Catalog
28 keys. Types, defaults, and flags below are asserted by
SettingsTests.test_catalog_self_check, so this table cannot drift
silently.
| Key | Env | Type | Default | Req | Null | Options | Enc | What it does |
|---|---|---|---|---|---|---|---|---|
model |
TAI_MODEL |
string | muse |
yes | no | - | no | Default model id. A stored pin counts as explicit. |
backends |
TAI_BACKENDS |
string | null | no | yes | - | yes | Extra OpenAI-compatible backends: ;-separated label|base|key[|model][|profile]; a $VAR key reads another env var. |
openrouter_key |
OPENROUTER_API_KEY |
string | null | no | yes | - | yes | OpenRouter key. Most free models work without one. |
openrouter_models |
TAI_OPENROUTER_MODELS |
string | null | no | yes | - | no | Comma-separated model ids. Null means live discovery. |
openrouter_enabled |
TAI_OPENROUTER |
bool | true | no | no | - | no | Offer the OpenRouter backend. |
opencode_enabled |
TAI_OPENCODE |
bool | true | no | no | - | no | Offer the opencode backend. CLI --no-opencode wins. |
opencode_models |
TAI_OPENCODE_MODELS |
string | null | no | yes | - | no | Comma-separated model ids. Null means live discovery. |
devplace_key |
DEVPLACE_API_KEY |
string | null | no | yes | - | yes | DevPlace key. Null disables the fallback backend. |
devplace_base |
TAI_DEVPLACE_BASE |
string | https://devplace.net/openai/v1 |
no | no | - | no | DevPlace API base URL. Trailing slashes stripped. |
devplace_models |
TAI_DEVPLACE_MODELS |
string | null | no | yes | - | no | Comma-separated model ids. Null means live discovery. |
pollinations_enabled |
TAI_POLLINATIONS |
bool | true | no | no | - | no | Offer the Pollinations backend. |
pollinations_models |
TAI_POLLINATIONS_MODELS |
string | null | no | yes | - | no | Comma-separated model ids. Null means live discovery. |
zen_enabled |
TAI_ZEN |
bool | false | no | no | - | no | Opt in to the opencode.ai zen backend. |
zen_models |
TAI_ZEN_MODELS |
string | null | no | yes | - | no | Comma-separated model ids. Null means live discovery. |
retry_seconds |
TAI_RETRY_SECONDS |
float | 0.0 | no | no | - | no | Seconds to retry all backends on total failure. 0 means forever. |
voice |
TAI_VOICE |
string | en-US-EmmaMultilingualNeural |
no | no | - | no | Edge TTS voice name for speak. |
theme |
TAI_THEME |
string | dark |
no | no | dark, light, nord, gruvbox, ansi | no | TUI theme, strict. CLI --theme wins. |
gemini_plan |
TAI_GEMINI_PLAN |
bool | false | no | no | - | no | Try the Gemini plan approval variant first. Env also accepts plan. |
codex_sandbox |
TAI_CODEX_SANDBOX |
bool | false | no | no | - | no | Re-enable Codex sandbox flags. Env also accepts read-only. |
box_caps |
TAI_BOX_CAPS |
bool | true | no | no | - | no | Apply caps to old sandbox boxes. False skips. Env honors only 0 as false. |
muse_bin |
MUSE_BIN |
string | null | no | yes | - | no | muse CLI path override. Null means PATH lookup. |
opencode_bin |
OPENCODE_BIN |
string | null | no | yes | - | no | opencode CLI path override. Null means PATH lookup. |
grok_bin |
GROK_BIN |
string | null | no | yes | - | no | grok CLI path override (then grokcli). Null means PATH lookup. |
claude_bin |
CLAUDE_BIN |
string | null | no | yes | - | no | claude CLI path override. Null means PATH lookup. |
codex_bin |
CODEX_BIN |
string | null | no | yes | - | no | codex CLI path override. Null means PATH lookup. |
gemini_bin |
GEMINI_BIN |
string | null | no | yes | - | no | gemini CLI path override. Null means PATH lookup. |
opencode_client_name |
OPENCODE_CLIENT_NAME |
string | null | no | yes | - | no | x-opencode-client header override. Null means built-in. |
opencode_client_ua |
OPENCODE_CLIENT_USER_AGENT |
string | null | no | yes | - | no | User-Agent override for zen. Null means built-in. |
Deliberately not cataloged
| Var | Why it stays out |
|---|---|
TAI_HOME |
locates the vault; cannot come from inside it |
TAI_PASSPHRASE |
unlocks the vault; cannot come from inside it |
TELEGRAM_BOT_TOKEN |
owned by telegram.env plus the service unit; move it only with a migration |
TAI_SESSION |
per-process derived context, resolved around store boot |
TAI_TUI_SOCK |
per-process socket rendezvous path |
TAI_TUI_VERIFY |
dev-only pipeline verification switch |
GRR_URL |
owned by grr.json (env wins there too) |
CAP_SESSION_CONFIG |
wire passthrough for one speak turn, not a setting |
TAI_PLEXER_* |
runtime plexer state, not settings |
TERM, NO_COLOR, SHELL, TMUX*, EDITOR, VISUAL, HISTFILE, USER, TZ, VIRTUAL_ENV, XDG_*, COLORFGBG, TERMINFO |
foreign standards tai reads but does not own |
Internal tuning constants (WORKER_STEPS, CAP_*, BOX_*, scheduler
intervals) are code, not settings: promoting one means a catalog
entry, a wiring decision, and a test, not a silent centrally-stored
override. mplex.toml, CAP fleet files, and per-directory backend
pins (dir_defaults) keep their own homes for the same reason.
Maintenance
To add a setting: append a setting_spec to SETTINGS_CATALOG, route
it through CONFIG_SETTING_FIELDS (read at Config build) or
SETTING_EXPORT_KEYS (read later at call sites), extend the catalog
table above, and extend SettingsTests. The catalog self-check
asserts every default validates, every options list holds its
default, env names are unique, and the two routing tuples partition
the catalog exactly. The theme row is pinned against TUI_THEMES so
a new theme breaks the test until the options list follows.