#Runner authoring
The checklist a new agent module has to pass before it ships — the list the OpenCode module was held to (#202, which the per-org module pick #201 makes swappable). The wire contract itself is protocol.md §2: commands on the runner's stdin, events on its stdout, one JSON object per line. This page is everything beside it.
Before you write a runner, check whether your agent speaks the Agent Client Protocol. If it does, the existing acp module may already drive it; see The ACP runner below.
- module.json. The mothership discovers every
modules/agents/<id>/module.jsonat start-up (read_agentincrates/colonizer/src/modules.rs). A manifest it cannot read is named as a problem and the module is not offered. The keys:
| Key | Read by the harness? | What it does |
|---|---|---|
id | yes, required | The module id: what Settings, agent.provider and an org's agent.module name |
entry | yes, required | A non-empty array of strings, the runner command. An argument that is a file in the module directory is rewritten to its in-VM path under /opt/colonizer/agent/ (the module mounts there read-only); a command starting with node also gets the vendored Node runtime mounted |
name, description | yes | What the pickers show |
settings | yes | The settings schema, the JSON Schema subset in protocol.md §4. Each property's env names the environment variable the runner receives the value in; an empty value is not passed at all |
secrets | partly | [{ "env": [...], "hosts": [...] }]: the credentials the agent uses and the hosts they are for. Every host must be covered by egress (item 2). A secret env named CLAUDE_CODE_OAUTH_TOKEN marks the module as needing a Claude login |
requires | mostly | binaries names what must be on the colony's PATH: a list containing claude marks the module as needing a Claude login, and the whole list is preflighted at launch and boot. On a stock preset image a binary is accepted when the harness stages it (claude), the runner fetches it (listed under fetched_by_runner), or it is one of the base tools every preset carries (bash, gzip, sh, tar, wget); anything else — no agent CLI is staged or shipped — refuses with the binary and, when the module pins it, the pinned version and install command. pins holds the versions, applied to a binary when the pin's key is the binary's name; a pin whose key is a package name (@google/gemini-cli) is carried for runners to read. A custom sandbox.image is trusted here and only checked by the runner's in-VM preflight. The free-text image description is not interpreted |
egress | yes | Item 2 |
session_resume | yes, optional | Item 9 |
loop_tools | yes, optional | "loop_tools": true when the runner serves the loop MCP tools loop_next and loop_stop (loops.md): a loop's brief only names the tools then, and the agent rows of GET /api/modules repeat the flag so the cockpit can warn. Anything but a boolean is a manifest error |
kind, protocol | no | Every shipped module sets "kind": "agent" and "protocol": "colonizer-runner/1"; do the same |
Declaring an exec_policy setting is a promise that the runner enforces the exec policy in that variable (Claude Code and ACP do). Without it, the harness refuses to launch the module while a policy is set — the install's exec_policy setting, the org's exec policy or a repo .colonizer/exec-policy.json — rather than run the agent unguarded.
- Egress declaration. Add
egress: { "api": [...], "auth": [...], "telemetry": [...], "extra": [...] }— bare hostnames, a leading*.allowed for wildcards (subdomains only:*.sentry.iocoverso1.sentry.io, notsentry.io). Any other key, or a scheme, port or path in a host, is a manifest error. A manifest whosesecrets[].hostsnames a host the egress union does not cover is refused at discovery. A test walks everymodules/agents/*/module.jsonand fails with "missing egress declaration" if the section is absent.
The declaration is enforced in allowlist mode (#601): the running module's api, auth and extra hosts join the colony's allow list (the union with the operator's egress_allow), so the agent reaches the hosts you capture below without the operator restating them. Keep telemetry out of the fence — it is recorded but never added, because there is no opt-in for telemetry; the operator lists a telemetry host in egress_allow if they want it. A host you declare is lowercased and must still parse as an operator entry (at least two labels); one that does not is dropped, which can only narrow reach. A declared host never reopens a blocked destination: the always-blocked deny set and the operator's egress_block compile ahead of every allow. Each boot records module: {agent, allow} — what the module contributed — in <session dir>/egress.json (sandbox-network.md). A colony in the default open egress mode reaches the public internet anyway; the declaration there changes nothing, because the public profile already covers it.
Capture procedure. The declaration should come from observation, not memory:
- Boot a test colony on your agent in the default
openegress mode (the harness passes--net publicplus port-scoped host rules) with a logging proxy in the path (point the agent's model endpoint at the proxy). The harness has no network-log facility of its own — the proxy, or msb's userspace-stack logs, is the capture point. What the policy allows and denies is in sandbox-network.md. - Run a representative task: the login/auth flow, model listing, and one tool call that reaches the network (a package install, a fetch).
- Collect the distinct hostnames from the proxy log.
- Classify them:
apiis the model/agent service,authits sign-in and token hosts,telemetryits usage and error reporting,extrathe rest (update checks, CDNs). - Drop hosts the task's providers already cover: model traffic goes through the provider gateway, so a host only reached through a
<provider>/route needs no entry.
The claude-code declaration comes from a live capture of the pinned CLI (2026-09-28, Claude Code 2.1.280 — modules/agents/claude-code/README.md); a credentialed model turn is still unobserved, so re-check it when the pin moves.
- Denial-hint hooks. Classify errored tool results and attach
denial: {class, hint}to thetool_resultevent — classesegress,read_only,tool_disabled, followingmodules/agents/claude-code/denials.mjs. Deliver the hint twice: on the event for the record, and to the agent itself through aPostToolUseFailurehook returninghookSpecificOutput.additionalContext— mid-turn, so it costs no extra turn — at most once per class per session, and never with adecisionor permission field: the hint is advice, not a verdict. Never changeis_error, the content or any return code. Ship a strip test — with the layer off, every event is identical apart from thedenialfield. Known limits: masked-path empty reads are not detectable from text, and tools the agent spawns throughshnever reach your classifier — they rely on the runner-level hints. (The masked-path attempt itself is reported separately, as thepath_policyevent of issue #647 —pathpolicy.mjs.) Only the claude-code runner implements this layer today (modules/agents/claude-code/runner.mjs, thePostToolUseFailurehook). - Preflight, naming the error. Check the agent binary and everything it needs before the first turn, and refuse with a message naming the missing thing and the way out: the codex runner's
preflight(modules/agents/codex/runner.mjs,export async function preflight) refuses a missing credential, a missing binary (CODEX_BINARY_MISSING, with the path and the pinned install command) and a version other than the pin; Hermes refuses a terminal backend other thanlocalby name. Report a refusal as anerrorlog plusstatuserrorwith the code as itsdetail. If the runner itself cannot start, agentd reportscannot start agent runnerwith the program name. Warn-and-degrade is against the house rule (architecture.md, "Configuration: refuse loudly, never degrade silently"). - Fixtures. Pin your runner's real output down in a test. The claude-code runner commits
test/fixtures/events.jsonl, which its own tests and the Rust tests incrates/colonizer/src/protocol.rsandevents.rsread. The other runners (codex, grok-build, hermes, opencode, pi, acp) check every event they emit against the required fields in agent-events.schema.json instead. Either is fine; do one. - Tests. A
"test"script inpackage.jsonthat runsnode --test(most modules point it attest/runner.test.mjs); tests intest/; no framework, no custom runner. Drive the realrunner.mjsover stdio against a stub of the agent's CLI, so the tests need no install, no key and no network. A module without dependencies may skippackage.jsonentirely, as OpenCode does. - Schema and protocol. A new event type goes into agent-events.schema.json and into the web's event types (
AgentEventBodyinweb/src/types.ts, listed inweb/src/agentEvents.test.ts, which fails when the schema and the web disagree), in the same change. Add it tocrates/colonizer/src/protocol.rsonly if the harness itself acts on it; anything else lands onAgentEvent::Otherand is still forwarded to the browser. agentd does not check event types: it forwards every stdout line that is a JSON object with a stringtype, and turns any other line into awarnlog. - CI.
cargo test --workspacewalks everymodules/agents/*/module.json: the egress test above, and a test that fails unless providers.md has a compatibility-table row starting `|<id>|for your module. Runner tests are not part ofcargo test: add a named step for your module to therunnerjob in.github/workflows/ci.yml` ("Test the ACP runner" is the most recent one). Both jobs must pass before merge. - Session resume (optional). If the runner can pick an old conversation back up, declare
session_resume: { "dir": "/absolute/in-vm/path" }inmodule.json: the harness mounts a writable host directory over that path (the colony session dir'stranscripts/), the runner announces its session id with anagent_sessionevent as soon as it knows its conversation id, and a colony suspended while it waits on its user boots again withCOLONIZER_RESUME_SESSIONset (#562) — the id goes to the backend's resume mechanism (Claude Code: the SDK'soptions.resume; codex:codex exec … resume; ACP:session/load) and the held answer arrives as the first user message. A colony is only suspended when both hold: the module declaressession_resumeand the runner has announced a session. Otherwise the colony keeps its microVM while its question waits, said once in the colony log. Shipped modules that declare it: Claude Code (/root/.claude/projects), Codex (/root/.codex) and ACP (/root/.gemini).
#The ACP runner
modules/agents/acp (#509) drives any Agent Client Protocol agent — JSON-RPC 2.0 over stdio — and maps it onto the runner protocol. It is a useful example of the checklist above, and it may save you writing a runner at all. Its status is planned: the runner and its tests are in the tree, and nothing stages the gemini binary into the colony image, but the gemini preset fetches its pinned bundle on first boot, and no end-to-end colony run has happened (module README).
- Settings.
agent(COLONIZER_ACP_AGENT):geminirunsgemini --experimental-acpandgrokrunsgrok agent stdio(both verified at the handshake; a full turn with a real key has not been run for either);customruns the command line incommand(COLONIZER_ACP_COMMAND).model(COLONIZER_MODEL) is sent withsession/set_model, only when the agent advertises model selection. - Manifest.
secretsandegressdeclare Gemini's host (generativelanguage.googleapis.com, secretGEMINI_API_KEY) and grok's (api.x.ai, secretXAI_API_KEY). Acustomagent's hosts and credential are whatever you set up for it as colony secrets. - Preflight. It refuses an unknown preset or an empty custom command (
ACP_AGENT_UNKNOWN) and a preset without its credential (ACP_CREDENTIAL_MISSING:GEMINI_API_KEYforgemini,XAI_API_KEYforgrok); a credential the agent refuses at the handshake isACP_AUTH_FAILED. Thegeminipreset resolves and, when it is not onPATH, fetches its pinned bundle here (a fetch failure isACP_AGENT_FAILED); otherwise it does not probe the binary first, so a missing agent shows up asACP_AGENT_FAILEDwhen the spawn fails. - Questions.
session/request_permissionbecomes aquestionwith 2–4 options, itsrisktaken from the ACP tool kind (read,search,fetch,thinkareread_only, everything elseworkspace_write). ACP has no free-text answer, so "Other" repliescancelled. - Workspace.
fs/read_text_fileandfs/write_text_fileare confined to the workspace;terminal/*commands only start with a workspacecwd, and the VM is the boundary. - Limits. Resume rides
session/loadwhen the agent advertises it (item 9); no denial hints, noplanevent (plans arrive as athinkingchecklist), andturn_end.cost_usdis always null. - Tests.
npm testdrives the realrunner.mjsagainsttest/fake-acp-agent.mjs, and checks every event against the schema's required fields.
The full ACP-to-runner mapping is in modules/agents/acp/README.md.