Harness adapter contract
| Field | Value |
|---|---|
| Status | Accepted |
| Accepted | 2026-11-08, explicitly approved by the maintainer |
| Authors | commixon (commixon), with Claude |
| Created | 2026-11-07 |
| Linear | COM-313; implementation COM-322 (adapter interface, supervision, fake harness), COM-354 (take-over), COM-329 (usage), COM-330 (standing decisions), COM-401 (grant narrowing), COM-384 (stale requests), COM-393 (codex fork, plan windows), COM-419 (attention while away), COM-353 (host-mode take-over) |
| Amended | 2026-11-08 (venom-org/venom#80, venom-org/venomx#47): 7.2, 7.3 and section 10: a host-mode take-over reports its pane’s process too, and venomx must confirm it; pane.attention on the take-over pane; unavailable.takeover_pane also covers agent.takeover_split, and conflict.takeover_pane_gone is new; 2026-11-08 (venom-org/venom-spec#76): records venom-org/venom#50: compact and mcp_off capabilities, the compact operation, mcp_servers, system and tools on context, agent.fork_ended, the verbs agent.context, agent.compact, agent.mcp_off and agent.fork’s result; 2026-11-08 (venom-org/venom-spec#74): records venom-org/venom#51 and #100: fork completes asynchronously and never holds the reactor, and a "head" fork copies at its first start; 2026-11-08 (venom-org/venom-spec#75): names what venom-org/venom#72 built: turn.prompt and the tool call’s raw_input; 2026-11-09 (venom-org/venom-spec#114, compatible addition, accepted under the maintainer’s authorisation for that run): RFC-0021 section 8 adds the rewind capability and operation, start’s fork_at, turn.ended’s harness_end, and checkpoint on agent.fork_ended; the adapter version stays 1; 2026-11-09 (venom-org/venom-spec#124, compatible addition): records venom-org/venom#74: section 9.3, an answer whose delivery the harness crash beat becomes a one-shot decision and a resume; 2026-11-10 (venom-org/venom-spec#141, compatible addition): records venom-org/venom#107: sections 4, 7.1, 7.2, 7.4, 10 and 12.1, the resume record carries fork_source, a take-over of a fork that has not started creates the agent’s conversation from it, or is refused with unsupported.harness_capability when the adapter’s catch_up is "none" or the record has fork_at; fork_source ends only on evidence that the copy exists; a start, resume_command or reattach with fork_source resumes a copy that already exists, and reattach passes fork_at on too; take-back’s catch_up of a copy imports only the turns made since the take-over; a take-back catch_up that finds no conversation while fork_source is set is not a failure |
| Supersedes | None; answers RFC-0018 open question 1 and the harness half of open question 2, and extends RFC-0018’s event list |
| Superseded by | None |
Summary
venom drives coding-agent harnesses (claude-code, codex; later
opencode and any ACP agent) through one internal interface, the harness
adapter. This RFC defines that interface: the capability descriptor an
adapter declares, the operations venom calls (start, send, steer,
interrupt, respond, compact, fork, release, reattach, catch-up), the normalized
events an adapter emits (turns, text, tool calls, native sub-agents,
requests, usage, context), how approvals, questions and plans travel back
to the harness, what take-over and a venom daemon restart require, the
error model, and the doctor checks. It then maps the contract onto the
two alpha harnesses through their official channels only: Claude Code’s
stream-json print mode with a permission prompt tool, hooks and
--resume, and Codex’s codex app-server JSON-RPC. venom implements it;
no other repo does.
Motivation
RFC-0018 fixed what the user sees: agents, states, attention, tool-call rows, take-over. Its section 9 lists six things it needs from every harness and leaves the contract to “a separate RFC” (open question 1). COM-322 (the adapter interface and a fake harness), COM-354 (take-over and take-back) and COM-329 (usage accounting) are blocked until the shape is fixed. Without one contract:
- each adapter would invent its own event names, and the transcript, inbox and usage screens would special-case harnesses;
- capability gaps (Codex can’t edit a command before approving it; Claude Code queues a mid-turn message instead of injecting it) would surface as bugs instead of as options venom doesn’t offer;
- a venom restart would lose track of which harness conversation an agent is, and pending approvals would vanish (COM-317 decided they survive).
The model follows t3code’s ProviderAdapter (ecosystem doc, section 1):
capabilities, send, steer, interrupt, approvals sent back as requests, fork.
venom drops t3code’s rollback for v1 (checkpoints are P4, now
RFC-0021) and adds
take-over and catch-up, which t3code doesn’t need because it never hands a
conversation to the harness’s own UI.
Ecosystem constraints that shape the design:
- Official channels only (ecosystem decision; CLIProxyAPI “avoid” column). venom launches the vendor’s own binary with documented flags or protocols, under the user’s own sign-in. It never imitates a vendor client, never reads or forwards vendor credentials, and never drives a TUI by scraping its screen.
- No dynamic plugins in v1. Adapters are compiled into venom. A harness that venom has no adapter for joins through ACP, which is itself an out-of-process protocol; venom needs no plugin ABI of its own.
Design
1. Overview by example
The user starts an agent, it asks to run a command, the user approves it from the inbox, and the turn ends. The left column is the adapter-facing side (normalized, section 5); the right column is what venom publishes (RFC-0018 “Agent-contract impact” and section 5.4 below).
venom core adapter (claude-code) harness process
────────── ───────────────────── ───────────────
start(agent a2, cwd, scope) ───────► spawn `claude -p --input-format stream-json ...`
◄─────── session {harness_session} ◄── system/init
send("Add hello capability bits") ─► write user message on stdin ────►
◄─────── turn.started {turn t1}
◄─────── text {"I'll run the tests."} ◄── assistant (text)
◄─────── tool.started {call c1, exec} ◄── assistant (tool_use)
◄─────── request {r1, approval, call c1} ◄── permission prompt (MCP)
agent a2 → needs_you, inbox item, pane.attention urgent
human presses `y`
respond(r1, allow_once) ───────────► answer the permission prompt ──►
◄─────── request.resolved {r1}
◄─────── tool.ended {c1, ok, 4.2s} ◄── user (tool_result)
◄─────── usage {t1, in, out, cache} ◄── result
◄─────── turn.ended {t1, completed}
agent a2 → settled (unread)
The same exchange on the wire for both alpha harnesses is in section 13.
2. Terms
| Term | Meaning |
|---|---|
| adapter | venom code (Zig, compiled in) that implements this contract for one harness kind. One adapter value per harness kind; one binding per agent. |
| harness process | An OS process venom starts to drive one conversation in protocol mode (claude -p …, codex app-server). |
| conversation | The harness’s own durable record of the turns (a Claude Code session, a Codex thread). It outlives processes. |
harness_session |
The harness’s id for the conversation (Claude Code session_id, Codex thread.id). RFC-0018 section 1: an attribute of the agent, never its name. |
| binding | The durable record that ties an agent to a conversation: harness kind, harness_session, cwd, last turn, transcript reference. |
| protocol mode | venom drives the conversation through the adapter. |
| TUI mode | The harness’s own interactive UI drives it (take-over, RFC-0018 section 8.3). |
| between turns | No turn of this conversation is in progress: the last turn.ended has been seen and no send is outstanding. |
Invariants:
- A binding has at most one driver at a time: venom in protocol mode, or one harness TUI. An adapter MUST NOT start a second protocol-mode process for a conversation that has a live one.
- One harness process serves one binding. Adapters MAY share a process
between bindings later (one
codex app-serverhosts many threads), but v1 implementations SHOULD NOT; one process per agent keeps crash and resource accounting per agent. - Harness processes are children of the venom daemon (section 9).
3. Capability descriptor
probe (section 4) returns a descriptor for the installed harness
version. venom MUST offer a user action only when the descriptor declares
it, and MUST fail a verb that needs an undeclared capability with
unsupported.harness_capability (section 10) rather than emulating it
silently.
{
"harness": "claude-code",
"adapter": 1,
"harness_version": "2.1.319",
"channel": "stream-json",
"caps": {
"send": true,
"steer": "queue",
"interrupt": true,
"requests": {
"approval": {"edit": true, "always": "session_rule"},
"question": {"multi": true, "free_text": true},
"plan": true
},
"fork": "head",
"resume_tui": true,
"idle_boundary": true,
"release": true,
"catch_up": "transcript_file",
"attention_away": "hooks",
"native_subagents": "observe",
"usage": {"per_turn": true, "context": "estimate", "plan_windows": "experimental"},
"durable_requests": false,
"liveness": "none",
"set_model": false,
"compact": true,
"mcp_off": true,
"permission_modes": ["manual", "acceptEdits", "plan", "dontAsk", "auto"]
}
}
| Capability | Values | Meaning | RFC-0018 |
|---|---|---|---|
send |
true |
Start a turn with a user message. Every adapter MUST support it. | §3, §8 |
steer |
"none", "queue", "inject" |
A message sent while a turn runs: rejected, delivered as the next turn, or delivered into the running turn. | §6 (promptable) |
interrupt |
bool | End the running turn early; the conversation stays usable. | §8.3 interrupt now |
requests.approval |
object or false |
The harness asks before a tool call. edit: the call’s arguments can be changed on approval. always: how “allow always” reaches the harness ("session_rule", "harness_rule", "none"). |
§3 approval |
requests.question |
object or false |
The harness asks the human a structured question. | §3 question |
requests.plan |
bool | The harness asks the human to approve a plan before acting. | §3 plan |
fork |
"none", "head", "at_turn" |
Copy the conversation to a new one: from its latest turn only, or through a named turn. | §10 fork to a fresh agent |
resume_tui |
bool | §9 item 1: a command reopens the conversation in the harness’s TUI. | §8.2, §8.3 |
idle_boundary |
true |
§9 item 2: turn.ended is reliable. REQUIRED. |
§2 settled |
release |
bool | §9 item 3: stop driving cleanly between turns, then reattach. |
§8.3 |
catch_up |
"none", "api", "transcript_file" |
§9 item 4: read turns that ran outside venom, through the harness’s API or its transcript file. | §8.3 step 4 |
attention_away |
"none", "turn_end", "hooks" |
§9 item 5: while in TUI mode, the harness can report a turn end, or also “waiting for approval or input”. | §8.3 step 3 |
native_subagents |
"none", "observe", "stop" |
Harness-native sub-agents are visible as events; stop also lets venom stop one. |
§6 Native |
usage |
object | §9 item 6. per_turn: token counts per turn. context: "none", "estimate", "exact". plan_windows: "none", "experimental", "stable". |
§10 |
durable_requests |
bool | A pending request survives the harness process exiting and can be answered after reattach (section 9.3). |
— |
liveness |
"none", "api" |
The adapter can tell whether a conversation is being driven by another process (section 9.4). | — |
set_model |
bool | The model can change between turns without a new conversation. | §11 |
compact |
bool | The harness can shrink the conversation on request (compact, section 4). |
§10 compact now |
mcp_off |
bool | The harness can start without named MCP servers (start’s mcp_off), and reports the ones it has (mcp_servers, 5.2). |
§10 turn off MCP servers |
permission_modes |
list | Harness permission modes venom may pass at start (RFC-0018 agents.mode). |
§5 input box |
Rules:
idle_boundaryandsendare REQUIRED; an adapter that can’t provide them is not an adapter. Everything else is optional.- The descriptor depends on the harness version and MAY change after a
harness upgrade. venom re-probes on daemon start, on
doctor, and when a harness process reports a different version than the last probe. - Unknown capability names MUST be ignored by venom (forward compatibility); a missing capability means its most restrictive value.
4. Operations
venom calls these on an adapter. They are described as JSON objects
because the same shapes appear in venom’s event log and in
harness.show --harness=<h> --json; the Zig interface is a vtable with one
function per operation (nullclaw pattern, ecosystem doc section 1). Every operation
returns a result or an error object from section 10.
| Operation | When | Params | Result |
|---|---|---|---|
probe |
Daemon start, doctor, version change |
{} |
The section 3 descriptor, or unavailable.harness_* |
start |
New agent, first send |
{agent, cwd, add_dirs, env, model?, permission_mode?, system_append?, harness_session?, fork_source?, mcp_off?} |
{harness_session} |
send |
Between turns, or mid-turn per steer |
{text, mode}; mode is "turn", "steer" or "interrupt_then_send" |
{turn} once turn.started is known |
interrupt |
A turn is running | {turn} |
{}; a turn.ended {reason: interrupted} follows |
respond |
A request is pending | {request, answer} (section 6.3) |
{}; a request.resolved follows |
compact |
Between turns | {} |
{}; it runs as a turn, so turn.started and turn.ended follow |
fork |
Between turns | {through_turn?} |
{harness_session} of the new conversation, when it completes (below) |
release |
Between turns | {} |
{resume}: the section 7 resume record |
reattach |
After release, restart, import |
{resume} |
{harness_session} |
resume_command |
Take-over | {resume, hooks} |
{argv, cwd, env} to run in a split |
catch_up |
Take-back, restart, import | {harness_session, after_turn?} |
A list of normalized events (section 5), oldest first |
stop |
agent.stop, archive, shutdown |
{grace_ms} |
{}; the process has exited |
Preconditions and errors:
sendwithmode = "turn"while a turn runs: ifsteer = "queue", the adapter delivers it after the turn and returns once the new turn starts; ifsteer = "none", it fails withconflict.turn_running.sendwithmode = "steer"requiressteer = "inject", elseunsupported.harness_capability."interrupt_then_send"requiresinterrupt.fork,releaseandresume_commandneed the conversation between turns, elseconflict.turn_running. RFC-0018 section 8.3’s after this turn is venom waiting forturn.endedbefore callingrelease.compactneeds thecompactcapability, the conversation between turns (elseconflict.turn_running) and a conversation to compact (elsenot_found.harness_session). It MUST NOT wait for the harness: like asendof modeturn, it writes the request and the turn arrives as events.forkMUST NOT hold the reactor, and venom doesn’t wait for it:{harness_session}arrives when it completes. An adapter that needs the harness’s answer reports that it is still pending, and venom calls it again, with the same arguments, on a later reactor turn. The adapter bounds the wait itself (timeout.harness_request). Verbs, other effects and approval expiry go on meanwhile.- venom runs
forkas aharness.forkoutbox effect, and the effect’s completion is the result: its detail is the new conversation’s id, or the section 10 code when it failed. Its preconditions are checked when it is asked for and again when it starts. An agent runs one fork at a time, oldest first. An idle agent gets a process for the fork, which ends when the fork is over and isn’t restarted, unless asendcame meanwhile, which it then serves. While that process ends,sendfails withconflict.turn_running. A daemon that stops mid-fork leaves the effect pending and the next one forks again; at worst the harness keeps a copy nobody uses. - Any operation on a binding that is in TUI mode, other than
catch_upandstop, fails withconflict.handed_over. startwithharness_sessionset opens an existing conversation (used byagent.import); without it the adapter creates one. Adapters that can choose the id up front SHOULD do so, so the binding is durable before the first byte of output (section 9.1).start’sfork_source, withharness_session, says the agent is a fork that has not started yet and names the conversation it copies. It comes from the log, so it survives a restart. An adapter that copies at the first start uses it; one whoseforkalready made the conversation ignores it. The resume record carries it too (7.1), so a take-over before the first start makes the copy in itsresume_command(7.2).- A
startorresume_commandwithfork_sourceMUST resumeharness_sessioninstead of copying when that conversation already exists, because an earlier take-over made the copy beforefork_sourceended (for example, take-back’scatch_upfound the copy but could not read it). claude-code looks for the copy wherecatch_upwould (7.2): the recordedtranscript_refwhen it names<harness_session>.jsonl, else<config_dir>/projects/<encoded cwd>/<harness_session>.jsonl, where the cwd is the physical path with symlinks resolved, as Claude Code files it. - While the resume record has
fork_source,reattachis astartwithfork_source, and withfork_atwhen the record has it (RFC-0021 section 8): it makes the copy, or resumes the one that exists. resume_commandfor a fork that has not started (the resume record hasfork_source) fails withunsupported.harness_capabilitywhen the adapter’scatch_upis"none"or the record hasfork_at: take-back could not see the copy, or the copy would not stop atfork_at. Nothing changes (section 10): the agent stays as it was and its first start still makes the copy.fork_sourceends only on evidence that the copy exists, and venom logs that evidence as aprotocolbinding: thesessionof a start given it arrives (whether that start copied or resumed an existing copy), or take-back’scatch_up(7.4) reads the copy’s transcript. venom MAY also end it when a hook reports atranscript_reffor the copy (7.2). Atuibinding does not end it, so if a take-over’s command never made the copy (the split never ran it, or it failed), the next start or take-over makes it.start’smcp_offnames MCP servers the process must not offer the model. It needs themcp_offcapability. A running process can’t change them, soagent.mcp_offlogs the servers, for every later process of the agent, and tells a live process to end without waiting. The nextsendstarts one.
5. Normalized events
5.1 Envelope
An adapter emits events to venom core in order, per binding:
{"type":"tool.ended","turn":"t3","at":"2026-11-07T12:00:04.210Z","data":{"call":"c7","status":"ok","exit":0,"duration_ms":4210}}
| Member | Required | Meaning |
|---|---|---|
type |
yes | One of the types in 5.2 |
turn |
for turn-scoped types | venom’s turn id (t<n>, per agent), not the harness’s. The adapter keeps the mapping. |
at |
no | Harness timestamp if it reports one, else omitted; venom assigns ts when it logs the event (RFC-0003 section 2) |
data |
yes | Typed payload |
venom core appends each event to its durable log (RFC-0018, COM-317) and derives agent state from it. Adapters do not change agent state directly.
5.2 Event types
| Type | Data | Notes |
|---|---|---|
session |
{harness_session, model?, harness_version?, cwd} |
Once per process start; also after fork and reattach |
turn.started |
{harness_turn?, origin} |
origin: user (venom’s send), harness (the harness started a turn on its own, e.g. a background task finished), catch_up |
turn.prompt |
{text} |
A prompt that reached the harness: right after the turn.started of the turn it starts, or inside a running turn when it was steered in. catch_up emits the ones it reads from the harness. A turn may have none: a compaction’s, and turns in logs from before venom-org/venom#72. Internal: agent.show returns it, and it is not in 5.4’s published events. |
text |
{message, text, final} |
Agent prose. Adapters MAY emit deltas (final: false) and MUST emit one final: true per message with the full text. |
reasoning |
{message, text} |
Optional; shown only in verbose density |
tool.started |
A tool-call object (5.3) with status pending or running |
|
tool.updated |
{call, status?, body_delta?} |
Live output tail (RFC-0018 open_running_after) |
tool.ended |
{call, status, exit?, duration_ms?, body?} |
Exactly one per tool.started, also when the turn is interrupted (status: interrupted) |
subagent.started |
{call, child_ref, title, prompt?} |
Native sub-agent; call is the tool call that started it |
subagent.ended |
{call, status, summary?} |
status: ok, error, stopped |
request |
Request object (6.1) | The agent is blocked |
request.resolved |
{request, by} |
by: venom (answered through respond), harness (withdrawn, timed out or answered elsewhere), turn_end (not after failed.harness_exited: 6.3) |
usage |
Usage object (8.1) | At least once per turn, before turn.ended |
context |
{used, window, source, system?, tools?} |
Whenever the harness reports a new size; not turn-scoped (8.2) |
mcp_servers |
{servers} |
The MCP servers this process has configured, as it reports them at its start; the latest report wins. Not turn-scoped. Logged as mcp.servers. |
plan_windows |
Plan windows object (8.3) | Per harness and account, not per agent |
attention.away |
{kind, reason} |
Only in TUI mode (section 7.3) |
turn.ended |
{reason, error?} |
reason: completed, interrupted, failed, limit. The idle boundary. |
exited |
{code?, signal?, stderr_tail?, expected} |
The harness process ended; expected is true after stop or release |
Ordering rules:
turn.endedis the last turn-scoped event of its turn. Everytool.startedandrequestof the turn is closed (tool.ended,request.resolvedwithby: turn_end) before it. The one exception: a turn ended withfailed.harness_exitedkeeps its pending requests, as stale (6.3, 9.3).- Sub-agent events nest: a sub-agent’s own tool calls carry
parent_call(5.3) set to thesubagent.startedcall. - An adapter MUST ignore harness message types and fields it does not know, and MAY log them at debug level with the raw line. A harness upgrade adding a message type MUST NOT fail a turn.
- Unparseable harness output is a
failed.harness_protocolerror (section 10) only when the adapter can no longer tell where the turn is; a single bad line is logged and skipped.
5.3 Tool-call object
{
"call": "c7",
"parent_call": null,
"kind": "exec",
"name": "Bash",
"title": "zig build test",
"args": {"command": "zig build test", "cwd": "/home/u/venom-org/venom-std-com-246"},
"raw_input": {"text": "{\"command\":\"zig build test\"}", "truncated": false, "bytes": 28},
"status": "running",
"harness_ref": "toolu_01A"
}
| Member | Meaning |
|---|---|
call |
venom’s call id (c<n>, per agent) |
parent_call |
The native sub-agent’s starting call, or null |
kind |
Normalized kind, below. Drives RFC-0018 section 5 rows and Explored grouping. |
name |
The harness’s tool name, shown as the row’s verb (Bash, Edit, commandExecution) |
title |
One line for the row (zig build test, venom-std/src/proto/frame.zig) |
args |
Normalized arguments for the kind, below |
raw_input |
{text, truncated, bytes}, on tool.started: the harness’s own input for the call, as JSON text, for J. Adapters keep at most harness.body_max inline and set truncated, and a cut text is not valid JSON. bytes is the whole size. Absent when the harness gave none, and in logs from before venom-org/venom#72. Only agent.tool returns it: not agent.show, and not agent.tool_call (5.4). |
status |
pending, awaiting_approval, running, ok, error, declined, interrupted |
exit |
Process exit code for exec when the harness reports it, else absent. Never guessed from text. |
duration_ms |
Reported by the harness, else measured by the adapter from start to end |
body |
{text, truncated, bytes} for output or diff; adapters keep at most harness.body_max (default 256 KiB) inline and set truncated |
harness_ref |
The harness’s id for the call, to match catch-up and hooks |
kind |
args |
Rows (RFC-0018 §5) |
|---|---|---|
exec |
{command, cwd} |
Bash zig build test exit 0 · 4.2s |
edit |
{path, change: "modify"|"create"|"delete", added?, removed?} |
Edit path +6 −4; merged as Edit ×N |
read |
{path} |
merged into Explored |
search |
{query, path?} |
merged into Explored |
list |
{path} |
merged into Explored |
fetch |
{url?, query?} |
web fetch or search |
mcp |
{server, tool} |
|
agent |
{child_ref} |
the sub-agent row that opens it (]) |
question, plan |
— | the call behind a question or plan request |
other |
{} |
anything unmapped |
5.4 Published events
venom republishes adapter events on venom events --json under RFC-0018’s
agent.* names. This RFC adds fields and four types to RFC-0018 section
“Agent-contract impact”:
| Adapter event | Published as |
|---|---|
turn.started, turn.ended |
agent.turn_started {agent, turn, origin}, agent.turn_ended {agent, turn, reason} (new) |
text with final: true |
agent.text {agent, turn, message, text} (new) |
tool.started, tool.ended |
agent.tool_call {agent, call, turn, kind, verb, title, status, exit?, duration_ms?} (RFC-0018, fields added) |
subagent.* |
agent.subagent {agent, call, child, status} (new); child is the native child agent venom creates |
request |
agent.attention (RFC-0018) |
usage |
agent.usage (RFC-0018), adds turn |
context |
agent.context {agent, used, window} (new); source, system and tools are not published |
mcp_servers and the agent.mcp_off log record are internal, so neither is
published. The verb agent.context (8.2) returns what they say. It shares its
name with the event, which carries only the size.
One more published type comes from no adapter event: venom publishes
agent.fork_ended {agent, key, ok, fork?, code?} when a fork ends. key is
what agent.fork returned. On success fork is the new agent. Otherwise
code is the section 10 code, no agent was made and the source did not change.
Deltas (text with final: false, tool.updated) are not log events.
venom delivers them only to live views of that agent (agent.view) and
does not replay them.
6. Requests: approvals, questions, plans
6.1 Request object
{"request":"r4","kind":"approval","turn":"t3","call":"c7","tool":{"kind":"exec","name":"Bash","args":{"command":"zig build test","cwd":"/home/u/venom-org/venom-std-com-246"}},"reason":null,"answers":["allow_once","allow_always","allow_edited","deny","cancel_turn"],"always":{"label":"Bash(zig build test:*)","scope":"session"}}
{"request":"r5","kind":"question","turn":"t3","call":"c8","questions":[{"id":"q1","header":"Framing","text":"Keep varint framing?","options":[{"label":"Keep varint"},{"label":"Fixed u32"},{"label":"Both, negotiated"}],"multi":false,"free_text":true}]}
{"request":"r6","kind":"plan","turn":"t3","call":"c9","plan":"1. Add HelloCaps to proto.zig\n2. Negotiate in hello\n3. Tests"}
| Member | Meaning |
|---|---|
request |
venom’s request id (r<n>, per agent). The adapter keeps the harness’s id (JSON-RPC id, MCP call id). |
kind |
RFC-0018 attention kind: approval, question, plan |
call |
The tool call the request belongs to; that call’s status is awaiting_approval until resolved |
answers |
For approval: the answers this harness accepts for this request, from 6.3 |
always |
What “allow always” would add on the harness side, if anything |
questions[].id |
Stable within the request; adapters derive it when the harness has none (Claude Code keys answers by question text) |
6.2 Who may answer
The request is raised by the agent’s principal (RFC-0005 section 4). The
adapter learns the raiser from its binding, never from harness output.
inbox.answer (RFC-0018) checks respond and the self-approval rule
(responder ∉ {raiser} ∪ descendants(raiser)) before calling respond.
An agent may deny, reject, cancel or answer a question, but never allow or
approve, for any agent: that always takes a person
(denied.agent_approval), and the request stays pending.
An adapter MUST NOT answer a request on its own, with one exception: a
request that matches a standing decision in venom’s grant store (6.4).
6.3 Answers
| Kind | Answer | Meaning |
|---|---|---|
approval |
{"answer":"allow_once"} |
Run this call |
{"answer":"allow_always"} |
Run it and record the narrowest grant (RFC-0018 key a); future matching calls are answered from the grant store |
|
{"answer":"allow_edited","args":{"command":"zig build test -Doptimize=Debug"}} |
Run it with changed arguments; needs requests.approval.edit |
|
{"answer":"deny","reason":"use the wasm target"} |
Don’t run it; reason is optional and reaches the model |
|
{"answer":"cancel_turn"} |
Don’t run it and end the turn | |
question |
{"answers":{"q1":["Keep varint"]}} |
Picked labels; free text as the single element when free_text |
{"text":"Ask me after the tests pass"} |
A reply that answers no question | |
plan |
{"answer":"approve"} |
Proceed |
{"answer":"reject","feedback":"Split step 2"} |
Stay in planning; feedback reaches the model |
Rules:
respondwith an answer not in the request’sanswersfails withinvalid_params.answer. Answering a resolved request fails withconflict.request_resolved.- When a turn ends or is interrupted with requests pending, the adapter
emits
request.resolved {by: turn_end}; the inbox item closes. The exception is a turn ended withfailed.harness_exited: the process that asked is gone and the call did not run, so its requests stay in the inbox as stale (9.3). Every other end closes them,failed.harness_protocolincluded. - No adapter timeout. RFC-0018’s
attention.approval_timeoutis venom’s, and venom answersdenywith reasontimeoutthroughrespond.
6.4 Standing decisions
“Allow always” is recorded in venom’s grant store, scoped as RFC-0018
section 3 says. The harness-side rule (always) is an optimization that
saves a round trip; it is session-scoped where the harness allows it, so
it never outlives the conversation or writes to the user’s harness
settings files. When a request matches a standing decision, venom answers
it immediately through respond and logs request.resolved {by: venom}
with the grant that matched; no attention is raised. This is also what
keeps “always” working after reattach, when the harness process has
forgotten its session rules.
6.4.1 Grant labels
Every approval carries the narrowest grant that covers it (grant on the
request and the inbox item), computed by venom from the tool call it
belongs to, so the inbox shows the grant that will be stored before the
human presses a:
| Call | Grant | Example |
|---|---|---|
exec call |
exec:<program>, the first word of the command after leading NAME=value assignments, narrowed as in 6.4.2 |
zig build test → exec:zig |
| other call with a title | <kind>:<title> |
edit:src/proto.zig |
| other call without a title | tool:<name> |
tool:linear_get_issue |
A request has no grant, and can be allowed once but not always, when
it names no tool call, when its exec call has no command, or when the
command was cut short (the claude-code adapter shows the first line of a
multi-line command followed by …; a title ending in … is cut short).
allow_always on it fails with invalid_params.answer (“this approval
has no grant to keep … allow it once”). A grant is matched by exact string
equality against the grant computed for the new request.
6.4.2 Narrowing: no program-level grant for code-running or destructive commands
A program-level grant must not approve arbitrary code or destruction
later: “always” on bash -c 'zig build' must not approve bash -c 'rm -rf ~', and “always” on rm build/x must not approve rm -rf ~. So an exec
grant is the exact command, exec:<command> (trimmed, byte for byte,
assignments included), when any of these holds:
- Shell syntax. The command contains
;,&,|,<,>,`,$,(,)or a line break (it can run or redirect more than one program:zig build; rm -rf ~). - Odd program word. The program word is anything but letters, digits,
.,_,+and-: a path (./zig-out/bin/x,/bin/rm), a quoted or escaped word ('rm',\rm), or none at all (A=1). - Risky assignment. A leading assignment sets
PATH,BASH_ENV,ENV,IFS,SHELLOPTS,BASHOPTS,PS4,PROMPT_COMMAND,ZDOTDIR,EDITOR,VISUAL,PAGER,SHELL,HOME,MAKEFLAGS, or a name starting withLD_,DYLD_,GIT_,PYTHON,NODE_,PERL,RUBY,BASH_FUNC_; or its value contains/(CC=/tmp/cc). - Code-running program (its arguments are code):
- shells:
sh,bash,zsh,dash,ksh,mksh,fish,csh,tcsh,ash,busybox,toybox; - interpreters:
python(andpython3,python3.12, …),node,nodejs,deno,bun,perl,ruby,irb,php,lua,luajit,tclsh,wish,Rscript,julia,osascript,pwsh,powershell,awk,gawk,mawk,nawk,sed,vim,vi,nvim,emacs,gdb; - wrappers (run the program they are given):
env,sudo,doas,su,pkexec,runuser,xargs,nice,ionice,timeout,nohup,setsid,stdbuf,unbuffer,time,command,builtin,exec,eval,source,.,strace,ltrace,valgrind,chroot,unshare,nsenter,flock,watch,parallel,entr,script,ssh,docker,podman,tar,rsync,!; - task and package runners:
make,gmake,just,npx,bunx,pnpx,uvx,pipx,uv,pip,pip3.
- shells:
- Destructive program:
rm,rmdir,unlink,shred,dd,mkfs*,wipefs,fdisk,sfdisk,parted,truncate,mv,chmod,chown,chgrp,kill,pkill,killall,shutdown,reboot,halt,poweroff,crontab,systemctl. findwith an action that runs or writes:-exec,-execdir,-ok,-okdir,-delete,-fprint,-fprint0,-fprintf,-fls.
git has no program-level grant. It is exec:git <subcommand> for
status, diff, log, show, rev-parse, ls-files, ls-tree,
blame, grep, describe, shortlog, cat-file, rev-list, fetch,
add, commit, switch, checkout, branch, tag, stash, merge,
pull, push, remote, reset, help, version (git --no-pager log -5 → exec:git log), and the exact command for:
- any global option before the subcommand other than
--no-pager/-P(-c,--config-env,-C,--git-dir,--work-tree,--exec-path…); - any other subcommand, since an alias can run a shell (
git yolo,git config,git rebase,git rm,git clean,git restore…); - destructive forms:
pushwith-f,--force*,--force-with-lease*,--mirror,--delete,-d,--prune, a short-flag cluster withf, or a refspec starting with+or:;reset --hard|--merge|--keep;checkout -f|--force|-B|--|.|--ours|--theirs|-p;switch -f|-C|--discard-changes|--force-create;branch -d|-D|--delete|-f|-m|-M|-C(and short clusters with those letters);tag -d|-f;stash drop|clear|pop.
Because a request’s grant is computed from its own command, a risky command
never matches a coarser standing grant: rm -rf ~ is not covered by
exec:rm build/x, git push --force not by exec:git push, and
zig build; rm -rf ~ not by exec:zig. Narrowing to the exact command was
chosen over refusing “always” for these classes, so a repeated, reviewed
command (make test) can still be allowed always; the exact command is
what the inbox shows. The harness-side rule (claude-code
updatedPermissions) is the exact command too.
Known limits, not covered by narrowing: build tools that run project code
(zig build, cargo, npm run, go generate) keep a program-level grant,
and an exact grant for make test or bash script.sh does not pin the
contents of the Makefile or script the agent may change later; git hooks
run under exec:git commit. Revoke with always.revoke when in doubt.
6.4.3 Store, verbs and events
Standing grants are per workspace (the agent’s workspace) and live in
venom’s event log, so they survive a daemon restart by replay:
allow_alwayslogsalways.granted {workspace, grant, agent, request, at}with the answer, once per workspace and grant. Recording one needswriteon the workspace, and an agent’s own principal can never record one (denied.self_approval: it would answer its own requests).always.list [--workspace <path>](read): the standing grants{grants: [{workspace, grant, agent, request, at}]}, oldest first, for the workspaces the caller may read.always.revoke --workspace <path> --grant <grant>(writeon the workspace): logsalways.revoked {workspace, grant, at}; matching approvals ask again. An unknown grant fails withnot_found.grant.inbox.batch --answer allow_alwaysneeds all marked approvals to carry the same grant (invalid_params.batchotherwise).
The shared grant.* verbs are RFC-0005 caller grants, a different thing.
7. Take-over and take-back
7.1 Release and the resume record
release stops protocol mode between turns and returns a resume record,
which venom stores in the binding:
{"harness":"claude-code","harness_session":"6f1c2e0a-7d1b-4c55-9a1e-3f0d7b2c8e41","cwd":"/home/u/venom-org/venom-std-com-246","last_turn":"t3","harness_last_turn":"b7e1c9d0-5a4f-4e2b-8c3d-1f2a3b4c5d6e","transcript_ref":"/home/u/.claude/projects/-home-u-venom-org-venom-std-com-246/6f1c2e0a-7d1b-4c55-9a1e-3f0d7b2c8e41.jsonl"}
harness_last_turn and transcript_ref are what the harness reported
(Claude Code: the last message uuid and the hooks’ transcript_path;
Codex: the last turn id); catch_up uses them to find where to start.
fork_source (with RFC-0021’s fork_at, when
the binding has one) is present only while the agent is a fork whose
conversation does not exist yet (section 4). harness_session then names the
conversation the copy will have.
7.2 Resume command
resume_command returns the argv venom runs in the take-over split
(RFC-0018 section 8.2, through pane.split with RFC-0013 cwd and env):
{"argv":["claude","--resume","6f1c2e0a-7d1b-4c55-9a1e-3f0d7b2c8e41","--settings","{\"hooks\":{\"Notification\":[{\"matcher\":\"permission_prompt|idle_prompt\",\"hooks\":[{\"type\":\"command\",\"command\":\"venom agent hook --agent a2\"}]}],\"Stop\":[{\"hooks\":[{\"type\":\"command\",\"command\":\"venom agent hook --agent a2\"}]}],\"SessionEnd\":[{\"hooks\":[{\"type\":\"command\",\"command\":\"venom agent hook --agent a2\"}]}]}}"],"cwd":"/home/u/venom-org/venom-std-com-246","env":{"VENOM_AGENT":"a2"}}
-
The command MUST resume the same conversation, not a copy, so turns made in TUI mode land in it.
-
A fork that has not started. When the resume record has
fork_source, the agent’s conversation does not exist yet. The command MAY create it, asstartwould: a copy offork_sourceunder the idharness_session, opened in the TUI. That copy is the agent’s own conversation, so this still meets the rule above. When that conversation already exists, because an earlier take-over made it, the command resumes it instead of copying (section 4). The command is refused withunsupported.harness_capabilitywhen the adapter’scatch_upis"none"or the record hasfork_at, and the agent is unchanged (section 4). Whenfork_sourceends is section 4. -
The env MUST NOT carry the agent’s
VENOM_TOKEN(RFC-0005 section 3.6).venom agent hookpresents no token, even one it inherited. It reads the hook’s JSON on stdin and forwards it to the daemon verbharness.hook. -
Which process may report.
agent.takeoveranswers before the command runs, so the client reports the process afterwards withagent.takeover_split {agent, pid}, acting for no agent. PID 1 is never accepted, and the rest depends on who runs the split:- Own mode. Only the venom TUI named by the take-over’s
tui:<pid>may call it, and only for its own child. - Host mode. A pane’s process is venomx’s child, not the client’s, so no
client’s word can prove it. The client names the pane first
(
agent.takeover_pane, 7.4), then reports the spawn result’spid(RFC-0005 section 3.3). venom asks venomx, on its own connection, as for 7.4.pane.list(RFC-0007) must show the recorded pane live, taggedgroup = <agent>andvenom.split = take_over, running that samepid. The process’sstarttimemust match thestartvenomx gives, and its parent must be the venomx that answered (the socket’s peer). Otherwise the call failsinvalid_params.pid. There is no check that the caller is a particular TUI. - Pane ended. venomx shows the recorded pane exited, or no longer lists
it:
conflict.takeover_pane_gone. Nothing is recorded. Its end takes the agent back as usual (7.4), so a client need not report this to anyone. - venomx can’t say. Not reachable, too old to report a process, or
grants.unknownhides the pane from venom:unavailable.takeover_pane. Nothing is recorded. A pane not named yet, or a take-over with no split:conflict.not_client_split.
harness.hookaccepts a report only for ahanded_overagent, from a caller with the recorded(pid, starttime)on its process chain, and otherwise failsdenied.outside_pane. This also refuses a shell split beside it, in the same TUI or the same venomx session. - Own mode. Only the venom TUI named by the take-over’s
-
What this authenticates. venom’s daemon has no pane principal to go by, in either mode: venomx’s principals are venomx’s. The caller is
unknownwithread, and the chain check is the only thing that lets it report. -
The boundary of the host-mode check. It shows what a labelled pane runs, not who opened that pane. The tags keep out read-only splits, which can’t write them, so a shell split can’t pass as the take-over. They are still labels (RFC-0016 section 4):
pane.tagneeds onlywriteon the pane, andspawncan set any tags at creation. So a venomwritecaller who can create venomx panes can stage a pane and report its process, as it already could with a child of its own throughtui:<self>. An agent has neither: RFC-0005 section 3.8 refuses it venomx’s owner token. What that gains is a way to raise attention, which stays an untrusted hint (below). venomx’s socket is trusted at same-UID level, like every same-UID channel (RFC-0005 section 12): the parent check only ties the process to whatever answers there. -
Hooks go in only when the split can report. That is
--pane=tui:<pid>from that TUI, and--pane=venomx(the TUI’s pane is about to open). A take-over from the plain CLI gets none, and has no attention while away. One naming an existing pane gets none installed either, so it raises nothing by itself; a reported pid plus a hook run by hand from inside that pane still does. When venomx can’t confirm the process, the hooks are in the command and each ends in a refusal, which Claude Code shows as a hook error; the client says why whenagent.takeover_splitfails. -
This attention is not trusted. Anything inside the split, the model’s own Bash included, can forge or replace a report. A report only raises attention. It MUST NOT answer a request or authorise anything.
-
A hook must not get in the harness’s way.
venom agent hookis bounded in time, prints nothing on success, and exits 1 with the error code on stderr on any failure, a refusal included. It does not use the RFC-0002 exit codes, because exit 2 makes Claude Code block on the hook. -
transcript_reffrom a hook is recorded when it names<harness_session>.jsonl. It is read only as a regular file, not a symlink, under the harness’sconfig_dir/projects, opened non-blocking with a 256 MiB cap. Otherwise venom falls back to its expected path.
7.3 While handed over
- The binding is in TUI mode; venom shows
⇄ in <harness>(RFC-0018 §8.3). - With
attention_away = "hooks", the harness reports waiting states throughvenom agent hook, which venom turns intoattention.away {kind: approval|question, reason}, a log record published asagent.attention. The agent stayshanded_overand shows under “Needs you”;inbox.listitems gainaway. Every report replaces the away item, even in the same words, because a hook fires once per prompt. Answering happens in the TUI; the inbox item shows Focus split instead of the RFC-0018 keys. In own mode the TUI marks the split. In host mode the control pane setspane.attentionon the take-over pane, with the kind’s level and the reason, instead of on the agent’s views (RFC-0016 section 6), and clears it when the item goes. A report re-marks the pane even in the same words, so a mark a client cleared by focusing the pane comes back for the next prompt. No verb moves a venomx client’s focus (RFC-0016 section 3), so there Focus split names the pane. - With
"turn_end", only turn ends are reported (forreviewattention); with"none", nothing is, and the agent stays⇄.
7.4 Exit signal and take-back
The take-over ends on the first of:
- the split’s process exits: venomx
pane.exited(RFC-0012, RFC-0017), or, inui.chrome = .own, venom’s own child-exit for the split; - Take back (
T): venom closes the split (pane.kill, RFC-0012), which ends the harness TUI with SIGHUP, waits for the exit, then continues. A host-mode control pane holds onlywrite selfand can’t kill it, so thereTexplains that the harness must be quit in its pane, and the pane’s exit takes the agent back; - a harness hook reporting the session ended (Claude Code
SessionEnd). This is advisory: venom still waits for 1 before reattaching, because the TUI may write its last turn while exiting.
Then venom calls catch_up {after_turn: harness_last_turn}, logs the
returned events with turn.started {origin: catch_up} (including their
usage), calls reattach, and marks the agent settled, unread if any
turn was imported. If catch_up fails, venom still reattaches, logs
failed.harness_catch_up on the agent, and the transcript shows a gap
marker; the conversation itself is intact in the harness. While fork_source
is set (section 4), a catch_up that finds no conversation is not a failure:
the take-over never made the copy, so nothing is imported, and venom logs no
failed.harness_catch_up and shows no gap marker. When it finds the copy, the
copy begins with fork_source’s history, which is the source’s, not the
fork’s: catch_up MUST import only the turns that started at or after the
take-over, so the source’s turns and their usage are not counted again on the
fork. The same cut-off keeps turns venom already logged in protocol mode from
being imported twice.
Only one writer. While the harness TUI may still run, the agent stays
handed_over: take-back fails and no turn starts. In host mode the client
asks with agent.takeover --pane=venomx, then names the pane with
agent.takeover_pane {agent, pane}, and venom asks venomx whether that pane
runs, including after a daemon restart. agent.takeback then fails with
conflict.takeover_pane_running, unavailable.takeover_pane when venomx
can’t say, or conflict.owned_by_client for another running venom TUI’s
split. --force overrides all three and may lose lines the TUI is still
writing.
8. Usage, context and plan windows
8.1 Usage object
{"turn":"t3","model":"claude-opus-4-7","tokens_in":1200,"tokens_out":812,"cache_read":40210,"cache_write":2048,"includes_subagents":true,"cost_estimate_usd":0.21,"source":"exact"}
- Values are per turn. When the harness reports running totals
(Claude Code’s
modelUsagein a streaming session, Codex’stokenUsage.total), the adapter subtracts the totals it saw at the previous turn’s end. Afterreattach, the first report re-bases the totals; the adapter MUST NOT double-count catch-up turns already logged. includes_subagentssays whether native sub-agent usage is inside these numbers. RFC-0018’s tree totals add children only when it is false.cost_estimate_usdis passed through only when the harness computes one and the account bills per token (8.3billing). venom MUST NOT derive a cost for subscription plans (RFC-0018 section 10).- A turn with several models emits one
usageper model.
8.2 Context
context {used, window, source}. source is exact when the harness
reports the current context size, estimate when the adapter derives it
(input plus cache tokens of the last model call). RFC-0018’s meter shows
an estimate with a ~ prefix.
system and tools are optional: the tokens of the harness’s system prompt
and instructions, and of its tool definitions, when it says. Neither alpha
adapter reports them today.
The verb agent.context answers RFC-0018’s context panel: what fills the
window, the largest items, and which actions the adapter declares. No harness
venom drives says what fills it, so fills is an estimate from what venom
holds, and the panel labels it so:
fills[].kind |
Tokens |
|---|---|
messages |
The transcript’s text and the results of tool calls other than read, at four bytes a token. A compaction or the harness’s own pruning leaves this too high, so messages and files are scaled down to fit the reported size, never up. |
files |
The results of read calls, likewise |
system, tools |
As the adapter reported them. With either present, the part of the reported size the transcript does not explain counts as messages. |
fixed |
With neither present, that same unexplained part. It is mostly the system prompt, the instructions and the tool definitions, and it is not split by guesswork. |
largest is at most five tool results, largest first, on the same scale.
{"agent":"a1","title":"Add a hello verb","harness":"claude-code","used":124000,"window":200000,"source":"estimate","warn_at":80,"fills":[{"kind":"fixed","tokens":30000},{"kind":"messages","tokens":61000},{"kind":"files","tokens":33000}],"largest":[{"call":"c4","kind":"read","name":"Read","title":"spec/vectors/all.json","tokens":14000}],"mcp":{"servers":["linear"],"unused":["linear"],"off":[]},"actions":{"compact":true,"fork":true,"mcp_off":true}}
mcp leaves out venom’s own servers. unused is the reported servers the
agent has not called and that are not off yet. actions.mcp_off also needs an
unused server to turn off.
8.3 Plan windows
{"harness":"codex","account":"chatgpt","billing":"subscription","windows":[{"id":"primary","label":"5h","used_pct":31,"resets_at":"2026-11-07T15:00:00Z"},{"id":"secondary","label":"7d","used_pct":12,"resets_at":"2026-11-12T09:00:00Z"}],"source":"stable"}
billing:subscription,api_keyorunknown, from the harness’s own auth status (section 11), never from credentials.- Windows are rendered verbatim, in the harness’s order.
labelcomes from the harness (window length or its own label); venom does not rename meters. - An adapter with
plan_windows = "experimental"MAY emit them; venom shows them with an “experimental” note and never alerts on them. turn.ended {reason: limit}carries{resets_at?}so RFC-0018’slimitattention can say when it resumes.
9. Daemon restart and adoption
This is the harness half of RFC-0018 open question 2. The venom half (transcripts and inbox items survive in the durable event log) was decided in COM-317.
9.1 What is durable
The binding is a log record written before the first send of a
conversation and updated on session, release, fork and every
turn.ended:
{"type":"agent.binding","agent":"a2","harness":"claude-code","harness_session":"6f1c2e0a-7d1b-4c55-9a1e-3f0d7b2c8e41","cwd":"/home/u/venom-org/venom-std-com-246","mode":"protocol","last_turn":"t3","harness_last_turn":"b7e1c9d0-5a4f-4e2b-8c3d-1f2a3b4c5d6e","transcript_ref":"/home/u/.claude/projects/-home-u-venom-org-venom-std-com-246/6f1c2e0a-7d1b-4c55-9a1e-3f0d7b2c8e41.jsonl"}
That is why adapters that can choose harness_session up front SHOULD
(Claude Code --session-id): a crash between process start and the
harness’s first output then still leaves a resumable record.
9.2 Harness processes do not outlive the daemon
In v1, harness processes are venom’s children and end with it:
- Clean shutdown: for each running turn, venom calls
interrupt, waits up toharness.shutdown_grace(default"5s") forturn.ended, thenstop. The binding’s turn is logged asinterruptedwith detaildaemon_shutdown. - Crash: each harness process is started in its own process group with
a parent-death signal (Linux
PR_SET_PDEATHSIG, SIGTERM). Where that is not available, venom keeps the process group ids in its runtime directory and terminates leftover groups on the next start, before it reattaches anything. - On start, venom does not restart harness processes eagerly. Each
agent whose last logged turn has no
turn.endedgetsturn.ended {reason: failed, error: {code: "failed.daemon_restart"}}, and RFC-0018 shows it asfailed.R(retry) callsreattachand sendsharness.resume_prompt(default"Continue from where you stopped."). Settled agents reattach lazily on their nextsend. - Before logging that synthetic end, venom calls
catch_upso that output the harness wrote to its transcript after venom’s last logged event is not lost.
Keeping harness processes alive across a venom restart (re-adopting their pipes) is out of scope for v1 (open question 2).
9.3 Pending requests after a restart or a harness crash
A pending request becomes stale on a daemon start and when its turn ends
with failed.harness_exited (6.3). Either way the harness process that
asked is gone, and the call did not run. The inbox item survives (COM-317),
marked stale.
- If the adapter declares
durable_requests, answering it callsreattachand thenrespond; the original call runs as asked. - Otherwise, answering records a one-shot decision for that call
(
decision.recorded), then callsreattachand sendsharness.resume_prompt. When the harness asks for the matching call again within that agent’s next turn, venom answers it from the decision (as in 6.4) and consumes it. A deny also adds its reason to the resume prompt. A decision not consumed by the end of that turn expires. - The key is the tool’s kind and name and the digest of the call’s whole input: SHA-256, with JSON object keys sorted and everything else byte for byte. The request kind and the cwd must match too. A call whose input differs in any way is asked again. With no input there is no key: no decision is recorded, the agent still resumes, and the call is asked again.
cancel_turnon a stale request records no decision and does not resume: there is no turn left to cancel. The inbox item closes.- A failed resume closes the decision and brings the request back
(
request.reopened, published asagent.attention), and it can be answered again. - An answer the crash beat. A request answered while it was still live
is resolved at once, but its answer may never reach the harness: the
process dies before
responddelivers it, which then fails withfailed.harness_exited(orconflict.request_resolved, once the dead turn has dropped its requests). Once that turn has ended with its process gone (failed.harness_exitedor a daemon start), the answer is handled as if it had answered the request stale: a one-shot decision and a resume, as above, and the human is not asked again. Whichever comes last triggers it: the failed delivery, or theturn.ended. A turn that ends any other way, any other failure, andcancel_turndrop the answer as before. To make this possible, venom keeps the answered request and its turn with the pending answer.
decision.recorded and decision.closed {by: consumed|expired|resume_failed}
are internal log records and are not published.
turn.ended {t3, failed, failed.harness_exited} r4 stays in the inbox, stale
inbox.answer r4 allow_once decision.recorded {r4}; reattach; resume prompt
turn.started {t4}, tool.started {c9}
request {r5, same key} answered from r4, no attention raised
decision.closed {r4, by: consumed}
An answer the crash beat:
request {r6}, inbox.answer r6 allow_once request.resolved {r6}; respond queued
harness process dies; respond fails failed.harness_exited
turn.ended {t5, failed, failed.harness_exited} decision.recorded {r6}; reattach; resume prompt
turn.started {t6}, request {r7, same key} answered from r6, no attention raised
9.4 Conversations venom did not start
venom never takes over a harness process it did not start: it has no channel to one (stdio belongs to whoever started it), and attaching would violate invariant 1.
It can import a conversation: venom agent import --harness claude-code --session <id> --workspace <path> creates an agent with a
binding to that harness_session, runs catch_up to fill the
transcript, and leaves it settled. The first send reattaches.
- With
liveness = "api", the adapter checks that no other process is driving the conversation, andimportfails withconflict.harness_session_liveif one is. - With
liveness = "none", venom cannot tell.importrequires--forceand records that the user confirmed the other client is closed. Driving a conversation from two clients at once is undefined harness behaviour, so venom does not try to detect it after the fact.
10. Errors
Adapter errors use RFC-0002 section 7 error objects; the class fixes the exit code of the venom verb that surfaced it.
| Code | Exit | When | Agent effect |
|---|---|---|---|
unavailable.harness_not_found |
6 | Binary not on PATH |
Not started; doctor fails |
unavailable.harness_not_signed_in |
6 | Harness reports no usable sign-in | Not started; data.fix is the harness’s own sign-in command |
unsupported.harness_version |
10 | Below the adapter’s min_version |
Not started |
unsupported.harness_capability |
10 | Verb needs an undeclared capability; or resume_command for a fork that has not started, when the adapter’s catch_up is "none" or the resume record has fork_at (section 4) |
No change |
conflict.turn_running |
5 | fork, release, or send without queueing, mid-turn |
No change |
conflict.handed_over |
5 | Protocol operation while in TUI mode | No change |
conflict.request_resolved |
5 | respond to a closed request |
No change |
conflict.harness_session_live |
5 | import of a conversation another process drives |
Not imported |
conflict.harness_fork_refused |
5 | The harness refused fork |
No change |
conflict.takeover_pane_running |
5 | agent.takeback while the harness runs in the agent’s venomx pane (7.4) |
Stays handed_over |
conflict.owned_by_client |
5 | agent.takeback while another venom TUI owns the split (7.4) |
Stays handed_over |
conflict.takeover_pane_gone |
5 | agent.takeover_split for a venomx pane that has ended (7.2) |
Stays handed_over until the take-back (7.4) |
unavailable.takeover_pane |
6 | venomx can’t say whether the agent’s pane runs (7.4), or which process it runs (7.2) | Stays handed_over; for 7.2, its hooks stay refused |
not_found.harness_session |
3 | reattach or import of a conversation the harness no longer has |
failed |
invalid_params.answer |
2 | Answer not offered by the request | No change |
failed.harness_exited |
1 | Process exited unexpectedly; data: {code, signal, stderr_tail} |
failed |
failed.harness_protocol |
1 | The adapter lost track of the turn (section 5.2 rule 4) | failed |
failed.harness_turn |
1 | Harness ended the turn with an error; data.harness keeps its error |
failed |
failed.usage_limit |
1 | Usage limit; data: {resets_at?} |
failed, attention limit |
failed.harness_catch_up |
1 | Transcript unreadable or not matching | Gap marker; see 7.4 |
failed.daemon_restart |
1 | Turn cut by a venom restart (9.2) | failed |
timeout.harness_start |
7 | No session within harness.start_timeout (default "30s") |
failed |
timeout.harness_request |
7 | The harness didn’t answer a request the adapter is waiting on, such as fork, within the adapter’s bound (codex: 30 s) |
No change |
Rules:
stderr_tailis at most 4 KiB, with secrets redacted by the same rules as venom’s log (RFC-0003 section 3).- A
forkthat fails after it was asked for reaches the caller ascodeonagent.fork_endedandforks(5.4), not as an exit code:agent.forkhas already returned. The exit column applies to a refusal when it is asked for. - venom does not retry a failed turn on its own. A crashed harness process
is restarted only by the next
send,respondon a stale request, orRretry, throughreattach. This keeps quota spend a human decision.
11. doctor checks per adapter
venom doctor (RFC-0001 section 6) runs these for each harness the config
enables. They are read-only and MUST NOT start a model turn or spend
tokens.
| Check id | ok when | Fix |
|---|---|---|
harness.<h>.found |
Binary on PATH |
The harness’s install command from its docs |
harness.<h>.version |
>= min_version; warn if newer than tested_version |
The harness’s update command |
harness.<h>.signed_in |
The harness’s own status command reports a sign-in | The harness’s own sign-in command |
harness.<h>.protocol |
The protocol channel starts and answers its handshake | venom harness show --harness=<h> --json |
harness.<h>.approvals |
The approval path is configured (Claude Code: its agents get venom’s permission prompt server and --permission-prompt-tool; Codex: covered by protocol) |
venom doctor --json |
harness.<h>.resume |
The resume command exists (resume_tui) |
— (warn; take-over is not offered) |
harness.<h>.hooks |
venom is on the PATH the harness TUI will see (attention_away = "hooks") |
A command that prints where venom is |
{"status":0,"checks":[{"id":"harness.claude-code.found","status":"ok","message":"claude 2.1.319 at /home/u/.local/bin/claude","fix":"venom doctor --json"},{"id":"harness.claude-code.signed_in","status":"ok","message":"Signed in (subscription)","fix":"claude auth status --json"},{"id":"harness.codex.signed_in","status":"fail","message":"codex is not signed in","fix":"codex login"}]}
12. Per-harness mapping
Verified against Claude Code 2.1.319 and Codex CLI 0.167.0 (generated
app-server bindings, codex app-server generate-ts), through their
official documentation and tooling only. Field names below are the
harnesses’; the adapter keeps them out of venom’s events.
12.1 claude-code
Channel: print mode with streaming JSON in both directions, which is the CLI form of the Claude Agent SDK. venom does not bundle the SDK (it would add a Node or Python runtime to a Zig daemon); see open question 1.
claude -p --input-format stream-json --output-format stream-json --verbose \
--session-id 6f1c2e0a-7d1b-4c55-9a1e-3f0d7b2c8e41 \
--mcp-config '{"mcpServers":{"venom":{"command":"/usr/bin/venom","args":["mcp"],"env":{"VENOM_SOCKET":"/run/user/1000/venom/venom.sock"}},"venom-permissions":{"command":"/usr/bin/venom","args":["agent","permission-server"],"env":{"VENOM_SOCKET":"/run/user/1000/venom/venom.sock"}}}}' \
--permission-prompt-tool mcp__venom-permissions__permission_prompt \
--permission-mode manual --add-dir /home/u/venom-org/venom-spec-com-246
venom mcp takes no parameters: the agent is the one its VENOM_TOKEN
names (RFC-0005 section 3.6), which Claude Code passes on to its stdio
servers. The permission prompt tool is on a second server,
venom-permissions (venom agent permission-server, same binary, socket
and token), not on venom mcp: Claude Code reads the tool result’s text
as the permission result JSON, while the generated MCP server (RFC-0004
section 5) wraps every result as {"result":…}, and a prompt stays open
until a human answers, which the generated server (one call at a time)
would block on. The permission server forwards each call to the daemon
verb harness.permission_prompt, which stays open until the answer and
returns the permission result as its result string; the server passes
that string through as the tool result text. Anything that keeps a prompt
from reaching venom is answered with a deny result (fail closed).
--permission-mode takes default, acceptEdits, plan, auto,
dontAsk, bypassPermissions, or manual (an alias of default since
2.1.200; claude --help of 2.1.319 lists manual in its place). venom
offers manual, acceptEdits, plan, dontAsk and auto, never
bypassPermissions.
| Contract | claude-code |
|---|---|
start |
The command above, in the agent’s cwd, with VENOM_TOKEN for the agent’s harness process principal in the env so venom’s MCP servers know the raiser. --session-id is chosen by venom (9.1). --resume <id> instead of --session-id when harness_session is given. With fork_source, --resume <fork_source> --fork-session --session-id <id>, unless the copy <id>.jsonl already exists (looked up as section 4 says): then --resume <id>. |
session |
system / init: session_id, model, cwd, claude_code_version, permissionMode, capabilities |
send |
One line on stdin: {"type":"user","message":{"role":"user","content":"…"}} |
steer |
"queue": a message written mid-turn is queued and starts the next turn (queued_turn_count on result) |
interrupt |
SIGINT to the process, which ends the turn (documented in headless mode; SIGTERM would leave it unfinished). If the process exits, the next send reattaches with --resume. |
turn.ended |
result: subtype success → completed; error_* → failed; an interrupted turn → interrupted; api_error_status 429 or a rate-limit error → limit |
text, reasoning |
assistant messages, text and thinking blocks; deltas from stream_event with --include-partial-messages |
| tool calls | tool_use blocks in assistant → tool.started; tool_result in the next user message → tool.ended (is_error → error). exit only when the structured tool_use_result carries one. Kinds: Bash exec; Edit, MultiEdit, Write, NotebookEdit edit; Read read; Grep search; Glob, LS list; WebFetch, WebSearch fetch; mcp__* mcp; Agent/Task agent; AskUserQuestion question; ExitPlanMode plan. |
| native sub-agents | observe: messages with parent_tool_use_id set belong to the Agent call with that id; --forward-subagent-text adds their text |
request approval |
The permission prompt tool: Claude Code calls mcp__venom-permissions__permission_prompt with {tool_name, input, tool_use_id}; the MCP call (and the harness.permission_prompt call behind it) stays open until venom answers. |
request question / plan |
The same tool, with tool_name AskUserQuestion (input questions[], ids derived from the question text) or ExitPlanMode |
respond |
Tool result text is a permission result: allow_once → {"behavior":"allow","updatedInput":<input>}; allow_edited → changed updatedInput; allow_always → adds updatedPermissions with a session destination rule; deny → {"behavior":"deny","message":<reason>}; cancel_turn → deny with "interrupt":true. Question → allow with updatedInput {questions, answers} keyed by question text; plan approve → allow, reject → deny with the feedback. |
fork |
"head": venom chooses the new id at once, without a process. The new agent’s first start, or a take-over before it (7.2), runs --resume <fork_source> --fork-session --session-id <id>; a start after a take-over that made the copy finds <id>.jsonl and runs --resume <id> |
compact |
/compact as a user message on stdin |
mcp_off |
--disallowedTools mcp__<server> for each server; mcp_servers from system/init’s mcp_servers |
release |
Close stdin between turns; the process exits after its turn. |
reattach |
start with --resume <harness_session>. While the resume record has fork_source, start with fork_source, and with fork_at when the record has it (section 4; RFC-0021 section 8) |
resume_command |
claude --resume <id> --settings '<hooks json>' (7.2). For a fork that has not started (fork_source in the resume record): claude --resume <fork_source> --fork-session --session-id <id> --settings '<hooks json>', which creates the agent’s conversation as start does, unless the copy <id>.jsonl already exists (looked up as section 4 says): then plain claude --resume <id> --settings '<hooks json>' (section 4). Only with config_dir (otherwise catch_up is "none"); refused with fork_at (section 4) |
catch_up |
"transcript_file": the session JSONL (transcript_path from hooks, also accepted by --resume). Read tolerantly: unknown line types skipped, matched on message uuid. Only lines stamped at or after the take-over count (7.4); a --fork-session copy keeps the source’s timestamps and message uuids on the lines it copies (checked with Claude Code on 2026-11-10), so a fork’s copied history is skipped too. Claude Code files transcripts under the physical cwd, symlinks resolved, with / and . written as - |
attention_away |
"hooks": Notification with matcher permission_prompt → approval, idle_prompt → question/turn end; Stop → turn end; SessionEnd → advisory exit (7.4) |
| usage | result.modelUsage per model (cumulative in the session; deltas per 8.1), includes_subagents: true; total_cost_usd only when billing = api_key |
| context | "estimate" from the last assistant message’s usage and modelUsage.contextWindow |
| plan windows | "experimental": rate-limit messages in the stream and the /usage report, both marked experimental in the SDK reference |
durable_requests |
false in v1. A PreToolUse hook can defer a call so the process exits and the session resumes with it later; this is the path to true (open question 3). |
liveness |
"none" |
doctor |
claude --version; claude auth status --json; protocol: claude --help lists --input-format, --session-id and --permission-prompts. --permission-prompt-tool is documented in the CLI reference (code.claude.com/docs/en/cli-reference) but hidden from claude --help in 2.1.319, whose --permission-prompts help names it; doctor MUST NOT fail on its absence from --help. approvals: the permission prompt server is configured for claude-code agents. |
Agent scope (RFC-0018 section 7, COM-335, COM-407). Writes to repos outside
a narrowed scope ask first. Claude Code has no write allowlist that leaves the
network alone, so this is best-effort routing, not containment. --settings
carries session-only permissions.ask rules: Edit(//<repo>/**), which covers
every file-editing tool, and for each file-writing program (rm, rmdir,
unlink, mkdir, touch, truncate, shred, tee, chmod, chown,
chgrp, cp, ln, install, rsync, mv, dd, sed) and for cd,
Bash(<prog> *<repo>/*), Bash(<prog> *<repo>) and Bash(<prog> *<repo> *),
with <repo> relative to the cwd where it can be. Without them acceptEdits
runs touch c/x unasked; with them it does not (checked on 2.1.319).
An approval on a command that visibly writes outside the scope is stamped
outside_scope (command_writes, venom’s judge of the command text), so it has
no grant and is always asked. Asking includes a cd out of scope, a program
named by a variable, eval, a shell reading from a pipe, process substitution,
and a target only the shell can resolve. Claude Code’s Bash keeps its folder
between commands (checked on 2.1.319), so venom replays earlier commands’ cds:
a command must be in scope from every folder they may have left the shell in,
and once that folder is unknown, relative targets and unknown programs ask.
Unenforceable on this harness, in Claude Code 2.1.319:
- A command Claude Code does not ask about never reaches the judge.
- Rules are matched without redirections: under
acceptEdits,echo x > c/yruns unasked. - After an approved
cd c,acceptEditsallowstouch yby itself, which writesc/y. Thecdis the only thing asked. - A user
allowrule or a mode can pass a command that names the repo in a way the patterns miss (a variable,git -C, a program not listed above).sort -o c/x,uniq a c/x,git clone u c/newandfind a -execare judged only if they are asked.
- Rules are matched without redirections: under
- What a program writes by itself.
make,python x.py,perl -i,awk,curl -o,tar -C,patch,zig build -p ../c, a sourced or named script, or a git hook name no path a rule or the judge can see. A standingexec:ziggrant answerszig build -p ../c. - Symlinks. Rules and the judge compare paths as written, so a write through
an in-scope or temporary symlink is not caught: `ln -s /org/c /tmp/l && echo x
/tmp/l/y
./tmpand/var/tmp` don’t count as outside. - False asks. The patterns also match reads and similar names (
sed -n p c/x,rm abc/x). They ask; they don’t refuse.
Claude Code’s sandbox (sandbox.filesystem.denyWrite) would hold all of these
for Bash, but it also cuts the network (allowed domains start empty), so it is
not turned on: COM-439.
12.2 codex
Channel: codex app-server, JSON-RPC 2.0 over stdio, one JSON object per
line with the "jsonrpc" member omitted. Handshake: initialize with
clientInfo, then the initialized notification.
| Contract | codex |
|---|---|
start |
Spawn codex app-server in the cwd; thread/start {cwd, model?, approvalPolicy: "on-request", sandbox: "workspace-write"} (with a narrowed scope, config in place of sandbox: see below); harness_session is thread.id |
session |
thread/started |
send |
turn/start {threadId, input: [{type: "text", text, text_elements: []}]} |
steer |
"inject": turn/steer {threadId, input, expectedTurnId}; a stale expectedTurnId means the turn just ended, so the adapter falls back to turn/start |
interrupt |
turn/interrupt {threadId, turnId} → turn/completed with status: "interrupted" |
turn.started, turn.ended |
turn/started; turn/completed with turn.status completed, interrupted, failed (error in turn.error; a reached rate limit → limit) |
text, reasoning |
item/completed with agentMessage / reasoning; deltas from item/agentMessage/delta |
| tool calls | item/started / item/completed: commandExecution → exec (command, cwd, exitCode, durationMs, aggregatedOutput; status declined → declined), and → read / search / list when every commandActions entry is read, search or listFiles; fileChange → edit (changes[] with path, kind, diff); mcpToolCall → mcp; webSearch → fetch; collabAgentToolCall → agent; dynamicToolCall → other. Live output: item/commandExecution/outputDelta. |
| native sub-agents | observe: collabAgentToolCall items (spawnAgent and friends, receiverThreadId/newThreadId as child_ref) |
request approval |
Server requests item/commandExecution/requestApproval (command, cwd, reason) and item/fileChange/requestApproval (reason, grantRoot); edit: false |
request question |
item/tool/requestUserInput (experimental): questions[] with id, header, question, options, isOther |
request plan |
Not offered: turn/plan/updated is progress, not an approval, and is shown as text |
respond |
Response to the server request: allow_once → {"decision":"accept"}; allow_always → "accept": venom’s grant (6.4) is authoritative and answers later matching requests. venom never sends "acceptForSession" or an execpolicy amendment for any agent (COM-405, COM-407): a prefix rule from cp a/x a/y (["cp"]) could let a later cp run without reaching venom, past a narrowed scope or plan. So requests.approval.always is "none". Rules already in the user’s codex config are outside venom’s control: a command they allow does not reach venom. deny → "decline", and a non-empty reason is sent as turn/steer right after; cancel_turn → "cancel". Question → {"answers":{"q1":{"answers":["Keep varint"]}}}. serverRequest/resolved → request.resolved {by: harness} when venom did not answer. |
fork |
"at_turn": thread/fork {threadId, lastTurnId?, excludeTurns}, then thread/unsubscribe for the new thread. An idle agent gets a process for the fork, ended afterwards; an archived agent is refused |
compact |
thread/compact/start {threadId} |
mcp_off |
false for now (venom-org/venom#99) |
release |
Between turns, stop the app-server process; the thread stays on disk |
reattach |
New codex app-server, thread/resume {threadId, excludeTurns}, so a long history can’t pass the 16 MiB line limit |
resume_command |
codex resume <id> (7.2) in the agent’s cwd, with -c notify=… for turn ends when the split can report |
catch_up |
"api": a short-lived codex app-server, before the reattach, reads thread/turns/list {sortDirection: "desc", itemsView: "full"} back to the last known turn or the hand-over. It needs no thread/resume, so no MCP server starts. The list has no usage, and the total that thread/resume replays cannot split several turns, so per-turn tokens come from the rollout at thread/read’s thread.path: how much token_count’s running total grew during each turn. The rollout is not a stable format: without it the turns are imported with no usage and a warning. A turn that failed natively ends interrupted with its error code, because take-back settles the agent |
attention_away |
"turn_end": -c notify=["venom","agent","hook","--agent",<id>,"--input"]; Codex appends its JSON as the last argument. Only agent-turn-complete of the agent’s own thread-id counts: Codex 0.167.0 also reports background threads, such as the one that writes the task’s title. The override replaces a notify in the user’s config for that run. Approvals while away are not reported |
| usage | thread/tokenUsage/updated: deltas of tokenUsage.total (inputTokens, cachedInputTokens, cacheWriteInputTokens, outputTokens) per turn |
| context | "estimate": modelContextWindow and the last call’s input tokens |
| plan windows | "stable": account/rateLimits/read and account/rateLimits/updated (primary/secondary with usedPercent, windowDurationMins, resetsAt in Unix seconds); sparse updates merge into the last read |
durable_requests |
false |
liveness |
"none" in v1; thread/list reports a thread status, which may give "api" later (open question 2) |
doctor |
codex --version; codex login status; protocol: start codex app-server, initialize, account/read, exit |
Agent scope (RFC-0018 section 7, COM-335, COM-407). workspace-write
always makes the cwd writable, so a scope that leaves repos read-only runs
under a permission profile instead (beta in Codex 0.167.0, and not to be
combined with sandbox), in config of thread/start and thread/resume:
{"default_permissions":"venom_scope","permissions":{"venom_scope":{"filesystem":{
":root":"read",":tmpdir":"write",":slash_tmp":"write",
"/org/a":"write","/org/c":"read"}}}}
Every writable root is write, every read-only repo read; the narrower path
wins. :slash_tmp is left out when the workspace is itself under /tmp, where
it would make the workspace writable. The sandbox holds this for commands and
patches alike: in-scope writes run unasked, as for a whole-workspace agent, and
a patch outside raises item/fileChange/requestApproval. The response must
report activePermissionProfile.id as venom_scope. A codex that does not
would run under its own config, so the conversation fails to open with
unsupported.harness_version.
Default worktree isolation leaves the source repos read-only, so most codex
agents run under a profile, and the adapter’s min_version is 0.167.0, the
version this was checked on.
A fileChange item can touch several paths, and the tool call’s title is the
first one, so the adapter judges all of them, move_path and grantRoot, and
marks the approval outside_scope. network stays unset, as under
workspace-write.
Unenforceable on this harness:
- An approved command may leave the sandbox. venom judges its text
(
command_writes, with the limits in 12.1), but what a program writes by itself is not visible, so a standingexec:ziggrant can answer azig buildthat writes out of scope. - A patch through a symlink in scope. The adapter compares paths as
written, so the approval is not stamped
outside_scope.
12.3 ACP (preview, non-normative)
For P4 and the generic adapter: initialize → capabilities;
session/new / session/load (loadSession capability) → start /
reattach; session/prompt → send, its response’s stop reason →
turn.ended; session/cancel → interrupt; session/update
notifications → text, tool.started / tool.updated, plans;
session/request_permission with its allow_once / allow_always /
reject_once / reject_always options → approval requests. No
resume_tui unless the agent documents a resume command. The mapping is
fixed in the ACP adapter’s issue, against this contract.
13. Traces
Ids, paths and times are illustrative. # lines are commentary, not part
of the stream.
13.1 claude-code: one approved command
# venom → claude stdin
{"type":"user","message":{"role":"user","content":"Run the proto tests and fix what fails (COM-246)."}}
# claude → venom stdout
{"type":"system","subtype":"init","session_id":"6f1c2e0a-7d1b-4c55-9a1e-3f0d7b2c8e41","cwd":"/home/u/venom-org/venom-std-com-246","model":"claude-opus-4-7","permissionMode":"default","tools":["Bash","Edit","Read","Grep","Glob","mcp__venom__agent_dlist","mcp__venom-permissions__permission_prompt"],"mcp_servers":[{"name":"venom","status":"connected"},{"name":"venom-permissions","status":"connected"}],"claude_code_version":"2.1.319","capabilities":["interrupt_receipt_v1"]}
{"type":"assistant","session_id":"6f1c2e0a-7d1b-4c55-9a1e-3f0d7b2c8e41","parent_tool_use_id":null,"message":{"role":"assistant","content":[{"type":"text","text":"I'll run the tests first."},{"type":"tool_use","id":"toolu_01A","name":"Bash","input":{"command":"zig build test","description":"Run proto tests"}}]}}
# claude → venom's permission prompt server (stdio of `venom agent permission-server`, with a2's VENOM_TOKEN)
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"permission_prompt","arguments":{"tool_name":"Bash","input":{"command":"zig build test","description":"Run proto tests"},"tool_use_id":"toolu_01A"}}}
# … agent a2 is needs_you; the human presses y in the inbox …
{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"{\"behavior\":\"allow\",\"updatedInput\":{\"command\":\"zig build test\",\"description\":\"Run proto tests\"}}"}]}}
# claude → venom stdout
{"type":"user","session_id":"6f1c2e0a-7d1b-4c55-9a1e-3f0d7b2c8e41","parent_tool_use_id":null,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01A","content":"All 112 tests passed.","is_error":false}]}}
{"type":"assistant","session_id":"6f1c2e0a-7d1b-4c55-9a1e-3f0d7b2c8e41","parent_tool_use_id":null,"message":{"role":"assistant","content":[{"type":"text","text":"All proto tests pass; nothing to fix."}]}}
{"type":"result","subtype":"success","session_id":"6f1c2e0a-7d1b-4c55-9a1e-3f0d7b2c8e41","is_error":false,"num_turns":2,"duration_ms":18234,"result":"All proto tests pass; nothing to fix.","usage":{"input_tokens":1200,"output_tokens":812,"cache_read_input_tokens":40210,"cache_creation_input_tokens":2048},"modelUsage":{"claude-opus-4-7":{"inputTokens":1200,"outputTokens":812,"cacheReadInputTokens":40210,"cacheCreationInputTokens":2048,"webSearchRequests":0,"costUSD":0.21,"contextWindow":200000,"maxOutputTokens":32000}},"total_cost_usd":0.21,"permission_denials":[]}
What the adapter emits for it:
{"type":"session","data":{"harness_session":"6f1c2e0a-7d1b-4c55-9a1e-3f0d7b2c8e41","model":"claude-opus-4-7","harness_version":"2.1.319","cwd":"/home/u/venom-org/venom-std-com-246"}}
{"type":"turn.started","turn":"t1","data":{"origin":"user"}}
{"type":"text","turn":"t1","data":{"message":"m1","text":"I'll run the tests first.","final":true}}
{"type":"tool.started","turn":"t1","data":{"call":"c1","parent_call":null,"kind":"exec","name":"Bash","title":"zig build test","args":{"command":"zig build test","cwd":"/home/u/venom-org/venom-std-com-246"},"status":"pending","harness_ref":"toolu_01A"}}
{"type":"request","turn":"t1","data":{"request":"r1","kind":"approval","call":"c1","tool":{"kind":"exec","name":"Bash","args":{"command":"zig build test","cwd":"/home/u/venom-org/venom-std-com-246"}},"reason":null,"answers":["allow_once","allow_always","allow_edited","deny","cancel_turn"],"always":{"label":"Bash(zig build test:*)","scope":"session"}}}
{"type":"request.resolved","turn":"t1","data":{"request":"r1","by":"venom"}}
{"type":"tool.ended","turn":"t1","data":{"call":"c1","status":"ok","duration_ms":4210,"body":{"text":"All 112 tests passed.","truncated":false,"bytes":21}}}
{"type":"text","turn":"t1","data":{"message":"m2","text":"All proto tests pass; nothing to fix.","final":true}}
{"type":"usage","turn":"t1","data":{"turn":"t1","model":"claude-opus-4-7","tokens_in":1200,"tokens_out":812,"cache_read":40210,"cache_write":2048,"includes_subagents":true,"source":"exact"}}
{"type":"context","turn":"t1","data":{"used":43458,"window":200000,"source":"estimate"}}
{"type":"turn.ended","turn":"t1","data":{"reason":"completed"}}
exit is absent on c1: the tool result carried no exit code. The row
shows ok · 4.2s instead of exit 0 · 4.2s. No cost_estimate_usd:
the account is a subscription (claude auth status).
13.2 codex: steer, approval, usage and plan windows
# venom → app-server
{"method":"initialize","id":0,"params":{"clientInfo":{"name":"venom","title":"venom","version":"0.1.0"}}}
{"id":0,"result":{"userAgent":"codex_cli_rs/0.167.0","codexHome":"/home/u/.codex","platformFamily":"unix","platformOs":"linux"}}
{"method":"initialized","params":{}}
{"method":"thread/start","id":1,"params":{"cwd":"/home/u/venom-org/venom-std-com-246","approvalPolicy":"on-request","sandbox":"workspace-write"}}
{"id":1,"result":{"thread":{"id":"thr_123"},"model":"gpt-5.5-codex","modelProvider":"openai","cwd":"/home/u/venom-org/venom-std-com-246","approvalPolicy":"on-request"}}
{"method":"thread/started","params":{"thread":{"id":"thr_123"}}}
{"method":"turn/start","id":2,"params":{"threadId":"thr_123","input":[{"type":"text","text":"Run the proto tests and fix what fails (COM-246).","text_elements":[]}]}}
{"id":2,"result":{"turn":{"id":"turn_456","status":"inProgress","items":[]}}}
{"method":"turn/started","params":{"threadId":"thr_123","turn":{"id":"turn_456","status":"inProgress","items":[]}}}
# the human types a steer message in venom while the turn runs
{"method":"turn/steer","id":3,"params":{"threadId":"thr_123","input":[{"type":"text","text":"Only the proto module, not the whole tree.","text_elements":[]}],"expectedTurnId":"turn_456"}}
{"id":3,"result":{"turnId":"turn_456"}}
{"method":"item/started","params":{"threadId":"thr_123","turnId":"turn_456","item":{"type":"commandExecution","id":"item_7","command":"zig build test -Dmodule=proto","cwd":"/home/u/venom-org/venom-std-com-246","status":"inProgress","commandActions":[{"type":"unknown","command":"zig build test -Dmodule=proto"}]}}}
# app-server → venom: server request
{"method":"item/commandExecution/requestApproval","id":50,"params":{"kind":"command","threadId":"thr_123","turnId":"turn_456","itemId":"item_7","startedAtMs":1794052800000,"environmentId":null,"command":"zig build test -Dmodule=proto","cwd":"/home/u/venom-org/venom-std-com-246"}}
# … the human presses a (always) …
{"id":50,"result":{"decision":"accept"}}
{"method":"serverRequest/resolved","params":{"threadId":"thr_123","requestId":50}}
{"method":"item/completed","params":{"threadId":"thr_123","turnId":"turn_456","item":{"type":"commandExecution","id":"item_7","command":"zig build test -Dmodule=proto","cwd":"/home/u/venom-org/venom-std-com-246","status":"completed","commandActions":[{"type":"unknown","command":"zig build test -Dmodule=proto"}],"exitCode":0,"durationMs":4210,"aggregatedOutput":"All 112 tests passed.\n"}}}
{"method":"thread/tokenUsage/updated","params":{"threadId":"thr_123","turnId":"turn_456","tokenUsage":{"total":{"totalTokens":44270,"inputTokens":43458,"cachedInputTokens":40210,"cacheWriteInputTokens":0,"outputTokens":812,"reasoningOutputTokens":320},"last":{"totalTokens":9100,"inputTokens":8800,"cachedInputTokens":8000,"cacheWriteInputTokens":0,"outputTokens":300,"reasoningOutputTokens":120},"modelContextWindow":272000}}}
{"method":"turn/completed","params":{"threadId":"thr_123","turn":{"id":"turn_456","status":"completed","error":null,"items":[],"durationMs":18234}}}
{"method":"account/rateLimits/updated","params":{"rateLimits":{"limitId":"codex","primary":{"usedPercent":31,"windowDurationMins":300,"resetsAt":1794063600},"secondary":{"usedPercent":12,"windowDurationMins":10080,"resetsAt":1794474000}}}}
What the adapter emits for the parts that differ from 13.1:
{"type":"tool.started","turn":"t1","data":{"call":"c1","parent_call":null,"kind":"exec","name":"commandExecution","title":"zig build test -Dmodule=proto","args":{"command":"zig build test -Dmodule=proto","cwd":"/home/u/venom-org/venom-std-com-246"},"status":"running","harness_ref":"item_7"}}
{"type":"request","turn":"t1","data":{"request":"r1","kind":"approval","call":"c1","tool":{"kind":"exec","name":"commandExecution","args":{"command":"zig build test -Dmodule=proto","cwd":"/home/u/venom-org/venom-std-com-246"}},"reason":null,"answers":["allow_once","allow_always","deny","cancel_turn"],"always":null}}
{"type":"request.resolved","turn":"t1","data":{"request":"r1","by":"venom"}}
{"type":"tool.ended","turn":"t1","data":{"call":"c1","status":"ok","exit":0,"duration_ms":4210,"body":{"text":"All 112 tests passed.\n","truncated":false,"bytes":22}}}
{"type":"usage","turn":"t1","data":{"turn":"t1","model":"gpt-5.5-codex","tokens_in":3248,"tokens_out":812,"cache_read":40210,"cache_write":0,"includes_subagents":false,"source":"exact"}}
{"type":"context","turn":"t1","data":{"used":8800,"window":272000,"source":"estimate"}}
{"type":"turn.ended","turn":"t1","data":{"reason":"completed"}}
{"type":"plan_windows","data":{"harness":"codex","account":"chatgpt","billing":"subscription","windows":[{"id":"primary","label":"5h","used_pct":31,"resets_at":"2026-11-07T15:00:00Z"},{"id":"secondary","label":"7d","used_pct":12,"resets_at":"2026-11-12T09:00:00Z"}],"source":"stable"}}
tokens_in is uncached input (inputTokens − cachedInputTokens), so it
adds up with cache_read the same way as Claude Code’s fields. There is no
allow_edited in answers: Codex can’t change a command on approval.
13.3 Take-over and take-back (claude-code)
$ venom agent takeover a2 --json
{"agent":"a2","state":"handed_over","split":"p14","argv":["claude","--resume","6f1c2e0a-7d1b-4c55-9a1e-3f0d7b2c8e41","--settings","{…hooks…}"]}
$ venom events --types '["agent.*"]' --resources '["agent:a2"]' --json
{"seq":210,"ts":"2026-11-07T12:10:02.000Z","source":"venom/5b0c8d1e-2f3a-4b5c-9d6e-7f8091a2b3c4","type":"agent.attention","data":{"agent":"a2","kind":"approval","reason":"waiting in claude-code","age_s":0}}
{"seq":214,"ts":"2026-11-07T12:14:40.000Z","source":"venom/5b0c8d1e-2f3a-4b5c-9d6e-7f8091a2b3c4","type":"agent.turn_started","data":{"agent":"a2","turn":"t4","origin":"catch_up"}}
{"seq":219,"ts":"2026-11-07T12:14:40.000Z","source":"venom/5b0c8d1e-2f3a-4b5c-9d6e-7f8091a2b3c4","type":"agent.turn_ended","data":{"agent":"a2","turn":"t4","reason":"completed"}}
{"seq":220,"ts":"2026-11-07T12:14:41.000Z","source":"venom/5b0c8d1e-2f3a-4b5c-9d6e-7f8091a2b3c4","type":"agent.state_changed","data":{"agent":"a2","from":"handed_over","to":"settled"}}
The approval at seq 210 came from the Notification hook while the
user was in Claude Code’s TUI; the user quit the TUI at 12:14, venomx
reported pane.exited, and venom imported turn t4 from the transcript
before reattaching.
14. Versioning
- The adapter contract carries an integer version (
adapterin the descriptor,1here). Adding optional capabilities, event types or members is compatible and does not bump it; consumers ignore what they don’t know (5.2 rule 3). Removing or changing the meaning of a member bumps it, through a new RFC. - Each adapter declares
min_version(refuse below) andtested_version(warn above, still run) for its harness. COM-322 fixes the first values; the versions in section 12 are the ones this RFC was checked against. - When a harness renames a protocol member, the adapter maps both names
for at least one
tested_versioncycle.
Agent-contract impact
- Verbs (venom schema, RFC-0004), all with
--json; RFC-0018’s list gains:harness.list,harness.show --harness=<h>: installed harnesses and their section 3 descriptors (read).agent.sendgainsmode:turn(default),steer,interrupt_then_send(writeon the agent, as RFC-0018).agent.interrupt,agent.fork(writeon the agent;forkalsospawnon its workspace, since it creates an agent),agent.import(writeon the workspace,spawn).agent.fork {agent, through_turn?, task?}returns{agent, key}at once. The result isagent.fork_endedon events (5.4), orforksonagent.show:[{key, state, fork?, code?}], oldest first, withstaterunning,okorfailed, absent withsinceorbefore. On success the fork is a new sub-agent of the source, titledfork of <title>, in the source’s folder (the only place its harness can resume the copy) and scope, with its mode, model and grants. Withtaskit queues for a slot; without one it startssettled, untilagent.send. An agent asking is also held to the sub-agent limits (denied.spawn_disabled,denied.spawn_depth,conflict.parent_stopped).agent.context {agent}(read): what fills the window (8.2).agent.compact {agent}(write): returns{agent}once it has asked the harness; the compaction runs as a turn.agent.mcp_off {agent, servers?}(write): returns{agent, off}. Withoutservers, the ones the agent has not used. venom’s own servers, or one the harness did not report, fail withinvalid_params.servers.harness.permission_prompt(write): the daemon side of Claude Code’s permission prompt tool (12.1), called by venom’s permission prompt server with the harness process’s token. Callable only by an agent’s own principal (denied.not_agentotherwise); the caller is the raiser for the self-approval check. The call stays open until the request is answered, then returns{"result": <permission result JSON>}.agent.permission_server: product-local (RFC-0011) command, the stdio MCP servervenom-permissionsthat venom passes to Claude Code with--mcp-config; its one toolpermission_promptforwards toharness.permission_promptand returns the result text verbatim.agent.hook: product-local (RFC-0011) command run by harness hooks in TUI mode; reads the hook JSON on stdin and forwards it toharness.hook.harness.hook(read, plus the process check in 7.2;denied.agent_callerfor an agent): reports a hook while handed over. Not for people.agent.takeover,agent.takeback(write; a take-over is denied to agents,denied.agent_caller).agent.takeover_split {agent, pid},agent.takeover_pane {agent, pane}(write;denied.agent_callerfor an agent): the client names the process or venomx pane that runs a take-over (7.2, 7.4).always.list(read) andalways.revoke(writeon the workspace): standing “allow always” grants (6.4.3).
--jsonoutput and exit codes: errors and exits from section 10; no new classes.agent.hookis the one exception: it exits 0 or 1 (7.2).- Events:
agent.turn_started,agent.turn_ended,agent.text,agent.subagent,agent.context,agent.fork_ended(new);agent.tool_callandagent.usagegain fields (section 5.4). Deltas are not log events.mcp.serversandagent.mcp_offare internal log records.always.grantedandalways.revokedare log events (6.4.3).decision.recorded,decision.closedandrequest.reopenedare log events (9.3); onlyrequest.reopenedis published, asagent.attention.attention.awayis a log event published asagent.attention(7.3). skill: venom’s skill documentsagent.send --modeand which harnesses support steer, edit-on-approve and take-over, fromharness.list.doctor: section 11 checks.config: new keysharness.<h>.enabled,harness.<h>.path,harness.<h>.extra_args(list, appended after the adapter’s own flags;doctorwarns if it repeats one of them),harness.shutdown_grace("5s"),harness.start_timeout("30s"),harness.body_max(262144),harness.resume_prompt.- Grants: as listed under Verbs. Answering goes through RFC-0018’s
inbox.answerwith RFC-0005’s self-approval rule. - Headless, OSC 7501 / OSC 133: no change. Adapters run headless; take-over uses venomx panes through existing verbs.
.{
.harness = .{
.claude_code = .{ .enabled = true, .path = null, .extra_args = .{} },
.codex = .{ .enabled = true, .path = null, .extra_args = .{} },
.shutdown_grace = "5s",
.start_timeout = "30s",
.body_max = 262144,
.resume_prompt = "Continue from where you stopped.",
},
}
Alternatives
- Bundle the Claude Agent SDK as a sidecar (a small Node or Python
program speaking venom’s NDJSON on one side and the SDK on the other).
It gets
interrupt()receipts,setModel,getContextUsageandcanUseToolwithout an MCP hop. Rejected for v1: it adds a language runtime to a Zig product and a second process per agent. Kept as the fallback if the CLI channel proves too thin (open question 1). - Drive the harness TUI through a PTY (send keys, read the screen). Rejected: fragile, not an official channel for protocol use, and no structured approvals or usage. TUIs appear only in take-over, driven by the human.
- One process per harness hosting every agent (one
codex app-serverfor all threads). Saves memory; loses per-agent crash isolation and accounting. Allowed later by invariant 2, not in v1. - Speak ACP to every harness, through vendor or community ACP bridges. Rejected for the alpha harnesses: the bridges are not the vendors’ own channels, and they drop take-over, plan windows and Codex’s steer. ACP stays the path for harnesses without an adapter.
- Keep harness processes alive across venom restarts (detach them, reconnect over a socket). Rejected for v1: neither alpha channel supports reconnecting to a running print or stdio process, and Codex’s own app-server daemon is experimental. Revisit with open question 2.
- Let adapters change agent state directly. Rejected: the state machine lives once in venom core and is derived from logged events, so replay after a restart gives the same state.
- Do nothing and write adapters directly against RFC-0018. Each adapter would invent events and the TUI would special-case harnesses.
Open questions
- Claude Code control channel. v1 uses documented CLI flags:
interrupt by SIGINT, approvals through
--permission-prompt-tool. The Agent SDK exchanges control messages over the samestream-jsonpipes (interrupt with receipts, permission requests, model and mode changes). Should the adapter speak those directly once they are documented as a wire format, or bundle the SDK as a sidecar? Deferred. COM-322 shipped the documented flags, and the adapter must not depend on undocumented messages. Revisit if those messages are documented as a wire format. - Process adoption. Codex ships an app-server daemon with a control
socket (
codex app-server daemon,proxy), andthread/resumeon a running thread rejoins it. This could let venom survive its own restart without interrupting Codex turns, and give Codexliveness = "api". Deferred: both are experimental in Codex 0.167.0; revisit when they are stable. - Durable requests for Claude Code. A
PreToolUsehook can defer a tool call so the process exits and the session resumes with it later. That would make pending approvals survive a restart exactly (9.3) instead of through a one-shot decision. Deferred to COM-385; v1 uses the one-shot decision. - Codex attention while away. Codex’s
notifysetting reports turn ends only. If Codex’s hooks report “waiting for approval” in its TUI,attention_awaybecomes"hooks". Deferred to COM-355, which adds Codex take-over. - Codex resume id.
codex resumetakes a session id or name;thread/startreturnsthread.id(andthread.sessionId). Deferred to COM-355, which confirms which one the TUI accepts and stores both. - Native sub-agent steering beyond observe and stop is RFC-0018 open
question 4. Deferred to COM-357. The
native_subagentscapability is where a richer level would be declared. - RFC-0018 back-references. Done (COM-317). RFC-0018 section 9 and open questions 1 and 2 point at this RFC.