Venom
RFC-0019venom-spec

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:

  1. 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.
  2. One harness process serves one binding. Adapters MAY share a process between bindings later (one codex app-server hosts many threads), but v1 implementations SHOULD NOT; one process per agent keeps crash and resource accounting per agent.
  3. 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_boundary and send are 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:

  • send with mode = "turn" while a turn runs: if steer = "queue", the adapter delivers it after the turn and returns once the new turn starts; if steer = "none", it fails with conflict.turn_running.
  • send with mode = "steer" requires steer = "inject", else unsupported.harness_capability. "interrupt_then_send" requires interrupt.
  • fork, release and resume_command need the conversation between turns, else conflict.turn_running. RFC-0018 section 8.3’s after this turn is venom waiting for turn.ended before calling release.
  • compact needs the compact capability, the conversation between turns (else conflict.turn_running) and a conversation to compact (else not_found.harness_session). It MUST NOT wait for the harness: like a send of mode turn, it writes the request and the turn arrives as events.
  • fork MUST 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 fork as a harness.fork outbox 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 a send came meanwhile, which it then serves. While that process ends, send fails with conflict.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_up and stop, fails with conflict.handed_over.
  • start with harness_session set opens an existing conversation (used by agent.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’s fork_source, with harness_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 whose fork already 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 its resume_command (7.2).
  • A start or resume_command with fork_source MUST resume harness_session instead of copying when that conversation already exists, because an earlier take-over made the copy before fork_source ended (for example, take-back’s catch_up found the copy but could not read it). claude-code looks for the copy where catch_up would (7.2): the recorded transcript_ref when 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, reattach is a start with fork_source, and with fork_at when the record has it (RFC-0021 section 8): it makes the copy, or resumes the one that exists.
  • resume_command for a fork that has not started (the resume record has fork_source) fails with unsupported.harness_capability when the adapter’s catch_up is "none" or the record has fork_at: take-back could not see the copy, or the copy would not stop at fork_at. Nothing changes (section 10): the agent stays as it was and its first start still makes the copy.
  • fork_source ends only on evidence that the copy exists, and venom logs that evidence as a protocol binding: the session of a start given it arrives (whether that start copied or resumed an existing copy), or take-back’s catch_up (7.4) reads the copy’s transcript. venom MAY also end it when a hook reports a transcript_ref for the copy (7.2). A tui binding 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’s mcp_off names MCP servers the process must not offer the model. It needs the mcp_off capability. A running process can’t change them, so agent.mcp_off logs the servers, for every later process of the agent, and tells a live process to end without waiting. The next send starts 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:

  1. turn.ended is the last turn-scoped event of its turn. Every tool.started and request of the turn is closed (tool.ended, request.resolved with by: turn_end) before it. The one exception: a turn ended with failed.harness_exited keeps its pending requests, as stale (6.3, 9.3).
  2. Sub-agent events nest: a sub-agent’s own tool calls carry parent_call (5.3) set to the subagent.started call.
  3. 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.
  4. Unparseable harness output is a failed.harness_protocol error (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:

  • respond with an answer not in the request’s answers fails with invalid_params.answer. Answering a resolved request fails with conflict.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 with failed.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_protocol included.
  • No adapter timeout. RFC-0018’s attention.approval_timeout is venom’s, and venom answers deny with reason timeout through respond.

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:

  1. Shell syntax. The command contains ;, &, |, <, >, `, $, (, ) or a line break (it can run or redirect more than one program: zig build; rm -rf ~).
  2. 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).
  3. 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 with LD_, DYLD_, GIT_, PYTHON, NODE_, PERL, RUBY, BASH_FUNC_; or its value contains / (CC=/tmp/cc).
  4. Code-running program (its arguments are code):
    • shells: sh, bash, zsh, dash, ksh, mksh, fish, csh, tcsh, ash, busybox, toybox;
    • interpreters: python (and python3, 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.
  5. 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.
  6. find with 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: push with -f, --force*, --force-with-lease*, --mirror, --delete, -d, --prune, a short-flag cluster with f, 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_always logs always.granted {workspace, grant, agent, request, at} with the answer, once per workspace and grant. Recording one needs write on 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> (write on the workspace): logs always.revoked {workspace, grant, at}; matching approvals ask again. An unknown grant fails with not_found.grant.
  • inbox.batch --answer allow_always needs all marked approvals to carry the same grant (invalid_params.batch otherwise).

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, as start would: a copy of fork_source under the id harness_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 with unsupported.harness_capability when the adapter’s catch_up is "none" or the record has fork_at, and the agent is unchanged (section 4). When fork_source ends is section 4.

  • The env MUST NOT carry the agent’s VENOM_TOKEN (RFC-0005 section 3.6). venom agent hook presents no token, even one it inherited. It reads the hook’s JSON on stdin and forwards it to the daemon verb harness.hook.

  • Which process may report. agent.takeover answers before the command runs, so the client reports the process afterwards with agent.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’s pid (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, tagged group = <agent> and venom.split = take_over, running that same pid. The process’s starttime must match the start venomx gives, and its parent must be the venomx that answered (the socket’s peer). Otherwise the call fails invalid_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.unknown hides 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.hook accepts a report only for a handed_over agent, from a caller with the recorded (pid, starttime) on its process chain, and otherwise fails denied.outside_pane. This also refuses a shell split beside it, in the same TUI or the same venomx session.

  • 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 unknown with read, 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.tag needs only write on the pane, and spawn can set any tags at creation. So a venom write caller who can create venomx panes can stage a pane and report its process, as it already could with a child of its own through tui:<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 when agent.takeover_split fails.

  • 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 hook is 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_ref from a hook is recorded when it names <harness_session>.jsonl. It is read only as a regular file, not a symlink, under the harness’s config_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 through venom agent hook, which venom turns into attention.away {kind: approval|question, reason}, a log record published as agent.attention. The agent stays handed_over and shows under “Needs you”; inbox.list items gain away. 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 sets pane.attention on 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 (for review attention); with "none", nothing is, and the agent stays ⇄.

7.4 Exit signal and take-back

The take-over ends on the first of:

  1. the split’s process exits: venomx pane.exited (RFC-0012, RFC-0017), or, in ui.chrome = .own, venom’s own child-exit for the split;
  2. 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 only write self and can’t kill it, so there T explains that the harness must be quit in its pane, and the pane’s exit takes the agent back;
  3. 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 modelUsage in a streaming session, Codex’s tokenUsage.total), the adapter subtracts the totals it saw at the previous turn’s end. After reattach, the first report re-bases the totals; the adapter MUST NOT double-count catch-up turns already logged.
  • includes_subagents says whether native sub-agent usage is inside these numbers. RFC-0018’s tree totals add children only when it is false.
  • cost_estimate_usd is passed through only when the harness computes one and the account bills per token (8.3 billing). venom MUST NOT derive a cost for subscription plans (RFC-0018 section 10).
  • A turn with several models emits one usage per 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_key or unknown, from the harness’s own auth status (section 11), never from credentials.
  • Windows are rendered verbatim, in the harness’s order. label comes 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’s limit attention 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 to harness.shutdown_grace (default "5s") for turn.ended, then stop. The binding’s turn is logged as interrupted with detail daemon_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.ended gets turn.ended {reason: failed, error: {code: "failed.daemon_restart"}}, and RFC-0018 shows it as failed. R (retry) calls reattach and sends harness.resume_prompt (default "Continue from where you stopped."). Settled agents reattach lazily on their next send.
  • Before logging that synthetic end, venom calls catch_up so 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 calls reattach and then respond; the original call runs as asked.
  • Otherwise, answering records a one-shot decision for that call (decision.recorded), then calls reattach and sends harness.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_turn on 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 as agent.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 respond delivers it, which then fails with failed.harness_exited (or conflict.request_resolved, once the dead turn has dropped its requests). Once that turn has ended with its process gone (failed.harness_exited or 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 the turn.ended. A turn that ends any other way, any other failure, and cancel_turn drop 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, and import fails with conflict.harness_session_live if one is.
  • With liveness = "none", venom cannot tell. import requires --force and 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_tail is at most 4 KiB, with secrets redacted by the same rules as venom’s log (RFC-0003 section 3).
  • A fork that fails after it was asked for reaches the caller as code on agent.fork_ended and forks (5.4), not as an exit code: agent.fork has 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, respond on a stale request, or R retry, through reattach. 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/y runs unasked.
    • After an approved cd c, acceptEdits allows touch y by itself, which writes c/y. The cd is the only thing asked.
    • A user allow rule 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/new and find a -exec are judged only if they are asked.
  • 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 standing exec:zig grant answers zig 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 standing exec:zig grant can answer a zig build that 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 (adapter in the descriptor, 1 here). 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) and tested_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_version cycle.

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.send gains mode: turn (default), steer, interrupt_then_send (write on the agent, as RFC-0018).
    • agent.interrupt, agent.fork (write on the agent; fork also spawn on its workspace, since it creates an agent), agent.import (write on the workspace, spawn).
    • agent.fork {agent, through_turn?, task?} returns {agent, key} at once. The result is agent.fork_ended on events (5.4), or forks on agent.show: [{key, state, fork?, code?}], oldest first, with state running, ok or failed, absent with since or before. On success the fork is a new sub-agent of the source, titled fork of <title>, in the source’s folder (the only place its harness can resume the copy) and scope, with its mode, model and grants. With task it queues for a slot; without one it starts settled, until agent.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}. Without servers, the ones the agent has not used. venom’s own servers, or one the harness did not report, fail with invalid_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_agent otherwise); 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 server venom-permissions that venom passes to Claude Code with --mcp-config; its one tool permission_prompt forwards to harness.permission_prompt and 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 to harness.hook.
    • harness.hook (read, plus the process check in 7.2; denied.agent_caller for 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_caller for an agent): the client names the process or venomx pane that runs a take-over (7.2, 7.4).
    • always.list (read) and always.revoke (write on the workspace): standing “allow always” grants (6.4.3).
  • --json output and exit codes: errors and exits from section 10; no new classes. agent.hook is 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_call and agent.usage gain fields (section 5.4). Deltas are not log events. mcp.servers and agent.mcp_off are internal log records. always.granted and always.revoked are log events (6.4.3). decision.recorded, decision.closed and request.reopened are log events (9.3); only request.reopened is published, as agent.attention. attention.away is a log event published as agent.attention (7.3).
  • skill: venom’s skill documents agent.send --mode and which harnesses support steer, edit-on-approve and take-over, from harness.list.
  • doctor: section 11 checks.
  • config: new keys harness.<h>.enabled, harness.<h>.path, harness.<h>.extra_args (list, appended after the adapter’s own flags; doctor warns 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.answer with 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, getContextUsage and canUseTool without 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-server for 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

  1. 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 same stream-json pipes (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.
  2. Process adoption. Codex ships an app-server daemon with a control socket (codex app-server daemon, proxy), and thread/resume on a running thread rejoins it. This could let venom survive its own restart without interrupting Codex turns, and give Codex liveness = "api". Deferred: both are experimental in Codex 0.167.0; revisit when they are stable.
  3. Durable requests for Claude Code. A PreToolUse hook 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.
  4. Codex attention while away. Codex’s notify setting reports turn ends only. If Codex’s hooks report “waiting for approval” in its TUI, attention_away becomes "hooks". Deferred to COM-355, which adds Codex take-over.
  5. Codex resume id. codex resume takes a session id or name; thread/start returns thread.id (and thread.sessionId). Deferred to COM-355, which confirms which one the TUI accepts and stores both.
  6. Native sub-agent steering beyond observe and stop is RFC-0018 open question 4. Deferred to COM-357. The native_subagents capability is where a richer level would be declared.
  7. RFC-0018 back-references. Done (COM-317). RFC-0018 section 9 and open questions 1 and 2 point at this RFC.