Skip to main content

Personality config reference

A personality is a directory at ~/.ethos/personalities/<id>/ with three files:

FilePurpose
SOUL.mdFirst-person identity prose. Loaded as the system-prompt baseline. Free-form markdown.
config.yamlFlat key: value config — fields documented below. Dotted keys (e.g. fs_reach.read) express nested structure.
toolset.yamlFlat YAML list of tool names this personality is allowed to call.

An optional sibling file tools.yaml configures a tool per personality. It is not a field on the frozen PersonalityConfig schema (like mcp.yaml, it is a sibling artifact loaded by the registry), so it does not touch .personality-field-count. In v1 only web_search is configurable — it binds a provider and a named secret:

# ~/.ethos/personalities/researcher/tools.yaml
web_search: { provider: exa, secret: exa-main }

secret is a NAME only (resolving to providers/<provider>/<name> in the vault) — never a value — so the directory stays shareable and committable (§V S9). The personality's own tools.yaml is the source of truth; the global ~/.ethos/config.yaml toolSettings map is a fallback layer for personalities (especially read-only built-ins) that don't declare the tool. Resolution order: tools.yamltoolSettings.<id>toolSettings._default → first backend with a key present.

Source

The schema type lives in packages/types/src/personality.ts (PersonalityConfig). The loader / parser lives in extensions/personalities/src/index.tsparseConfigYaml (flat keys + the safety: nested block), parseToolsetYaml (the - name list), and parseToolsYaml (the optional tools.yaml sidecar).

The schema is frozen — adding a top-level field requires the personality-schema-change PR label and a bump to .personality-field-count. Internal-only fields (id, soulFile, skillsDirs, metadata) are populated by the loader and are not user-editable.

Minimal example

# ~/.ethos/personalities/researcher/config.yaml
name: Researcher
description: Deep reading and synthesis.
model: claude-opus-4-7
# ~/.ethos/personalities/researcher/toolset.yaml
- read_file
- write_file
- web_search

Memory is always per-personality — each personality reads and writes ~/.ethos/personalities/<id>/MEMORY.md automatically. No configuration field is required.

name

Type: string · Default: title-cased directory id · Required

Human-readable label. Surfaces in ethos personality list, the picker UIs, and the chat header.

name: Engineer Paired

description

Type: string · Default: unset

One-line summary shown in pickers and ethos personality list.

description: Builds and ships features for this repo.

model

Type: string · Default: top-level config.yaml model

Per-personality model override. Used by the LLM provider when this personality drives the turn. Falls back to the global model from ~/.ethos/config.yaml when unset. The wiring layer also honours modelRouting.<id> from config.yaml — both routes converge on the same per-personality model.

model: claude-opus-4-7

provider

Type: string · Default: top-level config.yaml provider

Per-personality provider override. Only meaningful when the wiring layer has the named provider registered.

provider: openrouter

platform

Type: string · Default: unset

Channel binding hint. Recognised values (used by the load-time safety gate): telegram, discord, slack, whatsapp, email. Bound channels combined with safety.approvalMode: off are rejected at config load.

platform: slack

capabilities

Type: comma-separated strings · Default: unset

Free-form capability tags. Surfaces to skill-filtering and adapter routing.

capabilities: read, write, web

streamingTimeoutMs

Type: integer (ms) · Default: AgentLoop default (120000)

Watchdog for the LLM stream. If no chunk arrives within this many milliseconds, the agent aborts the stream and emits an error event. Reset on every chunk — slow-but-progressing streams are unaffected. Thinking-mode personalities (Opus extended thinking) typically need longer; fast personalities (Haiku) can pick tighter.

streamingTimeoutMs: 300000

fs_reach.read / fs_reach.write

Type: comma-separated absolute paths · Default: AgentLoop fallback scope

Per-personality filesystem allowlist for the read_file / write_file tools. The runtime resolves these substitutions once per turn:

TokenResolves to
${ETHOS_HOME}~/.ethos
${self}This personality's id.
${CWD}The personality's working directory — fs_reach.workdir when declared, otherwise the process working directory.

When unset, the fallback is:

