Files
clawmates/docs/TOOL-CALL-ARCHITECTURE.md
T
Omar SobhandClaude Opus 5 563b074116 docs: ZeroClaw upstream, scanned against what we actually run
331 behind, 54 ahead. The previous scan said 218 and its conclusion about the
egress commit was wrong, so it is marked superseded rather than edited.

Merge cost is smaller than the number suggests: 660 files changed upstream, 52
by us, and **18 overlap**. `claude_cli.rs` — the provider every mission runs
through — exists in our tree and in zero upstream files, so it cannot conflict.

The find worth recording is not a feature. Upstream defaulted skills to compact
injection on 2026-08-05 (#8313), then restored the full default for v0.8.x on
2026-08-13 (#9913). Eight days. That is our `index` arm, tried at larger scale
and pulled back out of the stable line — evidence bearing directly on our own
open question of whether to flip the default, and with our own data at n=1 per
arm it argues for more pairs before flipping, not fewer.

Their documentation also states plainly what ours should: "Compact mode reduces
prompt size; it is not an isolation boundary for untrusted skill sources."
Progressive disclosure is a token optimisation. It is not a security control.

Also noted, as a documented limit rather than a surprise: upstream fixed
case-insensitive allowlist matching (#9568) and symlink-escape path resolution
(#9384) in their command gate. Ours resolves no paths, so a symlink to `curl`
defeats it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WZb5A2kfVfjpdwSochkuHz
2026-08-27 09:58:01 -05:00

15 KiB
Raw Blame History

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 yesvm_tool_tap yesvm_tool_gate
Composed microVM yes yes — same tap yes — same gate
Direct session yes no mechanism at all no
Container / ZeroClaw yes (see below) yescontainer_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 0s 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 <doc> --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_configclaude -p --mcp-config <file> --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.<alias>.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 <doc> 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.

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.