Skip to main content
reference

config.yaml reference

~/.ethos/config.yaml is a flat key: value file. Dotted keys (e.g. retention.messages, providers.0.provider) are how nested structures appear on disk — there is no indentation-based nesting. Inside double quotes exactly two escapes exist: \\ is a backslash and \" is a quote. Every other backslash is literal, so "C:\tmp" and "C:\Users\me" read as written. Any other value, single-quoted included, is read with one quote stripped from each end. Ethos quotes a value only when it would not read back unchanged. Ethos refuses to write a value containing a newline, tab or other control character, and the error names the key — the file is line-based, so such a value could not be read back.

Source​

The full field set lives in the EthosConfig interface in packages/config/src/index.ts. parseConfigYaml reads values; writeConfig writes them. Fields marked @internal are managed by the runtime (e.g. activeContext by ethos set) — do not hand-edit them.

A key the parser never reads, such as a misspelling, is kept in the file but has no effect. It produces a warning naming the key and, when one is close, the key it was probably meant to be: config.yaml: 'personalty' has no effect — the config parser did not read it; did you mean 'personality'?. ethos doctor prints these warnings, as do ethos serve, ethos gateway and ethos boot at startup. A warning never stops startup. Keys that only the web UI reads (approvalMode, verbosity, debugMode, contextLayering, the display.voice_* tuning keys) are not reported. The check lives in ConfigKeyUse in the same file. It does not yet cover the named rosters, teams.*, webhooks.*, quick_commands.*, channel_filter.* or models.*.

Minimal example​

schemaVersion: 1
provider: anthropic
model: claude-sonnet-5
apiKey: ${secrets:providers/anthropic/apiKey}
personality: researcher

This is what ethos setup writes for a default Anthropic install. The key value itself lives in the secrets vault (~/.ethos/secrets/providers/anthropic/apiKey); config.yaml carries only the reference. Everything below is optional.

schemaVersion​

Type: integer · Default: 1 · Required: no

Config file schema version. writeConfig emits it as the first line of every write; the current version is 1 (CURRENT_ETHOS_CONFIG_SCHEMA_VERSION). A file without the line is treated as schemaVersion: 1, with a one-time startup warning suggesting ethos setup or adding the line by hand.

schemaVersion: 1

provider​

Type: string · Default: anthropic · Required (effectively)

LLM provider id. Resolved at wiring time against the registered provider list. Built-in values: anthropic, openrouter, openai, ollama, gemini. Custom values may resolve through plugins.

provider: anthropic

model​

Type: string · Default: claude-opus-5-5 · Required (effectively)

Model id to pass to the provider. Format depends on the provider — Anthropic uses raw model names, OpenRouter uses vendor/model. The parser (parseConfigYaml in packages/config/src/index.ts) falls back to claude-opus-5-5, the catalog's Anthropic default, when the line is absent; the two are pinned equal by packages/wiring/src/__tests__/config-default-model.test.ts. ethos setup defaults to the selected provider's catalog default (getDefaultModel in packages/wiring/src/model-catalog.ts).

model: claude-sonnet-5

apiKey​

Type: secret reference · Default: empty · Required

Primary provider API key, as a ${secrets:<ref>} reference. ethos setup stores the value in the vault under providers/<provider>/apiKey and writes the reference here; rotate it with ethos secrets set providers/<provider>/apiKey <value>. Every write refuses a recognizable plaintext key in this field (validateNoPlaintextSecrets in packages/config/src/index.ts). For multi-key rotation, leave this set to the most-trusted key and add fallbacks via ethos keys add (which writes ~/.ethos/keys.json). The ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY env vars override this at wiring time when set.

apiKey: ${secrets:providers/anthropic/apiKey}

personality​

Type: string · Default: researcher · Required

Id of the default personality. Built-ins: researcher, engineer, reviewer. System: personality-architect, team-architect. User personalities live under ~/.ethos/personalities/<id>/.

personality: engineer

memory​

Type: markdown | vector | vault · Default: unset (treated as markdown)

Memory backend. One selection serves the agent, the web memory editor, the CLI memory verbs and the nightly pass:

  • markdown — per-personality files under ~/.ethos/personalities/<id>/ (MEMORY.md, USER.md) (default).
  • vector — SQLite + embeddings at ~/.ethos/memory.db. No file editor: the web Memory page and ethos memory restore|retract|supersede|history refuse, because there are no files to edit.
  • vault — the same file layout inside a directory you own (an Obsidian vault), set by memoryVault.path.
memory: vault
memoryVault.path: /Users/you/Documents/Vault

Notes:

  • Switching backends mid-stream does not migrate data — export from one, then import into the other.
  • A change takes effect on restart. A running ethos serve / ethos gateway logs memory backend changed — restart required to apply and keeps the backend it booted with.
  • Vector mode requires an embeddings-capable provider key.
  • ethos backup does not archive vault content — see Back up and restore.

memoryVault.*​

Read only when memory: vault.

KeyTypeDefaultDescription
memoryVault.pathstring—Absolute path to the vault root. Required under memory: vault; boot fails without it.
memoryVault.agentDirstringEthosSubtree of the vault the agent owns. Writes are confined to it; search may read the whole vault.
memoryVault.prefetchcomma listMEMORY.md, USER.mdKeys injected into the prompt tail for a personality scope.
memoryVault.excludecomma list—Names never read during list() / search(), on top of dot-directories and sync-conflict files.
memory: vault
memoryVault.path: /Users/you/Documents/Vault
memoryVault.agentDir: Ethos
memoryVault.prefetch: MEMORY.md, USER.md
memoryVault.exclude: Archive, Templates

Notes:

  • Content lands in <path>/<agentDir>/personalities/<id>/; provenance history and diff blobs in <path>/<agentDir>/.ethos-meta/ (dot-prefixed so Obsidian ignores it).
  • The approval queue and its tombstones stay at ~/.ethos/ for every backend — they are gate state, not memory content.

memoryApproval.*​

