- Status: Stable
- Scope: Where Relavium reads configuration from, and how global and per-project settings are merged.
- Related: workflow-yaml-spec.md, agent-yaml-spec.md, ../desktop/keychain-and-secrets.md, ../cli/commands.md, ../../architecture/local-first-and-security.md
Relavium uses a two-level configuration model that mirrors VS Code's user vs. workspace split: a global config in the user's home directory, and a per-project config committed alongside the code. The per-project layer overrides the global layer. A directory the user opens is the workspace — there is no separate "project" concept; the filesystem directory is the unit of organization, which makes git integration trivial.
Config files are TOML 1.0. They are decoded to plain objects by the surface that reads them (the CLI in build phase 2 — ADR-0048), then validated against the strict @relavium/shared config schemas (ADR-0033); the engine never reads config files itself.
Created on first launch. Holds user-wide preferences and the registry of MCP servers.
~/.relavium/
config.toml # global preferences, MCP server registrations, update channel
ipc.json # desktop loopback server discovery (port, authToken, pid) — see ipc-contract.md
history.db # cross-project run history (SQLite; desktop: SQLCipher, CLI: unencrypted + 0600/0700 OS perms — ADR-0050) — see shared-core/database-schema.md
secrets.enc # RESERVED encrypted-file key fallback (deferred past v1.0; Argon2id KDF) — see keychain-and-secrets.md
tmp/ # scratch space agents may write to under the sandbox tier
API keys are not stored in
config.toml. They live in the OS keychain; the CLI's headless/CI fallback is the env varRELAVIUM_<PROVIDER>_API_KEY. Thesecrets.encencrypted-file fallback is deferred past v1.0 (it needs a proper Argon2id KDF). See ../desktop/keychain-and-secrets.md.
Committed to git (minus secrets) so a team shares the same workflows and agents.
<projectRoot>/.relavium/
project.toml # project-level defaults and overrides
workspace.toml # OPTIONAL shared variables (default model, shared tool configs)
workflows/*.relavium.yaml # workflow definitions
agents/*.agent.yaml # agent definitions
runs.db # OPTIONAL project run metadata (summaries only, no event logs)
.relaviumignore # which .relavium/ files git should ignore (e.g. secrets, runs.db)
Opening a workspace loads every *.relavium.yaml and *.agent.yaml under <projectRoot>/.relavium/. An optional workspace.toml can declare shared variables (default model, shared tool configs) inherited by all workflows in that workspace.
For any single setting, the last writer wins, evaluated in this order:
flowchart LR
G["~/.relavium/config.toml\n(global)"] --> W["<projectRoot>/.relavium/workspace.toml\n(workspace)"]
W --> P["<projectRoot>/.relavium/project.toml\n(project)"]
P --> CLI["CLI flag / env var\n(per-invocation)"]
- Global (
~/.relavium/config.toml) — lowest precedence; user defaults. - Workspace (
workspace.toml) — shared, committed variables for everything in the directory. - Project (
project.toml) — project-specific overrides. - Per-invocation — a CLI flag or environment variable for a single run; highest precedence. See ../cli/commands.md.
MCP server registrations follow the same merge: globally registered servers (config.toml) plus any project-scoped servers. See ../shared-core/mcp-integration.md.
update_channel = "stable" # stable | beta
[catalog]
# Refresh the model catalog (price, ceilings, reasoning tiers) from models.dev automatically.
# DEFAULT false — and the default is the design (ADR-0071 §4).
#
# Relavium SHIPS a generated models.dev snapshot: a fresh install with no network prices every model
# it ships, offline, and contacts nobody. A local-first tool that reaches out to a third party by
# default violates its own spirit even when the payload is innocuous, so the standing egress is
# opt-in. `relavium models refresh` fetches regardless — a command the user typed IS consent, and
# that is what makes default-OFF a livable default rather than a dead end.
#
# `true` ⇒ a bare `relavium models` refreshes the catalog first (a failure is silent; the snapshot
# still answers). The refresh is ADDITIVE ONLY: it may add models and enrich thin ones, and it can
# never leave a model less priced than the shipped snapshot did.
auto_refresh = false
[preferences]
default_model = "claude-sonnet-4-6"
default_provider = "anthropic" # ADR-0059: the provider serving default_model — anthropic | openai | gemini | deepseek. Persisted by the /models picker + the onboarding wizard at pick time (authoritative there — the live model list is per-provider), so the next chat resolves the provider WITHOUT id inference. Load-bearing for a live-discovered id whose spelling the prefix map cannot place (e.g. chatgpt-4o-latest). Absent ⇒ inference from the id (catalog first, then prefix).
reasoning_effort = "medium" # ADR-0066 §6: the GLOBAL default reasoning-effort tier — off | low | medium | high | max; the fallback BELOW any [chat].reasoning_effort. Written by the /models picker's effort sub-step. Absent ⇒ no reasoning control (the provider default).
theme = "dark"
alt_screen = true # ADR-0068 §e (2.6.F): the full-screen alternate-screen renderer for the bare Home + `relavium chat`. Since Step 4b-3 the DEFAULT is ON — a TTY opens full-screen — so this key is the durable OPT-OUT: `false` keeps the byte-identical INLINE renderer (native scrollback + the emulator's own a11y — the screen-reader fallback), `true` forces it on. The `--no-alt-screen` flag is the per-invocation opt-out and overrides this key; a non-TTY / `--json` / CI path always renders inline regardless. Absent ⇒ the phase default (alt-ON since 4b-3 — ADR-0068 §b).
# What the full-screen renderer actually does (viewport, scroll keymap, mouse-wheel) is NOT restated here — see [ADR-0068](../../decisions/0068-full-screen-tui-renderer-ink7-harness.md) and, for the inline/screen-reader tradeoff this key controls, [accessibility.md](../cli/accessibility.md).
mouse = true # ADR-0068 §e (2.6.F Step 5e): terminal mouse reporting (DECSET 1002+1006) INSIDE the full-screen renderer — what makes the wheel scroll the transcript and what lets Relavium run its OWN click-drag selection (Step 6). DEFAULT ON. Set `false` (or pass `--no-mouse`) to turn it off durably: the wheel stops scrolling (PgUp/PgDn + Ctrl+Home/Ctrl+End still page), in-app selection and copy-on-select go with it, and the emulator's native click-drag SELECTION works again without a modifier. The `--no-mouse` flag overrides this key. Ignored outside the alt screen — the inline renderer never enables mouse reporting. See [accessibility.md](../cli/accessibility.md).
show_banner = true # ADR-0068 (2.6.F Step 5g): the branded Home banner — a wordmark + tagline plaque drawn where the plain `relavium` heading otherwise sits. `true` ⇒ always, `false` ⇒ never. ABSENT ⇒ shown only while the Home is EMPTY (no sessions/runs/agents to continue), so it greets a fresh install and auto-dismisses the moment there is something to continue. Degrades to plain ASCII under `NO_COLOR` / `--no-color`. A forced banner stands down on a terminal too short for it. Cosmetic: it gates no feature.
copy_on_select = true # ADR-0068 §e (2.6.F Step 6e): releasing a mouse drag writes the selection to the system clipboard over OSC 52. DEFAULT ON. Set `false` to keep the highlight while never touching the clipboard — a stray drag then cannot clobber what you copied elsewhere. There is no flag: `--no-mouse` already removes the gesture. Meaningless without `mouse`, and IGNORED when it is off. `/copy` copies the whole transcript on demand regardless. OSC 52 has no acknowledgement, so a copy is attempted, never confirmed — see [accessibility.md](../cli/accessibility.md) for the tmux and VS Code Remote SSH caveats.
[[mcp_servers]] # repeatable — an agent references one by name via `ref:` (ADR-0052 §5)
name = "filesystem"
transport = "stdio" # stdio | http | websocket
command = "npx -y @modelcontextprotocol/server-filesystem"
args = ["--root", "~/projects"]
autostart = true # accepted, reserved for a future always-on pool — NOT acted on in 2.R (a server connects on demand)
# url = "https://host/mcp" # for transport = http (Streamable HTTP); a `websocket` server uses wss://
# env = { TOKEN = "{{secrets.github_token}}" } # stdio only — resolved from the isolated mcp-secret:* keychain, injected into the spawned child; rejected on a network transport (header-auth is a follow-up)
# allow_local_endpoint = true # opt into a private/loopback url (network transports only, ADR-0053 §3)A transport = "http" / "websocket" registration requires a url (http(s) for http, ws(s) for
websocket); the url is SSRF-guarded (a private/loopback host is rejected unless allow_local_endpoint is set;
a remote host must be https/wss) and must not embed credentials. A registration's transport is
stdio | http | websocket, plus the deprecated sse alias of http (accepted for older servers, same
http(s) url) — symmetric with an inline agent.mcp_servers entry; prefer http for new servers. The
stdio-only fields (command/args/env) are rejected on a network registration, and the network-only fields
(url/allow_local_endpoint) on a stdio one. An agent consumes a registration with - ref: filesystem (see
../shared-core/mcp-integration.md).
Writing the global config (ADR-0063). Config is almost entirely read-only (hand-edited, git-committed). The one write path is the CLI persisting a chosen default:
/modelsand the 2.5.G onboarding wizard set[preferences].default_modeland (ADR-0059) itsdefault_providerin one atomic write, and (ADR-0066 §6) the/modelspicker's effort sub-step additionally sets[preferences].reasoning_effortfor a reasoning model — only those three[preferences]keys, and only through a typed setter (never a generic key/value writer), so a secret can never be written by construction (there is noapi_keyfield in the schema; keys live only in the OS keychain, ADR-0006). A partial write leaves the other key unchanged (a model-only pick never clears a prior effort default). The write is atomic + durable (a0600temp file in the0700~/.relavium/,fsyncthe file,renameover the target, thenfsyncthe parent directory so the rename itself survives a crash) and it both re-validates the merged object against the strictGlobalConfigSchemaAND re-parses the emitted TOML back through the schema before the rename — so "the file always re-parses on the next load" is a verified guarantee, and on any failureconfig.tomlis left untouched. Two documented tradeoffs: re-serialization drops comments and key ordering inconfig.toml(the global file is a preference store, not a hand-curated artifact — project/workspace files are never written by the tool); and there is no lock, so two concurrent writes resolve last-writer-wins (a lost update, never a torn file) — acceptable for a single-user, rarely-written preference store.
[defaults]
model = "claude-sonnet-4-6" # default model for agents that omit one
fs_scope = "sandboxed" # sandboxed | project | full (see filesystem tiers)
max_tokens_estimate = 4096 # per-call output-token estimate the pre-egress budget governor uses when a node/session omits maxTokens (ADR-0028) — not the model's absolute max, which would over-block
media_job_poll_initial_ms = 5000 # async media-job (generateMedia LRO) first-poll delay + backoff base (1.AG/ADR-0045 §7)
media_job_poll_max_ms = 30000 # backoff cap: poll interval = min(initial × 2^(n-1), max), no jitter
media_job_deadline_ms = 1800000 # abandon a job past this (from submit) as a retryable timeout (30 min)
media_gc_grace_days = 7 # grace (in DAYS, minimum 1 — the schema rejects 0) before a zero-reference media handle's CAS bytes are reclaimed by the host media GC (P4/D11, ADR-0042 §4c). Resolved by the CLI (DAYS → ms) and threaded into the run-end GC's grace window; omit ⇒ the built-in 7-day default (DEFAULT_MEDIA_GC_GRACE_MS).
[defaults.media_cost_estimate] # per-modality media-output UNIT-COUNT default for the pre-egress media cost estimate (1.AF/D17, ADR-0044 §3) — a COUNT, not a price; the per-unit price lives in the model catalog. Used when a media-output turn declares no volume. Omit the table for text-only workflows.
image = 1 # assumed images per media-output turn
audio = 60 # assumed audio-SECONDS per media-output turn
video = 10 # assumed video-SECONDS per media-output turn
[variables] # available to all workflows in this workspace
focus_area = "security and type safety"
[chat] # agent-session (chat-mode) defaults — see contracts/agent-session-spec.md
default_model = "claude-sonnet-4-6" # model for a chat session that names none; absent at every [chat] layer ⇒ falls back to global [preferences].default_model (ADR-0063)
default_provider = "anthropic" # ADR-0059: the provider serving [chat].default_model, resolved from the SAME config layer as the effective default_model (NEVER independently — a fresh model paired with a stale layer's provider dials the wrong adapter → 404). Absent when the model resolves ⇒ inferred from the id (catalog first, then prefix).
reasoning_effort = "medium" # ADR-0066: reasoning-effort tier baked onto the DEFAULT chat agent — off | low | medium | high | max; absent ⇒ no reasoning control (the provider default). Ignored on a model without a controllable reasoning tier.
fs_scope = "sandboxed" # SAME tier enum as [defaults].fs_scope above (not re-listed here)
max_turns = 50 # hard session TURN cap → SessionDeps.maxTurns (DoS fail-safe; absent ⇒ engine default 50; positiveInt — 0 is rejected here) — DISTINCT from max_messages
max_messages = 200 # history-trim threshold — consumed by `/trim` + auto-compaction (ADR-0062); older turns trimmed/summarized
auto_compact = true # ADR-0062: auto-compact when a turn's real input tokens exceed compact_threshold × the model's context window (absent ⇒ true)
compact_threshold = 0.8 # ADR-0062: the context-window fraction that triggers auto-compaction; a fraction in (0, 1] (absent ⇒ 0.8)
max_cost_microcents = 0 # 0 = unbounded; >0 = per-session pre-egress cost cap (the same governor as a workflow budget — ADR-0028)
on_exceed = "pause_for_approval" # fail | pause_for_approval | warn — when a session hits its cap
strict_cost_cap = false # ADR-0071 §K7: refuse a turn on a model we cannot PRICE. Default false — the cap
# degrades to allow with a one-time notice (an unpriced model is a hole in it);
# true blocks the turn. `models pricing <model>` closes the hole either way.
# INERT without a positive max_cost_microcents: with no cap there is nothing to
# enforce, so strict_cost_cap does nothing until a cap is also set.
allowed_commands = [] # !-shell allowlist (ADR-0061): EXACT full-command-string match; EMPTY/absent ⇒ !-shell disabled
allowed_command_globs = [] # opt-in glob form of the !-shell allowlist (riskier); empty/absent ⇒ noneProject-scoped MCP servers.
project.toml/workspace.tomlmay also declare[[mcp_servers]]entries (the same shape as the global block above); they merge with the global registrations per the resolution order — a project server overrides a global one with the samename. (Schema:ProjectConfigSchema.mcp_servers.)
The
[chat]block sets defaults for the agent-first chat entry point (agent-session-spec.md, ADR-0024), distinct from[defaults](which governs workflow runs). Itsallowed_commands/allowed_command_globskeys (the 2.5.D!-shell allowlist, detailed below) map to the SAME engineallowedCommands/allowedCommandGlobspolicy a workflowrun_commanduses (canonical home workflow-yaml-spec.md; empty/absent ⇒run_commanddisabled) — a chat surface over the one command allowlist, never a chat-specific fork of it. Session history persists in the existinghistory.db— there is no separatesessions.db. A chat session may carry its own pre-egress cost cap (max_cost_microcents+on_exceed), enforced by the same governor as a workflowbudget(ADR-0028) — so an open-ended chat that loops on tool calls fails safe, and "both entry points inherit resource governance" holds literally.
max_turnsis the surface-mapped form of the engine hard turn cap (SessionDeps.maxTurns, agent-session-spec.md) — a finite DoS fail-safe (engine default 50; absent ⇒ that default — apositiveInt, so0is rejected at the config layer and never reaches the engine's own<= 0 ⇒ defaultarm). It is distinct frommax_messages(a history-trim threshold that silently continues) and the within-turnmaxToolTurnstool-loop guard: asendMessagepastmax_turnsends loudly (session:turn_completedwitherror.code: 'turn_limit', no egress).
max_messages(revived in 2.5.F) is the bound/trimenforces (keep the last N messages, no LLM call) and the deterministic fallback if a summarization fails.auto_compact+compact_threshold(ADR-0062) drive automatic model-summarised compaction: after a turn completes, if its real input tokens exceedcompact_threshold(default0.8, a fraction in (0, 1]) × the serving model's context window, the session compacts before the next turn.auto_compactabsent ⇒ enabled; a model with no known context window (a custom base-URL id) skips auto-compaction, but manual/compactstill works. The summarization spend is accounted to the session budget and surfaced, never silent.
reasoning_effort(ADR-0066) is the normalized reasoning-effort tier —off | low | medium | high | max— baked onto the built-in default chat agent only (an explicit--agentowns its ownreasoning_effortin its YAML). It resolves per-field project → workspace, and (ADR-0066 §6) — likedefault_model— additionally falls back to the global[preferences].reasoning_effortbelow both[chat]layers. Each adapter maps the tier to its provider's native control; a model with no controllable reasoning tier ignores it (the engine gates on the model's capability). Absent ⇒ no reasoning control (the provider default). Interactively, the/effortcommand and a live chat's/modelseffort sub-step set the tier as a per-turn session override (no reseat) without editing config; the bare-Home/modelseffort sub-step instead writes the[preferences].reasoning_effortdefault for the next session. The active tier shows in the footer.The
[chat]block resolves per field (each key independently, last-writer-wins project → workspace) — a project that sets onlymax_turnsstill inheritsdefault_model/max_messagesfrom the workspace layer. (Contrast[defaults].media_cost_estimate, which resolves whole-object: the highest layer present replaces the table outright.)default_modelandreasoning_efforteach have one extra fallback: absent at both[chat]layers, each falls through to its global[preferences]counterpart (default_modelper ADR-0063 §1;reasoning_effortper ADR-0066 §6) — the write targets of/models(model + its effort sub-step) and the wizard — so a user's "preferred model / effort everywhere" governs chat too, exactly as[preferences].default_modelalready governs a workflow's[defaults].model. Full precedence for each:[chat].<key>(project → workspace) →[preferences].<key>(global).default_provider(ADR-0059) also reads the global layer, but COUPLED todefault_model— NOT resolved independently: it is taken from the SAME layer that supplied the model (absent there ⇒ id inference), so a lower layer's stale provider can never pair with a higher layer's model (anopenaiprovider leaking onto a project'sclaudedefault_modelwould bind the wrong adapter). No OTHER[chat]field reads the global layer. The!-shell allowlist is a second coupled group:allowed_commands(exact) +allowed_command_globs(globs) are a coupled unit, so a project that sets either array owns the whole allowlist and does not inherit the other array from the workspace. Otherwise a project narrowingallowed_commandswould silently keep the workspace's broader globs — lock togit status, yet still allowgit pushvia an inheritedgit *. Only when a project sets neither allowlist array do both fall through to the workspace; a present array otherwise REPLACES (never merges) the lower layer's. This is what guarantees a narrower project can never inherit a broader workspace entry.
allowed_commands/allowed_command_globsgate the!-shell escape (2.5.D, ADR-0061) — a chat user typing!commandruns it through the onerun_commandboundary (they map to the engine's camelCaseallowedCommands/allowedCommandGlobs, the SAME allowlist a workflowrun_commanduses).allowed_commandsis exact full-command-string match (git status,ls -la—gitnever authorizesgit push --force);allowed_command_globsis the opt-in, riskier pattern form. Both default to EMPTY ⇒!-shell is disabled — theempty ⇒ disabledsymmetry security-review.md pins, with no chat-specific relaxation (there is no curated default:run_commandhas no argument/file confidentiality floor, so even a "read-only" default set —cat,grep— would reopen!cat .env→ provider).!-shell is first-class via a first-class opt-in (the user lists commands, or the 2.5.G onboarding offers a reviewed seed), and a non-allowlisted!cmdgets an actionable, secret-free deny hint naming the exact line to add.enforcePolicy(allowedCommands)runs before the mode-awareconfirmAction, so evenautomode never runs a command absent from the allowlist. Editing chatallowed_commandsis a security-review.md trigger.
No config file contains plaintext secrets. Keys resolve at call time from:
- Desktop — OS keychain (
tauri-plugin-keychain), with an optionalsecrets.encfallback. - CLI — OS keychain via
@napi-rs/keyring(not the archivedkeytar; see ADR-0019). - VS Code —
vscode.SecretStorage.
Non-key secrets (e.g. an MCP server's GITHUB_TOKEN) are stored the same way and referenced
from workflow/agent/MCP-server fields by name with {{secrets.<name>}} interpolation,
resolved from the store at run time — never written into the workflow file, a checkpoint, or
any event payload (see ../shared-core/mcp-integration.md
and the masking rule in sse-event-schema.md).
This is covered in full in ../desktop/keychain-and-secrets.md and ../../architecture/local-first-and-security.md.
The workflow and agent files inside .relavium/ carry their own schema_version (see workflow-yaml-spec.md). Because the entire .relavium/ directory is committed and shared, both the config files and the workflow/agent files are treated as stable, versioned, public formats — breaking changes require a migration path, never a silent reinterpretation of existing keys.