read: [~/.ethos/personalities/<self>/, ~/.ethos/skills/, ${CWD}]
write: [~/.ethos/personalities/<self>/, ${CWD}]

A declared list replaces the defaults for that direction — it is not merged with them. Paths outside the allowlist surface as a BoundaryError from ScopedStorage and are rendered as a user-facing tool error.

fs_reach.read: ${CWD}, ${ETHOS_HOME}/skills, ${ETHOS_HOME}/personalities/${self}
fs_reach.write: ${CWD}, ${ETHOS_HOME}/personalities/${self}

Notes:

  • Under the container execution posture, the derived read and write paths are the container's bind mounts (read-only and read-write respectively), so the app-layer allowlist and the OS-layer mount set never disagree.
  • The active personality's derived write paths are created at startup if missing. Read-only paths are not — a read prefix that does not exist is simply an empty scope.
  • Paths under /proc, /sys, /dev, or a Docker socket are never mounted into a container and are never pre-created.

fs_reach.workdir

Type: single absolute path · Default: the process working directory

The personality's working directory. It takes the same substitution tokens as fs_reach.read / fs_reach.write, resolves to an absolute path, and becomes ${CWD} for the rest of the fs_reach derivation. Every tool in the personality's toolset stands here: a bare relative path passed to read_file or write_file resolves against it, and the terminal tool runs its commands in it under both the local and the container execution posture.

fs_reach.workdir: ${ETHOS_HOME}/workspace/${self}

Notes:

  • A declared workdir is added to both the derived read list and the derived write list, so it stays reachable even when fs_reach.write is declared and therefore replaces the defaults.
  • One path, not a list. The dotted key is the only accepted syntax — an indented fs_reach: block is refused at load with Top-level key "fs_reach" cannot be a nested object in personality config.
  • A token that resolves to an empty string refuses the turn with FS_REACH_INVALID rather than synthesizing a path at the filesystem root.
  • Unset changes nothing: the working directory is the process working directory and the read/write lists derive exactly as they did before this field existed.
  • ethos personality show <id> prints the declared value (tokens unresolved) as a Workdir line under Filesystem reach.
  • Files written here are retrievable from a browser — see Retrieve files the agent wrote.

mcp_servers

Type: space-separated strings · Default: unset (no MCP access)

MCP server names this personality may reach. Server configs live globally in ~/.ethos/mcp.json; this is a per-personality allowlist. Missing or empty means no MCP access — explicit opt-in only.

mcp_servers: github linear

Notes:

  • Manage attachments interactively with ethos personality mcp <id> --attach <name> / --detach <name>.

plugins

Type: space-separated strings · Default: unset (no plugins active)

Plugins attached to this personality. Default-deny: a plugin not listed here is dormant for this personality — its tools, hooks, and injectors do not fire.

plugins: weather invoice-checker

Notes:

  • Manage attachments interactively with ethos personality plugins <id> --attach <id> / --detach <id>.
  • Use ethos plugins (plural) for the global attachment matrix.

budgetCapUsd

Type: float (USD) · Default: unset (no cap)

Per-session spending cap. When the running cost for the current session crosses this value, the next turn is refused with a typed BUDGET_EXCEEDED error. Session-scoped — resets on /new or ethos chat in a different working directory. Override mid-session with /budget reset.

budgetCapUsd: 1.00

context_engine

Type: string · Default: drop_oldest

Context-compaction engine name. Resolved against the runtime's engine registry when the conversation approaches the model's context window. Unknown names fall back to the built-in drop_oldest.

context_engine: summarize_oldest

context_engine_options.*

Type: scalar (string / number / boolean) · Default: unset

Free-form per-engine options. Keys are dotted (context_engine_options.<key>); values are typed automatically — integers, floats, true / false, otherwise strings.

context_engine_options.keep_last_n: 8
context_engine_options.summary_model: claude-haiku-4-5

context_layering.*

Workspace-aware context layering. Controls how AGENTS.md / CLAUDE.md files are discovered as the agent navigates the workspace.

