Adding a built-in agent
September 4, 2026 · View on GitHub
A checklist, written after adding pi, opencode, codex hooks and Muse Code, because
every one of those shipped with something missed that only turned up in use. The
order matters: the measuring comes first, and half the entries below exist
because a default was written from a CLI's --help and was wrong.
Two rules run through all of it.
Measure, never infer. --help describes intent; the binary decides. Every
default here has been wrong at least once for an agent whose help text said
otherwise. Where a claim can be checked with a live binary, check it, and put
what you saw in the comment: the version, and what the run actually printed.
Prefer a failure that is loud. An agent that refuses to start gets fixed the day it ships. An agent that starts and silently stops reporting gets found weeks later, by a user, from a symptom three layers away.
0. Install it, and find its offline mode first
Before anything else, find out whether the agent can run WITHOUT an account. It decides how much of the rest you can verify at zero cost, and it is usually undocumented:
- muse:
--provider echo, a real offline provider. Everything in this document except its attention state was measured with it. - codex: no offline provider, but
codex app-serveranswers protocol requests (hooks/list) without a turn, and a badmodel_providerfails after the session has started, which is enough to observe startup behaviour.
Where there is no offline mode, budget real turns and say so before spending them: a maintainer on the cheapest plan has very few.
1. The registry entry (default_agents() in lib.rs)
id, display_name, command, icon_id, color, and:
yolo_args— measured, not assumed. Empty is a legitimate ANSWER (pi asks for no approval at all), so write the comment that says it was checked rather than leaving a reader unsure whether it was skipped.resume_args— the cwd-based resume. Right for a worktree; see §2.session_id_args/resume_id_args— see §2. Getting this wrong is invisible until two tasks share a conversation.sandbox_allowed_paths— see §4. These grantfile-write*.signals— leave empty unless you have CAPTURED titles (§3).
Also: agent_dirs::state_dirs, docker::KNOWN_SAFE_AGENTS and
docker::base_agent_id_str's BUILTINS, the TS BUILTIN_FALLBACK in
lib/agents.ts (the two tables MUST agree; the TS one runs before the registry
loads), CliIcon's case, CLI_BRAND_COLOR, CLI_LABEL, index.css brand
vars for both themes, and a line in Dockerfile.default.
An existing install picks the new agent up through load_settings_inner's
merge, so no migration is needed. It also means every user gets the row.
2. Resume: three shapes, and how to tell which one you have
The question is only interesting for a REPO-ROOT task, because several of those share one cwd, so "resume the last session here" is another task's conversation.
- Mint (claude, grok, pi). Termic can hand the agent a uuid at launch, so
it owns the session. Both
session_id_argsandresume_id_args. - Capture (opencode, codex, muse). The agent will resume an id but will not
accept one at launch.
resume_id_argsonly, plus a way to learn the id after the fact:post_launch_capture(a shell command, opencode and muse) or the agent's own hook reporting it (codex, which is better, see §5). - Neither (agy).
resume_argsonly; repo-root tasks start fresh.
Test for the mint shape properly, because two agents looked like it and were not:
<agent> resume 11111111-1111-4111-8111-111111111111 # a uuid that does not exist
pi CREATES it (so one flag serves both mint and resume). codex answers
"no rollout found for thread id" and muse's TUI rejects --session-id outright
as an exec-only flag. Then prove resume actually carries HISTORY, not just
that it exits 0: ask the agent to remember a word, resume, ask for the word.
3. Work-state signals
Capture real titles before writing a pattern. script will not do: an agent TUI
that queries the terminal (DSR/CPR) hangs against it. Use tmux, which answers,
and read #{pane_title}; use tmux pipe-pane for the raw byte stream when you
need to know which OSC ids it emits.
Leave attention EMPTY unless you have captured the blocked state. A pattern
written from reasoning is what c35d297 had to remove from two agents.
Check whether the agent puts prose on OSC 9. Codex sends its ENTIRE final
message there at the end of every turn, and termic reads OSC 9 as "the agent
wants you", so every completion became a needs-you bell. If it does that, it
belongs in NOTIFY_NEVER_ATTENTION — and confirm the negative too, by driving it
to a real permission prompt and checking no OSC 9 appears.
4. The sandbox, which is where the loud failures live
Agent.sandbox_allowed_paths grants read AND write on a subpath. Two
consequences, both learned the hard way:
- Never list a SHARED directory. Muse's entry had
~/.local/bin, which is where claude, codex, agy and grok also keep a binary or shim: a sandboxed muse could have overwritten any of them. Use aregex:scoped to the agent's own files instead (claude's sidecar regex is the precedent). - Never list another agent's config. Some agents read their neighbours' personal rules; that is theirs to do uncaged, not termic's to grant.
List every state dir, including the macOS-native one. Agents commonly use
~/.config/<a> AND ~/Library/Application Support/<A>. Muse shipped without the
second and failed to start under ENFORCING, because the missing access was a
WRITE (session-name-authority/session-names.db).
Debugging a cage failure
DUMP_AGENT=<id> DUMP_PATH=<a worktree> DUMP_OUT=/tmp/a.sb \
cargo test --lib profile_dump -- --ignored --nocapture
cd <the worktree> && sandbox-exec -f /tmp/a.sb <agent> ...
with, in another shell:
log stream --predicate 'eventMessage CONTAINS "Sandbox:" AND eventMessage CONTAINS "deny"' --style compact
Three things that will waste your time otherwise. The system log dedupes
violations, so each run usually reveals ONE new path and you iterate. Bisect from
the working side ((allow file-read*) appended, then narrow) rather than adding
denied paths one at a time. And a path may only work as a broad subpath even
when every child is listed individually, because of macOS firmlinks
(/Library is really /System/Volumes/Data/Library) — that is what /Library
being a read root exists for.
Check the Sandbox dialog's monitoring mode too. It found muse's missing
Application Support dir immediately, listed with its access counts, which is
faster than any of the above.
5. Hooks (optional, and check the transport FIRST)
Hooks are only worth wiring where the terminal gets a state WRONG or cannot express it. Before designing anything, check the two things that make it possible at all:
- Does the agent pass
$TERMIC_PTYand$TERMIC_TASK_IDthrough to a hook command? Muse does not. It strips them (HOMEsurvives, custom vars do not), andshell_environment_policydoes not change it, so every generated script would exit 0 having written nothing. That killed muse hooks outright. - Does the readiness event fire at STARTUP? Muse's
SessionStartfires on the first PROMPT, despite its payload sayingsource: "startup", so it cannot gate readiness.
Then the shape: hooks_for, schema_for (ClaudeCompatible covers most,
including codex), settings_rel, SUPPORTED, and whether the agent needs a
required field in a config termic creates from scratch (muse rejects a
settings.json with no schema_version).
There is no UI step. Settings → Agents' hooks row and the welcome wizard's
list are both driven by SUPPORTED crossed with what is on PATH, so adding the
id there is what makes the agent appear. The corollary is the part that looks
like a bug and is not: an agent deliberately left out shows NOTHING in that
dropdown rather than a row explaining why. If you decide against hooks for an
agent, the reasoning goes in docs/agent-hooks.md — that is the only place
anyone will find it, and "why is muse missing from the hooks list" is a
question that has now been asked.
Watch for a trust model. Codex discovers hooks, reports them enabled, and
does not RUN them until a trusted_hash entry exists in its config.toml —
with no error, no log and no output in any failing state. If the agent has one,
ask the agent for the hash rather than computing it, or a patch release silently
turns every hook off.
Bump SCHEMA_VERSION whenever a script BODY changes, or existing installs keep
the old scripts forever. There is a test that fails if you forget.
6. Tests and docs
- Rust: the seeded-default test (assert the flags AND the reasoning, including what is deliberately EMPTY).
- TS:
agents.test.tsfor spawn-arg composition; keepBUILTIN_FALLBACKin step with the Rust table. - Grep the whole test suite for agents used as EXAMPLES. Giving codex
resume_id_argsbrokecli.e2e.ts, which used codex as its example of an agent that cannot resume by id, and it was caught by CI on main rather than locally. Run the FULLmake e2e, not the two specs you touched. - Docs:
docs/sandbox.md(vendor hosts, the Docker agent list),docs/agent-hooks.mdif hooks were considered — including if they were REJECTED, with the measurement, so nobody repeats the investigation. - README's built-in list.
- Do NOT touch
CHANGELOG.md; that is the maintainer's.
7. Before saying it works
The suites do not catch what this feature class gets wrong. Run the agent by hand in a worktree AND in a repo-root task, with the sandbox both off and enforcing, and check: it starts, the spinner tracks a real turn, a completed turn produces ONE notification, resume brings the conversation back, and the Docker image still builds.