# Mission tool calls: what is actually true, and what to do *Research, 2026-08-19. Supersedes the claim "missions can't call tools at all", which I wrote and which is wrong.* ## The claim was wrong, and the truth is worse Mission agents **do** call tools. Three of the four execution paths end in `claude -p` with Claude Code's own toolset and permissions pre-accepted: | Path | Command | Tools | Permission | |---|---|---|---| | Solo microVM | `microvm_executor.rs:341` | `Read Edit Write Bash Agent` | `--permission-mode acceptEdits` | | Composed microVM | same, per node | same | same | | Direct session | `session_executor.rs:118` | `Read Edit Write Bash` | `acceptEdits` | | Container / ZeroClaw | `claude -p --output-format json` | see below | n/a | So the real position is not "no tools". It is: > **Mission agents run `Bash` and `Write` with permissions pre-accepted, and > nothing in this platform can gate them.** That is a stronger finding than the one it replaces. "Can't call tools" sounds like a missing feature. "Calls tools freely, ungated, and mostly unobserved" is a security posture, and it is the one we have. ## Observe versus gate — they are different, and both are partial As of 2026-08-21, with the container-tier hooks shipped: | Path | Has tools | We observe | We gate | |---|---|---|---| | Solo microVM | yes | **yes** — `vm_tool_tap` | **yes** — `vm_tool_gate` | | Composed microVM | yes | **yes** — same tap | **yes** — same gate | | Direct session | yes | **no mechanism at all** | no | | Container / ZeroClaw | yes (see below) | **yes** — `container_tool_hooks` | **yes** — same | The direct-session row is the only remaining gap, and it is dormant: `CLAWMATES_MISSION_EXECUTOR` is unset in production, so that tier never runs. Checking that before building a tap for it is the reason there is no tap for it. `vm_tool_tap` installs a **`PostToolUse`** hook, which fires *after* the tool has already run, and `exit 0`s unconditionally because a non-zero `PostToolUse` talks back to the model. It is telemetry and says so. It is structurally incapable of gating — which is why the gate is a separate `PreToolUse` hook rather than a stricter version of this one. **What the tap records.** Until 2026-08-21 it kept the tool's name and the path its arguments named, and threw the arguments themselves away. A phase that recorded `Bash × 6` could not answer whether it ran the tests, whether it committed, or whether it called an API a skill forbids. It now keeps the arguments, bounded: file bodies become a byte count, over-long strings are truncated with a marker. That is what makes `skill_use` able to score behaviour rather than the agent's own account of it. The §15 `GatePolicy` has exactly **one** enforcement site — `Runtime::drive`, the chat loop — and its approvals are keyed to `(session_id, message_id)`, which no mission phase can produce. A mission agent's `Bash` call is gated by nothing, anywhere. ## Why the container tier looked tool-free `claude_cli` builds `claude -p … --output-format json`, which returns a single final result object, and the provider then hardcodes `tool_calls: Vec::new()`. The tool calls happen; the transport discards them. That is why `ToolTrace.calls` was empty on gw-04, and the comment in `topology_exec.rs` reads that emptiness as "§15 by construction: agents are provisioned tool-free". It is not by construction. It is the output format. **Proven, not assumed.** Against the live runtime (Claude Code 2.1.228): ``` claude -p "Run the bash command: echo hello" \ --output-format stream-json --verbose --allowedTools Bash ``` emits exactly what we need: ``` assistant block: tool_use Bash user block: tool_result assistant block: text event: result success ``` The calls are fully observable. We ask for the wrong output format. ## The door is deployed — 2026-08-21 Proven against the real binary in the runtime container, which is a two-minute loop rather than the ten-minute rebuild-and-run-a-mission one I reached for first: ``` claude -p --mcp-config --strict-mcp-config "List the MCP resources you can see" → lists skill:global/a11y-checklist, … (see the count caveat below) claude -p … "Read skill:global/workspace-repo-commit-protocol, reply with its first heading" → # Mission repo + commit protocol ``` Connect, list and **read** all work. `mission_orchestrator::install_skills_door` writes the document at launch (mode `0600`, under `/root`, never the checkout) and points the daemon at it in the same step. **No `--allowedTools` change was needed.** That question was left open rather than guessed, and the guess would have been wrong in an expensive way: the daemon exposes no config read, so "adding" the MCP tools would have meant overwriting the seed's `tools` list and stripping `Write` and `Bash` from every mission agent — to solve a problem that does not exist. ### It needed a credential first, and that was not "config" The section below called this "config, not code". That was wrong, and the reason is authentication. `/mcp/skills` authenticated via `AuthService::authenticate`, which returns a full `AuthedUser` carrying the user's role; there was no narrower credential in the system. The document sits in a file the agent can `cat` — it runs `Bash` with egress — so the documented approach meant handing an **owner-privileged API token to something explicitly untrusted**. Checked before concluding: no such credential was in a mission container, so it would have been a new exposure rather than an existing one. `auth_sessions.scope` fixes it. `authenticate` delegates to `authenticate_scoped(token, SCOPE_FULL)`, so every existing caller rejects a narrow token and a route opts in by name; `/mcp/skills` is the only opt-in. Measured live with one token: ``` POST /mcp/skills → 53 skill resources GET /api/missions → 401 ``` ### And confirmed from inside a real mission Run 8, all three agents, per-agent attributed: ``` Pedro ListMcpResourcesTool ReadMcpResourceTool Write … Ebele ListMcpResourcesTool ReadMcpResourceTool Read … Ahmad ListMcpResourcesTool ReadMcpResourceTool Write … ``` The agent's own report: *"53 MCP resources are available, all from the `clawmates_skills` server … `skill:global/workspace-repo-commit-protocol` was read and its first heading is `# Mission repo + commit protocol`."* **Count caveat, and it is the usual lesson.** The first probe's transcript said "58 MCP resources" and that number went into this document as though it were a measurement. It was the model's own paraphrase. The authoritative count is 53 — `resources/list` returns 53 and `SELECT count(*) FROM skills` is 53. A model's self-report is not an observation, which this project has now learned three separate times. ### What this does NOT yet buy **Trigger is still not measured.** `pinned_skills_text` still inlines full skill bodies, so the agent is still handed skills rather than reaching for them. The door makes retrieval *possible*; Trigger becomes real only when delivery switches to progressive disclosure — and that could regress Compliance, so it wants an A/B rather than a flip. ## How it looked before it was plugged in `claude_cli.rs` is **ours** — upstream `zeroclaw-labs/zeroclaw` has no such file. So is the feature that solves this, our own commit `88eef99d4 feat(providers): claude_cli --mcp-config + allow/disallow tools (act via door)`. The provider already accepts: - `mcp_config` → `claude -p --mcp-config --strict-mcp-config`, so Claude Code's **own** MCP client connects to our door; - `tools` → `--allowedTools`, auto-approving door tools; - `disallowed_tools` → `--disallowedTools`, locking out `Bash`, `Write`, `Edit`, `Read`, … so **the gated door is the only actuator**. `deploy/clawmates-runtime/agent.config.example.toml` documents the whole shape as `[providers.models.claude_cli.door]`. And in the live runtime: `/zeroclaw-data/clawmates-mcp.json` **does not exist**, the config has no `[providers.*]` block at all, and every mission claw is bound to `claude_cli.default` — which sets none of the three fields. So my earlier conclusion that "`claude_cli` cannot reach MCP, therefore the skills server is unreachable" was wrong in its reasoning. The capability is built, documented by us, and simply never deployed. Related: we have been setting `agents..mcp_bundles`, which configures **ZeroClaw's own** MCP client for its native agent loop. A `claude_cli` agent's actuator is the claude subprocess, which reads `mcp_config` on the **provider**. We were turning a knob connected to a loop that does not run. ## Upstream: nothing that solves this, one thing worth taking We are **218 commits behind** `upstream/master`. Scanning for anything relevant: > **Superseded 2026-08-27** — now 331 behind, and the egress conclusion below > is wrong: `net_guard` cannot see a mission's tool calls. See > `UPSTREAM-SCAN.md` and `MISSION-EGRESS.md`. - **No upstream work on `claude_cli`** — the file is ours; upstream has none. - **ACP** (Agent Client Protocol) exists in the fork already (`zeroclaw-gateway/src/acp.rs`, `zeroclaw-channels/src/acp_channel.rs`); the three upstream commits since our merge are workspace-default and tool-approval-localization fixes, not new capability. ACP *does* surface tool calls natively and is a credible long-term transport, but it is a bigger move than the two fixes below and buys the same observability. - **`feat(plugins): add shared egress policy foundation (#9137)`** — a network guard in `zeroclaw-infra::net_guard` with DNS pinning, IPv4-mapped metadata blocking, proxy-conflict surfacing. This is the one upstream item genuinely worth pulling: it is defence for the egress problem we have not solved, and it is hardening we would otherwise write ourselves. ## RESOLVED 2026-08-21 — hooks, not the transport Everything above the line is still an accurate account of how mission tools work. The recommendation that followed it was wrong, and this is what actually happened. ### `stream-json` was necessary-looking and useless Shipped, deployed, and driven with a real mission: **zero `tool.call` events**, with the parser working perfectly. `TurnEvent::ToolCall` — the thing the gateway turns into a frame — is emitted from `tool_execution.rs`, i.e. only for tools **ZeroClaw itself executes**. Claude Code runs its tools inside its own subprocess, so the event never fires. A provider that *knows* about the calls changes nothing. Worse, v1 was harmful: it returned the observed calls as `ChatResponse.tool_calls`, meaning "execute these", so the loop tried to run `Bash`, got `Unknown tool: Bash`, and fed that back to the model — an extra LLM round-trip per call, telling the agent its own work failed. The mission completed correctly anyway, which is why it would have gone unnoticed. Fixed in fork `295d6d3b8` (log them, never return them); the corrected image `streamjson2` was built and **deliberately not deployed**, since it delivers nothing on its own. ### What worked: hooks `claude -p --settings ` honours `PreToolUse` and `PostToolUse`, so the gate blocks and the tap records **without ZeroClaw being involved at all**. That sidesteps the entire `TurnEvent` problem instead of trying to solve it. Shipped, and verified end to end on a real mission: ``` tool.call 10 Bash 6, Read 3, Write 1 file.touch 4 research/tapproof.md ``` - `--settings` on `claude_cli` — fork `be9c34b1c` - `container_tool_hooks` writes both hook scripts and one settings document into the mission container - `set_claude_cli_settings` points the provider at it - `phase_runner::drain_finished_container_phases` collects the tap into `mission_events`, idempotent by truncation ### The lesson worth keeping Two of the three ranked recommendations below were written from reading the code, and the first one was confidently wrong in a way that took a deployment and a live mission to discover. The gate (§2) was right. The transport fix (§1) was not — the evidence for it was an absence, and an absence had another explanation. ## The original recommendations, kept for the record ### 1. ~~Observability — switch `claude_cli` to `stream-json`~~ *(WRONG — see above)* In our fork: `--output-format stream-json --verbose`, parse `tool_use` / `tool_result` blocks into `ChatResponse.tool_calls` instead of `Vec::new()`. Unblocks: real `tool.call` events on the container tier; `ToolTrace.calls` non-empty; Skill-Use **Trigger** becomes observable on that tier for the first time. Risk: the parser must handle a stream rather than one object, and `--verbose` is required alongside it. ### 2. A real gate — use the `PreToolUse` hook *(small, mechanism already proven)* `vm_stop_gate.rs` records that `PreToolUse` **fires** under `claude -p` in our image, and it is used nowhere — one doc-comment mention, zero call sites. Same install pattern as the tap, but exit-2-to-deny instead of exit-0. This is the only pre-execution gate available to the microVM and direct-session paths, and it is the one thing that would let §15 mean something for missions. It must be added to `vm_tool_tap::guest_settings`, the single settings writer, or it will clobber `Stop` and `PostToolUse`. Also: give the direct-session path the tap at all. It is the same settings file, written into a container instead of a VM, and today that tier is completely dark. ### 3. Deploy the door we already built *(config, not code)* Create `/zeroclaw-data/clawmates-mcp.json`, define a door-shaped provider alias, and bind mission claws to it. Gets the container tier a gated actuator and makes the `clawmates_skills` MCP server genuinely reachable — which would let skills move from inlined bodies to progressive disclosure, and make Trigger measurable the way the paper defines it. Sequencing note: (1) and (2) are independent of (3) and worth doing first, because they make the container tier legible before we change what it can do. ### Not recommended yet Replacing the mission executor with `ProviderExecutor` or the chat `Runtime`. The blockers are real and structural, not wiring: `Runtime::send_message` requires a chat session row, an agent row, two message rows and an `agent_runs` row, and its approval key is `(session_id, message_id)`; `ToolContext` carries no path, checkout, container or VM handle; `cm-runtime`'s `files` tool is a blob store that **rejects absolute paths by construction**, and its `shell` tool executes in a per-agent sandbox with zero egress and no mission mount. Every mission-shaped concept would have to be invented first. The cheap fixes above deliver the observability and the gate. The executor swap is a different project, and it should be justified by something other than tool calls — which, it turns out, we already have.