FieldTypeDefaultDescription
context_layering.modestatic | progressive | offstaticstatic loads context once at session start from workingDir. progressive also discovers sub-AGENTS.md as the agent reads / writes files; injected on the next turn. off skips context-file injection entirely.
context_layering.max_depthintegerruntime defaultMaximum directory depth to walk when discovering context files.
context_layering.discovery_filescomma-separated stringsAGENTS.md, CLAUDE.mdFilenames to scan for at each depth.
context_layering.cap_total_charsintegerruntime defaultCap on the total character budget injected.
context_layering.mode: progressive
context_layering.max_depth: 3
context_layering.discovery_files: AGENTS.md, CLAUDE.md, SOUL.md
context_layering.cap_total_chars: 12000

skill_evolution.*

Auto-triggered skill evolution. When enabled: true, the skill-evolver auto-trigger queues an analysis after every turn that crosses the min_tool_calls threshold and is outside the cooldown window. The built-in engineer personality ships with it enabled (min_tool_calls: 5, cooldown_minutes: 60).

FieldTypeDefaultDescription
skill_evolution.enabledbooleanfalseMaster switch. Off by default — opt-in per personality.
skill_evolution.min_tool_callsintegerruntime defaultMinimum tool calls in a turn before evolution runs.
skill_evolution.cooldown_minutesintegerruntime defaultCooldown between evolution runs.
skill_evolution.modelstringtop-level modelOverride which LLM the evolver uses for analysis. Falls back to the personality's model when unset.
skill_evolution.enabled: true
skill_evolution.min_tool_calls: 5
skill_evolution.cooldown_minutes: 60
skill_evolution.model: claude-sonnet-4-6

Notes:

  • The global cron schedule for running the evolver lives in config.yaml (evolver.cron_enabled, evolver.schedule), not here. These personality-level keys control whether a personality participates and the per-turn trigger thresholds.

safety

Per-personality safety config. Unlike the other fields, safety: is a true nested block — YAML indentation matters here.

safety:
approvalMode: manual
observability:
storeToolArgs: redacted
storeToolBodies: redacted
storeLlmPayloads: metadata
redactPatterns:
- sk-ant-
- sk-or-

safety.approvalMode

Type: manual | smart | off · Default: manual

Decides what happens when a tool call is classified dangerous.

ValueBehaviour
manualEvery dangerous classification surfaces the approval modal; safe auto-fires; blocked errors out.
smartAn auxiliary fast-model call reviews each dangerous classification and either auto-approves, auto-denies, or escalates to manual. Trades latency and dollars for reduced approval fatigue.
offdangerous classifications auto-fire without prompting; the hardline blocked floor still applies.

Notes:

  • approvalMode: off paired with any channel ingress (platform: telegram / discord / slack / whatsapp / email) is rejected at config load.

safety.observability.*

Controls what the observability store persists for this personality.

FieldValuesDescription
safety.observability.storeToolArgsnone | redacted | fullTool-call arguments.
safety.observability.storeToolBodiesnone | redacted | fullTool-call result bodies.
safety.observability.storeLlmPayloadsnone | metadata | fullLLM request and response payloads.
safety.observability.redactPatternsstring[]Substrings redacted from anything stored.

voice.*

