Harnesses¶
A harness is the coding-agent CLI leo drives — claude, codex, and opencode today. Every template and task picks a harness (directly or via cascade) and configures it through a strictly validated harness_options: map instead of a flat, harness-specific field list.
All three harnesses run every leo primitive: scheduled tasks (leo run <task> / cron), ephemeral agents, and persistent tasks (templates.* and tasks.* with runtime: persistent, which deliver into a template's agent — see Persistent Tasks). The only thing that stays claude-only is channel plugins — codex and opencode message via leo's own MCP tools instead (see Support matrix and Session driver semantics below).
Support matrix¶
claude | codex | opencode | |
|---|---|---|---|
Scheduled tasks (leo run) | ✅ | ✅ | ✅ |
| Ephemeral agents | ✅ | ✅ | ✅ |
| Persistent tasks | ✅ | ✅ | ✅ |
Channel plugins (channels:/dev_channels:) | ✅ | ❌ (use leo_send_message MCP tool) | ❌ (use leo_send_message MCP tool) |
Session driver semantics¶
Every harness drives a live agent (spawned directly, or ensure-exists'd as a persistent task's target) the same way: a resident, interactive TUI process lives inside the leo-managed tmux session (leo-<name>) for the whole lifetime of the agent. Messages are injected by pasting into that pane — a readiness probe confirms the TUI's input line is idle and the pasted text actually landed before send-keys Enter fires — and delivery is fire-and-forget: leo does not wait on (or return) a synchronous per-turn result for any harness. leo attach and leo agent attach are a plain tmux attach for all three harnesses — same status bar, same Ctrl-b d detach, same remote-ssh attach flow, no per-harness special casing.
What differs is only session-id bookkeeping and per-harness launch specifics:
claude.--session-idis pinned up front at spawn time (the id is chosen by leo, not discovered). Recovery from a quick exit follows the existing ladder:--session-id→--resume→ fresh.codex. There's no start-time flag to choose a session id, so codex always launches fresh (-a never, approval policy hardcoded to "never") and leo discovers the session id after the first turn by scanning~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonlfor the newest rollout file whose recorded cwd matches the workspace, then stores it. A later restart/resume launchescodex resume <session-id>instead of a fresh session. Workspaces are auto-trusted by pre-writing a[projects."<dir>"]\ntrust_level = "trusted"entry into~/.codex/config.tomlbefore first spawn, so the "do you trust this directory?" dialog never appears. Recovery ladder:resume <id>→ fresh (clearing the stored id).opencode. Same discover-after-first-turn pattern: leo runsopencode session listafter the first turn to find the new session id and stores it; a later restart/resume launches with-s <session-id>. Config, permissions, and the MCP bridge all ride theOPENCODE_CONFIG_CONTENTenvironment overlay set at spawn time — there is no separate config file leo writes to disk. Recovery ladder:-s <id>→ fresh (clearing the stored id).
idle_suspend_after now works identically across all three harnesses — every harness has a resident tmux TUI that can be suspended (tmux session killed) and resumed (relaunched with the stored session id), so the idle sweep no longer skips codex/opencode. This applies equally to a persistent task's target agent — see Persistent Tasks → Idle-suspend interaction.
Migration note. If you're updating from a pre-uniform-tmux-TUI build: stale ~/.leo/state/opencode/*.json (old server port/password state) and ~/.leo/state/transcripts/*.log (old per-turn codex/opencode transcripts) files are inert under the new model and can be deleted manually. Any running codex/opencode agent started under the old model must be stopped and respawned to pick up the tmux-TUI supervise model — there is no live in-place migration.
What a harness is¶
Leo doesn't hardcode "Claude Code" into its config schema or argv builders anymore. Instead, each supported CLI is an adapter: a small Go package that knows how to validate its own options, translate a harness-neutral launch spec into argv, and (for interactive harnesses) parse session/channel state.
- Three adapters are registered today:
claude(internal/harness/claude),codex(internal/harness/codex), andopencode(internal/harness/opencode). - Adapters are compiled-in Go, contributed by PR — there is no runtime plugin mechanism for harnesses. (Don't confuse this with channel plugins — Telegram, Slack, etc. — which remain regular Claude Code plugins installed via
claude plugin install.)
Adding a new harness means writing a new package under internal/harness/ and blank-importing it from internal/config/harness.go; it does not touch any consumer's config shape beyond that adapter's own harness_options keys.
Config shape¶
Every scope that used to accept flat claude fields now has two knobs:
harness:— the adapter name for that scope. Optional; when unset on a template/task it cascades fromdefaults.harness, anddefaults.harnessitself falls back to the built-in defaultclaudewhen unset. Applies todefaults,templates.*, andtasks.*— there is no separate top-levelharness:key;defaultsis the root of the cascade.harness_options:— a map of adapter-specific keys. The adapter named by that scope's resolvedharness:strictly validates the map at config load and on every web-UI save: unknown keys, wrong value types, and invalid enum values are all rejected with a precise error.
defaults:
harness: claude # optional; this *is* the built-in default
model: sonnet
harness_options:
permission_mode: default
allowed_tools: [Read, Grep]
templates:
assistant:
workspace: ~/.leo/workspace
channels: [plugin:telegram@claude-plugins-official]
harness_options:
permission_mode: acceptEdits
remote_control: true
agent: leo
allowed_tools: [Read, Edit, Bash]
disallowed_tools: [WebFetch]
coding:
workspace: ~/agents
harness_options:
permission_mode: auto
remote_control: true # template-own-only; see below
tasks:
daily-briefing:
schedule: "0 7 * * *"
prompt_file: prompts/daily-briefing.md
harness_options:
permission_mode: bypassPermissions
enabled: true
Merge rules¶
harness:cascade: scope value →defaults.harness→claude. A scope that names a harness must name a registered adapter, or config validation fails.harness_options:merge:defaults.harness_optionsis layered underneath a scope's ownharness_options, one top-level key at a time — the scope's value for a given key always wins; it does not deep-merge nested values. This only happens when the scope resolves to the same harness asdefaults; options never leak across harnesses (e.g. adefaults.harness: codexblock's options are not merged into atemplates.fooentry that setsharness: claude). This applies the same way whether a template backs ephemeral agents or a persistent task's target — the merge rule is per-scope, not per-primitive.- Template
remote_controlis template-own-only. Unlike every other claude option,templates.*.harness_options.remote_controldoes not inherit fromdefaults.harness_options.remote_control— the defaults layer is ignored for this one key on templates. It defaults totruewhen unset (ephemeral agents are remote-controllable out of the box).
Claude option reference¶
These are the seven harness_options keys the claude adapter accepts. Unknown keys are rejected.
| Key | Type | Meaning |
|---|---|---|
permission_mode | string | One of acceptEdits, auto, bypassPermissions, default, dontAsk, plan. Passed as --permission-mode. |
bypass_permissions | bool | Legacy fallback for --dangerously-skip-permissions. Only consulted when permission_mode is empty — set permission_mode instead where possible. Task-level bypass_permissions is now honored (previously only defaults respected it). |
remote_control | bool | Enables --remote-control (claude.ai / mobile app attach). |
agent | string | Path to a Claude Code subagent file, passed via --agent. |
allowed_tools | list of strings | Tool whitelist, passed via --allowed-tools. |
disallowed_tools | list of strings | Tool blacklist, passed via --disallowed-tools. |
append_system_prompt | string | Extra text appended to the system prompt. |
Model validation is also delegated to the adapter: for claude, model: must be one of sonnet, opus, haiku, sonnet[1m], opus[1m] (empty is always valid — it means "let claude choose").
channels: / dev_channels: remain top-level fields (not harness_options) but are only valid on a channel-supporting harness. claude is the only one today (SupportsChannels() == true); setting channels: on a scope whose resolved harness doesn't support channels is a validation error.
Codex option reference¶
codex runs every leo primitive — scheduled tasks, ephemeral agents, and persistent tasks. Ephemeral agents and persistent-task targets drive a resident TUI in tmux, described in Session driver semantics above. One-shot scheduled tasks (leo run <task> without runtime: persistent) stay headless: each firing spawns a fresh codex exec --json --skip-git-repo-check [--model …] [--sandbox …] [-c mcp_servers.leo.*…] <message> and leo parses the JSON event stream.
| Key | Type | Meaning |
|---|---|---|
sandbox | string | One of read-only (codex's own default when unset), workspace-write, danger-full-access. Passed as --sandbox. |
Other things to know:
- Git metadata and
.agentsare re-opened in the sandbox. Codex'sworkspace-writesandbox deliberately keeps.git,.agents, and.codexunder each writable root read-only, which breaks unattended commits (git addcannot create.git/index.lock— and in a linked git worktree the real git dir lives outside the workspace entirely) and project-skill creation. Since leo runs codex with approval policynever, there is no escalation path, so leo grants these back explicitly: every launch appends-c sandbox_workspace_write.writable_roots=[…]covering the workspace's.agentsdir plus the resolved git dir and git common dir (worktree-aware). Inert unlesssandbox: workspace-write. Note this override replaces anysandbox_workspace_write.writable_rootsset in~/.codex/config.toml. - No
approval:key. Headlesscodex exechas no approval flag at all — upstream removed it, and approval policy is hardcoded tonever. Settingharness_options.approvalis rejected:option "approval" is not supported: codex exec always runs non-interactively (approval policy "never"). - No
append_system_prompt. Codex's equivalent mechanism is a workspaceAGENTS.mdfile, not a CLI flag. Setting the key is rejected:option "append_system_prompt" is not supported: codex has no append-system-prompt equivalent (use the workspace AGENTS.md). - Model is free-form.
codex.ValidateModelonly rejects whitespace; the actual model name is validated server-side by codex (an invalid one fails the run with amodel_not_founderror, not a leo validation error). An unsetmodel:does not inheritdefaults.modelacross harnesses — see Cross-harness model cascade below. max_turnsis ignored. Codex has no per-turn cap leo can drive; the value validates but has no effect.- Auth is
CODEX_API_KEYset in the task'senv:, or ambientcodex loginstate on the host running leo. Leo does not manage codex credentials. --skip-git-repo-checkis always passed. Leo workspaces are frequently not git repos, and codex refuses to run in one otherwise.- Resume happens via codex's own thread mechanism (
codex exec resume <thread-id>under the hood) — leo persists thethread_idit reads from thethread.startedevent and passes it back on the next invocation for the same task. - MCP bridge. When the web UI is enabled and leo can read a non-empty
state/api.tokenfile, leo injects its own MCP server into each invocation via per-invocation-c mcp_servers.leo.*overrides — no config-file mutation. (Web-enabled-but-no-token is not an error; leo silently skips wiring the bridge in, since the leo MCP server would be doomed to authenticate anyway.) This includesmcp_servers.leo.default_tools_approval_mode="approve", scoped to just the leo server: without it, headless codex auto-cancels every MCP tool call (user cancelled MCP tool call) even though the turn still completes with exit 0. - Messaging goes through leo's
leo_send_messageMCP tool — codex has no channel-plugin concept (SupportsChannels() == false).
Opencode option reference¶
opencode runs every leo primitive — scheduled tasks, ephemeral agents, and persistent tasks. Ephemeral agents and persistent-task targets drive a resident TUI in tmux, described in Session driver semantics above. One-shot scheduled tasks (leo run <task> without runtime: persistent) stay headless: each firing spawns a fresh opencode run --format json --dir <workspace> [--model …] [-s <session-id>] <message> and leo parses the JSON event stream.
| Key | Type | Meaning |
|---|---|---|
permission | map | Per-tool permission: allow, ask, or deny, or a nested pattern map of the same (e.g. {bash: {"git push *": ask}}). Delivered via a per-spawn config overlay — see below. |
Other things to know:
- Delivery mechanism.
permission(and leo's MCP bridge entry) are not passed as argv — opencode's permission system is config-only. Leo builds a JSON overlay and sets it as theOPENCODE_CONFIG_CONTENTenvironment variable for that one spawn. Opencode deep-merges this over the user's ownopencode.json/global config; leo never mutates that file. - Model must be
provider/model.opencode.ValidateModelrejects anything without a/, e.g.anthropic/claude-sonnet-4-5. An unsetmodel:does not inheritdefaults.modelacross harnesses — see Cross-harness model cascade below. max_turnsis ignored, same as codex.- No
append_system_prompt. Rejected:option "append_system_prompt" is not supported: opencode has no append-system-prompt equivalent (use AGENTS.md or the instructions config). - Leo's nudge goes through the global AGENTS.md. Every harness gets a small system-prompt nudge pointing the agent at the
leo_skillMCP tool (see Directory Structure), but opencode has no per-invocation system-prompt flag. Instead, the daemon maintains a managed block (delimited by<!-- BEGIN LEO (managed) -->/<!-- END LEO (managed) -->) inside opencode's global~/.config/opencode/AGENTS.md(or$XDG_CONFIG_HOME/opencode/AGENTS.md), which opencode merges with any project-levelAGENTS.mdrather than overwriting. Only the managed block is ever touched; the rest of the file — including content you've written yourself — is left alone. This only happens when opencode is actually configured somewhere inleo.yaml. - Resume uses opencode's own session IDs (
-s ses_…), read from thesessionIDfield present on every event in the stream. - MCP bridge rides in the same
OPENCODE_CONFIG_CONTENToverlay, under amcp.leoentry, gated the same way as codex's — web UI enabled and a readable, non-emptystate/api.token. - Parsing quirks leo works around:
- EOF-as-turn-end. Older opencode versions can omit the terminal
step_finishevent on a truncated--format jsonstream (upstream #26855); leo treats reaching EOF as the end of a turn rather than requiringstep_finish. - In-stream errors fail the attempt even on exit 0. Opencode sometimes exits
0after emitting anerrorevent mid-stream. Leo parses the stream and treats anyerrorevent as a failed attempt regardless of the process exit code — this is a deliberate cross-harness behavior change (claude exits non-zero on real errors, so this path is effectively unreachable there). - Messaging goes through
leo_send_message, same as codex —SupportsChannels() == false.
Cross-harness model cascade¶
defaults.model does not cascade to a task whose resolved harness differs from defaults.harness — model identifiers are harness-specific (opus means nothing to codex; codex/opencode need their own model strings), so leaking a claude default into a codex or opencode task would silently pass garbage. Concretely: if tasks.t.harness: codex (or opencode) and tasks.t.model is unset, TaskModel returns "" regardless of what defaults.model is set to — an empty model means "let the harness pick its own default." The fall-through to defaults.model (then the built-in default) only applies when the task's resolved harness matches defaults.harness.
Example: codex and opencode tasks¶
defaults:
harness: claude
model: sonnet
tasks:
codex-refactor:
schedule: "0 3 * * *"
prompt_file: prompts/codex-refactor.md
harness: codex
model: gpt-5.3-codex
harness_options:
sandbox: workspace-write
env:
CODEX_API_KEY: ${CODEX_API_KEY}
enabled: true
opencode-triage:
schedule: "0 9 * * *"
prompt_file: prompts/opencode-triage.md
harness: opencode
model: anthropic/claude-sonnet-4-5
harness_options:
permission:
bash: ask
edit: allow
enabled: true
Note there is no approval: key for codex — approval policy is fixed at never for headless exec and isn't configurable.
Migration table¶
Every flat claude field that used to live directly on defaults, templates.*, or tasks.* moved one level down, under harness_options, with the same key name (shipped in v0.8.0, 2026-07-11):
| Old field | New field |
|---|---|
permission_mode | harness_options.permission_mode |
bypass_permissions | harness_options.bypass_permissions |
remote_control | harness_options.remote_control |
agent | harness_options.agent |
allowed_tools | harness_options.allowed_tools |
disallowed_tools | harness_options.disallowed_tools |
append_system_prompt | harness_options.append_system_prompt |
For example:
# before
templates:
assistant:
permission_mode: acceptEdits
remote_control: true
# after
templates:
assistant:
harness_options:
permission_mode: acceptEdits
remote_control: true
The old flat fields are no longer detected or rejected — a stray permission_mode: (etc.) at the top level of a scope is now silently ignored as an unknown YAML key (the loader is non-strict) rather than producing a validation error. Move it under harness_options: to have it take effect.
providers: is gone¶
The entire providers: top-level section and every scope's provider: field have been removed — there is no replacement config key. Leo no longer brokers named third-party endpoints; it delegates that entirely to the harness's own environment.
For claude, point a scope at a third-party Anthropic-Messages-compatible endpoint (z.ai GLM, OpenRouter, Moonshot, DeepSeek, MiniMax, …) using its existing per-scope env: map:
templates:
scout:
workspace: ~/agents/scout
env:
ANTHROPIC_BASE_URL: https://api.z.ai/api/coding/paas/v4
ANTHROPIC_AUTH_TOKEN: ${GLM_API_KEY}
env: works the same way on tasks: — both oneshot tasks and persistent tasks with an implicit target (runtime: persistent with no template:) can target a custom endpoint:
tasks:
nightly-report:
schedule: "0 6 * * *"
prompt_file: nightly-report.md
env:
ANTHROPIC_BASE_URL: https://api.z.ai/api/coding/paas/v4
ANTHROPIC_AUTH_TOKEN: ${GLM_API_KEY}
This is the same mechanism providers: used internally to inject ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN — it just skips the indirection of a named provider table, and there's no more api_key_cmd/default_model convenience layer; resolve secrets into env: values yourself (e.g. with a wrapper script or your shell's env before leo reads its own environment).
Model validation still applies. model: is validated by the resolved harness regardless of env: overrides — for claude that means only sonnet, opus, haiku, sonnet[1m], or opus[1m] pass validation (or leave model: unset). If your third-party endpoint expects a different model identifier, leave model: unset in leo.yaml and have the proxy on the other end of ANTHROPIC_BASE_URL remap the request, or set the desired model via an endpoint-specific env var your proxy reads instead of --model. This is a real behavior change from providers:, which allowed an arbitrary default_model string once a provider was set.
Validation behavior¶
Every one of these mistakes is caught at config load (leo validate, CLI startup, daemon boot) and before every web-UI config save. Each error names the exact scope and the exact field:
(The old flat-field migration errors, e.g. templates.foo.permission_mode has moved to templates.foo.harness_options.permission_mode, were removed once the migration had shipped for long enough — those fields are now silently ignored unknown keys instead. providers: remains a hard error since there is no replacement key to auto-detect a typo against.)
Other harness-related validation errors follow the same style:
- An unregistered
harness:name:defaults.harness "foo" is not a registered harness (available: claude, codex, opencode). - An unknown
harness_optionskey, wrong type, or invalid enum value: reported by the adapter'sDecodeOptions, e.g.defaults.harness_options: unknown option "foo" (valid: agent, allowed_tools, append_system_prompt, bypass_permissions, disallowed_tools, permission_mode, remote_control). channels:/dev_channels:on a harness that doesn't support them:templates.foo.channels: the codex harness does not support channel plugins; use leo's MCP tools for messaging.- A kind the harness can't run: the literal error text baked into
internal/config/config.goistemplates.foo.harness: the <name> harness cannot run ephemeral agents yet (only scheduled tasks) — see docs/configuration/harnesses.md(same pattern for aruntime: persistenttask's.harness→ "cannot run persistent tasks yet (persistent tasks deliver into agents)"). This is dead code for every built-in harness today —claude,codex, andopencodeall passSupportsKindfor every primitive (see Support matrix) — it only fires for a future harness whose adapter doesn't implementSupportsKindfor a given kind.
If you hit any of these on an existing leo.yaml, move the named field under harness_options (or drop provider/providers and switch to env:) and re-run leo validate.
Web UI¶
The web UI's Defaults, Tasks, and Templates forms all edit harness: and harness_options: directly — there is no separate "harness" management page; the adapter registry backs an ordinary form field.
- Harness dropdown. Every one of those three forms has a
Harnessfield (internal/web/schema/registry.go'sfHarness()) rendered as aKindSelectover the"harnesses"option source, which listsharness.Names()— the registered adapter names (claude,codex,opencode) — with an empty first entry labeled "inherit" (internal/web/schema/options.go'sTryFor("harnesses")/namedKeys). Leaving it unset falls through the same scope-then-defaults-then-built-in-claudecascade the CLI/YAML path uses. - Harness-options sub-form. Changing the dropdown fires an htmx
GET /web/partials/harness-optionsrequest (hx-getwired ininternal/web/templates/components/form.html, handled byhandleHarnessOptionsPartialininternal/web/handlers_harness.go), which swaps in a typed sub-form for the newly selected adapter viaharness.Harness.OptionsSchema()(internal/web/templates/components/harness_options_partial.htmlre-rendering theharness_optionstemplate). EachOptionFieldpicks its control byType(internal/harness/options.go,internal/web/templates/components/harness_options.html): OptionBool→ a tri-state<select>(inherit / on / off) — bools are tri-state because unset must stay distinguishable fromfalseunder the key-wiseharness_optionscascade, not a two-state checkbox.OptionEnum→ a<select>populated fromEnumValues.OptionString→ a plain text input (used foragent, which also resolves aSource: "agents"option list — the claude sub-agent names the running web server can list — falling back to a plain input if that source can't be resolved).OptionStringList→ a single text input parsed as comma-separated values on submit (ApplyHarnessOptionsininternal/web/schema/harnessform.go).OptionText→ a multi-line<textarea>.OptionYAMLMap→ a multi-line<textarea>parsed as YAML on submit.
Concretely, per adapter (OptionsSchema() in each adapter's options.go): claude exposes permission_mode (enum), bypass_permissions (bool), remote_control (bool), agent (string, agent-list source), allowed_tools / disallowed_tools (string lists), and append_system_prompt (text); codex exposes sandbox (enum: read-only/workspace-write/danger-full-access); opencode exposes a single permission field (YAML map). - Opencode's permission field is the one OptionYAMLMap field in the registry today — it renders as a YAML textarea (rows="4", monospace) so the nested {tool: {pattern: verdict}} shape from Opencode option reference can be edited directly; ApplyHarnessOptions unmarshals it as YAML on submit and only sets the key when the parsed map is non-empty. - Model input. The model field on every scope (fModel() / KindDatalist in internal/web/schema/registry.go) is a free-text input backed by a <datalist> that leo refreshes via an out-of-band htmx swap whenever the harness dropdown changes (harnessPartialData.ModelOpts in handleHarnessOptionsPartial). schema.ModelSuggestions only populates that datalist for claude, straight from claude.ValidModels() — codex and opencode have no fixed model list, so their inputs instead show a placeholder format hint from schema.ModelPlaceholder ("e.g. gpt-5.3-codex" for codex, "provider/model, e.g. anthropic/claude-sonnet-5" for opencode). A stale datalist is harmless either way: whatever is typed is only ever accepted after the resolved harness's ValidateModel passes on save (see Validation behavior). - Inherited-value placeholders. When a scope's resolved harness matches defaults.harness, each option control shows the corresponding defaults.harness_options value as a grayed-out placeholder/inherit-label (inherit (<value>) for selects, inherit: <value> for text/textarea inputs) rather than a stored value — mirroring the same-harness-only merge rule in Merge rules. handleHarnessOptionsPartial recomputes this placeholder set against the newly selected harness on every dropdown change, not the stored one, so switching a scope onto the defaults harness lights placeholders up and switching away drops them. The defaults form itself never shows inherited placeholders — it has no higher scope to inherit from. - Validation on save. Every config-saving handler funnels through validateAndSave (internal/web/handlers.go), which calls the same Config.Validate() the CLI and daemon boot use — adapter DecodeOptions, ValidateModel, kind/channel support checks, cron syntax, and everything else in Validation behavior — before writing anything to disk. A rejected save never reaches leo.yaml; the error message (adapter-produced, field-named) is surfaced back to the form as a flash message instead. - Harness visibility elsewhere. Template and task list pages show a harness column/badge per row (dimmed when the value is inherited rather than set on that scope), so the resolved harness is visible without opening each form.