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 andethos memory restore|retract|supersede|historyrefuse, because there are no files to edit.vault— the same file layout inside a directory you own (an Obsidian vault), set bymemoryVault.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 gatewaylogsmemory backend changed — restart required to applyand keeps the backend it booted with. - Vector mode requires an embeddings-capable provider key.
ethos backupdoes not archive vault content — see Back up and restore.
memoryVault.*
Read only when memory: vault.
| Key | Type | Default | Description |
|---|---|---|---|
memoryVault.path | string | — | Absolute path to the vault root. Required under memory: vault; boot fails without it. |
memoryVault.agentDir | string | Ethos | Subtree of the vault the agent owns. Writes are confined to it; search may read the whole vault. |
memoryVault.prefetch | comma list | MEMORY.md, USER.md | Keys injected into the prompt tail for a personality scope. |
memoryVault.exclude | comma 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).
| Key | Type | Default | Description |
|---|---|---|---|
memoryApproval.mode | off | automated | all | off | off — 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.cap | number | 200 | Per-scope queue ceiling. At cap the oldest candidate is dropped, logged, and reported as an observability event. |
memoryApproval.ttlDays | number | 30 | Candidates 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. allalso gates consolidation: both the inline fallback and the nightly pass's MEMORY.md / USER.md rewrite wait in the queue asconsolidationentries (MemoryEditing.consolidationin packages/wiring/src/memory-backend.ts). The nightly pass still updates itsmemory-meta.jsondecay sidecar straight away.- Every backend is gated,
memory: vectorincluded. Under vector an approved candidate is written intomemory.dband records no history entry, because vector keeps no history. (composeGatedVectorMemoryandcreatePendingMemoryStorein packages/wiring/src/memory-backend.ts.) - A change takes effect on restart, like
memoryitself.
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 withapprovedBy: evidenceonce it reaches N sessions. Nothing is written before then.automatedorall: 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_SESSIONSinextensions/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.
| Field | Type | Description |
|---|---|---|
modelRegistry.<alias>.provider | string | The provider entry's explicit providers.<i>.id, not a provider type. |
modelRegistry.<alias>.modelId | string | The vendor id sent on the wire, e.g. claude-opus-4-8. |
modelRegistry.<alias>.label | string | Label shown in the model pickers. Display only. |
modelRegistry.<alias>.contextWindow | integer | Context window in tokens. |
modelRegistry.<alias>.effort | off | low | medium | high | How hard this alias reasons. Unset sends each provider's own default request. See Effort mapping. |
modelRegistry.<alias>.costPer1kInput / .costPer1kOutput | number | USD per 1k tokens. Display only; nothing bills on it. |
modelRegistry.<alias>.fallbacks | comma list | Aliases tried in order when this entry's provider cannot answer. Same provider entry only. |
modelRegistry.default | alias | The alias a turn uses when nothing else declares one. |
modelRegistry.roles.<role> | alias | Binds 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:
effortvalues: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 noeffortsends exactly the request it sent before the key existed.- An
effortoutside those four is dropped, and the config load printsconfig.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.
| Provider | What 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 id | Nothing. |
codex | reasoning.effort: low, medium or high; off → low. Unset stays medium, the value it always sent. |
openai and other OpenAI-compatible providers | reasoning_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, azure | Nothing. 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.effortwithout athinkingparam. Models that think by default send the same request as on any other turn.thinkingParamsForandcontinuesToolUseTurninextensions/llm-anthropic/src/index.ts; pinned byextensions/llm-anthropic/src/__tests__/thinking-capabilities.test.ts. - The Anthropic capability table is
ANTHROPIC_MODEL_CAPABILITIESin the same file. Dated snapshots,-latest, Bedrock, Vertex and OpenRouter spellings of a model id map to the same row. - The Codex mapping is
codexEffortinextensions/llm-codex/src/index.ts; the OpenAI mapping isopenAiReasoningEffortinextensions/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.
| Field | Type | Description |
|---|---|---|
providers.<i>.provider | string | Provider id for entry <i>. |
providers.<i>.apiKey | string | API key for entry <i>. |
providers.<i>.model | string | Optional model override for entry <i>. |
providers.<i>.baseUrl | string | Optional endpoint override for entry <i>. |
providers.<i>.apiVersion | string | Azure only: REST API version for entry <i>. |
providers.<i>.region | string | Bedrock only: AWS region for entry <i>. |
providers.<i>.awsProfile | string | Bedrock only: named AWS profile for entry <i>. |
providers.<i>.outputCapParam | max_tokens | max_completion_tokens | Azure 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>.serverCompaction | boolean | Anthropic only. true lets the provider compact the conversation server-side instead of the local context engine. Default off. See Server-side compaction. |
providers.<i>.serverCompactionTriggerTokens | integer | Input-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.pressureof the model's window, capped bycompaction.maxContextTokens), so turning the switch on changes who compacts, not when. - The summary block is saved in
sessions.dbas 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.compactionevent with codellm.server_compaction_rejectedlands inobservability.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.
| Field | Type | Default | Description |
|---|---|---|---|
telegram.bots.<i>.token | string | — | Bot token from BotFather. Required per entry. |
telegram.bots.<i>.id | string | sha256(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.type | personality | 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.name | string | — | Required. Personality id (for personality) or team name (for team). |
telegram.bots.<i>.bind.allowSlashSwitch | boolean | false | Allow 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>.defaultChannelMode | mention_only | thread_follow | all | regex_match | observe | mention_only | Reply-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>.useWebhook | boolean | false | Receive 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>.webhookUrl | string | — | 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>.webhookSecretToken | string | — | 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>.dropPendingUpdates | boolean | false | Discard 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>.replyPrefix | string | unset | Template put in front of every reply, with {name} and {emoji} from the bound personality. See <bot>.replyPrefix. |
telegram.bots.<i>.mentionByName | boolean | false | Count 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:
defaultChannelModevalues, asevaluateChannelMode()inpackages/core/src/channel-mode.tsresolves 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,observeincluded.- 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 tomention_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'stelegram/<botKey>/directory) and a stored entry wins over this key, but no command or API writes one — Slack's/ethos channel-modehas no Telegram equivalent. This key is the only way to put a Telegram chat intoobservetoday, and it applies to every group the bot is in. - A bot whose resolved mode is
observewhile BotFather's Group Privacy is still on records nothing at all. The adapter checksgetMeat startup and logsTelegram 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()inpackages/config/src/index.tscomputes the sha256-derived default whenidis 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.
| Field | Type | Default | Description |
|---|---|---|---|
slack.apps.<i>.botToken | string | — | xoxb- bot token. Required per entry. |
slack.apps.<i>.appToken | string | — | 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>.signingSecret | string | — | 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.socket | boolean | true | Connect 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.http | boolean | false | Receive 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>.webhookPath | string | the app's botKey | Route segment this app's HTTP receiver answers on, under /slack/events/. HTTP mode only. Leading and trailing slashes are stripped. |
slack.apps.<i>.id | string | sha256(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.type | personality | team | — | Required. Same semantics as the Telegram equivalent. |
slack.apps.<i>.bind.name | string | — | Required. Personality id or team name. |
slack.apps.<i>.bind.allowSlashSwitch | boolean | false | Allow per-channel /personality switching. In a channel only channel_filter.slack.ownerUserId may switch; a channel with no owner configured refuses. |
slack.apps.<i>.defaultChannelMode | mention_only | thread_follow | all | observe | mention_only | Reply-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>.receiptReaction | string | unset | Emoji 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>.replyPrefix | string | unset | Template put in front of every reply, with {name} and {emoji} from the bound personality. See <bot>.replyPrefix. |
slack.apps.<i>.mentionByName | boolean | false | Count a channel message that names the bound personality as a mention. See <bot>.mentionByName. |
slack.apps.<i>.allowedSlashUsers | string list | unset | Comma-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>.allowedBotIds | string list | unset | Comma-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>.longReplyThresholdChars | integer | 9000 | Reply 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:
/ethosand the App Home tab are default-deny as of this release, and the trust set comes fromchannel_filter.slack— the same allowlist that decides who may message the bot. A workspace with nochannel_filter.slack.ownerUserIdand norecipientAllowlistauthorizes nobody: every/ethosinvocation 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'sMEMORY.mdinto every later system prompt. Setchannel_filter.slack.ownerUserIdto your Slack user id to restore access.channel_filter.slack.enabled: falsedoes 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 byuserId. Two gates guard a bot message and both must open:allowedBotIdshere, plus the samebot_idinchannelFilter.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 againstrecipientAllowlistby the same id — a human's Slack user id, a bot'sbot_id. So an operator running the stricter context-visibility setting who allowlists a bot inallowedBotIdsbut not inrecipientAllowlistsees its history lines dropped while its live messages are dropped too, consistently. Under the defaultcontextVisibility: all, backfill is not filtered at all andallowedBotIdsalone 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.
| Field | Type | Default | Description |
|---|---|---|---|
teams.<name>.autoStop | boolean | true | When 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 withethos 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@mentionsthe 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 tomention_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,observeincluded. An out-of-range value is a parse error, not a silent fallback:ethos gatewayandethos bootprintdiscord: 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-modehas no Discord equivalent. This key is the only way to put a Discord channel intoobservetoday, and it applies to every channel the bot can see./ethos helpprints 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 noregex_match. Direct chats are answered under every mode,observeincluded. An out-of-range value is a parse error, not a silent fallback:ethos gatewayandethos bootprintwhatsapp[<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, notmention_only.DEFAULT_CHANNEL_MODEinextensions/platform-whatsapp/src/config.tsisall, because an unset mode has always meant "answer every group message" for code that constructsWhatsAppAdapterdirectly and flipping it would silence a working embedder on upgrade. The gateway never leaves it unset — it passesdefault_mode ?? 'mention_only'— so anethos gatewaydeployment getsmention_onlylike every other platform. Theallfallback 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-modehas no WhatsApp equivalent. This key is the only way to put a WhatsApp group intoobservetoday, 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.enqueueTurninextensions/gateway/src/index.tsenforces it. - Spend is the same figure
ethos usagereports, 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. /budgetin a chat shows the bot's spend today against this cap, next to the session cap./budget resetclears 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_unreadableevent. - 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).
| Placeholder | Filled 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 rendersOwl:, notOwl:. - 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.
applyReplyPrefixinextensions/gateway/src/reply-prefix.ts; pinned byextensions/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: withname: 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.mentionsPersonalityNameinpackages/core/src/channel-presence.ts; pinned bypackages/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.
| Platform | Configured by | Personality emoji used when | Default |
|---|---|---|---|
| Slack | slack.apps.<i>.receiptReaction | Its Slack emoji name is in the adapter's table (slackEmojiName, extensions/platform-slack/src/adapter.ts) | eyes (👀) |
| Telegram | Not a config.yaml key | Telegram's Bot API accepts it as a reaction (telegramReceiptEmoji, extensions/platform-telegram/src/index.ts) | 👀 |
| Discord | Not a config.yaml key | Always (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.emojiand 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
| Part | Meaning |
|---|---|
<platform> | The platform id the chat id belongs to: telegram, slack, discord, whatsapp, email, or a plugin platform's id. |
| value | The 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.audienceForinextensions/gateway/src/index.ts, which receives it asGatewayConfig.privateChatsfrombuildGatewayinapps/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
/newin 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 startandethos boot. Restart the gateway after editing. An edit made while the gateway runs is logged asgateway.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_filterowner 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 theFrom: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 ismx.google.com. The comparison ignores case. ethos gateway startandethos bootrecord 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-passdmarcresult, even whendkimpassed.
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/*.
| Field | Type | Default | Description |
|---|---|---|---|
web.host | string | 127.0.0.1 | Bind address. Precedence: --web-host CLI flag > ETHOS_WEB_HOST env var > this key > default. |
web.port | integer | 3000 | Listen port. Precedence: --web-port CLI flag > ETHOS_WEB_PORT env var > this key > default. |
web.corsOrigins | string | unset (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-tokenfile. The value must be at least 24 characters — a shorter one stops the boot with an error suggestingopenssl rand -hex 32. While it is set,ethos servedoes 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.
| Field | Type | Default | Description |
|---|---|---|---|
execution.docker.image | string | unset | The 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.cpu | number > 0 | 2 | Docker --cpus quota. Fractional values are allowed. |
execution.docker.diskMb | integer ≥ 1 | unset (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-bookwormprints the same index digest without pulling. It does not put the image on the machine — rundocker pullbefore the first tool call.- If you pulled through a mirror (
docker pull mirror.gcr.io/library/node:24-bookworm), use theRepoDigestsvalue it reports (mirror.gcr.io/library/node@sha256:…). Anode@sha256:…ref does not resolve locally under--pull=neverwhen only the mirror name exists. - The container runs as your host uid with all capabilities dropped, no network unless the personality's
safety.networkallows it, and only the personality'sfs_reachpaths mounted. The image needs no user account for that uid. - Check the result:
ethos doctorlists every docker-posture personality and flags a missing image;ethos personality show <id>prints the image (orNOT CONFIGURED) under## Execution. - Source:
dockerImageRefErrorin packages/config/src/index.ts;MissingDockerImageErrorin 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.
| Field | Default | Description |
|---|---|---|
retention.messages | 365d | Conversation 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.traces | 90d | Turn traces. |
retention.spans | 90d | Tool / LLM spans inside traces. |
retention.blobs | 7d | Large response payloads stored out-of-band. |
retention.archive | 730d | Archive partitions. |
retention.channelTranscript | 30d | Third-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.error | 90d | Error events from errors.jsonl. |
retention.events.audit | 365d | Audit events (key rotation, personality writes, approvals). |
retention.events.channel | 365d | Channel-adapter events (pairing, dedup). |
retention.events.install | forever | Install / 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.channelTranscriptprunes nothing, and is refused. Observe-mode transcripts live in one~/.ethos/channel-transcript.dbwith no personality column, and the nightly job reads the globalretention.channelTranscriptonly. 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 onlyGlobalfor 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 fixed0 3 * * *schedule that no config key changes. That job is created withmissedRunPolicy: 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 underretention.messages. Run Ethos on a machine that is up at 03:00 if a window here matters to you. ethos data prunedoes not coverretention.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.observedaudit 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 theauditcategory (365d) and in a different database, so it survives the transcript's30dwindow by roughly 12×. Loweringretention.channelTranscriptdoes not lower it; setretention.events.audittoo 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
retentionsub-block is parsed underpersonalities.<id>.*. Other top-level keys cannot be overridden per personality from this file — set them in the personality's ownconfig.yaml. - No category honours a per-personality window on the nightly prune today.
personalities.<id>.retention.*is read byethos retention show --personality <id>and byethos data prune --personality <id>(which prunes on demand, against the merged window). The scheduledobservability-prunejob reads the globalretention.*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.
| Field | Type | Default | Description |
|---|---|---|---|
channelDigest.enabled | boolean | false | Create the channel-digest cron job. Any value other than the literal true reads as false. |
channelDigest.cron | cron expression | 0 8 * * * | When the digest runs, in the host's local time. |
channelDigest.deliverTo | owner | inApp | owner | owner — 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.maxMessagesPerLane | integer ≥ 1 | 500 | Messages 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.maxLanesPerRun | integer ≥ 1 | 25 | Lanes 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.costWarnUsdPerLane | number > 0 | 0.5 | Spend 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 achannel.digest_undeliveredevent says why. The in-app copy still goes out. - An owner id that names a watched chat is refused.
ownerUserIdis 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: inAppunderethos gatewayrefuses to start. Onlyethos bootserves the web UI that the feed reaches, so underethos gatewayevery 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.lockfor 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 achannel.digest_skippedevent. A restart overlapping its predecessor is covered. Anethos gateway startbeside anethos bootnever gets that far: both take the gateway lock, and the second exits3. 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~/.ethosover 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).
| Field | Type | Default | Description |
|---|---|---|---|
notifications.quietHours | HH:MM-HH:MM | unset | The 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.timezone | IANA zone | the host's zone | The 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>.quietHours | HH:MM-HH:MM | off | unset | A 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.noticeHoldReasonandGateway.releaseHeldNoticesinextensions/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 outsideethos gateway startandethos 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.
| Key | Type | Default | Description |
|---|---|---|---|
logs.rotation.maxBytes | integer | 10485760 (10 MiB) | Maximum file size before rotation. Must be a positive integer. |
logs.rotation.maxFiles | integer | 5 | Maximum number of rotated backup files. Must be a positive integer. |
logs.rotation.enabled | boolean | true | Set 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.
| Field | Type | Default | Description |
|---|---|---|---|
backup.enabled | boolean | true | Set 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.cron | cron expression | 0 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.scope | comma-separated list | identity,state | Which 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.keep | integer ≥ 1 | 7 | How 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.dir | path | <ethosDir>/backups | Where 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>/backupsdefault is computed in code bybackupDirectoryinpackages/wiring/src/backup-schedule.ts; write a literal path if you want one.- The schedule only fires while a serving process is running.
ethos chatalone runs no cron. - A
statearchive 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.jsonand 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 backupfor 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-repolets an operator force past a red scanner finding with--force. It does not silently accept yellow findings — onlybuiltin(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 asafety.denyRulesentry 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 postureterminalis 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
truereads asfalse. - 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.
| Field | Type | Default | Description |
|---|---|---|---|
learningReplay.enabled | boolean | true | false 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.maxCases | integer ≥ 3 | 8 | Cases 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.maxCostUsd | number > 0 | 0.50 | Replay-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.maxCandidatesPerRun | integer ≥ 1 | 5 | Candidates 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
passis 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
passpromotes 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.
| Field | Type | Default | Description |
|---|---|---|---|
nightlyPass.enabled | boolean | false | Schedule the pass. Any value other than the literal true reads as false. |
nightlyPass.cron | cron expression | 0 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 evolveorethos personality evolvesits inpending_replayuntilethos learning replay <id>orethos nightly runmeasures it, or a human approves it with a reason.autoApproveandskill_evolution.promotion: autodo not skip this wait: nothing promotes itself without apassreplay. - 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
incompletefor too few cases, and the candidate waits for a human. - The schedule only fires while a serving process runs.
ethos chatalone 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 chatalone runs no cron. - Each run drafts for the default
personalityin this file, and submits every draft to the learning inbox as a candidate. None goes live without apassreplay 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,scopeand the rest) live in the personality config.yaml, not here. The evolver cron does not readskill_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.
| Field | Type | Description |
|---|---|---|
provider | string | Registered 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). |
model | string | Provider model id. Defaults to gpt-realtime for openai-realtime. |
apiKey | string | The provider key. Use a ${secrets:...} reference rather than a literal. |
baseUrl | string | Override the provider's endpoint. |
voice | string | Default 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. |
costPerMinuteUsd | float | The 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
providerand the constructed provider's owncaps.local, so an entry namedlocal-realtimebacked by a hosted model is refused before a session opens. List the provider id invoice.trustedPlugins, not the label. gemini-liveis contract-only in this release. It proves the provider contract is not OpenAI-shaped and is exercised by the shared conformance suite, but it declarescaps.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 withno_browser_tokenand 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
budgetCapUsdalso 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:
emailis 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.
| Field | Type | Default | Description |
|---|---|---|---|
ffmpegPath | string | ffmpeg (resolved on PATH) | Path or name of the binary. Set it when ffmpeg is installed somewhere the gateway's PATH does not reach. |
bitrateKbps | integer | 32 | Target 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. |
timeout | integer (seconds) | 30 | Budget 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 iswavwherever 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.
| Field | Type | Default | Description |
|---|---|---|---|
abandonAfterDays | integer (days) | 7 | Give 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. |
maxTotalMb | integer (MiB) | 512 | Total 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
abandonAfterDaysshortens 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.
| Field | Type | Description |
|---|---|---|
url | string | LiveKit server URL. Write the WebSocket form (wss://…); the SIP control plane normalizes it to https:// itself, so one key serves both legs. |
apiKey | secret ref | LiveKit project API key. |
apiSecret | secret ref | LiveKit 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-nodeinstalled 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.
| Field | Type | Description |
|---|---|---|
match | string | Required. 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.type | personality | team | Required. What answers. |
bind.name | string | Required. The personality id or team name. |
id | string | Stable 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.typeorbind.nameis a parse error naming its index. - A dialled number matching no entry is not dropped silently: the call is logged
screenedwith reasonno_bot_matchand 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.
| Field | Type | Default | Description |
|---|---|---|---|
provider | enum | — | 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. |
trunkId | string | — | Required. The LiveKit SIP trunk id the number is attached to, used on both the inbound and the outbound leg. |
fromNumber | string (E.164) | unset | Caller-ID presented on an outbound call. Read by the call tool; a trunk with no fromNumber dials with whatever the provider defaults to. |
username | string | unset | SIP registrar username for outbound trunk auth. |
password | secret ref | unset | SIP 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. |
webhookSecret | secret ref | unset | Shared 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. |
webhookPath | string | unset | HTTP 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. |
codec | enum | unset | Preferred 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:
providerandtrunkIdare 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/passwordorvoice/trunk/webhookSecretfrom the vault. Drop them withethos 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).
| Field | Type | Default | Description |
|---|---|---|---|
allowlist | comma-separated list | unset | Caller 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. |
receptionist | string | unset | Personality 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. |
concurrencyCap | integer | 2 | Ceiling 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". |
perCallerPerHour | integer | unset | Per-caller call ceiling inside a rolling hour, evaluated over a sliding window. Positive integer. |
dailyBudgetUsd | number | unset | Spend 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. |
prewarm | enum | allowlisted | Which 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.platform | string | unset | Platform id the call summary and capacity notices are delivered on, e.g. telegram. |
owner.chatId | string | unset | Chat id on that platform. |
owner.botKey | string | unset | Which 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 wholevoice.inboundblock and reports the offending key. A silently-dropped budget or concurrency cap costs real money on a surface strangers can dial. owner.platformandowner.chatIdare 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.
| Field | Type | Range | call | satellite | Description |
|---|---|---|---|---|---|
energyThreshold | number | 0 exclusive – 1 inclusive | read | read | Input energy above which the far end counts as speaking. Higher tolerates more line noise and makes barge-in harder to trigger. |
minSpeechMs | integer (ms) | ≥ 1 | read | read | How long that energy must persist before the barge-in is believed. The cough filter. |
silenceMs | integer (ms) | ≥ 1 | read | not read | How 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.silenceMsis 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.bargeInblock. 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 namingdisplay.voice_*as the replacement. Delete the key; the wholevoice.bargeInblock 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.
| Key | Type | Default | Range | Description |
|---|---|---|---|---|
display.voice_endpoint_silence_ms | integer (ms) | 700 | 300 – 1500 | How long you pause before the agent replies. |
display.voice_barge_threshold | number | 0.06 | 0.02 – 0.2 | Input energy that counts as interrupting the agent while it speaks. Lower is easier to interrupt. |
display.voice_barge_sustain_ms | integer (ms) | 250 | 100 – 800 | How long that energy must persist before the interruption is believed. |
display.voice_speech_threshold | number | 0.02 | 0.005 – 0.1 | Input energy that counts as speech at all. Lower picks up quieter speech. |
display.voice_speech_min_ms | integer (ms) | 150 | 100 – 500 | Ignore 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.
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Master 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. |
engine | enum | fallback | Which matcher a satellite runs. Any other value is ignored. Per-value meanings below. |
sensitivity | number | 0.5 | Match 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. |
confirmationFrames | integer | 2 | Consecutive 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. |
edgeStt | boolean | false | Ask 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. |
idleTimeout | integer (seconds) | 30 | How 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 itselftranscriptinethos listen doctorrows). 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 peersherpa-onnx-node(a per-architecture native binary, roughly 33 MB, deliberately not a repo dependency) plusencoder.onnx,decoder.onnx,joiner.onnxandtokens.txtin~/.ethos/models/wake/. The adapter is written against sherpa's documentedKeywordSpottersurface and has not been run against a real binary in this repository.openwakeword— accepted by the parser; no such engine ships.ethos listen doctorreports 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,idleTimeoutandenabledfrom the pushed frame each time it arms capture.ethos listenmatches nothing, so it ignores the pushedsensitivityandconfirmationFramesand readsidleTimeoutfor its capture machine from its ownconfig.yaml; with the key absent there, the capture machine's own 300-second default applies rather than the 30 seconds the server reports. sensitivityandidleTimeoutare also server-side. For a satellite that does not match phrases itself —ethos listen— the server runs the match, sosensitivitygoverns how much transcription slip a phrase tolerates andidleTimeoutgoverns 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 everyethos listen doctorrun. A missing directory is a warning for afallbackhost and a hard failure for asherpaone.
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.
| Field | Type | Default | Description |
|---|---|---|---|
phrase | string | — | 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. |
personality | string | — | 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. |
privileged | boolean | false | Opt-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?. |
enabled | boolean | true | Switch 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
phraseorpersonalityis 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 passandhey engineer, did CI passboth reach the agent asdid 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 toenabled: 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.
| Field | Type | Default | Description |
|---|---|---|---|
inputDevice | string | unset | Host-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. |
enabled | boolean | true | Switch 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.
| Field | Type | Description |
|---|---|---|
background.acp.agents.<name>.command | string | The 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>.args | comma-separated list | Arguments after command, e.g. the flag that puts the CLI into ACP mode. Optional. |
background.acp.agents.<name>.image | string | Digest-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--acpis the CLI's own documented flag ("Starts the agent in ACP mode" pergemini --help);--experimental-acpalso exists but is deprecated in favor of it.- An entry with both
commandandimageset registers immediately; there is no separate enable switch. Removing an entry (or itsimage) unregisters that runner — a job naming it then gets the samenot_availableanswer 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.
| Field | Type | Default | Description |
|---|---|---|---|
navigationTimeoutMs | integer (ms) | 30000 | Budget for one page load (goto / goBack). Must be 1000–600000; an out-of-range value is dropped, leaving the default. |
commandTimeoutMs | integer (ms) | 10000 | Budget for one element interaction (click). Same 1000–600000 bound, same drop-on-out-of-range. |
idleTimeoutMs | integer (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. |
headed | true | false | auto | auto | Whether 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.enabled | boolean | false | Reserved. Nothing reads this key yet — see the note below. Strictly true/false; anything else is fatal. |
profiles.enabled | boolean | false (absent = off) | Persistent per-personality browser profiles under ~/.ethos/browser-profiles/<personality-id>/. Strictly true/false; anything else is fatal. |
proxy.server | string | unset | Upstream 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.username | string | unset | Proxy username. Fatal without proxy.server. |
proxy.password | secret ref | unset | Proxy 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
*Msbudgets are bounded numbers and an out-of-range value is dropped, so the operator keeps running the value they already had.headed, bothenabledflags and the wholeproxyblock are fatal at boot instead. For these there is no "out of range" —headed: flaseorproxy.server: myproxy:3128is 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. headedabsent now meansauto, and that is a visible change on upgrade. Earlier releases always launched headless. On macOS and Windows, and on Linux/BSD withDISPLAYorWAYLAND_DISPLAYset, a real Chromium window now appears on the firstbrowse_url. Setbrowser.headed: falseto keep the old behaviour.headed: trueon 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.enabledis 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 servebesideethos 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 setprofiles.enabled: falseon the secondary. See run one process per profile.stealth.enabledhas 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 totruechanges no behaviour.proxy.passwordis never returned resolved to the web UI — Settings shows a redacted preview, the same asvoice.trunk.password.proxy.servercarries no such redaction, which is why an embeddeduser:password@in it is refused rather than stored: the URL form would put the password inconfig.yamlin 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.hostandweb.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: personalityis deprecated for one release: on load it is migrated topersonality: <name>and the file is rewritten automatically, with a warning saying no action is needed (migrateActiveContextPersonalityinpackages/config/src/index.ts). The team form is unchanged —activeContextnow 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
- CLI reference — every
ethossubcommand and the flags that override what config.yaml sets - Back up and restore an Ethos install — the
backup.*keys in practice, plus the one-command move to a new machine - Personality config reference — the per-personality
config.yamlandtoolset.yaml(different file, different schema) - How to configure providers — task-shaped recipe for switching between Anthropic, OpenAI, OpenRouter, and Ollama
- Run multiple Telegram bots from one process —
telegram.botslist shape in practice - Connect a Telegram bot to a team —
bind.type: teamandteams.*knobs in practice - Local voice: Kokoro TTS + Whisper large v3 STT — the
auxiliary.asr.*/auxiliary.tts.*pipeline keys and the egress gate in practice - Send and receive voice notes on a channel — the
voice.defaultMode/voice.channels.*/voice.transcode.*keys in practice, plus the/voicecommand - Run a wake satellite — the
voice.wake.*keys in practice, and whatethos listencan and cannot do - Glossary: personality — what the term means everywhere else in the docs