Type: dotted block · Default: unset (inherit the deployment's voice config)

How this personality sounds, which engines serve it, and how its call is drawn. A deployment picks the provider; the personality picks how it sounds and how it looks, so anything declared here beats the global auxiliary.tts.* / voice.* and display.call_style settings, and silence means inherit. The provider, voice and call-look keys are editable in the web Personalities tab (Identity step); tier, model and the language map are file-only.

FieldTypeDescription
voice.tts_voicestringVoice id handed to the TTS provider. Provider-specific and free-form — af_bella for Kokoro, alloy for OpenAI.
voice.languages.<tag>stringBCP-47 tag → voice id. Beats tts_voice when the turn's language is known. Two surfaces supply one: browser talk-mode reports the language it heard, and the gateway derives it from an inbound voice note's transcript with detectLanguage() (@ethosagent/voice-text). Detection is constrained to the tags declared here and to nothing else — a personality with no language map supplies no candidates, so no guess is made and tts_voice wins.
voice.tierpipeline | realtimePreferred voice engine, beating the deployment's voice.tier. A preference, not a guarantee: a deployment with no realtime provider serves pipeline either way. An unrecognised value is dropped rather than thrown on — a bad voice field must not make a personality unloadable.
voice.tts_providerstringNames an entry in the deployment's TTS roster (voice.tts.providers.<name>). A label the operator chose, never a provider id. A name this machine lacks falls back to the default auxiliary.tts entry, so a shared personality still speaks.
voice.stt_providerstringThe same, for the STT roster. A personality's voice is identity; its ear is a technical override.
voice.realtime_providerstringThe same, for the realtime roster. Consulted only on the realtime tier; falls back to voice.realtime.default.
voice.modelstringFast-lane model for spoken turns — a small, quick model for conversation, so a voice lane never waits on the agentic default. Pinned onto the lane's runner once when the session opens, not per turn, so every host that opens a voice lane (the LiveKit adapter, the SIP adapter) gets the routing without having to remember it. Resolves from the personality alone: the deployment's model is deliberately not a fallback, since handing it over would pin every spoken lane to the model this key exists to keep off it. Unset leaves the runner untouched.
voice.call_styleliquid | orb | ringsWhich treatment the Call Stage draws for this personality — liquid, the circle filling like a vessel; orb, a body deforming with the voice; rings, concentric rings breathing outward. Unset is not a fixed default: the treatment falls through to the operator's display.call_style when that names a concrete shape, and otherwise to one derived from the personality id, so every personality already looks distinct. An unrecognised value is dropped rather than thrown on.
voice.tts_voice: af_bella
voice.languages.es: ef_dora
voice.tier: realtime
voice.realtime_provider: live
voice.call_style: rings

Notes:

  • Voice-id precedence, resolved in one function (resolveVoicePreferences) so every surface agrees: voice.languages.<tag> > voice.tts_voice > the chosen entry's own voice > global auxiliary.tts.voice. A realtime call uses the same order, so switching tiers does not switch who you are talking to.
  • Naming a roster entry buys no trust. The egress gate keys on the entry's underlying provider, so an entry called local-anything backed by a hosted model is still refused.
  • voice.provider is accepted on read as the older spelling of voice.tts_provider and re-serialized as the new one; a file never carries both.
  • Talk-mode also needs voice_session in toolset.yaml. Without it the phone button renders disabled.
  • Call-look precedence, resolved in one function (resolveCallTreatment in packages/types/src/personality.ts) so every surface agrees: voice.call_style > a concrete display.call_style > derived from the personality id. display.call_style: personality is the default and is not a pin — it defers to the derivation.
  • Confirm what parsed with ethos personality show <id> — it emits a ## Voice block, and omits the section entirely when the personality declares no voice block. Its Call look line names the derived treatment when the key is unset, because there is no blank state to report.

toolset.yaml

Flat YAML list of tool names. Each entry on its own line, prefixed with - . Tools missing from this list are filtered out before the LLM sees them.

# ~/.ethos/personalities/researcher/toolset.yaml
- read_file
- write_file
- web_search
- web_extract
- browse_url

Notes:

  • An empty file (or one with only comments) means the personality runs with no external tools. The file may be omitted entirely for an internal-only personality.
  • Tools the personality requests but does not list are rejected by DefaultToolRegistry and returned to the LLM as is_error: true so the Anthropic tool-result contract remains intact.

SOUL.md

The first-person identity file. Markdown, no front-matter required. Loaded as part of the system prompt at every turn — combined with memory context and the dynamic personality config.

The file is mtime-cached by FilePersonalityRegistry.loadFromDirectory(); the loader re-reads it only when the on-disk mtime changes, so editing it during a chat session takes effect on the next turn.

skills/

Optional sibling directory at ~/.ethos/personalities/<id>/skills/. Per-personality skill files (markdown with frontmatter). The universal skill scanner picks them up alongside the global ~/.ethos/skills/ directory. Per-personality skills are always loaded unfiltered; global skills are filtered by capability mode by default.

See also