Skip to main content

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. The parser ignores quotes around values.

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.

Minimal example

provider: anthropic
model: claude-opus-4-7
apiKey: sk-ant-...
personality: researcher

This is what ethos setup writes for a default Anthropic install. Everything below is optional.

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-4-7 · Required (effectively)

Model id to pass to the provider. Format depends on the provider — Anthropic uses raw model names, OpenRouter uses vendor/model.

model: claude-opus-4-7

apiKey

Type: string · Default: empty · Required

Primary provider API key. 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: sk-ant-...

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 · Default: unset (treated as markdown)

Memory backend. markdown reads and writes ~/.ethos/MEMORY.md and ~/.ethos/USER.md. vector enables the SQLite + embeddings store at ~/.ethos/memory.db.

memory: vector

Notes:

  • Switching backends mid-stream does not migrate data — export from one, then import into the other.
  • Vector mode requires an embeddings-capable provider key.

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

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. When only one entry is set, the top-level provider / apiKey / model fields are used.

FieldTypeDescription
providers.<i>.providerstringProvider id for entry <i>.
providers.<i>.apiKeystringAPI key for entry <i>.
providers.<i>.modelstringOptional model override for entry <i>.
providers.<i>.baseUrlstringOptional endpoint override for entry <i>.
providers.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

telegram.bots.*

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

FieldTypeDefaultDescription
telegram.bots.<i>.tokenstringBot token from BotFather. Required per entry.
telegram.bots.<i>.idstringsha256(token)[:24]Stable, human-readable bot key. Used in session lane names (telegram:<botKey>:<chatId>), log output, and the internal Map<botKey, AgentLoop>. Set once; do not change after the bot goes live.
telegram.bots.<i>.bind.typepersonality | teamRequired. personality routes to a named personality. team routes to the team's coordinator personality and auto-starts the team supervisor.
telegram.bots.<i>.bind.namestringRequired. Personality id (for personality) or team name (for team).
telegram.bots.<i>.bind.allowSlashSwitchbooleanfalseAllow per-chat /personality switching. Disabled by default for identity-bound bots.
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:

telegramToken

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

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

telegramToken: 123456:ABC-DEF...

slack.apps.*

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

FieldTypeDefaultDescription
slack.apps.<i>.botTokenstringxoxb- bot token. Required per entry.
slack.apps.<i>.appTokenstringxapp- app-level token for Socket Mode. Required per entry.
slack.apps.<i>.signingSecretstringSigning secret for inbound webhook verification. Required per entry.
slack.apps.<i>.idstringsha256(botToken)[:24]Stable, human-readable app key. Used in session lane names and log output. Set once; do not change after the app goes live.
slack.apps.<i>.bind.typepersonality | teamRequired. Same semantics as the Telegram equivalent.
slack.apps.<i>.bind.namestringRequired. Personality id or team name.
slack.apps.<i>.bind.allowSlashSwitchbooleanfalseAllow per-channel /personality switching.
slack.apps.<i>.defaultChannelModemention_only | thread_follow | allmention_onlyWhen the agent replies in a channel with no per-channel override. thread_follow also answers follow-ups in threads it has already posted in; all answers every message.
slack.apps.<i>.receiptReactionstringeyesEmoji name (no colons) reacted onto an inbound message on arrival and removed once the reply lands. Needs the reactions:write scope; without it the reaction is skipped silently.
slack.apps.<i>.allowedSlashUsersstring listunsetComma-separated Slack user ids allowed to run /ethos and to see the App Home tab's memory, session, kanban, and channel sections. Narrows channel_filter.slack (ownerUserId + recipientAllowlist) — an id listed here that is not on that allowlist stays denied. Absent or empty means the channel_filter.slack allowlist alone. Both empty means nobody: the gate is default-closed.
slack.apps.<i>.allowedBotIdsstring listunsetComma-separated Slack bot_ids whose messages reach the agent, in new posts, edits, and thread backfill alike. Absent or empty drops every bot- and workflow-authored message — the gate is default-closed. Read a bot's id from the bot_id field of any message it has posted.
slack.apps.<i>.longReplyThresholdCharsinteger9000Reply length above which the agent posts a short lead message ending in "full answer attached" plus the complete text as an answer.md upload, instead of four or more chunked messages. 0 disables the fallback. The upload needs the files:write scope; without it the reply falls back to the chunked messages.
slack.apps.0.botToken: "xoxb-..."
slack.apps.0.appToken: "xapp-..."
slack.apps.0.signingSecret: "abc123..."
slack.apps.0.id: eng-slack
slack.apps.0.bind.type: team
slack.apps.0.bind.name: eng
slack.apps.0.allowedBotIds: B01DEPLOYBOT,B02ALERTBOT

Notes:

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

teams.*

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

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

Notes:

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

discordToken