Approve-before-store gate: a captured fact parks in a queue instead of being written, until someone approves it (ethos memory pending, or the web Memory page's Pending tab).

KeyTypeDefaultDescription
memoryApproval.modeoff | automated | alloffoff — writes go straight through; automated — gates writes the agent proposes on its own (capture, dream turns); all — gates every write, including explicit memory_write tool calls.
memoryApproval.capnumber200Per-scope queue ceiling. At cap the oldest candidate is dropped, logged, and reported as an observability event.
memoryApproval.ttlDaysnumber30Candidates older than this auto-REJECT (never auto-approve) and are tombstoned so capture does not re-propose them.
memoryApproval.mode: automated
memoryApproval.cap: 200
memoryApproval.ttlDays: 30

Notes:

  • Cap and TTL apply to every queue over one deployment — the runtime gate, ethos memory pending, and the web Pending tab.
  • all also gates consolidation: both the inline fallback and the nightly pass's MEMORY.md / USER.md rewrite wait in the queue as consolidation entries (MemoryEditing.consolidation in packages/wiring/src/memory-backend.ts). The nightly pass still updates its memory-meta.json decay sidecar straight away.
  • Every backend is gated, memory: vector included. Under vector an approved candidate is written into memory.db and records no history entry, because vector keeps no history. (composeGatedVectorMemory and createPendingMemoryStore in packages/wiring/src/memory-backend.ts.)
  • A change takes effect on restart, like memory itself.

memoryCapture.evidenceSessions​

Type: integer, 0–16 · Default: 0 (off)

How many distinct sessions must independently extract the same fact before proactive capture (memoryCapture.enabled: true) keeps it. At 0, capture behaves as it did before the key existed. Above 0, a re-extracted fact merges into its existing pending candidate and adds its session as evidence, instead of queuing a duplicate. What happens next depends on memoryApproval.mode:

  • off: the fact waits in the pending queue and is written to memory with approvedBy: evidence once it reaches N sessions. Nothing is written before then.
  • automated or all: the queue lists the most-evidenced candidates first, and each row shows its session count. A person still approves every candidate, whatever its count.
memoryCapture.enabled: true
memoryCapture.evidenceSessions: 3

Notes:

  • The TTL still counts from a candidate's first proposal (memoryApproval.ttlDays), so repeated sightings cannot keep a fact queued forever. When a candidate expires, its fact is tombstoned, like any other expired candidate.
  • A rejected or expired fact is never queued again, however many sessions extract it.
  • A negative, fractional or non-numeric value, or one above 16, is refused at load, and the error names the key. An entry records at most 16 sessions (MAX_EVIDENCE_SESSIONS in extensions/memory-approval/src/store.ts).
  • A change takes effect on restart.

baseUrl​

Type: string · Default: provider default

Override the provider's API endpoint. Required for OpenAI-compatible providers (OpenRouter, Ollama, local proxies).

baseUrl: https://openrouter.ai/api/v1

modelRouting.<personality>​

Type: string · Default: falls back to top-level model

Per-personality model override. The key is a personality id; the value is a model string for that personality's provider.

modelRouting.researcher: claude-opus-4-7
modelRouting.engineer: moonshotai/kimi-k2.6

modelRegistry.*​

Type: dotted block · Default: unset (the legacy model serves)

The deployment's roster of model aliases. A personality's model names an alias or a role, and the registry turns it into a provider entry and a vendor model id. Parsed by buildModelRegistry in packages/config/src/index.ts; the type is ModelRegistryEntry in packages/types/src/model-registry.ts.

FieldTypeDescription
modelRegistry.<alias>.providerstringThe provider entry's explicit providers.<i>.id, not a provider type.
modelRegistry.<alias>.modelIdstringThe vendor id sent on the wire, e.g. claude-opus-4-8.
modelRegistry.<alias>.labelstringLabel shown in the model pickers. Display only.
modelRegistry.<alias>.contextWindowintegerContext window in tokens.
modelRegistry.<alias>.effortoff | low | medium | highHow hard this alias reasons. Unset sends each provider's own default request. See Effort mapping.
modelRegistry.<alias>.costPer1kInput / .costPer1kOutputnumberUSD per 1k tokens. Display only; nothing bills on it.
modelRegistry.<alias>.fallbackscomma listAliases tried in order when this entry's provider cannot answer. Same provider entry only.
modelRegistry.defaultaliasThe alias a turn uses when nothing else declares one.
modelRegistry.roles.<role>aliasBinds a role (trivial, default, deep, dreaming) to an alias.
modelRegistry.opus-deep.provider: anthropic-main
modelRegistry.opus-deep.modelId: claude-opus-4-8
modelRegistry.opus-deep.effort: high
modelRegistry.opus-quick.provider: anthropic-main
modelRegistry.opus-quick.modelId: claude-opus-4-8
modelRegistry.opus-quick.effort: low
modelRegistry.default: opus-quick
modelRegistry.roles.deep: opus-deep

Two aliases can share one modelId and differ only in effort. A personality picks one by name (model: opus-deep), so effort needs no personality field.

Notes:

  • effort values: off — no extended reasoning where the model allows it; low, medium, high — increasing reasoning depth and token spend. There is no default value: an alias with no effort sends exactly the request it sent before the key existed.
  • An effort outside those four is dropped, and the config load prints config.yaml: 'modelRegistry.<alias>.effort' must be one of off, low, medium, high ('<value>'), so it was ignored.
  • The effort travels with the alias that answers. A mid-turn escalation to another alias sends that alias's effort, or none. On an unpinned turn a providers.* failover hop receives the same effort and maps it onto its own model.

Effort mapping​

Each provider maps effort to its own request field, or ignores it.

ProviderWhat effort sends
anthropic, adaptive-thinking models (Opus 4.6, 4.7, 4.8, 5, 5.5; Sonnet 4.6, 5; Fable 5, 5.1)low/medium/high → thinking: {type: adaptive} plus output_config.effort. off → nothing on a model that does not think by default (Opus 4.6–4.8, Sonnet 4.6); thinking: {type: disabled} plus effort low on Opus 5; effort low alone on a model whose thinking cannot be disabled (Opus 5.5, Sonnet 5, Fable 5 and 5.1).
anthropic, budget-thinking models (Haiku 4.5, Sonnet 4.5, Opus 4.5, Opus 4.1, Opus 4, Sonnet 4, Claude 3.7 Sonnet)thinking.budget_tokens: low 1024, medium 4096, high 16,384; off sends no thinking. The budget is capped at the request's max_tokens minus 1024 and dropped below 1024, so high under an 8,096-token cap (a Claude model the catalog does not list) thinks with 7,072. Raise models.<provider>/<model>.maxOutputTokens to give it the full budget.
anthropic, any other model idNothing.
codexreasoning.effort: low, medium or high; off → low. Unset stays medium, the value it always sent.
openai and other OpenAI-compatible providersreasoning_effort, only for an OpenAI reasoning model (o-series, GPT-5 family) on api.openai.com; off → low; gpt-5-pro always gets high. gpt-5-chat, o1-mini and o1-preview get nothing. OpenRouter, local runtimes and Azure get nothing.
gemini, xai, bedrock, azureNothing. The request is unchanged.

Notes:

  • Tool continuations. The Anthropic provider cannot send a previous turn's thinking block back to the API. So when a request continues a turn that called a tool, a budget-thinking model sends no thinking, and an adaptive model that does not think by default sends output_config.effort without a thinking param. Models that think by default send the same request as on any other turn. thinkingParamsFor and continuesToolUseTurn in extensions/llm-anthropic/src/index.ts; pinned by extensions/llm-anthropic/src/__tests__/thinking-capabilities.test.ts.
  • The Anthropic capability table is ANTHROPIC_MODEL_CAPABILITIES in the same file. Dated snapshots, -latest, Bedrock, Vertex and OpenRouter spellings of a model id map to the same row.
  • The Codex mapping is codexEffort in extensions/llm-codex/src/index.ts; the OpenAI mapping is openAiReasoningEffort in extensions/llm-openai-compat/src/transport.ts.

models.<provider>/<model>.maxOutputTokens​

Type: integer · Default: the model catalog's cap for that model, else the provider's default

The output-token cap sent with each request to that model. It beats the bundled model catalog (mergeModelProfile, packages/wiring/src/model-catalog.ts). The catalog records the documented cap for each Claude model on provider: anthropic: 128,000, or 64,000 for Claude Haiku 4.5. A Claude model the catalog does not list gets 8,096. The anthropic provider and OpenAI-compatible providers such as openrouter read this key. azure and bedrock do not.

models.anthropic/claude-opus-5.maxOutputTokens: 32000

providers.<i>.*​

Provider fallback chain. When two or more entries are present, the runtime wraps them in a ChainedProvider with cooldown-based failover. Index 0 is primary; higher indices fall back in order. With fewer than two entries, the top-level provider / apiKey / model fields are used instead. When Settings or ethos fallback add grows a chain from the top-level fields, entry 0 becomes the top-level provider, with the same key reference.

FieldTypeDescription
providers.<i>.providerstringProvider id for entry <i>.
providers.<i>.apiKeystringAPI key for entry <i>.
providers.<i>.modelstringOptional model override for entry <i>.
providers.<i>.baseUrlstringOptional endpoint override for entry <i>.
providers.<i>.apiVersionstringAzure only: REST API version for entry <i>.
providers.<i>.regionstringBedrock only: AWS region for entry <i>.
providers.<i>.awsProfilestringBedrock only: named AWS profile for entry <i>.
providers.<i>.outputCapParammax_tokens | max_completion_tokensAzure only: the output-cap parameter entry <i>'s deployment is sent. Default: max_completion_tokens when the deployment name is a reasoning model id (o4-mini, gpt-5-mini), max_tokens otherwise. Set max_completion_tokens for a reasoning deployment with any other name. Any other value is ignored with a warning at startup. The top-level fields count as entry 0. Settable at provision time via AZURE_OUTPUT_CAP_PARAM (ethos setup --from-env, see Run Ethos in Docker).
providers.<i>.serverCompactionbooleanAnthropic only. true lets the provider compact the conversation server-side instead of the local context engine. Default off. See Server-side compaction.
providers.<i>.serverCompactionTriggerTokensintegerInput-token count at which the server compacts. Default: the local compaction threshold for the model. A value below 50,000, the minimum in Anthropic's API documentation, is raised to 50,000 rather than rejected.

Any other providers.<i>.<field> line belongs to entry <i>: it moves with the entry when the chain is reordered and is removed with it. Its value is checked for plaintext credentials at boot like every other value. If one is found, Ethos refuses to start and names the line — run ethos secrets set providers/<i>/<field> <value>, then set the line to providers.<i>.<field>: ${secrets:providers/<i>/<field>}.

providers.0.provider: anthropic
providers.0.apiKey: sk-ant-...
providers.0.model: claude-opus-4-7
providers.1.provider: openrouter
providers.1.apiKey: sk-or-...
providers.1.model: anthropic/claude-opus-4-7

Server-side compaction​

With providers.<i>.serverCompaction: true on an anthropic entry, Anthropic summarizes the older turns itself once a request's input reaches the trigger (the compact_20260112 edit, beta compact-2026-01-12). The local context engine then stays out of the way for every turn that entry serves: the pre-request compaction, the overflow retry and the turn-end auto-compaction do not run, so a turn never compacts twice. The top-level spelling counts as entry 0, so a single-provider setup turns it on with providers.0.provider: anthropic plus providers.0.serverCompaction: true.

  • The trigger defaults to the threshold the local gate would compact at (compaction.pressure of the model's window, capped by compaction.maxContextTokens), so turning the switch on changes who compacts, not when.
  • The summary block is saved in sessions.db as one extra assistant row and sent back on every later request, as the API requires. Transcripts, exports and search show it as — context compacted by the provider — followed by the summary; the encrypted part is stored outside the searchable text.
  • On any other provider the flag is ignored with a warning at startup.
  • If the API refuses the edit (an unsupported model, for example), the request is retried once without it, an audit.compaction event with code llm.server_compaction_rejected lands in observability.db, and local compaction runs for the rest of that turn.
  • After a failover to a non-Anthropic provider, the saved summary reaches it as plain assistant text; the encrypted half is Anthropic-only and is dropped.
providers.0.provider: anthropic
providers.0.apiKey: ${secrets:providers/0/anthropic/apiKey}
providers.0.serverCompaction: true
providers.0.serverCompactionTriggerTokens: 150000

A compaction shows up as an assistant row that starts with the marker line, and as an llm.server_compacted event. ANTHROPIC_LOG=debug prints each request body, where the next request carries the block back:

sqlite3 ~/.ethos/sessions.db "SELECT role, substr(content, 1, 80) FROM messages WHERE tool_name = '_provider_compaction';"
sqlite3 ~/.ethos/observability.db "SELECT code, cause FROM events WHERE code LIKE 'llm.server_compact%';"

telegram.bots.*​

Multi-bot list shape. When set, the gateway creates one TelegramAdapter and one AgentLoop per entry. telegram.bots takes precedence over the legacy telegramToken scalar when both are present.

FieldTypeDefaultDescription
telegram.bots.<i>.tokenstring—Bot token from BotFather. Required per entry.
telegram.bots.<i>.idstringsha256(token)[:24]Stable, human-readable bot key. Used in session lane names (telegram:<botKey>:<chatId>), log output, and the internal Map<botKey, AgentLoop>. Set once; do not change after the bot goes live.
telegram.bots.<i>.bind.typepersonality | team—Required. personality routes to a named personality. team routes to the team's coordinator personality and auto-starts the team supervisor.
telegram.bots.<i>.bind.namestring—Required. Personality id (for personality) or team name (for team).
telegram.bots.<i>.bind.allowSlashSwitchbooleanfalseAllow per-chat /personality switching. Disabled by default for identity-bound bots. In a group chat only channel_filter.telegram.ownerUserId may switch; a group with no owner configured refuses.
telegram.bots.<i>.defaultChannelModemention_only | thread_follow | all | regex_match | observemention_onlyReply-and-record behaviour in group chats that have no per-chat override. Five values — one more than Slack's four; see the mode list below. Every value except mention_only acts on group messages nobody addressed to the bot, and Telegram delivers those only when BotFather's Group Privacy is off — see Platform prerequisites for group messages. An out-of-range value is a parse error, not a silent fallback.
telegram.bots.<i>.useWebhookbooleanfalseReceive updates over an inbound webhook instead of long-polling. Requires webhookUrl and webhookSecretToken — the adapter throws at startup without either. See Receive Slack and Telegram events over webhooks.
telegram.bots.<i>.webhookUrlstring—Required when useWebhook is true. The full public URL Telegram POSTs updates to, including the /telegram/webhook/<botKey> path — the host routes on that path, so a URL without it reaches no handler. Registered for you by setWebhook() at startup.
telegram.bots.<i>.webhookSecretTokenstring—Required when useWebhook is true. Echoed by Telegram in X-Telegram-Bot-Api-Secret-Token and compared by grammy before the update is processed. A credential: externalized to the secret vault exactly like token, so write it as ${secrets:<ref>}.
telegram.bots.<i>.dropPendingUpdatesbooleanfalseDiscard updates Telegram queued while the process was down. With the default, messages sent during a restart are answered once the bot is back. Poll mode only — bot.start() is never called for a webhook-mode bot, so the flag does nothing there. Set true only if a restart should skip everything queued while the bot was down.
telegram.bots.<i>.replyPrefixstringunsetTemplate put in front of every reply, with {name} and {emoji} from the bound personality. See <bot>.replyPrefix.
telegram.bots.<i>.mentionByNamebooleanfalseCount a group message that names the bound personality as a mention. See <bot>.mentionByName.
telegram.bots.0.token: "123456:ABCdefGhIJklmNopQRstuVwxYZ"
telegram.bots.0.id: researcher-bot
telegram.bots.0.bind.type: personality
telegram.bots.0.bind.name: researcher

telegram.bots.1.token: "654321:XYZabcDeFgHijKlMnOpqRsTuV"
telegram.bots.1.id: coder-bot
telegram.bots.1.bind.type: personality
telegram.bots.1.bind.name: engineer

Notes:

  • defaultChannelMode values, as evaluateChannelMode() in packages/core/src/channel-mode.ts resolves them:
    • mention_only — reply only when the message contains the bot's @username (default).
    • thread_follow — mention_only, plus every message in a forum topic the bot has already posted in.
    • all — reply to every message in the chat.
    • regex_match — mention_only, plus messages matching a per-chat stored pattern. No effect as a global default: the pattern is read from a per-chat override, and Telegram has no way to write one yet (see the bullet below), so a message that is not a mention never matches.
    • observe — record every message to the channel transcript and reply to none of them, not even an explicit @mention. Direct messages to the bot are answered under every mode, observe included.
    • An unrecognised mode fails closed — not answered, not recorded. It cannot reach the evaluator from this key (an out-of-range value here is a parse error, per the table above), but it can from the per-chat override store on disk — a hand-edited entry, or a mode written by a newer build and read by an older one. evaluateChannelMode() drops those before every other test rather than falling back to mention_only; the affected chat goes silent. Direct messages are still answered, which is the channel left to notice it through.
  • Per-chat overrides cannot be set on Telegram yet. The adapter reads a per-chat override store (ChannelOverrideStore, JSONL under the bot's telegram/<botKey>/ directory) and a stored entry wins over this key, but no command or API writes one — Slack's /ethos channel-mode has no Telegram equivalent. This key is the only way to put a Telegram chat into observe today, and it applies to every group the bot is in.
  • A bot whose resolved mode is observe while BotFather's Group Privacy is still on records nothing at all. The adapter checks getMe at startup and logs Telegram privacy mode is ON for @<username> — chats set to observe will record nothing. rather than leaving an empty transcript to explain itself.
  • Session key format in multi-bot mode: telegram:<botKey>:<chatId>. This differs from single-bot mode (telegram:<chatId>).
  • deriveBotKey() in packages/config/src/index.ts computes the sha256-derived default when id is omitted.
  • See Run multiple Telegram bots from one process for a full walkthrough.

telegramToken​

Type: string · Default: unset · Deprecated (use telegram.bots instead)

Legacy scalar shape — wires one bot bound to the default personality. Still supported; creates a single-element entry in the gateway's internal bot list. When both telegramToken and telegram.bots are set, telegram.bots takes precedence and this field is ignored with a deprecation warning.

telegramToken: 123456:ABC-DEF...

slack.apps.*​

Multi-app list shape for Slack. Each entry creates one Slack adapter bound to a personality or team. Supersedes the legacy scalar fields slackBotToken, slackAppToken, and slackSigningSecret when set.

FieldTypeDefaultDescription
slack.apps.<i>.botTokenstring—xoxb- bot token. Required per entry.
slack.apps.<i>.appTokenstring—xapp- app-level token for Socket Mode. Required only while mode.socket is in effect (the default); an HTTP-mode app has no Socket Mode connection and needs none. The adapter throws when mode.socket is on and this is absent.
slack.apps.<i>.signingSecretstring—Signing secret from the app's Basic Information page. Required per entry. Exercised only under mode.http, where it is the HMAC key every inbound Events API request is verified against; under Socket Mode it is carried but never used — the socket is authenticated by appToken and receives no HTTP requests to verify.
slack.apps.<i>.mode.socketbooleantrueConnect over Socket Mode (WebSocket). Mutually exclusive with mode.http — the adapter throws when both are true, because they are two transports for the same inbound event stream.
slack.apps.<i>.mode.httpbooleanfalseReceive Events API deliveries over inbound HTTP instead. Requires signingSecret, and requires setting mode.socket: false explicitly. Slack has no registration API: you must set the Event Subscriptions Request URL in the Slack app dashboard yourself. See Receive Slack and Telegram events over webhooks.
slack.apps.<i>.webhookPathstringthe app's botKeyRoute segment this app's HTTP receiver answers on, under /slack/events/. HTTP mode only. Leading and trailing slashes are stripped.
slack.apps.<i>.idstringsha256(botToken)[:24]Stable, human-readable app key. Used in session lane names and log output. Set once; do not change after the app goes live.
slack.apps.<i>.bind.typepersonality | team—Required. Same semantics as the Telegram equivalent.
slack.apps.<i>.bind.namestring—Required. Personality id or team name.
slack.apps.<i>.bind.allowSlashSwitchbooleanfalseAllow per-channel /personality switching. In a channel only channel_filter.slack.ownerUserId may switch; a channel with no owner configured refuses.
slack.apps.<i>.defaultChannelModemention_only | thread_follow | all | observemention_onlyReply-and-record behaviour in channels that have no per-channel override. Four values, not Telegram's five — the Slack adapter has no regex_match. thread_follow also answers follow-ups in threads the bot has already posted in; all answers every message; observe records every message and replies to none, not even an explicit @mention. DMs are answered under every mode. Anything but mention_only needs the message.channels / message.groups event subscriptions — see Platform prerequisites for unaddressed channel messages. /ethos channel-mode <mode> overrides it per channel. An out-of-range value is a parse error, not a silent fallback.
slack.apps.<i>.receiptReactionstringunsetEmoji name (no colons) reacted onto an inbound message on arrival and removed once the reply lands. When set, it wins over the bound personality's display.emoji; unset, the personality's emoji is used when its Slack name is known, else eyes. See Receipt reaction. Needs the reactions:write scope; without it the reaction is skipped silently.
slack.apps.<i>.replyPrefixstringunsetTemplate put in front of every reply, with {name} and {emoji} from the bound personality. See <bot>.replyPrefix.
slack.apps.<i>.mentionByNamebooleanfalseCount a channel message that names the bound personality as a mention. See <bot>.mentionByName.
slack.apps.<i>.allowedSlashUsersstring listunsetComma-separated Slack user ids allowed to run /ethos and to see the App Home tab's memory, session, kanban, and channel sections. Narrows channel_filter.slack (ownerUserId + recipientAllowlist) — an id listed here that is not on that allowlist stays denied. Absent or empty means the channel_filter.slack allowlist alone. Both empty means nobody: the gate is default-closed.
slack.apps.<i>.allowedBotIdsstring listunsetComma-separated Slack bot_ids whose messages reach the agent, in new posts, edits, and thread backfill alike. Absent or empty drops every bot- and workflow-authored message — the gate is default-closed. Read a bot's id from the bot_id field of any message it has posted.
slack.apps.<i>.longReplyThresholdCharsinteger9000Reply length above which the agent posts a short lead message ending in "full answer attached" plus the complete text as an answer.md upload, instead of four or more chunked messages. 0 disables the fallback. The upload needs the files:write scope; without it the reply falls back to the chunked messages.
slack.apps.0.botToken: "xoxb-..."
slack.apps.0.appToken: "xapp-..."
slack.apps.0.signingSecret: "abc123..."
slack.apps.0.id: eng-slack
slack.apps.0.bind.type: team
slack.apps.0.bind.name: eng
slack.apps.0.allowedBotIds: B01DEPLOYBOT,B02ALERTBOT

Notes:

  • /ethos and the App Home tab are default-deny as of this release, and the trust set comes from channel_filter.slack — the same allowlist that decides who may message the bot. A workspace with no channel_filter.slack.ownerUserId and no recipientAllowlist authorizes nobody: every /ethos invocation answers "You are not authorized to use this command," and App Home shows a notice in place of its private sections. This is a breaking change — before it, any workspace member could run /ethos memory add, which writes the bound personality's MEMORY.md into every later system prompt. Set channel_filter.slack.ownerUserId to your Slack user id to restore access. channel_filter.slack.enabled: false does not reopen these surfaces; it only disables the message filter.
  • An allowlisted bot's message carries userId = <bot_id>, and the gateway's channel filter allowlists by userId. Two gates guard a bot message and both must open: allowedBotIds here, plus the same bot_id in channelFilter.slack.recipientAllowlist (or no filter configured at all). See Run an agent on Slack for the filter block. There is no bypass — an operator who opens one gate and forgets the other sees the bot's messages silently dropped at the gateway.
  • The same two gates apply to the bot's lines in thread backfill, and only under channel_filter.slack.contextVisibility: allowlist. Backfilled history is attributed line by line, and each line is checked against recipientAllowlist by the same id — a human's Slack user id, a bot's bot_id. So an operator running the stricter context-visibility setting who allowlists a bot in allowedBotIds but not in recipientAllowlist sees its history lines dropped while its live messages are dropped too, consistently. Under the default contextVisibility: all, backfill is not filtered at all and allowedBotIds alone decides which bots appear in it.

teams.*​

Per-team runtime knobs. These apply to teams that the gateway auto-starts via bind.type: team in telegram.bots or slack.apps.

FieldTypeDefaultDescription
teams.<name>.autoStopbooleantrueWhen true, the gateway stops the team supervisor when the gateway shuts down. Set to false to leave the supervisor running across gateway restarts.
teams.eng.autoStop: false

Notes:

  • <name> must match the team manifest filename stem at ~/.ethos/teams/<name>.yaml.
  • With autoStop: false, stop the supervisor manually with ethos team stop <name>.
  • See Connect a Telegram bot to a team for a usage example.

discordToken​

Type: string · Default: unset

Bot token for the Discord gateway.

discord.defaultChannelMode​

Type: mention_only | thread_follow | all | observe · Default: mention_only

Reply-and-record behaviour in every guild channel that has no per-channel override. Discord is single-bot — its credential is the top-level discordToken scalar, with no discord.bots roster — so this is a top-level key, not an indexed entry like telegram.bots.<i>.defaultChannelMode or slack.apps.<i>.defaultChannelMode.

  • mention_only — reply only when the message @mentions the bot (default).
  • thread_follow — mention_only, plus every message in a thread the bot has already posted in.
  • all — reply to every message in the channel.
  • observe — record every message to the channel transcript and reply to none of them, not even an explicit @mention.
  • An unrecognised mode fails closed — not answered, not recorded. Not reachable from this key, which is validated at parse (below), but reachable from a per-channel override written by hand or by a newer build. evaluateChannelMode() drops those before every other test rather than falling back to mention_only. Direct messages are still answered.
discord.defaultChannelMode: observe

Notes:

  • Four values, not Telegram's five — the Discord adapter has no regex_match. DMs are answered under every mode, observe included. An out-of-range value is a parse error, not a silent fallback: ethos gateway and ethos boot print discord: invalid defaultChannelMode '<value>' and exit non-zero.
  • Read once at gateway startup. Restart the gateway after editing.
  • Unlike Slack and Telegram, Discord needs no extra platform-side switch for messages nobody addressed to the bot. The Message Content Intent the adapter already requires for mentions covers them too — see Confirm intents.
  • Per-channel overrides cannot be set on Discord. The adapter reads a per-channel override store (ChannelOverrideStore, JSONL under ~/.ethos/discord/<botKey>/) and a stored entry wins over this key, but no command or API writes one — Slack's /ethos channel-mode has no Discord equivalent. This key is the only way to put a Discord channel into observe today, and it applies to every channel the bot can see. /ethos help prints the channel's effective mode.

discord.approvalRoleIds​

Type: comma-separated list of Discord role ids · Default: unset

Roles whose members may click Approve / Deny on a Discord approval card. Unset, the adapter refuses every click with "Approval roles not configured. No one can approve." (DiscordAdapter.handleApprovalDecision in extensions/platform-discord/src/index.ts), and each approval is denied when its timeout expires.

discord.approvalRoleIds: 1234567890123456789,9876543210987654321

Notes:

  • A role check comes first. The approval coordinator then accepts only the bound decider: the requester in a DM, the platform owner in a group.
  • Read once at gateway startup. Restart the gateway after editing.

discord.post_thinking_placeholder​

Type: boolean · Default: true

The "Thinking…" placeholder message the Discord adapter posts while a turn runs, so a channel is not silent between the user's message and the reply. false disables it — no placeholder is posted, and the reply arrives as the first message. Parsed into EthosConfig.discordPostThinkingPlaceholder (packages/config/src/index.ts); a value that is neither true nor false is dropped and reads as unset.

discord.post_thinking_placeholder: false

Notes:

  • Read once at gateway startup. Restart the gateway after editing.

slackBotToken​

Type: string · Default: unset

xoxb- bot token for the Slack gateway. Required together with slackAppToken and slackSigningSecret for Slack to bind.

slackBotToken: xoxb-...
slackAppToken: xapp-...
slackSigningSecret: ...

slackAppToken​

Type: string · Default: unset

xapp- app-level token for Slack Socket Mode. See slackBotToken for the example.

slackSigningSecret​

Type: string · Default: unset

Slack request signing secret. Verifies inbound webhooks when running Slack in HTTP mode.

whatsapp.<i>.default_mode​

Type: all | mention_only | observe · Default: mention_only

Reply-and-record behaviour in the group chats of the WhatsApp account at index <i> that have no per-chat override. Note the key name: default_mode, snake_case — not defaultChannelMode. The whole whatsapp.<i>.* block is snake_case (session_dir, allowed_numbers, phone_number), and this key follows its own block rather than the other three platforms. It is the real key, not a typo.

  • all — reply to every message in the group.
  • mention_only — reply only when the message mentions the bot (default).
  • observe — record every message to the channel transcript and reply to none of them, not even an explicit mention.
whatsapp.0.default_mode: observe

Notes:

  • Three values — the smallest of the four adapters' sets. WhatsApp has no threads, so no thread_follow, and no regex_match. Direct chats are answered under every mode, observe included. An out-of-range value is a parse error, not a silent fallback: ethos gateway and ethos boot print whatsapp[<i>]: invalid default_mode '<value>' and exit non-zero.
  • Read once at gateway startup. Restart the gateway after editing.
  • The adapter's own fallback is all, not mention_only. DEFAULT_CHANNEL_MODE in extensions/platform-whatsapp/src/config.ts is all, because an unset mode has always meant "answer every group message" for code that constructs WhatsAppAdapter directly and flipping it would silence a working embedder on upgrade. The gateway never leaves it unset — it passes default_mode ?? 'mention_only' — so an ethos gateway deployment gets mention_only like every other platform. The all fallback is reachable only by embedding the adapter yourself.
  • Per-chat overrides cannot be set on WhatsApp. The adapter reads a per-chat override store (ChannelOverrideStore, JSONL under ~/.ethos/whatsapp/<botKey>/) and a stored entry wins over this key, but no command or API writes one — Slack's /ethos channel-mode has no WhatsApp equivalent. This key is the only way to put a WhatsApp group into observe today, and it applies to every group that account is in.

<bot>.budget.dailyUsd​

Type: positive number (USD) · Default: unset (no daily cap)

Caps what one channel bot may spend per UTC day. Set it on the bot's own entry: telegram.bots.<i>.budget.dailyUsd, slack.apps.<i>.budget.dailyUsd or whatsapp.<i>.budget.dailyUsd.

telegram.bots.0.budget.dailyUsd: 5

Notes:

  • Once the bot's spend since 00:00 UTC meets the cap, each new message gets one reply saying so, and no agent turn runs until the next UTC day. Gateway.enqueueTurn in extensions/gateway/src/index.ts enforces it.
  • Spend is the same figure ethos usage reports, narrowed to that bot's sessions. The gateway reads it at most once a minute and adds its own turns' costs in between. Tool-reported costs are not in that figure, so they do not count toward this cap.
  • /budget in a chat shows the bot's spend today against this cap, next to the session cap. /budget reset clears only the session cap, never this one.
  • A value that is not a positive number is a parse error, and the bot entry is dropped. The legacy scalar bots (telegramToken, discordToken, email) have no entry to hold it, so they cannot be capped this way.
  • If the spend cannot be read, the turn runs, and the gateway records a gateway.daily_budget_unreadable event.
  • This is an operator setting, not part of a personality: two deployments of the same personality can set different caps. The per-session cap is budgetCapUsd.

<bot>.replyPrefix​

Type: string template · Default: unset (replies unchanged)

Text the gateway puts in front of every reply the bot sends, filled from the personality bound to the chat. Set it on the bot's own entry: telegram.bots.<i>.replyPrefix, slack.apps.<i>.replyPrefix, or the top-level discord.replyPrefix (Discord is single-bot).

PlaceholderFilled with
{name}The bound personality's name (its id when it has none). Follows a /personality switch.
{emoji}The bound personality's display.emoji.
telegram.bots.0.replyPrefix: "{emoji} {name}: "
discord.replyPrefix: "[{name}] "

With display.emoji: 🦉 and name: Owl, the first line renders 🦉 Owl: in front of the reply.

Notes:

  • These are the only two placeholders. Any other text is copied as written, and a value is never re-scanned for placeholders.
  • A placeholder with no value is removed with one adjacent space, so "{emoji} {name}: " for a personality with no emoji renders Owl: , not Owl:.
  • Quote a value that ends in a space. Unquoted, the trailing space is lost.
  • When a reply opens with a markdown block (a heading, a list item, a block quote, a code fence), the prefix is trimmed and put on its own line, so the block still renders.
  • The prefix is part of the reply's content. The gateway applies it before outbound dedup and before the delivery ledger records the reply, so a redelivery sends the stored text as-is and nothing is prefixed twice. A streamed reply carries it in the first chunk and in the final edit. Adapters never add it. applyReplyPrefix in extensions/gateway/src/reply-prefix.ts; pinned by extensions/gateway/src/__tests__/reply-prefix.test.ts.
  • Gateway notices (errors, empty-reply notices, credential links) and the spoken text of a voice note get no prefix.
  • This is an operator setting, not a personality field: two deployments of the same personality can prefix differently.

<bot>.mentionByName​

Type: boolean · Default: false

Count a group message that names the bound personality as a mention of the bot, exactly like an @mention. Set it on telegram.bots.<i>.mentionByName, slack.apps.<i>.mentionByName or the top-level discord.mentionByName.

telegram.bots.0.mentionByName: true

Notes:

  • The match is a whole word, case-insensitive, on the personality's name: with name: Owl, "hey Owl, can you check this?" counts and "Owlish" does not. Regex characters in the name are matched literally, and composed and decomposed accents match each other. mentionsPersonalityName in packages/core/src/channel-presence.ts; pinned by packages/core/src/__tests__/channel-presence.test.ts.
  • A match sets InboundMessage.isGroupMention. The channel filter is unchanged, so a member the filter does not allow is still dropped.
  • Direct messages never need it.
  • A message that reaches the bot only by naming it gets no receipt reaction on Slack and Discord. On Telegram it gets one only when the chat's mode would have answered it without the name.

Receipt reaction​

The emoji a channel bot reacts with when a message arrives, cleared when the reply lands. It is chosen in this order: an explicitly configured receipt reaction, then the bound personality's display.emoji, then the platform default.

PlatformConfigured byPersonality emoji used whenDefault
Slackslack.apps.<i>.receiptReactionIts Slack emoji name is in the adapter's table (slackEmojiName, extensions/platform-slack/src/adapter.ts)eyes (👀)
TelegramNot a config.yaml keyTelegram's Bot API accepts it as a reaction (telegramReceiptEmoji, extensions/platform-telegram/src/index.ts)👀
DiscordNot a config.yaml keyAlways (Discord takes any unicode emoji)👀

Notes:

  • If the platform refuses the personality's emoji (a Telegram chat can narrow its allowed reactions), the adapter retries once with the default. The reaction is never dropped for the personality's sake.
  • With no display.emoji and no configured reaction, every platform reacts exactly as before.

gateway.private_chats.<platform>​

Type: comma-separated chat ids · Default: unset (no trusted rooms)

Group chats on <platform> that the operator vouches for as private (trusted rooms). A listed room is treated like a direct message when the gateway decides a turn's room audience: its turns read and write the personality's private memory, and the memory tools come back. An unlisted group never does. See Keep memory out of group chats.

gateway.private_chats.telegram: -1001234567890,-1009876543210
gateway.private_chats.slack: C0123TEAM
PartMeaning
<platform>The platform id the chat id belongs to: telegram, slack, discord, whatsapp, email, or a plugin platform's id.
valueThe platform's own chat ids, exactly as the adapter reports them: a negative Telegram group id, a Slack channel id, a WhatsApp @g.us JID.

Notes:

  • Read by Gateway.audienceFor in extensions/gateway/src/index.ts, which receives it as GatewayConfig.privateChats from buildGateway in apps/ethos/src/commands/gateway.ts.
  • A thread or topic inside a listed chat is private too. Listing a thread id alone does nothing: the check reads the parent chat id.
  • A chat whose session already ran shared stays shared after you list it. Send /new in the chat to start a session that follows the list.
  • The list is keyed on platform and chat id only. Every bot on that platform honours it.
  • It has no filter side effects. Unlike a channel_filter.<platform> block, listing a room does not turn on the sender allowlist, pairing or mention gating.
  • Read once at gateway startup, by ethos gateway start and ethos boot. Restart the gateway after editing. An edit made while the gateway runs is logged as gateway.private_chats changed — restart required to apply.
  • A room you remove from the list stays trusted until that restart.
  • Ids are trimmed and de-duplicated. An id containing whitespace, or an entry that lists no ids, is dropped with a startup warning. A warning never stops startup.
  • A misspelt platform matches no chat, so the room stays shared.
  • The web UI's config saves keep this key untouched.

emailImapHost​

Type: string · Default: unset

IMAP server hostname for the email gateway. The email block requires all six of emailImapHost, emailImapPort, emailUser, emailPassword, emailSmtpHost, emailSmtpPort to bind.

emailImapHost: imap.gmail.com
emailImapPort: 993
emailUser: you@example.com
emailPassword: ...
emailSmtpHost: smtp.gmail.com
emailSmtpPort: 587

emailImapPort​

Type: integer · Default: unset

IMAP server port. Conventional values: 993 (TLS), 143 (STARTTLS).

emailUser​

Type: string · Default: unset

Mailbox username — typically the full email address.

emailPassword​

Type: string · Default: unset

Mailbox password. Use an app-specific password where the provider supports it (Gmail, Fastmail).

emailSmtpHost​

Type: string · Default: unset

SMTP server hostname for outbound mail.

emailSmtpPort​

Type: integer · Default: unset

SMTP server port. Conventional values: 587 (STARTTLS), 465 (TLS).

emailTrustedAuthservId​

Type: string · Default: unset

The authserv-id your mailbox's own receiving mail server stamps on incoming mail: the first token of its Authentication-Results header (RFC 8601). The email gateway treats a sender's From: address as their identity only when the topmost Authentication-Results header carrying this id reports dmarc=pass with header.from equal to the From: domain, or dkim=pass with header.d equal to, or a parent of, that domain. Every other header is ignored. Enforced by resolveEmailSender in extensions/platform-email/src/index.ts.

emailTrustedAuthservId: mx.google.com

Notes:

  • Unset means every sender is unverified. An unverified sender gets the user id email-unverified:<sha256 of the lowercased address> instead of their address. That id is stable per address and cannot equal a verified user's id, so it never inherits that user's memory, channel_filter owner status or approvals. The message reaches the personality with a one-line [unverified sender] notice in front of it, and runs in its own session rather than the verified sender's. Replies still go to the From: address.
  • To find the value, open the raw source of any mail this account received and read the first word after Authentication-Results:. On Gmail that is mx.google.com. The comparison ignores case.
  • ethos gateway start and ethos boot record one warn-level observability event (email.sender_auth_unconfigured) and print one console line at startup when email is configured without this key.
  • A header the parser cannot read, found above the trusted one, makes the sender unverified. So do more than one From: header, and an explicit non-pass dmarc result, even when dkim passed.

web.host, web.port, web.corsOrigins​

Type: dotted group · Default: per-field below

Bind address, port, and CORS origins for the ethos serve web API — the process that mounts /v1/* and /rpc/*.

FieldTypeDefaultDescription
web.hoststring127.0.0.1Bind address. Precedence: --web-host CLI flag > ETHOS_WEB_HOST env var > this key > default.
web.portinteger3000Listen port. Precedence: --web-port CLI flag > ETHOS_WEB_PORT env var > this key > default.
web.corsOriginsstringunset (no CORS)Comma-separated origins (or *) allowed to call /v1/* directly from a browser. No CLI flag exists for this one. Precedence: ETHOS_API_CORS_ORIGINS env var > this key > default (unset).
web.host: 0.0.0.0
web.port: 3000
web.corsOrigins: "https://chat.example.com"

Notes:

  • See Serve Ethos as an OpenAI-compatible backend for these keys in practice, including the CORS caveat for server-side clients.
  • ETHOS_WEB_TOKEN (env var only, no config key) supplies the web UI's bootstrap token instead of the generated <dataDir>/web-token file. The value must be at least 24 characters — a shorter one stops the boot with an error suggesting openssl rand -hex 32. While it is set, ethos serve does not print the sign-in URL; the operator already holds the token.

execution.docker.*​

Type: dotted group · Default: per-field below

The container every docker-sandboxed tool call runs in. A personality whose toolset includes terminal, run_code, run_tests, lint or a process_* tool, and which declares no execution: requirement, resolves to the docker posture (unless Ethos itself runs in a container). Those tools run inside execution.docker.image and nothing else.

FieldTypeDefaultDescription
execution.docker.imagestringunsetThe sandbox image, digest-pinned: <image>@sha256:<64 hex digits>. Required for the docker posture to execute anything. Unset → every exec tool returns Docker sandbox has no image configured… naming this key. An unpinned value (node:24-bookworm) is a config warning and is ignored — it never blocks boot.
execution.docker.cpunumber > 02Docker --cpus quota. Fractional values are allowed.
execution.docker.diskMbinteger ≥ 1unset (no quota)Best-effort --storage-opt size=<N>m. Enforced only on btrfs, zfs, devicemapper, or overlay2 over xfs with pquota; elsewhere Ethos warns once and runs without the quota.

Recommended image (verified 2026-09-25): node:24-bookworm — official Debian 12 image with bash, node 24, python3 3.11, git and curl. That covers the shell terminal uses (bash -lc) and both run_code runtimes with their tool shims (python3, node). A smaller image works when you need less: node:24-bookworm-slim has no python3 or git, so run_code with runtime: python fails in it.

Pin the digest you pulled. Containers start with --pull=never, so the image must already be on the machine, under the same repository name as the ref:

docker pull node:24-bookworm
docker inspect --format '{{index .RepoDigests 0}}' node:24-bookworm
node@sha256:64af3819f9275802414d7cdc38c27e9d82bd564dec4d4da87d008255d36c63b4
execution.docker.image: node@sha256:64af3819f9275802414d7cdc38c27e9d82bd564dec4d4da87d008255d36c63b4
execution.docker.cpu: 2

Notes:

  • docker buildx imagetools inspect node:24-bookworm prints the same index digest without pulling. It does not put the image on the machine — run docker pull before the first tool call.
  • If you pulled through a mirror (docker pull mirror.gcr.io/library/node:24-bookworm), use the RepoDigests value it reports (mirror.gcr.io/library/node@sha256:…). A node@sha256:… ref does not resolve locally under --pull=never when only the mirror name exists.
  • The container runs as your host uid with all capabilities dropped, no network unless the personality's safety.network allows it, and only the personality's fs_reach paths mounted. The image needs no user account for that uid.
  • Check the result: ethos doctor lists every docker-posture personality and flags a missing image; ethos personality show <id> prints the image (or NOT CONFIGURED) under ## Execution.
  • Source: dockerImageRefError in packages/config/src/index.ts; MissingDockerImageError in extensions/execution-docker/src/index.ts.

verbose​

Type: boolean · Default: false

Print a per-turn timing summary (LLM time, TTFT, tool wall-clock, tokens, cost) after every chat response.

verbose: true

Notes:

  • Toggle within a session with /verbose — that override is session-local and never written here.

skin​

Type: string · Default: engine default

Named skin override. Built-in values: default, mono, paper. Applies across the TUI and the web ConfigProvider. This is the only place a skin is set — a personality is an identity, not a theme, so personalities carry no skin field.

skin: mono

retention.*​

Per-category TTLs for the observability store and the observe-mode channel transcript. Unset fields fall back to the runtime defaults shown below.

Duration grammar: forever, or a whole number followed by d (days), w (weeks), m (months, 30 days) or y (years, 365 days) — 30d, 12w, 6m, 2y. Hours and minutes are not in the grammar; 12h is not a retention value. A value outside the grammar is dropped at load with a warning naming the key, and the category prunes on its default window instead. ethos doctor prints that warning, as do ethos serve, ethos gateway and ethos boot at startup.

FieldDefaultDescription
retention.messages365dConversation message history. The same window also removes a session row, with its compaction and decision rows, once the row has not been updated inside the window and holds no message inside it. ethos gateway start and ethos boot run that session prune at startup and then hourly (pruneExpiredSessions in session-retention.ts); forever turns it off.
retention.traces90dTurn traces.
retention.spans90dTool / LLM spans inside traces.
retention.blobs7dLarge response payloads stored out-of-band.
retention.archive730dArchive partitions.
retention.channelTranscript30dThird-party group-chat text recorded verbatim by an observe-mode channel, in ~/.ethos/channel-transcript.db. Pruned against when Ethos saw the message, not when the platform says it was sent, so a backdated message cannot outlive the window. Short by design: the corpus grows with the room's traffic rather than with your use of the agent, and only the last digest window is ever read.
retention.events.error90dError events from errors.jsonl.
retention.events.audit365dAudit events (key rotation, personality writes, approvals).
retention.events.channel365dChannel-adapter events (pairing, dedup).
retention.events.installforeverInstall / migration events. Never deleted by default.
retention.messages: 365d
retention.traces: 90d
retention.channelTranscript: 7d
retention.events.error: 90d
retention.events.install: forever

Notes:

  • A per-personality window for retention.channelTranscript prunes nothing, and is refused. Observe-mode transcripts live in one ~/.ethos/channel-transcript.db with no personality column, and the nightly job reads the global retention.channelTranscript only. Both setters refuse the combination and name the reason: ethos retention set channelTranscript <duration> --personality <id>, and the web Settings page — where the Personality column offers only Global for this subkey, and a save carrying one is rejected before it is written. Set the global key instead. A value an earlier build stored is still cleared the usual way: ethos retention reset channelTranscript --personality <id>, or removing the row in Settings and saving.
  • Pruning runs once a night, at 03:00 local time, and a missed run is not made up. All retention categories are aged out by a single system cron job (observability-prune) on a fixed 0 3 * * * schedule that no config key changes. That job is created with missedRunPolicy: skip, so a machine that was asleep or powered off at 03:00 rolls the run forward rather than executing it late — a laptop that is never awake at 03:00 never prunes anything, indefinitely and silently. There is no prune at startup, apart from the session-row prune described under retention.messages. Run Ethos on a machine that is up at 03:00 if a window here matters to you.
  • ethos data prune does not cover retention.channelTranscript. It prunes the observability store only. The channel transcript is a separate database with no manual prune command; the nightly job is the only thing that ages it out.
  • Per-message audit events outlive the transcript text they describe. Every message an observe-mode channel records also emits a channel.observed audit event carrying the platform, chat id and sender id — no message text, but a durable record of who spoke where and when. That event is in the audit category (365d) and in a different database, so it survives the transcript's 30d window by roughly 12×. Lowering retention.channelTranscript does not lower it; set retention.events.audit too if that matters to you.

personalities.<id>.retention.*​

Per-personality retention overrides. Same sub-fields as the top-level retention.* block; values apply only to data tagged with the matching personality id.

personalities.engineer-paired.retention.messages: 730d
personalities.engineer-paired.retention.traces: 180d

Notes:

  • Only the retention sub-block is parsed under personalities.<id>.*. Other top-level keys cannot be overridden per personality from this file — set them in the personality's own config.yaml.
  • No category honours a per-personality window on the nightly prune today. personalities.<id>.retention.* is read by ethos retention show --personality <id> and by ethos data prune --personality <id> (which prunes on demand, against the merged window). The scheduled observability-prune job reads the global retention.* block only, so a value set here shortens nothing on its own.

channelDigest.*​

Type: object · Default: off

The scheduled summary of what observe-mode channels recorded. One digest per lane — a lane is a chat and a thread — delivered to the owner's DM from the bot that watched it, plus the in-app notifications feed. Nothing is ever posted back into the observed room.

Off unless enabled: true. Seeded only by ethos gateway start and ethos boot; ethos serve runs no channel adapters and creates no job.

FieldTypeDefaultDescription
channelDigest.enabledbooleanfalseCreate the channel-digest cron job. Any value other than the literal true reads as false.
channelDigest.croncron expression0 8 * * *When the digest runs, in the host's local time.
channelDigest.deliverToowner | inAppownerowner — the owner's DM and the in-app feed. inApp — the feed only. The feed always gets a copy, so this names the delivery that can be switched off, not a choice between two. Any other value is a parse error.
channelDigest.maxMessagesPerLaneinteger ≥ 1500Messages read per lane per run. The oldest undigested ones are taken first; the rest are counted, reported in the digest's footnote, and read by the next run. A whole-string check — 500 messages is a parse error, not 500.
channelDigest.maxLanesPerRuninteger ≥ 125Lanes one run may send to the model. A real ceiling: it binds before any money is spent. Lanes past it are deferred with their cursors untouched, so nothing is lost — one watched Slack channel with 80 threads in a day is otherwise 80 model calls and 80 direct messages from one nightly run.
channelDigest.costWarnUsdPerLanenumber > 00.5Spend notice threshold for one lane, in USD. Not a ceiling — a digest is a single model call that reports its cost after billing, so a lane that costs more is still delivered, with a footnote saying what it cost.
channelDigest.enabled: true
channelDigest.cron: 0 8 * * *
channelDigest.deliverTo: owner
channelDigest.maxLanesPerRun: 25

Notes:

  • Owner delivery needs channel_filter.<platform>.ownerUserId. A platform with no owner id has nowhere to send its digests: each lane is counted undelivered, its cursor is left alone, and a channel.digest_undelivered event says why. The in-app copy still goes out.
  • An owner id that names a watched chat is refused. ownerUserId is a bare platform id, and on Telegram or WhatsApp nothing in it distinguishes a DM from a group — so a value copied from an observed chat would post the summary into a room it is about, in front of the people it is about. Any observed chat on that platform is refused, not just the lane being summarised.
  • deliverTo: inApp under ethos gateway refuses to start. Only ethos boot serves the web UI that the feed reaches, so under ethos gateway every digest would be generated and discarded. The error names both fixes.
  • Delivery is what consumes a lane. A cursor advances only on a confirmed delivery, so a failed send, a missing owner, a refused owner or a crash mid-run all leave the messages to be digested again. Duplicates are possible by design; a lost digest is not.
  • The digest turn gets no tools, no memory, no session and no plugin hooks. It is a bare model call, not an agent turn — the one place in the system where unfiltered third-party text meets a model. See Security controls.
  • Two runs on one machine cannot overlap. A run holds ~/.ethos/channel-digest.lock for its whole duration; a second run on the same host skips rather than waiting, names the process holding the lock in its run output, and records a channel.digest_skipped event. A restart overlapping its predecessor is covered. An ethos gateway start beside an ethos boot never gets that far: both take the gateway lock, and the second exits 3. The guarantee stops at the host boundary. The lock identifies its holder by process id and boot id, both facts about the local machine, so a lock written by another host can read as abandoned and be taken over rather than respected — two machines sharing one ~/.ethos over a network mount do not exclude each other. Both then send the same rooms to the model, deliver the same digest to the same owner, and the later write erases the cursors the earlier one advanced. Run one gateway per ~/.ethos.

notifications.*​

Type: object · Default: off

Quiet hours for notices the gateway sends without being asked: a finished background job's wake notice and an owner notice such as a post-call summary. Inside the window the notice is held in ~/.ethos/notify-queue.db and delivered within a minute of the window ending, through the same delivery ledger as every other tracked send. A reply to a message the user just sent is never held, and neither is a notice about that message (a dead-lettered or interrupted turn).

FieldTypeDefaultDescription
notifications.quietHoursHH:MM-HH:MMunsetThe daily window, in notifications.timezone. A start later than the end crosses midnight. A malformed value is a warning and turns quiet hours off.
notifications.timezoneIANA zonethe host's zoneThe zone the window is read in, e.g. Europe/London. An unknown zone is a warning and falls back to the host's.
notifications.bots.<botKey>.quietHoursHH:MM-HH:MM | offunsetA per-bot window, keyed by the bot's botKey (its id: or derived key). off turns quiet hours off for that bot only.
notifications.quietHours: 22:00-07:00
notifications.timezone: Europe/London
notifications.bots.work-slack.quietHours: off

In any chat, /mute <30m|2h|1d> holds that chat's notices for the given time, outside quiet hours too, and /mute off ends it early. A mute lasts at most 30 days, is stored beside the chat's session in ~/.ethos/gateway/lanes/<botKey>.json, and survives a restart. In a group chat only the owner set in channel_filter.<platform>.ownerUserId can mute or unmute; a group on a platform with no owner refuses it (Gateway.handleMuteCommand). Plain /mute, which shows the current mute, stays open to everyone.

Notes:

  • Held, never dropped. The hold and the release are Gateway.noticeHoldReason and Gateway.releaseHeldNotices in extensions/gateway/src/index.ts. The release runs at the top of every delivery sweep: at boot and every 60 seconds. If no held-notice store is wired, which only happens outside ethos gateway start and ethos boot, the notice is sent at once rather than kept only in memory.
  • What is not held yet. Cron job output, watcher wakes, the channel digest and deliver: 'parent' background reviews are delivered on their own schedules and ignore quiet hours.
  • Daylight saving. The window is read from the zone's wall clock each time it is checked, so it moves with a DST change.

logs.rotation​

Type: object · Default: { maxBytes: 10485760, maxFiles: 5, enabled: true }

Controls rotation of ~/.ethos/logs/errors.jsonl. When the file exceeds maxBytes, it is renamed to errors.jsonl.1 and older backups shift up to maxFiles.

KeyTypeDefaultDescription
logs.rotation.maxBytesinteger10485760 (10 MiB)Maximum file size before rotation. Must be a positive integer.
logs.rotation.maxFilesinteger5Maximum number of rotated backup files. Must be a positive integer.
logs.rotation.enabledbooleantrueSet to false to disable rotation entirely.
logs.rotation.maxBytes: 20971520
logs.rotation.maxFiles: 10
logs.rotation.enabled: true

backup.*​

Scheduled local snapshots of ~/.ethos/. On by default: a serving process (ethos serve, ethos gateway start, ethos boot) seeds a backup cron job at first start and writes an identity,state archive at 04:00 local every night, keeping the newest seven. Nothing here uploads anywhere — the archive stays on this machine.

FieldTypeDefaultDescription
backup.enabledbooleantrueSet to false to remove the scheduled job. Any value other than the literal true reads as false; the job is created, rescheduled, or removed to match on every serving-process start.
backup.croncron expression0 4 * * *When the scheduled backup fires, in the host's local time. Editing it patches the existing job rather than creating a second one.
backup.scopecomma-separated listidentity,stateWhich scopes the scheduled run archives. Values: identity — config, mcp.json, MEMORY.md, USER.md, cron/jobs.json, personalities/; state — the durable databases plus skills/, teams/, users/, digests/, cron/output/ and plugin pins; telemetry — observability.db, opt-in. An unknown name fails that backup run, naming it, and leaves the other system jobs alone.
backup.keepinteger ≥ 17How many scheduled archives survive rotation, oldest deleted first. A non-integer or 0 is a config parse error. Rotation only ever deletes files named ethos-scheduled-<timestamp>.tar.gz — a manual ethos backup archive parked in the same directory is never swept up.
backup.dirpath<ethosDir>/backupsWhere archives are written and rotated. A relative value resolves under the data directory, not the process working directory.
backup.enabled: true
backup.cron: 0 4 * * *
backup.scope: identity,state
backup.keep: 7
backup.dir: /mnt/snapshots/ethos

Notes:

  • ${ETHOS_HOME} is not a token this file expands. The <ethosDir>/backups default is computed in code by backupDirectory in packages/wiring/src/backup-schedule.ts; write a literal path if you want one.
  • The schedule only fires while a serving process is running. ethos chat alone runs no cron.
  • A state archive holds conversation history — sessions, session cards, memory. Treat the backup directory as sensitive as ~/.ethos/ itself.
  • Secrets are never archived. secrets/, keys.json, web-token, web-admin.json, web-sessions.json and MCP OAuth token files are excluded; the archive lists what is missing so a restore can prompt for it.
  • See Back up and restore an Ethos install for these keys in practice, and ethos backup for the manual command.

security.trusted_github_orgs​

Type: comma-separated list · Default: ethosagent, anthropic

The GitHub organizations whose skills and plugins install at the trusted-repo trust tier. Everything else on github.com installs at community. The value replaces the default list rather than extending it, so removing an organization you do not trust — including either shipped default — is expressible. Matching is exact on the organization path segment; github.com/ethosagent-evil/x does not match ethosagent, and any path containing . or .. is refused outright.

security.trusted_github_orgs: acme-corp, ethosagent

An explicitly empty value trusts no organization. It is a real setting, not the same as leaving the key out — an absent key keeps the shipped default in force.

security.trusted_github_orgs: ""

Notes:

  • trusted-repo lets an operator force past a red scanner finding with --force. It does not silently accept yellow findings — only builtin (code shipped inside Ethos itself) does that. Adding an organization here grants an override lever, not a scan bypass.
  • Organization names are case-sensitive here. Write the organization exactly as it appears in the github.com/<org>/<repo> path.
  • The list is read at the composition root and passed into the scanner. Defined in packages/safety/scanner/src/trust-tiers.ts.

goals.allowCheckCommands​

Type: boolean · Default: false

Allows a goal created from the web UI or API to carry a check with a verify command. When the goal is judged, the command runs as the goal's personality would run a terminal call, and the check passes if it exits 0 (createAcceptanceCheckExecutor, packages/wiring/src/acceptance-check-executor.ts).

goals.allowCheckCommands: true

With the key off, a create request whose checks carry a non-empty command is refused whole with FORBIDDEN. The command is never dropped silently (GoalsService.create, apps/web-api/src/services/goals.service.ts). The goal form shows a Verify command field on each check only while the key is true.

Notes:

  • The command runs wherever that personality's shell runs. It is refused when the personality does not hold terminal, when a safety.denyRules entry matches, or when the approval check would flag it (a judge pass has nobody to approve, so a flagged command fails the check). Under a host-local execution posture terminal is always flagged, so a local personality's verify commands never run on the host. Anyone who can create a goal through the web UI or API can still run commands inside the personality's sandbox. Enable this key only when every such person is trusted with that personality's shell.
  • Commands time out after 30 seconds.
  • The key is read on every create, so a change takes effect without a restart. Any value other than the literal true reads as false.
  • The goal detail page shows each check's command, so you can see what a goal will run.

learningReplay.*​

Type: object · Default: every field at its default below · Required: no

Settings for the replay that measures a learned skill or Expression change against frozen past tasks before it can go live. Absent keys take LEARNING_REPLAY_DEFAULTS, applied by resolveLearningReplay in packages/config/src/index.ts. Every number is validated as the whole string, so a value with trailing text (8 cases, 0.5usd) is a parse error, not the leading number.

FieldTypeDefaultDescription
learningReplay.enabledbooleantruefalse stops replay running at all: the nightly pass skips its replay step and ethos learning replay <id> refuses with replay_unavailable. It does not let anything go live unmeasured. A candidate with no verdict is never auto-promoted, so every candidate waits for a human, who must give a reason to approve it. Any value other than the literal true reads as false.
learningReplay.maxCasesinteger ≥ 38Cases replayed per candidate, at most 3 of them target cases. A value below 3 is a parse error: a replay of fewer than 3 cases can never reach a pass verdict. Values above 8 are clamped to 8 (MAX_REPLAY_CASES, extensions/learning-inbox/src/replay.ts).
learningReplay.maxCostUsdnumber > 00.50Replay-loop spend per candidate, in USD. A run that crosses it stops and scores incomplete, so the candidate cannot auto-promote. Written as digits with an optional decimal part (0.5, 2). Grader calls are not counted against it.
learningReplay.maxCandidatesPerRuninteger ≥ 15Candidates one nightly run replays, across all personalities together, oldest first. The rest wait for the next run.
learningReplay.enabled: true
learningReplay.maxCases: 8
learningReplay.maxCostUsd: 0.50
learningReplay.maxCandidatesPerRun: 5

Notes:

  • Replay measures approach, not answers. Tools are stubbed in a dry run, so a replay scores tool choice, arguments, voice and approach, but not whether an answer that depends on real tool output got better. A pass is not proof the change improved answers.
  • Replay runs real models and costs money on every candidate it measures, for both the baseline arm and the candidate arm.
  • See Why does a learned change need a replay before it goes live? for the verdict rules and when a pass promotes on its own.

nightlyPass.*​

Type: object · Default: off · Required: no

The scheduled nightly learning pass. When enabled, a serving process (ethos serve, ethos gateway start, ethos boot) creates a nightly-pass cron job (systemJobSpecs, packages/wiring/src/system-jobs.ts). The pass freezes new replay cases, drafts skill and Expression candidates, and replays pending_replay candidates. ethos nightly run [<id>] runs the same pass once, on demand, whatever this key says.

FieldTypeDefaultDescription
nightlyPass.enabledbooleanfalseSchedule the pass. Any value other than the literal true reads as false.
nightlyPass.croncron expression0 3 * * *When the pass fires. Only meaningful when enabled is true.
nightlyPass.enabled: true
nightlyPass.cron: 0 3 * * *

Notes:

  • Off by default means candidates are not replayed on a schedule. A draft from the post-turn fork, skill_propose, ethos evolve or ethos personality evolve sits in pending_replay until ethos learning replay <id> or ethos nightly run measures it, or a human approves it with a reason. autoApprove and skill_evolution.promotion: auto do not skip this wait: nothing promotes itself without a pass replay.
  • With it off, no new cases are frozen on a schedule. The pass freezes up to 10 cases per personality per run. Without regression cases in the pool, a replay scores incomplete for too few cases, and the candidate waits for a human.
  • The schedule only fires while a serving process runs. ethos chat alone runs no cron.
  • How many candidates one run replays, and what each may spend, are learningReplay.* settings.

evolver.cron_enabled​

Type: boolean · Default: false

When true, a serving process (ethos serve, ethos gateway start, ethos boot) creates a skill-evolver cron job that runs ethos evolve run --quiet on the schedule defined by evolver.schedule (systemJobSpecs, packages/wiring/src/system-jobs.ts). false removes the job the next time a serving process starts.

evolver.cron_enabled: true

evolver.schedule​

Type: string (5-field cron expression) · Default: "0 3 * * *"

Controls when the evolve cron fires. Only meaningful when evolver.cron_enabled is true.

evolver.schedule: 0 3 * * *

Notes:

  • The schedule only fires while a serving process runs. ethos chat alone runs no cron.
  • Each run drafts for the default personality in this file, and submits every draft to the learning inbox as a candidate. None goes live without a pass replay or a human approval; see Why does a learned change need a replay before it goes live?.
  • Per-personality skill evolution keys (skill_evolution.enabled, promotion, scope and the rest) live in the personality config.yaml, not here. The evolver cron does not read skill_evolution.enabled: that key gates the post-turn fork and the nightly skill drafter.

voice.tier​

Type: pipeline | realtime · Default: unset (the surface decides)

The deployment's default voice engine. pipeline — speech-to-text → the agent turn → text-to-speech, the only tier local providers can serve, and the explicit private/offline mode. realtime — one hosted speech-to-speech session owns listening and speaking together. Unset means "try realtime where a provider is configured, otherwise pipeline". An unrecognised value is ignored rather than thrown on, so a typo cannot make the config unloadable. A personality's own voice.tier beats this.

voice.tier: realtime

Only an explicit pipeline refuses a realtime call outright — the browser is told pipeline_preferred and continues silently, because nothing went wrong. Editable in Settings → Voice, along with every key below.

voice.realtime.providers.<name>.*​

Type: dotted roster · Default: unset (no realtime provider)

Named hosted speech-to-speech engines. <name> is a label you choose — a personality points at it by name, and voice.realtime.default names the one everything else uses. Labels are restricted to [A-Za-z0-9_-]+. There is no auxiliary.* fallback for this roster: no entry means no realtime tier.

FieldTypeDescription
providerstringRegistered provider id. Required. openai-realtime — OpenAI Realtime, 24 kHz in and out, issues browser credentials. gemini-live — Gemini Live, 16 kHz in / 24 kHz out, cannot serve a browser call (see the note below).
modelstringProvider model id. Defaults to gpt-realtime for openai-realtime.
apiKeystringThe provider key. Use a ${secrets:...} reference rather than a literal.
baseUrlstringOverride the provider's endpoint.
voicestringDefault voice id for this entry. The speaking personality's own voice.tts_voice beats it — switching tiers must not switch who you are talking to.
costPerMinuteUsdfloatThe provider's published rate, typed by you. A realtime session bills by wall-clock audio time, so this is the only number that turns duration into money, and the only thing voice.realtime.sessionBudgetUsd can act on. Absent = no rate known, nothing accrues, and a budget cannot bite.
voice.realtime.providers.live.provider: openai-realtime
voice.realtime.providers.live.model: gpt-realtime
voice.realtime.providers.live.apiKey: ${secrets:voice/realtime/providers/live/apiKey}
voice.realtime.providers.live.costPerMinuteUsd: 0.06

Notes:

  • The label buys nothing. The local-only egress gate keys on the entry's provider and the constructed provider's own caps.local, so an entry named local-realtime backed by a hosted model is refused before a session opens. List the provider id in voice.trustedPlugins, not the label.
  • gemini-live is contract-only in this release. It proves the provider contract is not OpenAI-shaped and is exercised by the shared conformance suite, but it declares caps.ephemeralToken: false — there is no browser credential to mint, and no server-relay path ships in this phase. A browser call selecting it is refused with no_browser_token and continues on the pipeline tier behind a visible notice. This is a stated limitation, not a bug.

voice.realtime.default​

Type: string · Default: unset

Which roster entry a call uses when the personality names none — voice.realtime.default: live for the example above. A label from that roster, never a provider id. Naming an entry the deployment does not have is reported as unknown_entry and the call continues on the pipeline.

voice.realtime.sessionBudgetUsd​

Type: float (USD) · Default: unset (no cap) · Must be > 0

Spending cap on one realtime session — the entry's costPerMinuteUsd times the session's audio minutes, plus what any agent_consult turns spent. On reaching it the session speaks a short sign-off and then closes, in that order, and the call strip shows a budget reached chip. A session on an entry with no costPerMinuteUsd accrues nothing, so this cap never fires for it.

voice.realtime.sessionBudgetUsd: 1.50

Notes:

  • A lowered cap takes effect on the next call; removing a cap takes effect on restart. Where the live read and the boot snapshot disagree, the boot cap stands — the direction to be wrong in when the subject is money.
  • The personality's own budgetCapUsd also governs this lane; the lower of the two binds.

voice.trustedPlugins​

Type: comma-separated provider ids · Default: key absent (gate off)

The local-only egress gate. Declaring the key at all arms it — an empty value therefore means "trust nothing non-local" and is not the same as omitting the key. Providers advertising caps.local (local-stt, local-tts, command-stt, command-tts) always pass; every other provider must be named by its provider id. A refused provider fails at resolution, before it is handed a byte, on every surface — gateway, browser, ethos doctor, and the realtime mint.

voice.trustedPlugins: openai-tts, openai-realtime

Notes:

  • Settings → Voice → Restrict voice egress arms the gate and edits the list. The declared-but-empty form is the one shape it cannot write (the web config writer drops empty values) — set that line by hand.
  • The refusal reads <kind> provider "<id>" is not local and is not in voice.trustedPlugins — refusing to send audio off this machine. See Keep voice on this machine.

voice.defaultMode​

Type: off | mirror_inbound | all · Default: mirror_inbound

Where a conversation starts on spoken replies, on any channel whose adapter declares voice output — Telegram, Slack, Discord, and WhatsApp today.

  • off — never speak.
  • mirror_inbound — speak back when spoken to (default).
  • all — speak every reply.

An unrecognised value is ignored. Change it per conversation in chat with /voice off|mirror_inbound|all; that choice is written to ~/.ethos/voice/lane-modes.json and outlives both /new and a gateway restart. A channel switched off with voice.channels.<platform>.ttsOut: false stays silent even in all. See Send and receive voice notes on a channel.

voice.defaultMode: all

voice.channels.<platform>.ttsOut​

Type: boolean, keyed by platform id · Default: unset (the platform follows voice.defaultMode)

Which channels may speak their replies. Accepted platform ids are telegram, slack, discord, whatsapp, and email (VOICE_CHANNEL_PLATFORMS in packages/config/src/index.ts); an unknown id or a non-boolean value is dropped on read, so a typo cannot invent a channel entry no adapter will act on.

An explicit false is an operator decision and outranks the conversation's mode — /voice all in a silenced Slack channel still produces text only. An explicit true changes nothing on its own; the conversation's mode still decides when to speak.

voice.channels.slack.ttsOut: false
voice.channels.whatsapp.ttsOut: true

Notes:

  • email is accepted by the parser but has no voice sink — the email adapter declares no voice caps, so the value is inert.
  • Editable in Settings → Voice, one switch per channel.

voice.transcode.<field>​

Type: dotted group · Default: the per-field defaults below

The ffmpeg stage that normalizes inbound audio before speech-to-text and converts synthesized replies into the container each adapter declared. ffmpeg is an optional runtime dependency of ethos gateway start: without it, audio already in an accepted format still passes through untouched, everything else is skipped rather than delivered unplayable, and the gateway prints ⚠ ffmpeg not found once at startup.

FieldTypeDefaultDescription
ffmpegPathstringffmpeg (resolved on PATH)Path or name of the binary. Set it when ffmpeg is installed somewhere the gateway's PATH does not reach.
bitrateKbpsinteger32Target bitrate for compressed containers (opus, mp3, aac). Must be an integer in 8–320; a value outside that range, or a non-integer, is dropped, not clamped, and the default stands. 32 kbps is a speech setting — raise it only if you are shipping music.
timeoutinteger (seconds)30Budget for one ffmpeg invocation. Must be an integer in 1–600; out-of-range values are dropped. Note the unit: the key is seconds, the internal option is milliseconds.
voice.transcode.ffmpegPath: /opt/homebrew/bin/ffmpeg
voice.transcode.bitrateKbps: 48
voice.transcode.timeout: 45

Notes:

  • Only the outbound leg reads bitrateKbps. Inbound normalization targets the STT provider's preferred container, which is wav wherever the provider accepts it.
  • Editable under Settings → Voice → Advanced.

voice.artifacts.<field>​

Type: dotted group · Default: the per-field defaults below

Retention for synthesized voice notes held on disk under ~/.ethos/voice/artifacts/. An artifact is written before the send and deleted the moment its delivery obligation is confirmed, so in a healthy deployment this directory is empty; these two keys bound what happens to the recordings whose delivery is never confirmed. Both are enforced by Gateway.pruneVoiceArtifacts(), which runs at boot and hourly.

FieldTypeDefaultDescription
abandonAfterDaysinteger (days)7Give up on an undelivered obligation after this long and delete its recording. Must be an integer in 1–365; out-of-range values are dropped, not clamped.
maxTotalMbinteger (MiB)512Total on-disk cap for the artifact directory, evicting oldest-first once exceeded. The backstop for runaway accumulation when neither delivery nor abandonment has fired. Must be an integer in 1–102400; out-of-range values are dropped.
voice.artifacts.abandonAfterDays: 3
voice.artifacts.maxTotalMb: 128

Notes:

  • Redelivery re-sends the stored recording rather than synthesizing a second one — see Why does a redelivered voice note re-send the recording?. Shortening abandonAfterDays shortens the window in which that repair is still possible.
  • Editable under Settings → Voice → Advanced.

voice.livekit.<field>​

Type: dotted group · Default: unset — no LiveKit transport

LiveKit project credentials. Required for telephony: voice.trunk names the trunk, and this block carries the credentials Ethos signs its SIP control-plane requests with.

FieldTypeDescription
urlstringLiveKit server URL. Write the WebSocket form (wss://…); the SIP control plane normalizes it to https:// itself, so one key serves both legs.
apiKeysecret refLiveKit project API key.
apiSecretsecret refLiveKit project API secret. Signs the JWTs on the SIP control plane and on room access tokens.
voice.livekit.url: wss://<your-project>.livekit.cloud
voice.livekit.apiKey: ${secrets:voice/livekit/apiKey}
voice.livekit.apiSecret: ${secrets:voice/livekit/apiSecret}

Notes:

  • All three are required together. A partial block is a parse error, not a half-configured transport.
  • These credentials make the SIP trunk client and the token minter constructible — no SDK and no native binary are involved. Room audio is a separate leg needing @livekit/rtc-node installed on the host; without it a call is answered, gated and logged but carries no voice. See Give an agent a phone number.
  • Editable under Settings → Voice → LiveKit.

voice.bots.<i>.<field>​

Type: indexed dotted list · Default: none — no number or room reaches a personality

Which number or LiveKit room each agent answers. This is the telephony routing table — there is no separate number-to-personality mapping. It mirrors telegram.bots[], except match replaces token: a voice bot has no platform token.

FieldTypeDescription
matchstringRequired. E.164 number or LiveKit room name this bot answers. * is the only wildcard and matches any run of characters; the pattern is anchored, so it must match the whole value.
bind.typepersonality | teamRequired. What answers.
bind.namestringRequired. The personality id or team name.
idstringStable key used in the lane key (voice:<botKey>:sip:<caller>) and in the call log. Defaults to a short sha256 of match — set it explicitly if you want the key to survive a number change.
voice.bots.0.match: "+15550000000"
voice.bots.0.bind.type: personality
voice.bots.0.bind.name: receptionist
voice.bots.1.match: "+1555*"
voice.bots.1.bind.type: personality
voice.bots.1.bind.name: engineer

Notes:

  • First match in config order wins, so a specific number can precede a broad wildcard — as in the example above.
  • An entry missing match, bind.type or bind.name is a parse error naming its index.
  • A dialled number matching no entry is not dropped silently: the call is logged screened with reason no_bot_match and the owner is notified.
  • A team-bound voice bot has no single personality to pin, so a call to it falls through to the deployment's default.
  • Editable under Settings → Voice → Numbers.

voice.trunk.<field>​

Type: dotted group · Default: no trunk — telephony is off

The SIP trunk that gives an agent a phone number. A rented PSTN number on a trunk provider is pointed at LiveKit SIP, so an inbound or outbound call arrives as one more room participant. Which number reaches which personality (a directory of files that decides the agent's tools, memory, and model) is not configured here — a voice.bots[] entry whose match is an E.164 pattern is that mapping.

FieldTypeDefaultDescription
providerenum—Required. Selects which inbound-webhook signature scheme the gateway's call listener verifies with; the providers do not agree on how a request is signed, and that is the one fact the verifier cannot read off the payload. Values below. An unrecognised value is a parse error and the whole trunk block is dropped.
trunkIdstring—Required. The LiveKit SIP trunk id the number is attached to, used on both the inbound and the outbound leg.
fromNumberstring (E.164)unsetCaller-ID presented on an outbound call. Read by the call tool; a trunk with no fromNumber dials with whatever the provider defaults to.
usernamestringunsetSIP registrar username for outbound trunk auth.
passwordsecret refunsetSIP auth password. Must be written as ${secrets:voice/trunk/password} — writeConfig moves a plaintext value into the vault under that ref, and loading a config with a plaintext value here fails. Authenticates us to the trunk, on the outbound leg.
webhookSecretsecret refunsetShared secret the inbound-call listener verifies the trunk's request signature against. Same vault handling as password, under ${secrets:voice/trunk/webhookSecret}. Authenticates the trunk to us, on the inbound leg — a deployment rotating one is not forced to rotate the other.
webhookPathstringunsetHTTP path the listener mounts the trunk webhook at. Must start with /; anything else is a parse error. Left unset, the listener applies its own default.
codecenumunsetPreferred call codec. Values below. Parsed and validated, but read by nothing today — the media leg does not negotiate from it. Declaring it changes only what ethos config print echoes back.

provider values:

  • twilio — a Twilio Elastic SIP Trunk.
  • telnyx — a Telnyx SIP connection.
  • livekit — LiveKit's own SIP service signs the webhook.
  • generic — any other trunk. No provider-specific signature scheme is applied, so pair it with a network-level control.

codec values:

  • opus — wideband, where the trunk carries it. The better ear.
  • g711 — the narrowband PSTN codec every trunk speaks. The fallback. (Ethos ships a μ-law and A-law codec, but nothing selects between the two from this key yet.)
voice.trunk.provider: livekit
voice.trunk.trunkId: ST_1
voice.trunk.fromNumber: +15550000000
voice.trunk.webhookSecret: ${secrets:voice/trunk/webhookSecret}
voice.trunk.webhookPath: /voice/inbound
voice.trunk.codec: opus

Notes:

  • provider and trunkId are required together. A block carrying one without the other is a parse error and the trunk is dropped — telephony stays off rather than half-configured.
  • Removing the whole block does not delete voice/trunk/password or voice/trunk/webhookSecret from the vault. Drop them with ethos secrets remove <ref> if the credentials are retired.

voice.inbound.<field>​

Type: dotted group · Default: no policy — the consumer's own defaults apply

A phone number is the one surface strangers reach without being invited. This block is the answering policy: who gets through, what a call may cost, and where the summary lands. Every caller is answered by receptionist in a restricted scope — no owner memory, no privileged tools — and is refused outright when no receptionist is set. Caller ID is not identity: the calling party sets it, so an allowlist match only decides pre-warm (decideInboundCall, extensions/platform-voice/src/sip/inbound-gate.ts).

FieldTypeDefaultDescription
allowlistcomma-separated listunsetCaller numbers treated as known for prewarm: allowlisted. E.164 patterns using the same * wildcard grammar as voice.bots[].match, matched against the whole number. Entries are trimmed and empties dropped. A match does not reach the owner's own personality: caller ID is spoofable and no verification step exists, so every caller goes to receptionist, or is refused if there is none.
receptioniststringunsetPersonality id answering every inbound caller. Its own memory (scope personality:<id>, fixed by turn setup — there is no scope setting) and its toolset are the restriction, and a call turn carries no user id, so no caller's or owner's USER.md is read; there is no second restriction system. Unset makes every call a refusal (screened, reason not_allowlisted, or caller_unverified when the caller ID matched allowlist) rather than a screened conversation.
concurrencyCapinteger2Ceiling on concurrent inbound calls; callers over the cap get busy handling and the owner is notified. Must be a positive integer — 0 and fractions are parse errors, not "no cap".
perCallerPerHourintegerunsetPer-caller call ceiling inside a rolling hour, evaluated over a sliding window. Positive integer.
dailyBudgetUsdnumberunsetSpend ceiling in USD per day across all inbound calls, reset on the UTC day boundary. Must be greater than zero. Counts LLM token spend only, at the provider's own estimate — STT, TTS, LiveKit media and PSTN minutes are not in the total, so the cap trips on real spend but trips late relative to a day's true cost. Browser talk-mode and channel voice notes do not route through the call dispatcher and never count against it.
prewarmenumallowlistedWhich callers would get the realtime provider socket opened during ring. Decided per call and acted on by nothing today — the SIP↔realtime bridge is built and unit-tested but has no production caller, so no socket is opened on ring or on answer. Values below.
owner.platformstringunsetPlatform id the call summary and capacity notices are delivered on, e.g. telegram.
owner.chatIdstringunsetChat id on that platform.
owner.botKeystringunsetWhich bot sends the notice, in a multi-bot deployment.

prewarm values:

  • allowlisted — pre-warm on ring for known callers only, and warm on answer for everyone else. No provider spend on calls that may be screened.
  • none — always warm on answer. Cheapest; the caller hears a beat of dead air.
  • all — warm every ring, including the ones that get screened.
voice.inbound.allowlist: +1555123*, +447700900000
voice.inbound.receptionist: receptionist
voice.inbound.concurrencyCap: 3
voice.inbound.perCallerPerHour: 5
voice.inbound.dailyBudgetUsd: 12.50
voice.inbound.prewarm: allowlisted
voice.inbound.owner.platform: telegram
voice.inbound.owner.chatId: 4242

Notes:

  • Malformed values are parse errors, not ignored. Unlike the voice.wake.* knobs, a bad value here drops the whole voice.inbound block and reports the offending key. A silently-dropped budget or concurrency cap costs real money on a surface strangers can dial.
  • owner.platform and owner.chatId are required together. One without the other is a parse error naming the missing key, rather than a half-built destination that quietly drops the notification this block exists to deliver.
  • An unrecognised field name under voice.inbound. is a parse error, so a typo cannot look configured while doing nothing.
  • Gates run cheapest-refusal-first: daily budget → per-caller rate → concurrency → receptionist. A refusal releases whatever it took, so a wall of refused calls leaves the concurrency counter at zero rather than wedging the line.
  • The end-to-end behaviour of every key here is in Give an agent a phone number.

voice.bargeIn.<surface>.<field>​

Type: dotted group, keyed by surface · Default: the consumer's own thresholds

How eagerly the agent stops talking when it hears you, tuned per audio surface. A phone line is noisier than a room and a satellite sits across that room, so one global threshold is wrong on at least one of the two.

Two surfaces: call (telephony) and satellite (a wake satellite — a separate process that owns a microphone). Any other surface name is a parse error.

call reaches the SIP lane's VAD and endpoint detector. satellite reaches the ethos listen microphone's VAD.

Browser talk-mode is not tuned here. The web talk lane endpoints in the browser, from the display.voice_* keys.

FieldTypeRangecallsatelliteDescription
energyThresholdnumber0 exclusive – 1 inclusivereadreadInput energy above which the far end counts as speaking. Higher tolerates more line noise and makes barge-in harder to trigger.
minSpeechMsinteger (ms)≥ 1readreadHow long that energy must persist before the barge-in is believed. The cough filter.
silenceMsinteger (ms)≥ 1readnot readHow long silence must last to end an utterance.
voice.bargeIn.call.energyThreshold: 0.4
voice.bargeIn.call.minSpeechMs: 220
voice.bargeIn.call.silenceMs: 700
voice.bargeIn.satellite.energyThreshold: 0.2

Notes:

  • Every field is optional per surface — tune the one knob a room is wrong about, not all three. Only the surfaces you declare are present in the parsed config; the rest keep the consumer's defaults.
  • voice.bargeIn.satellite.silenceMs is accepted and read by nothing. A satellite ends an utterance on a count of silent audio frames, and a frame is only a duration once the capture device reports its frame size — there is no conversion the config layer can do honestly. To make a satellite wait longer before replying, there is no knob today.
  • Out-of-range values and unknown field names are parse errors that drop the whole voice.bargeIn block. A threshold typed against a misspelled surface is not a slightly different setting — it is no tuning at all, and the operator would only learn that from a line the agent kept talking over.
  • voice.bargeIn.browser.* parsed in earlier releases and reached nothing. It is now a parse error naming display.voice_* as the replacement. Delete the key; the whole voice.bargeIn block is dropped while it is present.

display.voice_<field>​

Type: flat keys · Default: per field below

Endpointing and barge-in for talk-mode in the web UI. This is the browser counterpart to voice.bargeIn — the web talk lane runs its detector in the browser, so these are the keys it reads.

Edit them in Settings → Voice → Advanced voice tuning, or write them directly.

KeyTypeDefaultRangeDescription
display.voice_endpoint_silence_msinteger (ms)700300 – 1500How long you pause before the agent replies.
display.voice_barge_thresholdnumber0.060.02 – 0.2Input energy that counts as interrupting the agent while it speaks. Lower is easier to interrupt.
display.voice_barge_sustain_msinteger (ms)250100 – 800How long that energy must persist before the interruption is believed.
display.voice_speech_thresholdnumber0.020.005 – 0.1Input energy that counts as speech at all. Lower picks up quieter speech.
display.voice_speech_min_msinteger (ms)150100 – 500Ignore bursts shorter than this.
display.voice_endpoint_silence_ms: 900
display.voice_barge_threshold: 0.08

Notes:

  • A value saved through Settings is clamped to the range. A value that will not parse as a number falls back to the default rather than failing the load — these keys are read on every talk-mode turn, so a typo must not take the lane down.
  • The five keys are independent. Set the one that is wrong; the rest keep their defaults.

display.slow_turn_notice_ms​

Type: integer (ms) · Default: 8000 · Required: no

How long a non-streaming gateway lane stays silent before it gets its one per-turn "working on it" ack. 0 disables the whole notice family for non-streaming lanes, not just the first timer. Streaming lanes are unaffected — their tool progress edits the draft message in place — and email lanes never get the notice, whatever the value. A negative or non-numeric value is ignored and the default applies.

display.slow_turn_notice_ms: 12000

voice.wake.<field>​

Type: dotted group · Default: the per-field defaults below

Deployment-wide settings for wake satellites — separate processes that own a microphone and connect to the web API at GET /satellite/ws. The whole group is pushed to every connected satellite in a routes frame on connect, and again whenever Settings is saved.

Out-of-range numbers and unrecognised engine ids are ignored, not clamped — the default stands and the rest of the file still loads, so one typo cannot make a deployment unconfigurable.

FieldTypeDefaultDescription
enabledbooleantrueMaster switch for the whole fleet. Only an explicit false turns wake off; the key's absence means the operator never disabled it, not that they disabled it. ANDed with the per-node voice.wake.nodes.<id>.enabled — either one saying no means no.
engineenumfallbackWhich matcher a satellite runs. Any other value is ignored. Per-value meanings below.
sensitivitynumber0.5Match threshold, 0–1. Higher tolerates more transcription slip and produces more false accepts; 0 demands an exact match. A value outside the range is dropped.
confirmationFramesinteger2Consecutive agreeing frames before an acoustic spot counts — the false-accept damper. Must be an integer in 1–10. Ignored by the fallback engine, which decides a transcript once and has nothing to confirm.
edgeSttbooleanfalseAsk satellites to transcribe on-device and send text instead of audio. The node's probed capability is the veto: neither shipped host has an on-device recognizer, so both report edgeStt: false and the server ANDs this down to false.
idleTimeoutinteger (seconds)30How long a conversation stays open, in two places. On a satellite it ends the LISTENING state. On the server it bounds the addressing window: after a wake phrase picks a personality, further utterances within this long reach that same personality with no phrase. Must be an integer in 5–600. It ends listening and addressing only — never the session, so a re-wake an hour later resumes the same conversation with its history.

engine values:

  • fallback — the built-in transcript matcher (it names itself transcript in ethos listen doctor rows). Matches wake phrases against recognized text after speech-to-text, so it loads no native binding and no model file. The default.
  • sherpa — sherpa-onnx keyword spotting in the acoustic stream, before recognition. Requires the optional peer sherpa-onnx-node (a per-architecture native binary, roughly 33 MB, deliberately not a repo dependency) plus encoder.onnx, decoder.onnx, joiner.onnx and tokens.txt in ~/.ethos/models/wake/. The adapter is written against sherpa's documented KeywordSpotter surface and has not been run against a real binary in this repository.
  • openwakeword — accepted by the parser; no such engine ships. ethos listen doctor reports it unavailable by name rather than falling through to another engine.
voice.wake.engine: fallback
voice.wake.sensitivity: 0.6
voice.wake.confirmationFrames: 3
voice.wake.idleTimeout: 45

Notes:

  • Which host reads what. The desktop satellite matches phrases itself, so it applies sensitivity, confirmationFrames, idleTimeout and enabled from the pushed frame each time it arms capture. ethos listen matches nothing, so it ignores the pushed sensitivity and confirmationFrames and reads idleTimeout for its capture machine from its own config.yaml; with the key absent there, the capture machine's own 300-second default applies rather than the 30 seconds the server reports.
  • sensitivity and idleTimeout are also server-side. For a satellite that does not match phrases itself — ethos listen — the server runs the match, so sensitivity governs how much transcription slip a phrase tolerates and idleTimeout governs the addressing window. Both are read from this file on the server, not from the satellite's.
  • Read-only in the UI. Settings → Voice shows these values and does not write them; the route table below is what the web editor edits. Change the scalars in this file.
  • The ~/.ethos/models/wake/ directory is probed on every ethos listen doctor run. A missing directory is a warning for a fallback host and a hard failure for a sherpa one.

voice.wake.routes.<id>.<field>​

Type: dotted group, keyed by route id · Default: no routes

The phrase → personality table. The route id is yours to choose and must match [A-Za-z0-9_-]+; an id outside that charset is dropped, because a key the serializer could not write back would corrupt the file later.

FieldTypeDefaultDescription
phrasestring—The spoken trigger, e.g. engineer. Required. Matched with an optional leading greeting on either side — hey, hi, hello, ok, okay, yo, hey there — so a phrase written engineer also answers to "hey engineer", and one written hey engineer also answers to "engineer". Write it without the greeting; the two spellings are one trigger, so two routes differing only by a greeting can never both fire — the first in the table wins.
personalitystring—Personality id this phrase wakes. Required. Resolved against the live registry at wake time, so a route naming a deleted or renamed personality is refused rather than silently defaulted.
privilegedbooleanfalseOpt-in required before a privileged personality is reachable by voice — one whose toolset can reach a tool the approval layer would stop and ask about. See Why can't a voice in the room reach a privileged personality?.
enabledbooleantrueSwitch a route off without deleting it.
voice.wake.routes.kitchen.phrase: engineer
voice.wake.routes.kitchen.personality: engineer
voice.wake.routes.kitchen.privileged: true

Notes:

  • A route missing phrase or personality is dropped entirely rather than half-built — a half-route would look configured in the Settings table and never fire.
  • The phrase must open the utterance. Matching is head-anchored and word-aligned, so "so I said hey engineer to nobody" does not fire. The longest matching phrase wins, and the matched words — the greeting included — are stripped before the turn runs, so engineer, did CI pass and hey engineer, did CI pass both reach the agent as did CI pass.
  • Implicit routes. Every unprivileged personality also answers to its own name, synthesized server-side and never written to this file. Those carry the id auto:<personalityId> — outside the charset above, so they can never collide with one of yours. A configured route naming a personality suppresses that personality's implicit route, including a route you set to enabled: false.
  • Saving the table in Settings → Voice → Wake routes pushes it to every connected satellite. Hand-editing this file applies on the next satellite reconnect or server restart; nothing watches the file.
  • Implicit routes are shown in the Settings editor and cannot be saved back — they are not entries in this file.

voice.wake.nodes.<id>.<field>​

Type: dotted group, keyed by node id · Default: no overrides

Per-satellite overrides. The key is the node's own stable id, which it generates once and persists in ~/.ethos/listen-node-id on that machine — ethos listen doctor prints it on the node id row.

FieldTypeDefaultDescription
inputDevicestringunsetHost-specific capture device id. Pushed on the wire and read by no shipped host today — ethos listen enumerates only its stdin pipe, and the desktop host has no capture device at all.
enabledbooleantrueSwitch one microphone off without touching the others. ANDed with the fleet-wide voice.wake.enabled.
voice.wake.nodes.pi-kitchen-f089dce2.enabled: false

Notes:

  • An entry with no recognised field is dropped rather than kept as an empty object.
  • A node muted from its Settings row is muted over the wire instead, and the satellite persists that choice across restarts — that path does not write this key.

background.acp.agents.<name>.*​

Type: dotted roster · Default: unset (no ACP-native coding-agent job runners)

Real Agent Client Protocol (ACP) coding-agent CLIs a background job can run on — Claude Code's official adapter, Gemini CLI, and any other CLI that speaks ACP's session/* methods over stdio. <name> is the id the job registers under (a delegate_task call's runner parameter names it directly, e.g. runner: 'claude' or runner: 'gemini'); each configured entry becomes its own independent job runner, never a single shared 'acp' runner with a sub-parameter.

FieldTypeDescription
background.acp.agents.<name>.commandstringThe ACP agent binary to exec inside the sandbox container. Required — an entry missing command or image is dropped entirely rather than half-built.
background.acp.agents.<name>.argscomma-separated listArguments after command, e.g. the flag that puts the CLI into ACP mode. Optional.
background.acp.agents.<name>.imagestringDigest-pinned container image (@sha256:...) with command reachable inside it. Required. A bare tag does not satisfy the containment posture this needs — same reason pi_image must be digest-pinned.
background.acp.agents.claude.command: claude-agent-acp
background.acp.agents.claude.image: localhost:5555/ethos-acp-claude@sha256:...

background.acp.agents.gemini.command: gemini
background.acp.agents.gemini.args: --acp
background.acp.agents.gemini.image: localhost:5555/ethos-acp-gemini@sha256:...

Notes:

  • gemini's --acp is the CLI's own documented flag ("Starts the agent in ACP mode" per gemini --help); --experimental-acp also exists but is deprecated in favor of it.
  • An entry with both command and image set registers immediately; there is no separate enable switch. Removing an entry (or its image) unregisters that runner — a job naming it then gets the same not_available answer a machine that never built the image would.
  • Each entry needs its own container image with credentials for that agent mounted in — there is no shared auth story across entries. Neither agent's credential mount is wired yet.

browser.<field>​

Type: dotted group · Default: the per-field defaults below

How this machine launches Playwright browser sessions for the browser toolset. Every key here is an operator concern — posture for this host — not personality identity. Which personality may hand its browser to a human is its toolset.yaml's business, not this block's.

FieldTypeDefaultDescription
navigationTimeoutMsinteger (ms)30000Budget for one page load (goto / goBack). Must be 1000–600000; an out-of-range value is dropped, leaving the default.
commandTimeoutMsinteger (ms)10000Budget for one element interaction (click). Same 1000–600000 bound, same drop-on-out-of-range.
idleTimeoutMsinteger (ms)600000 (10 min)How long an untouched session survives before the idle sweeper closes it. Must be 60000–86400000; out of range is dropped. Under a minute the sweeper would reap a session between two tool calls of one turn; past a day an open session is a leak, not a cache. A session handed to a human is skipped while the takeover is live.
headedtrue | false | autoautoWhether a session launches with a visible window. auto — headed where this machine has a display, headless otherwise (default). true — always headed. false — always headless. Any other value is a fatal parse error.
stealth.enabledbooleanfalseReserved. Nothing reads this key yet — see the note below. Strictly true/false; anything else is fatal.
profiles.enabledbooleanfalse (absent = off)Persistent per-personality browser profiles under ~/.ethos/browser-profiles/<personality-id>/. Strictly true/false; anything else is fatal.
proxy.serverstringunsetUpstream proxy for every browser session. Endpoint only — scheme, host and port. A username, password, path, query or fragment in the URL is refused; credentials go in proxy.username and proxy.password. Requires an explicit scheme — http, https, socks4 or socks5. A bare myproxy:3128 is refused, because a URL parser reads myproxy: as the scheme. A malformed value is fatal.
proxy.usernamestringunsetProxy username. Fatal without proxy.server.
proxy.passwordsecret refunsetProxy password. Supports ${secrets:…} and is externalized on write. Fatal without proxy.server.
browser.headed: auto
browser.idleTimeoutMs: 600000
browser.profiles.enabled: true
browser.proxy.server: http://proxy.example.com:3128
browser.proxy.username: ethos
browser.proxy.password: ${secrets:browser/proxy/password}

Notes:

  • Two validation strictnesses, deliberately. The three *Ms budgets are bounded numbers and an out-of-range value is dropped, so the operator keeps running the value they already had. headed, both enabled flags and the whole proxy block are fatal at boot instead. For these there is no "out of range" — headed: flase or proxy.server: myproxy:3128 is an operator stating an intent, and substituting the framework default for it is exactly how a proxy someone believed was carrying their traffic ends up not carrying it. Boot refuses rather than fail open.
  • headed absent now means auto, and that is a visible change on upgrade. Earlier releases always launched headless. On macOS and Windows, and on Linux/BSD with DISPLAY or WAYLAND_DISPLAY set, a real Chromium window now appears on the first browse_url. Set browser.headed: false to keep the old behaviour.
  • headed: true on a machine with no display does not fail. It falls back to headless and the session reports the fallback once: ⚠ browser.headed: true, but this machine has no display (no DISPLAY or WAYLAND_DISPLAY) — running headless.
  • profiles.enabled is safe for one Ethos process at a time. The mutex that serialises two sessions over the same profile directory is in-memory and process-local, so a second process on the same host running the same personality (ethos serve beside ethos gateway, the desktop app beside a CLI chat) never sees it. It collides at Chromium's own single-instance lock on the user data directory instead, and its browser fails to launch — there is no 15-second wait and no ephemeral fallback across processes. Run one process per personality profile, or set profiles.enabled: false on the secondary. See run one process per profile.
  • stealth.enabled has no consumer. No stealth engine and no stealth-tier tool ship in this release; the key parses and is stored, and nothing reads it. Setting it to true changes no behaviour.
  • proxy.password is never returned resolved to the web UI — Settings shows a redacted preview, the same as voice.trunk.password. proxy.server carries no such redaction, which is why an embedded user:password@ in it is refused rather than stored: the URL form would put the password in config.yaml in plaintext and hand it back on every read.
  • The browser SSRF guard does not survive a proxy. Read the browser proxy limit before setting proxy.server.
  • Profiles are keyed by personality id and the id must match [A-Za-z0-9_-]+; one that does not is silently ignored rather than allowed to name a directory. See Persistent profiles for what a profile holds and what happens when two sessions want the same one.

web.searxng.url​

Type: string · Default: unset — the rung is not offered

A self-hosted SearXNG metasearch endpoint, offered to web_search as a keyless last rung. It takes no API key and holds no secret ref, so it is tried only after a personality's configured provider, the global tool settings, and every keyed backend with a key present have all come up empty.

web.searxng.url: http://searxng.internal:8080

Notes:

  • Taken verbatim and unvalidated, like web.host and web.corsOrigins. A value that is not a parseable URL means the rung is simply not offered; a wrong-but-parseable endpoint fails loudly at the first search, naming the host and the key.
  • Useful next to bot-wall detection: when a page is walled, a search rung you host yourself is one of the few paths that does not depend on the walled vendor.

activeContext​

Type: managed · Required: no

Managed by ethos set personality <id> / ethos set team <name>. The runtime writes two dotted keys: activeContext.type (personality | team) and activeContext.name (id or team name). Hand-editing is not supported — values are interpreted only when both keys are present and type is recognised.

Notes:

  • activeContext.type: personality is deprecated for one release: on load it is migrated to personality: <name> and the file is rewritten automatically, with a warning saying no action is needed (migrateActiveContextPersonality in packages/config/src/index.ts). The team form is unchanged — activeContext now only names a team.

File location and permissions​

~/.ethos/config.yaml is written by ethos setup and ethos personality set. The companion ~/.ethos/keys.json (chmod 600) holds the rotation pool — manage it through ethos keys, not by hand.

The directory can be relocated with the ETHOS_STATE_DIR env var, read by ethosDir() in packages/config/src/index.ts.

See also​