ACP client: drive a foreign agent under keryx's policy¶
keryx acp makes keryx an ACP agent that an editor drives. This guide is
about the other direction. keryx agents external run makes keryx an ACP
client: it launches an external Agent Client
Protocol agent as a subprocess, gives it
keryx's project context, and answers every question that agent asks, using
keryx's own policy.
The foreign agent runs in a disposable git worktree. keryx decides its permission questions and serves its file reads and writes from its own code. Each run is recorded as a keryx session. keryx does not contain what the agent does with its own tools. The limits section says exactly what that means.
keryx agents external run also drives the other transport, one-way
line-stream agents (codex-cli, claude-cli, antigravity-cli) through
their own codec rather than two-way JSON-RPC — the same command, the same
gates, but no MCP server offer and no permission bridge, because a line-stream
agent never asks keryx anything mid-run. See Harness → External children
for that path, and its agy (Antigravity CLI) subsection for the install,
login, one-time consent and data-collection notes specific to that agent. Of the
line-stream agents only claude-cli and codex-cli can write, as a stored diff you review and
land as a new local branch; see Let an external agent write.
The MCP server keryx hands the agent reads the whole real project
The agent works in a throwaway worktree, but the read-only MCP server keryx
offers it is started for the real project root, not the worktree. Its
reads cover the whole live project — including uncommitted files the
worktree does not have — and they go straight to that server, so they
bypass the permission bridge: no prompt, no decision entry. "Read-only"
means no tool marked mutating is offered. It does not mean nothing is
written: some of those read tools refresh keryx's own cache and report files
under .metaproject/data/**. If the project holds anything the foreign agent
must not read, turn the MCP module off for the run (modules.mcp.enabled)
and the agent gets no MCP server at all.
Run one¶
keryx agents external list # gemini-acp shows `transport: acp`
keryx agents external run gemini-acp --task "Find why the resume suite is flaky"
keryx agents external run gemini-acp --task "..." --unattended # refuse anything that needs you
keryx agents external run gemini-acp --task "..." --write # allow writes into the worktree
The same gates apply as for every external agent:
- the
externalAgentscapability, which is off by default and turned on withexternalAgents.enabled: truein the keryx user config; - a hard disable in CI and under a remote transport;
- the per-agent config (
externalAgents.agents.gemini-acp); - the nesting depth marker;
- a
--versionprobe.
The agent must also print its final answer as a subagent-result document, as
every external child does. keryx validates that answer. A run whose final text
is not a valid document ends as Error, not as a silent success.
What keryx advertises, and what it serves¶
In initialize, keryx advertises exactly what it will serve and nothing more:
| Capability | Read-only run | --write run |
|---|---|---|
fs.readTextFile |
yes: served from the worktree | yes |
fs.writeTextFile |
no | yes: into the worktree only, gated as risk write |
terminal |
no | no (never in this release) |
elicitation |
not advertised | not advertised |
keryx's own code serves every fs/* request:
- Confinement. Each path is confined to the worktree by its real path. A
file that does not exist yet is checked through its nearest existing
ancestor, so a symlinked directory that points outside the worktree cannot be
used to create a file outside it.
../traversal, agent credential files and managed flow-state files are refused. - Refusals. A call to a method keryx did not advertise (
terminal/*,elicitation/create, orfs/write_text_fileon a read-only run) gets a JSON-RPC error that names the reason.
Every served or refused request is written to the run record.
How permission questions are answered¶
When the agent sends session/request_permission, keryx answers through the
same gate as its own tools, resolveApprovalDecision. The tool call's kind
decides the risk:
toolCall.kind |
keryx risk | Outcome |
|---|---|---|
read, search, think |
read | allowed, unless a location is outside the worktree, which makes it a prompt |
edit, move |
write | prompt |
delete |
destructive | prompt |
execute |
shell, or destructive if the command is classified destructive | prompt |
fetch |
network | denied |
other, switch_mode, missing |
destructive shell | prompt |
| anything that targets a managed flow-state file | — | denied |
Four rules apply on top of the table.
-
Only this run's session, only during its turn. A permission or
fs/*request naming any other session id, or arriving before the prompt starts or after its answer, is refused with a named error and recorded; asession/updatefor another session is ignored. -
The permission mode is always
ask. The tool kind, input and locations come from the foreign agent's own description of the call. keryx'strustandautomodes are a statement about keryx's own tools, so they are lowered toaskfor a foreign agent. The run record shows the lowering, for examplerequested: trust, effective: ask, clamped: true. - Only an explicit yes allows a call. keryx selects the agent's
allow_onceoption in two cases: the gate returnedauto(a read inside the worktree), or a human approved the prompt and the approval echoes that prompt's fingerprint. keryx never selectsallow_always, because a grant the agent remembers is one keryx can no longer see. If the agent offers noallow_onceoption, the call is refused. - Otherwise the run fails closed. Each of these cases selects
reject_once, or answerscancelledif the agent offered noreject_once: - a policy deny;
- an unattended run (
--unattended, no TTY, or no approver); - an approver that does not answer within 45 s;
- a human "no".
Each question becomes one decision entry in the record: the mapped risk, the
gate decision, the verdict, timedOut, and the reason (policy,
unattended, timeout, human or no-allow-option).
Context: keryx's own MCP server¶
session/new receives two things:
cwd: the disposable worktree.mcpServers: one stdio server that launches the running keryx build (process.execPathplus this build's CLI entry, never akeryxfound onPATH) as:
--read-only hides every tool the registry marks as mutating, because the
agent's MCP calls go straight to that server and never pass the permission
bridge. Keep in mind what that server sees: the whole real project at
<project-root>, not the worktree, with no per-call approval, and some of
its read tools still write keryx's own .metaproject/data/** cache and
report files. If the MCP module is disabled for the project, or the optional
@modelcontextprotocol/sdk is not installed, the run still goes ahead and
the record says context: not offered — <reason>.
The first prompt is the same one every external child gets: the directive, the
task and the result schema. The project's AGENTS.md and
.metaproject/index.md go with it. They are sent as resource blocks only if
the agent advertised promptCapabilities.embeddedContext. Otherwise they are
sent inline as text.
The record¶
Each run is saved as a keryx session with provider acp:<agent-id>. The model
field holds the agent's own name and version. keryx sessions list shows the
session, and the session directory contains:
context.jsonl/archive.jsonl: the prompt, the tool calls and the agent's reply, redacted.acp-run.json: the redacted run record. It holds:- the argv and the worktree path;
agentInfofrominitialize;- the mode clamp and the advertised capabilities;
- the context offer;
- every permission decision, and every
fs/terminal/elicitationrequest with its outcome; - the reported tool calls;
usagefromusage_update, or"missing";costas the agent reported it, e.g.{ "amount": 3, "currency": "EUR" }, or"missing". A missing cost is never recorded as 0, and keryx never converts a currency.acp-worktree.patch(--writeruns only): the worktree's diff, captured before the worktree is removed. It is never applied. To keep it, review it and apply it through keryx's ownapply_patchgate.
Size bounds¶
A foreign agent cannot make keryx hold unbounded output:
- Lines: a line longer than 64 MiB with no newline stops the run with a named reason, and the agent is killed.
- Stderr retained: keryx keeps only its first 16 KiB and last 48 KiB, and counts the bytes it dropped from the middle.
- Stderr read: retention alone bounds memory, not how long a flood of ordinary short lines can keep the run alive. A second, separate budget counts stderr bytes as they are read; passing it stops the run with a named reason and kills the agent, the same as the output budget below — independent of it, so noisy stderr cannot starve a legitimate large result's budget or vice versa. This ACP path defaults to 16 MiB. The Claude/Codex line-stream path below uses a different default for a reason specific to it.
- What the run produces: the assistant text plus the recorded events are capped at 16 MiB. Past that, the run fails with a named reason and the agent is killed.
The Claude and Codex agents, which use the non-ACP (line-stream) path, get the
same line ceiling and the same 16 MiB cap on their recorded events. Their raw
stdout is kept as its first 256 KiB and last 768 KiB, same as the ACP path's
retention shape. Their stderr-read budget defaults to 256 MiB, not 16 MiB:
codex exec narrates itself on stderr by design and prints the contents of
files it reads, so a legitimate read-heavy run can be verbose there in a way
no ACP agent is documented to be — and since stderr memory is already
bounded by retention regardless of this budget, the only job left for it is to
end a pathological flood sooner than the run's own wall-clock timeout, not to
protect a normal chatty run. Both defaults (16 MiB for ACP, 256 MiB for
line-stream) can be overridden per agent with maxStderrBytes on that agent's
registry entry (src/harness/external/registry.ts).
What keryx cannot control¶
These limits come from the protocol, not from a missing feature. Read them before trusting a run:
- The agent's internal tools are invisible to keryx. A foreign agent has its own shell, file editing, web access and subagents. When it uses them, no ACP message is sent, so keryx can neither see nor gate the action.
- Not advertising a capability is a request, not a boundary. Leaving out
terminalorfs.writeTextFilestops the agent from asking keryx. It does not stop the agent from doing the same thing with its own tools. - Permission decisions depend on the agent's description. keryx classifies
each call from the
kind,rawInputandlocationsthe agent sends. A call the agent mis-describes is mis-classified. This is why the mode is alwaysask. - The agent's MCP calls skip the permission bridge. They go straight to the
MCP servers keryx handed it. keryx's own server offers no mutating tool for
that reason — but it reads the whole real project, not the worktree, and
some of its read tools write keryx's own
.metaproject/data/**cache and report files. - The agent process runs without an OS sandbox in this release. It needs
the network to reach its model and its own credential directory under
$HOME. - The disposable worktree is the real containment, as it is for every
external agent (decision D-08). Whatever the agent writes with its own tools
lands in a throwaway
git worktreecheckout that is removed on every exit path, including a timeout, a crash and a spawn failure. The operator's project tree is never touched. The tests check this with a hash of the project tree taken before and after each run.
Not in this release¶
- the TUI or
/delegatedriving an ACP agent; - discovering agents from the ACP registry;
- running several agents together;
- a
terminal/*served through the OS sandbox; - OS-sandboxing the agent process itself;
- other ACP agents, such as the Claude Code adapter;
- recording an ACP run as a flow-task attempt.