Built-in Agents Reference
September 17, 2026 · View on GitHub
The exact configuration and runtime behavior of every built-in coding agent, plus the checklist for adding a new one.
Thurbox is agent-neutral: it knows nothing about a coding agent's model,
prompts, permissions, or tools — only how to launch the CLI and how that CLI
resumes, forks, and reports status. Every agent is data, not code. The
built-ins are just the entries thurbox ships pre-seeded; a user adds their own
by editing agents.toml (see CONFIG.md for the file
format). This document is the single source of truth for what each built-in's
data actually is and why, and it exists because adding a new built-in touches
several files that are not updated automatically — see
Adding a new built-in agent.
Related docs:
- CONFIG.md → agents.toml — the config-file format and
per-field syntax (
{id}substitution,resume_latest,hook_schema). - FEATURES.md → Agent definitions — the agents-as-data design.
- ARCHITECTURE.md — the
session::AgentDefpurity rule. ../.agents/skills/thurbox-agents/→ Agent Definitions — the in-repo working reference.
Where the built-ins live
The built-in registry is not hand-constructed in Rust. It is an embedded
TOML document, BUILTIN_AGENTS_TOML, in
src/agent/agent_config.rs. builtin_registry()
parses that string, and seed_agents_toml() writes it verbatim to
~/.config/thurbox/agents.toml on first run. So the same text is both the
in-binary fallback and the seed the user then edits — keep it authoritative.
Status hooks (working / blocked / done) are wired separately by the built-in
hooks extension, whose per-agent mechanism is declared in
extensions/hooks/extension.toml. An
AgentDef says how to launch an agent; the hooks manifest says how that agent
reports state back. Adding a built-in agent means touching both.
The built-in agents
Ten entries ship pre-seeded — nine coding agents and a plain shell.
default = "claude".
| Name | Command | Resume | Fork | ID model | Status hooks |
|---|---|---|---|---|---|
claude | claude | --resume {id} | --resume {id} --fork-session | pinned (--session-id {id}) | --settings arg patch (claude.json) |
codex | codex | resume --last | fork --last | id-less (resume_latest) | config_merges → ~/.codex/hooks.json |
antigravity | agy | --continue | — (none) | id-less (resume_latest) | config_merges → ~/.gemini/settings.json |
opencode | opencode | --continue | --continue --fork | id-less (resume_latest) | external_files → ~/.config/opencode/plugin/ |
aider | aider | --restore-chat-history | — (none) | id-less (resume_latest) | --notifications-command arg patch (blocked only) |
copilot | copilot | --continue | — (none) | id-less (resume_latest) | external_files → ~/.copilot/hooks/ |
vibe | vibe | — (starts fresh) | — (none) | none | external_files → ~/.vibe/hooks.toml |
pi | pi | --session-id {id} | --fork {id} | pinned (--session-id {id}) | external_files → ~/.pi/agent/extensions/ |
omp | omp | --resume {home}/.omp/…/thurbox-{id}.jsonl | — (none) | path-pinned (--session <file>) | external_files → ~/.omp/agent/extensions/ |
shell | bash -i (powershell -NoLogo on Windows) | — (none) | — (none) | none | none (see below) |
shell — the entry that is not a coding agent
shell exists so a session can be something other than an agent: you run
whatever you like in it, and an external driver can start its own tool with its
own flags rather than asking thurbox to model them. It is the ready-made form of
session create --command, and the only built-in whose command differs by
platform, because a native-Windows host runs psmux and PowerShell rather than
tmux and bash.
args = ["-i"] on POSIX is load-bearing rather than decoration: a plain bash
in a pane executes what is sent to it but renders no prompt and no echo, so
anything reading the screen to decide whether the session is ready sees a blank
pane that is nonetheless working.
It declares no resume_args or fork_args, and that absence is the statement:
a shell has no conversation. session restart replays its recipe in the same
directory (a shell barely notices — its history and cwd live on disk),
session fork gives you a second shell beside it, and session create --resume
silently starts fresh rather than attaching to anything (only a raw
--command session refuses --resume outright; see
FEATURES.md). Nothing
wires status hooks for it either, so a shell session reports hook_state: null
until something in it calls thurbox-cli session signal — which anything in
the pane can, since
THURBOX_SESSION is in its environment.
ID model: pinned vs. id-less
This is the single most important behavioral distinction, because it decides whether resume/fork can target this session or only the last one.
- Pinned (
claude,pi) — the CLI accepts a thurbox-generated UUID at creation vianew_session_args = ["--session-id", "{id}"], so it can resume/fork that exact session by id.resume_lateststaysfalse. - Path-pinned (
omp) — the CLI generates its own internal session id and can't accept thurbox's, but it takes a session file path. thurbox maps its UUID to a deterministic JSONL under OMP's default root (--session {home}/.omp/agent/sessions/thurbox-{id}.jsonlon create,--resumeon the same path to restore), so restart reattaches the exact session even though the id itself is opaque. The new{home}token is expanded to the resolved home dir at spawn time by thurbox (args are POSIX-quoted, so a literal~would never expand), and it translates onto the remote/WSL home. It has nofork_args(OMP can't pin a fork's target file to a thurbox UUID), soCtrl+Fstarts fresh. - id-less (
codex,antigravity,opencode,aider,copilot) — the CLI can neither pin nor report a session id, so the agent's own flags resolve "the last session in this directory" (codex resume --last,--continue,--restore-chat-history). These entries setresume_latest = trueand use no{id}token. This works because restart reuses the session's cwd and a single-repo fork reuses the parent's cwd. Caveat: a multi-repo fork lands in a fresh symlink workspace, so--last/--continuefinds no parent session there (multi-repo restart still works — same workspace dir). - none (
vibe) — no resume group at all, so it always starts fresh on restart. The live tmux process is what carries its state across TUI restarts.
resume_latest only changes when the resume group fires
(session_ops::resume_trigger_for): for id-less agents restart always triggers
resume; for claude it defers to an on-disk transcript check.
Fork
Agents that declare no fork_args (antigravity, aider, copilot, vibe,
omp) cannot fork — Ctrl+F starts a fresh session instead. None of those
CLIs support forking a conversation to a thurbox-pinned target.
Status hook mechanisms
Every built-in reports status via the hooks extension, but how the hook is
delivered differs by what each CLI supports. Two entries below (grok, kimi)
are not built-in agents: their hooks are installed into their own config dirs
all the same, so a session running one — started from a --command shell, a
custom agents.toml entry, or an outside driver — reports state whoever launched
it. Naming the agent on the row (session create --reports-as grok) is what
resolves the coverage. All are declared in
extensions/hooks/extension.toml; the
embedded hook assets live in
extensions/hooks/ and are include_str!'d by
src/session_ops/builtin_hooks.rs.
-
agent_patches(arg injection) — appends args to the agent's launch command, reversibly.claude:--settings {home}/claude.json(claude merges it with the user's own settings, never clobbering). Full lifecycle: idle/working/blocked/done.aider:--notifications-command "thurbox-cli session signal --state blocked". aider has only a "waiting for input" callback, so blocked is the only state it can report.
-
config_merges(reversible deep-merge into a shared config file) — for agents whose hooks live in a file thurbox must not overwrite. JSON by default;format = "toml"selects the TOML merge for an agent whose shared config is TOML (kimi). Either way the semantics match: objects/tables recurse, arrays union, a type conflict with the user's value is left alone, and uninstall prunes exactly our entries. Install prunes first too, in both formats, so a payload that renamed an event or edited a command replaces the entry already on disk instead of stacking a second copy beside it — without that, a fixed hook stays broken, because the stale one is still there and still firing. How "ours" is decided is the same in both: an ownership stamp on each shipped entry. TOML carries it as a comment above the entry; JSON has no comments, so it goes in the command itself, as a trailing# managed by thurbox …a shell ignores. Matching on thesession signalcommand instead would delete a hook the user wired themselves — whichextensions/hooks/README.mdinvites — and install runs at startup and on every heartbeat tick, so it would delete it again every time they put it back.One exception, once: a file written before thurbox stamped what it merges (hooks < 1.11) has nothing to match but the command, so the first install to find an unstamped file sweeps our old entries out of the events the payload owns. Leaving them would leave their broken commands firing beside the fixed ones. That sweep is gated on the file carrying no stamp at all, so it runs exactly once — and what it costs is a hook the user had written under one of those events before upgrading.
Uninstall matches both the stamp and the command, and stays document-wide: it is explicit and one-shot, and must leave nothing of ours orphaned whatever shape the payload had when it wrote it. It still takes a user hook that calls
session signalwith it, in JSON; the TOML one passes over it.codex: merged into~/.codex/hooks.json(SessionStart→idle, UserPromptSubmit/PostToolUse→working, PermissionRequest and PreToolUse matchingrequest_user_input→blocked, Stop→done). It has two block edges and both are structured, like kimi's: a real approval event, and therequest_user_inputtool call — the questions plan mode asks, which fire no approval event, the turn simply sitting inside a tool call until you answer. PostToolUse is the edge back out of either.PreToolUseis used for nothing else: it fires beforePermissionRequest, so an unmatched group re-assertingworkingnever cleared a block, and it could not be kept beside the matched one anyway — codex runs an event's hooks concurrently, so two groups signalling different states are a race. The matcher is matched against the whole tool name, which is what keeps the non-blockingrequest_user_input_asyncout of it. Two things are codex's alone:Stopmust write JSON to stdout on a zero exit (echo '{}'— its schema isdeny_unknown_fields,decision/reasonplus the universal fields) or the turn fails with "hook returned invalid stop hook JSON output", and every other event folds plain-text stdout into the model's context. Both are why every shipped hook command redirects its own output away — seetests/hook_stdout_contract.rs. Verified against codex-cli 0.154.0 over real turns: every event fires; an approval prompt reportsblockedand returns toworkingwhen granted; arequest_user_inputquestion reportsblockeduntil it is answered; and a barerequest_usermatcher registered beside the anchored one never fires.codex execpins its approval policy tonever, so the approval edge only shows in an interactive session.kimi(Kimi Code CLI): merged into~/.kimi-code/config.toml— TOML, so the merge isagent::toml_merge(format = "toml"on the[[config_merges]]entry) rather than the JSON one;toml_editkeeps the user's comments and key order. Kimi reads hooks from a[[hooks]]array in that one shared file and has no drop-in hooks dir, so a managed file would clobber the user's whole configuration. SessionStart→idle, UserPromptSubmit/PreToolUse/PostToolUse→ working, PermissionRequest→blocked, PermissionResult→working, Stop→done. The only agent here whose block edge is a structured permission event on both sides — a real request event and a real result event — soblockedneither false-fires nor latches. Kimi accepts exactly four keys per hook entry (event/command/matcher/timeout) and refuses to load the whole config file on a fifth, sokimi-hooks.tomlmust never grow one (a test pins this). Experimental.antigravity(agy): merged into the shared~/.gemini/settings.json(agy adopted claude's hook schema — PreToolUse/PostToolUse→working, Notification→blocked, Stop→done; no UserPromptSubmit, so working fires at the first tool call). PostToolUse is the edge out of blocked: no agent here has a "permission granted" event, so the tool completing is the first thing said once the prompt is answered.
-
external_files(drop a standalone managed file into the agent's config dir) — refused if a non-managed file already exists there (the agent goes unreported, never broken).opencode: a plugin at~/.config/opencode/plugin/thurbox-status.js(idle/working/blocked/done). The only agent with a real permission-reply event, sopermission.replied→working clears the block exactly when it ends rather than at the next tool boundary.copilot:~/.copilot/hooks/thurbox-status.json(sessionStart→idle, userPromptSubmitted/preToolUse/postToolUse→working, agentStop→done, notification matched topermission_prompt→blocked). Ships bothbashandpowershellcommands. Experimental.vibe: a managed~/.vibe/hooks.toml(pre_tool→working, post_agent→done; no blocked — vibe has no permission/notification hook). Verified against vibe 2.21.0.pi: a TypeScript extension at~/.pi/agent/extensions/thurbox-status.ts(session_start→idle, agent_start/tool_execution_start/tool_execution_end→ working, agent_end→done; blocked inferred only from anask_user_questiontool call, cleared when that tool ends). Experimental.grok(xAI's Grok Build CLI):~/.grok/hooks/thurbox-status.json— grok loads every*.jsonin that dir on its own, so a standalone drop never touches a hook file the user wrote. It is the global dir deliberately: global hooks are always trusted and load on first launch, while<project>/.grok/hooksadditionally needs the folder granted trust in grok's own~/.grok/trusted_folders.toml(or a--trustlaunch flag, which would only cover sessions thurbox itself launches). grok is Claude-Code-compatible, so the mapping mirrors claude: SessionStart→idle, UserPromptSubmit/PreToolUse/PostToolUse→working, Notification (itsmessagematched to permission/approval)→blocked, Stop→done. Every command is deliberately$-free: a$VARreference without an inline:-defaultmakes grok silently refuse to load the whole hook file, so the blocked edge pipes the extracted message throughgrepinstead of claude'scase(a test pins it). Experimental.omp: a TypeScript extension at~/.omp/agent/extensions/thurbox-status.ts, mirroring pi's but mapping OMP's structured user-question tool — namedask— to blocked (it recognizes bothaskand pi'sask_user_question), cleared by tool_execution_end. Verified against OMP 17.0.6. Experimental.
Each requires_dir guard makes the drop a no-op when that agent isn't installed,
so a fresh install with only claude present doesn't scatter files for agents you
don't have.
Deliberately uninstrumented. Two agents have a hook surface thurbox cannot
reach with any of the three mechanisms, and are left out of the table so they
keep reporting uncovered honestly rather than claiming a payload that never
fires:
- cursor (
cursor-agent). The only scope its CLI is known to load hooks from is per-project<repo>/.cursor/hooks.json, and only when the agent is launched with--trust— which is a[[agent_patches]]flag, so it would cover nothing an outside driver launches, and a per-repo file is not a config dir any mechanism here writes to. User-scope~/.cursor/hooks.jsonis documented for the IDE; in the CLI it is reported to run only the shell/MCP/file-edit hooks, none of which can saydone. Wiring it there would latch a session atworkingforever. It becomes a plainconfig_mergesentry into~/.cursor/hooks.jsonthe momentstop/sessionStartare confirmed to fire from user scope in the CLI. - muse (Muse Code). Its hooks exist only as capabilities of a native plugin:
installing one means running
muse plugins installandmuse plugins approve(the management CLI itself gated behindMUSE_EXPERIMENTAL_PLUGINS), and a dropped-in config file is silently ignored. An extension manifest writes files; it does not run an agent's install-and-approve commands, so there is nothing to ship. Muse does keep a durable per-session event log (${XDG_DATA_HOME:-~/.local/share}/muse/sessions/YYYY/MM/DD/<uuid>/session.jsonl) whoserunstarted/terminalevents bracket every turn — reading it would be a fourth delivery mechanism (a poller, not a hook) and is a separate decision, not a variation on these three.
This list has a machine-readable twin. session::hook_status:: AGENT_HOOK_COVERAGE carries the same per-agent facts — the states each payload
signals, its delivery mechanism, the file it lands in, and whether its blocked
is a text match on a notification body — and is what session get/list report
as hook_coverage / hook_states_reportable / hook_delivery /
hook_blocked_is_heuristic, so a consumer can tell "this agent is quiet" from
"this agent cannot say that". The table is asserted against the embedded
payloads by a test rather than maintained by hand beside them: change a
payload's events and that test fails until the table agrees.
thurbox-cli session doctor [uuid] is the runtime half — whether a given
session's payload is actually installed where its agent reads it, whether a hook
command could resolve thurbox-cli at all, and whether what was last reported
is corroborated by the pane. A --command session is the one shape it does not
judge: thurbox never had an agent there to wire, so it reports no hooks
expected rather than broken wiring.
A pane can run an agent thurbox did not launch. A --command session is
named after the command's file stem, so a driver that opens a shell and starts
claude inside it has no declared agent for the fields above to resolve
against. When the pane probe has already named one, that name is used and
coverage reads presumed (hook_coverage_source: "detection") — deliberately
not full, because seeing an agent in a pane is evidence about the process and
never about whether its hooks are wired. Undeclared and unprobed, it stays
coverage none with hook_blocked_is_heuristic: false, which asserts the block
signal is structured when it is claude's text match on a notification
message. session create --reports-as <agent> and
thurbox-cli session reports-as <ref> <agent> (--clear to take it back) let
the driver name the agent that actually reports; it is stored on the row
(sessions.reports_as, schema v44), survives restart, and changes nothing about
the launch — session restart still replays the recorded command. Only an agent
in the table above (or a custom agent that asserts a family with hook_schema)
may be declared: a name with no coverage would unlock nothing, silently.
On a shared host (hosts.toml share_sessions = true, the default — see
ADR-24) none of the remote rewriting applies: the host's own thurbox-cli
launches the agent with the host's own hooks extension, so each agent reports
exactly as it does locally, into the host's database, which the remote thurbox
mirrors. That includes a Windows (psmux) host, whose remote path stays
gated (psmux_hook_rewrite_supported) but is simply not used when the host
has — or is provisioned with — a CLI.
hook_schema (custom rebrands only)
The built-ins never set hook_schema — the hooks extension already knows them by
name. It exists for a user's custom agent that runs a built-in under a
different name (e.g. a rebranded-claude CLI called fleet): set hook_schema = "claude" and it inherits claude's --settings hook wiring under its own name.
Only the per-arg-patch families (claude, aider) need it; the config-dir
agents (codex/opencode/antigravity/vibe/copilot/pi/omp/grok/kimi) wire through their own config
dir, so a rebrand sharing that dir already reports without it. See
CONFIG.md and the AgentDef.hook_schema doc comment.
Adding a new built-in agent
Adding support for a new CLI as a user needs only an agents.toml edit — no
recompile (that's the whole point of agents-as-data). But promoting a CLI to a
shipped built-in touches several places that are not kept in sync
automatically. Work the checklist top to bottom:
-
Seed entry — add an
[[agents]]block toBUILTIN_AGENTS_TOMLinsrc/agent/agent_config.rs. Setcommand, the resume/fork/new-session groups, andresume_latestper the ID model above. Add a short comment explaining the CLI's resume/fork semantics, as the existing entries do. Decide the ID model first — whether the CLI can pin a session id — because it determines every*_argsgroup. -
Status hooks — add the wiring to
extensions/hooks/extension.tomland the hook asset file underextensions/hooks/. Pick the mechanism from Status hook mechanisms:agent_patchesif hooks are launch flags,config_mergesif they live in a shared config file thurbox must not clobber,external_filesfor a standalone drop into the agent's own config dir. Register any new embedded asset with aninclude_str!insrc/session_ops/builtin_hooks.rs, and add its row toAGENT_HOOK_COVERAGEinsrc/session/hook_status.rs(the drift test fails until it matches the payload), and — for aconfig_merges/external_fileswiring — the matching entry inremote_asset_for(src/session_ops/remote_hooks.rs), so remote sessions get the same payload (a test fails until local and remote agree). Bump the hooks extensionversion. Skipping this step means the new agent launches fine but never shows working/blocked/done.This step also stands alone: an agent thurbox ships no
[[agents]]entry for still gets its hooks installed into its own config dir, which is what makesgrok/kimireport for a driver that launches them itself. Steps 1, 3 and 4 are about launching an agent, not instrumenting one. -
Docs — update every list of built-ins (these are prose, not generated, so they drift):
- The built-in agents table above.
- CONFIG.md → agents.toml — the parenthetical list and the "N built-ins" count.
- FEATURES.md → Agent definitions — the prose list and the pinned-id sentence.
../.agents/skills/thurbox-agents/→ Agent Definitions and Custom-agent status hooks — the built-in lists and the resume/fork caveats.
-
Tests — the seed TOML must still parse and produce the expected registry. Run the agent-config tests and update any fixture that enumerates the built-in set:
cargo nextest run -E 'test(agent)' -
Verify end-to-end (optional but recommended) — install the CLI and confirm in a sandbox that resume, fork, and the status dot all behave:
scripts/dev/sandbox.sh
What is auto-handled (don't do these)
- No provider code —
agent::GenericProviderdrives anyAgentDeffrom its data; there is no per-agent Rust launcher to write. - No agent picker change — the new-session picker reads the registry.
- No migration for existing users —
agents.tomlis only seeded when absent, so a new built-in appears for fresh installs; existing users keep their file and add the entry themselves (this is intentional — thurbox never rewrites a user's edited config).