# Worker mode — running a whole ticket without a provider key Worker mode swaps **who answers the model calls**. Everything else in a run is real: real sandboxes, real repos, real gates, a real keystone, a real pull request. An external agent CLI (Claude Code by default) answers each call instead of a provider API, so a complete ticket can be exercised end to end at zero provider cost, as often as you like. This exists because the only whole-system feedback this project had was a live run costing roughly $10 and 90 minutes — and every one of those was cancelled before it proved anything. Worker mode is that run, minus the money and the clock. ## What the worker is (and is not) The worker enters as the **model**, not as an agent. Per call it receives what the provider would have received — the system prompt, the whole conversation including previous tool calls and their results, and the tool definitions with their JSON schemas — and answers with assistant text or tool calls. agent-smith executes those tool calls. That distinction is the whole point. Handing an agent CLI the ticket and a sandbox and letting it use its own tools would bypass the machinery under test: the master loop, the ledger, the reminders, the acceptance gate, the keystone. A green run would then say nothing about agent-smith. ## Prerequisites - The Claude Code CLI on PATH, authenticated headlessly (`claude setup-token`). Any binary works that reads a prompt on **stdin** and prints an answer on **stdout**. - No provider key. Worker mode never proxies a credential and never impersonates a provider; the run spends nothing against an agent budget. ## Configuring a run Worker mode is reachable **only** by declaring it on an agent. Nothing else — no missing key, no provider outage, no default — can fall into it: ```yaml agents: worker: type: external_worker # the ONLY thing that selects worker mode model: sonnet # passed through to the CLI as --model endpoint: /usr/local/bin/claude # optional: the CLI binary (default: `claude` on PATH) network_timeout_seconds: 1800 # per-call wait (default 300 = 5 minutes) worker_structured_result: true # optional: ask the CLI for its structured result projects: my-project: agent: worker ... ``` Then trigger the run exactly as usual (CLI, webhook, poller, dashboard). There is nothing to start alongside it and nothing to poll: the run spawns the CLI once per model call and waits for it. Runs are unattended. ### What the call cost `worker_structured_result` asks the CLI for its own structured result (`--output-format json`) instead of bare text. With it, a worker call reports the tokens it used and the USD figure the CLI itself names, and the run's `result.md` carries a `worker_cli:` block under `cost:`. It is **opt-in per agent**, and unset means: on for the default `claude` binary, off for anything else. That is the whole point of the knob — this document promises that any binary reading a prompt on stdin and printing an answer works, and an unknown binary handed an unknown flag exits non-zero, which the bridge treats as a dead call. Two things the reported figure is not: - **It is not this run's spend.** No money is charged against an agent budget on this transport. The figure is carried in its own channel with its own label, never added to `total_usd` and never reported as an unpriced model — pricing it from the model table would invent money the run never paid, and leaving it unpriced would raise a pricing alarm for a call that has no price by design. - **It is not comparable to a provider call.** A trivial probe reported a third of a cent on twenty-six thousand cache-creation tokens: the CLI's own system prompt and tool schemas, charged per call, not this run's context. Token VOLUME is real on any transport, so the token arm of the per-pipeline budget cap counts worker calls. The USD arm does not. The answer is unwrapped at the transport, before anything else reads it. That matters more than it sounds: a structured result is never empty, so the retry-on-silence check and the empty-turn path would both stop firing if anything downstream still read raw stdout — and an empty answer would become a failed run where it used to be a nudge. ### Environment overrides | Variable | Effect | Default | | --- | --- | --- | | `AGENTSMITH_WORKER_CLI` | CLI binary; wins over `agent.endpoint` | `claude` | | `AGENTSMITH_WORKER_CLI_ARGS` | extra CLI arguments, space-separated | – | | `AGENTSMITH_WORKER_CWD` | working directory of the CLI process | the temp dir | The working directory defaults to a neutral temp directory on purpose: the worker answers a model call, so it must not pick up the project instructions or the source tree of the repo the run is changing. ## What goes over the wire There are no request/reply files. Each call renders one JSON envelope (`protocol: agentsmith.worker/1`) into the CLI prompt on stdin: ```json { "protocol": "agentsmith.worker/1", "request_id": "9f13c0a2b7de", "run_id": "2026-08-13T09-00-00-abcd", "step_index": 7, "role": "coding-master", "phase": "Implementation", "repo": "primary", "agent_type": "external_worker", "model": "sonnet", "messages": [ { "role": "system", "content": [ { "type": "text", "text": "..." } ] }, { "role": "assistant", "content": [ { "type": "tool_call", "call_id": "call_1", "name": "write_file", "arguments": { "path": "src/A.cs" } } ] }, { "role": "tool", "content": [ { "type": "tool_result", "call_id": "call_1", "result": "written" } ] } ], "tools": [ { "name": "write_file", "description": "...", "input_schema": { "...": "..." } } ], "options": { "max_output_tokens": 8192, "not_rendered": ["Seed"] } } ``` The CLI answers with one JSON object and nothing else: ```json { "text": "Adding the guard.", "tool_calls": [ { "name": "write_file", "arguments": { "path": "src/A.cs", "content": "..." } } ] } ``` `error` instead of an answer refuses the call explicitly. `options.not_rendered` names every sampling option that was set but has no field in the envelope — the payload declares its own gaps rather than leaving them to a comment. This envelope is deliberately the payload p0166e's MCP worker mode will carry. When that lands, the CLI subprocess is replaced by a JSON-RPC method; the payload does not change. ## When something goes wrong Nothing degrades silently. A non-zero exit, a timeout, an empty answer, an unparseable reply, or a call naming a tool that was never offered fails the call with `ExternalWorkerCallException`, naming the request, the run, the step, the role and the elapsed time — for example: ``` External worker call failed after 41.2s — request 9f13c0a2b7de (run=2026-08-13T09-00-00-abcd step=7 role=coding-master phase=Implementation): the worker's answer is not the agreed JSON object … ``` Per-call duration is logged for every call. Wall time and the CLI's own session caps are the real limits here, so watch the durations — and with `worker_structured_result`, a CLI that reports its own error (out of turns, an error during execution) fails the call with its stated reason even though it exited zero. ## Proving the wiring without a CLI `ExternalWorkerTicketTests` in `tests/AgentSmith.PipelineHarness/Presets/` drives a whole mechanical ticket through the same bridge with a scripted worker — one spec, the change set applied at once, a green verdict, a pull request, no provider key, deterministic in CI. It proves the **wiring**. Whether a live agent CLI can actually drive the loop to green is what your local run proves; the bridge is identical, only the subprocess is scripted.