Type: string · Default: unset

Bot token for the Discord gateway.

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.

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).

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. Values accept duration strings — 30d, 12h, forever. Unset fields fall back to the runtime defaults shown below.

FieldDefaultDescription
retention.messages365dConversation message history.
retention.traces90dTurn traces.
retention.spans90dTool / LLM spans inside traces.
retention.blobs7dLarge response payloads stored out-of-band.
retention.archive730dArchive partitions.
retention.events.error90dError events from errors.jsonl.
retention.events.audit365dAudit events (key rotation, personality writes, approvals).
retention.events.channel365dChannel-adapter events (pairing, dedup).
retention.events.installforeverInstall / migration events. Never deleted by default.
retention.messages: 365d
retention.traces: 90d
retention.events.error: 90d
retention.events.install: forever

personalities.<id>.retention.*

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

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

Notes:

  • Only the retention sub-block is parsed under personalities.<id>.*. Other top-level keys cannot be overridden per personality from this file — set them in the personality's own config.yaml.

logs.rotation

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

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

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

security.trusted_github_orgs

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

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

security.trusted_github_orgs: acme-corp, ethosagent

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

security.trusted_github_orgs: ""

Notes:

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

evolver.cron_enabled

Type: boolean · Default: false

When true, registers an in-process cron job that runs ethos evolve run --quiet on the schedule defined by evolver.schedule. The cron job executes inside the chat process — no separate daemon.

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:

  • Skill evolution config that is per-personality (e.g. skill_evolution.enabled, skill_evolution.min_tool_calls) lives in the personality config.yaml, not here. The evolver cron keys above control the global schedule; the personality keys control which personalities participate and when.

voice.tier

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

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

voice.tier: realtime

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

voice.realtime.providers.<name>.*

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

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

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

Notes:

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

voice.realtime.default

Type: string · Default: unset

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

voice.realtime.sessionBudgetUsd

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

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

voice.realtime.sessionBudgetUsd: 1.50

Notes:

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

voice.trustedPlugins

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

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

voice.trustedPlugins: openai-tts, openai-realtime

Notes:

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

voice.defaultMode

Type: off | mirror_inbound | all · Default: mirror_inbound

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

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

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

voice.defaultMode: all

voice.channels.<platform>.ttsOut

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

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

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

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

Notes:

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

voice.transcode.<field>

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

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

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

Notes:

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

voice.artifacts.<field>

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

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

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

Notes:

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

voice.livekit.<field>

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

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

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

Notes:

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

voice.bots.<i>.<field>

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

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

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

Notes:

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

voice.trunk.<field>

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

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

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

provider values:

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

codec values:

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

Notes:

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

voice.inbound.<field>

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

A phone number is the one surface strangers reach without being invited. This block is the answering policy: who gets through, what a call may cost, and where the summary lands. Callers outside allowlist are answered by receptionist in a restricted scope — no owner memory, no privileged tools — and are refused outright when no receptionist is set.

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

prewarm values:

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

Notes:

  • Malformed values are parse errors, not ignored. Unlike the voice.wake.* knobs, a bad value here drops the whole voice.inbound block and reports the offending key. A silently-dropped budget or concurrency cap costs real money on a surface strangers can dial.
  • owner.platform and owner.chatId are required together. One without the other is a parse error naming the missing key, rather than a half-built destination that quietly drops the notification this block exists to deliver.
  • An explicitly empty allowlist is not expressible. A flat key: value line with no value does not parse, so voice.inbound.allowlist: and an absent key are the same file. Set voice.inbound.receptionist and leave allowlist out — that is the screen-everyone policy.
  • 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 → allowlist. A refusal releases whatever it took, so a wall of refused calls leaves the concurrency counter at zero rather than wedging the line.
  • The end-to-end behaviour of every key here is in Give an agent a phone number.

voice.bargeIn.<surface>.<field>

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

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

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

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

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

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

Notes:

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

display.voice_<field>

Type: flat keys · Default: per field below

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

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

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

Notes:

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

voice.wake.<field>

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

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

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

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

engine values:

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

Notes:

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

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

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

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

FieldTypeDefaultDescription
phrasestringThe 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.
personalitystringPersonality id this phrase wakes. Required. Resolved against the live registry at wake time, so a route naming a deleted or renamed personality is refused rather than silently defaulted.
privilegedbooleanfalseOpt-in required before a privileged personality is reachable by voice — one whose toolset can reach a tool the approval layer would stop and ask about. See Why can't a voice in the room reach a privileged personality?.
enabledbooleantrueSwitch a route off without deleting it.
voice.wake.routes.kitchen.phrase: engineer
voice.wake.routes.kitchen.personality: engineer
voice.wake.routes.kitchen.privileged: true

Notes:

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

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

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

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

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

Notes:

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

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.

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_DIR env var.

See also