Protocol Specification
Wire protocol: 0.4 · Document revision: 1.10 · Status: Stable
The full specification is maintained in the repository at spec/protocol.md.
Profile YAML
command: python3 # argv[0] — always a single token, never split
args: ["{{PROFILE_DIR}}/my_agent.py"] # argv[1..]; defaults to [] when omitted
# {{PROFILE_DIR}} = profile's own directory
# cwd is optional. If omitted, defaults to the bridge's process cwd.
# If relative, resolves against {{PROFILE_DIR}} (the profile's directory).
cwd: /path/to/workspace
env:
MY_API_KEY: "${MY_API_KEY}"
env_allowlist: [MY_API_KEY] # optional: restrict ${VAR} expansion
timeout_secs: 600 # per-turn wall-clock timeout (secs), default 1800
budget_secs: 900 # optional: per-turn wall-clock budget (secs); no default
deadline: "2025-01-01T12:00:00+00:00" # optional: absolute ISO-8601 turn deadline (explicit offset)
kill_grace_secs: 5 # SIGTERM → SIGKILL grace period, default 5
streaming: true # forward {"type":"partial"} events in real time
permission: false # optional tool authorization (keep stdin open; see full spec)timeout_secs is the turn's default wall-clock limit. Two optional fields tighten it: budget_secs (a per-turn budget measured by the bridge) and deadline (an absolute ISO-8601 instant with an explicit timezone offset — naive values are a profile validation error). The earliest expiry of the three wins, and expiry follows the same kill sequence and exit code as a plain timeout. All three are bridge-side profile fields; none of them appear on the wire. A bridge that runs an agent in-process through an executor is not required to honour budget_secs / deadline.
Placeholders are substituted without invoking a shell. The argv is built from two fields:
command— argv[0]. A single token, never split, even if it contains whitespace.args— a YAML list of argv[1..] tokens. Defaults to[]when omitted.
The resulting argv ([command, *args]) is passed to execve directly, which lets command carry a path with spaces:
command: "/path with spaces/my agent"
args: [](0.2 had a shorthand where args absent + command containing whitespace meant "split command". 0.3 removed it — command is always one token.)
Input — stdin turn object
Before the agent reads its first byte of stdin, the bridge writes exactly one NDJSON line: the turn object.
{"type":"turn","message":"hello","session_id":"","session_name":"default",
"attachments":[],"permission":false,"protocol_version":"0.4"}Required fields
| Field | Description |
|---|---|
type | Literal "turn". |
message | User message text. May be "" (see "Empty turns" in the full spec). |
session_id | Session ID from the previous turn ("" = new session). |
protocol_version | Protocol version string, e.g. "0.4". Opaque and non-comparable — agents MUST NOT order or range-check it. |
Optional fields (present = relevant)
| Field | Description |
|---|---|
session_name | Human-readable session name (default "default"). |
from_user | Platform-specific sender identifier. MAY be included when the platform knows the sender; absent or "" = unknown. Agents MUST ignore it when they do not need it. |
attachments | Array of {kind, url, ...} (e.g. {"kind":"image","url":"https://..."}). The only attachment channel — there are no single-attachment convenience vars. Absent/[] = none. |
permission | true when the profile has permission: true; absent/false otherwise. |
Secrets and per-CLI config travel in environment variables (the profile env block), not the turn object. The per-turn request does not travel in env vars in 0.4.
stdin / EOF
- When
permissionis absent/false, the bridge writes the turn line and then closes stdin (EOF). The agent MUST NOT block on stdin after reading the turn. - When
permission: true, the bridge keeps stdin open for mid-turn{"type":"permission_response"}lines (see full spec).
Output — stdout NDJSON events
Every stdout line is a JSON object terminated by \n, carrying a type field. The vocabulary is closed: partial, result, error, and (when permission is on) permission_request.
{"type":"partial","text":"..."} ← streaming chunk; forwarded when streaming: true
{"type":"result","text":"..."} ← terminal reply body (at most one); optional usage
{"type":"error","message":"..."} ← user-readable error; any mode; fails the turn
{"type":"permission_request",...} ← optional tool authorization (permission: true)Optional session_id may appear on any of these events. There is no {"type":"session"} or {"type":"text"} event in 0.4.
A line that is not valid JSON, is not an object, or lacks a recognised type is ignored (logged to stderr) — it is not treated as reply body. Reply body is carried by result (and forwarded partials when streaming).
session_id on events — first non-empty
Session continuity is a field on events, not a dedicated event type.
- The bridge persists the first non-empty
session_idobserved in the turn. - Early events MAY omit the field; once known, agents SHOULD attach it to subsequent events.
- Stateless agents omit
session_identirely (and MUST NOT mint an id the tool cannot resume with). - A later different non-empty value is a violation (keep the first; warn).
{"type":"partial","text":"thinking..."}
{"type":"partial","text":"answer","session_id":"cli-sess-9f3a2c1e-4b8d-4a2f-b6c1-2e8d4f5a7b9c"}
{"type":"result","text":"","session_id":"cli-sess-9f3a2c1e-4b8d-4a2f-b6c1-2e8d4f5a7b9c"}If session_id and an error appear in the same turn, the bridge MUST still persist the session id for the next turn even though the current turn is reported as failed.
Partial events
Streaming chunks. Forwarded immediately when streaming: true; ignored by the runner otherwise. An optional role ("output" | "thinking") distinguishes assistant output from reasoning.
{"type":"partial","text":"Here is the first part. "}
{"type":"partial","role":"thinking","text":"Let me consider..."}Result events — terminal reply body
At most one result per turn. Optional usage for token/cost stats. When streaming already delivered the body via partials, result.text MAY be "".
{"type":"result","text":"Here is the complete answer.","session_id":"cli-sess-…"}Error events
User-readable error. Honored regardless of streaming mode. The bridge forwards it to the user, suppresses further partials, and MUST treat the turn as failed even if the exit code is 0. A result after an error is discarded.
{"type":"error","message":"Upstream API rate limited. Try again in 60s."}Optional tool permission
Opt-in via profile permission: true. Not general HIL — tool authorization only. On the in-process executor path permission: true is only accepted by an executor that declares an approval channel (claude-code today); every other executor refuses to run — an error event plus a non-zero exit code, never a silent fallback to --dangerously-skip-permissions / --yolo. Profiles that do not ask for permission keep those auto-approve flags, and AGENTPROC_AUTO_APPROVE=0 / false turns any of them into the same refusal.
{"type":"permission_request","request_id":"1","tool_name":"Bash","input":{"command":"echo ok > f.txt"}}Bridge writes on stdin after the user approves:
{"type":"permission_response","request_id":"1","behavior":"allow"}See the full spec for stdin keep-open rules, timeouts, and field definitions.
In-process executor argv
On the spawn path the message travels on stdin, never argv. An in-process executor drives a third-party CLI, so it can honour that rule only when the CLI reads its prompt from stdin: then the executor MUST keep the message off argv and return the CLI's stdin payload from the optional buildInitialStdin(message, sessionId) hook, which the runner writes to the CLI's stdin. claude-code does this in both modes (claude --print --input-format stream-json, one stream-json user line on stdin).
CLIs that only take their prompt as a command-line argument are the documented exception: buildArgs may place the message in the target CLI's argv. That argv is readable by any same-user process via ps(1), is capped by the OS argument-length limit (~128 KiB Linux, ~32 K chars Windows), and can be mistaken for flags if the message begins with -. When the message is a positional argument the executor MUST put -- immediately before it and every flag before that. Run such executors on a dedicated host, container, or VM. See the full spec for details.
Exit Codes
| Code | Meaning |
|---|---|
0 | Success |
1 | Generic agent error |
124 | Timeout (bridge-imposed; matches GNU timeout) |
130 | Interrupted by SIGINT |
143 | Terminated by SIGTERM |
Precedence when multiple failure signals arrive: timeout (124) > error event (1) > process exit code.
When the process exits non-zero without emitting an error event, bridges SHOULD send a generic error message to the user.
Timeout Handling
When the turn's effective limit is reached — timeout_secs, or whichever of budget_secs / deadline expires first:
- Bridge sends
SIGTERMto the agent's whole process group. - Bridge waits
kill_grace_secs(default 5) for the process to exit. - If still running, bridge sends
SIGKILLto the same group.
Already-received partial events are forwarded. The agent SHOULD handle SIGTERM by flushing buffered partials and exiting promptly.
Killing the tree matters: agents spawn helpers (shell tool calls, builds, sub-agents) that inherit the agent's credentials and workspace write access, so signalling only the direct child leaves them running with nobody left to reap them. The same group signal applies to a cooperative cancel, Ctrl-C, and a bridge-internal CLI timeout that fires before the bridge's own limit; a bridge whose CLI has its own process group must forward an incoming termination to that group. Windows has no process groups and degrades to terminating the direct process.
A bridge MUST NOT wait unboundedly for the agent's inherited stdio after the direct child exits: those pipes only reach EOF when every holder is gone, so a surviving helper would keep the turn open forever. The bridge drains for a short bounded window (1 s in the reference runners), then abandons the pipes and finishes the turn with what it has.
Observability
Bridges MAY record bridge-side timing and an event journal (opt-in, off by default, file-only — never stdout; stdout/stderr stay byte-identical when disabled): per-event seq + millisecond ISO-8601 UTC ts, and a run result carrying started_at/duration measured by the bridge (distinct from the agent's self-reported usage.duration_ms). See the full spec's "Event traceability (optional)".
Design Principles
- Process boundary is the only contract. Any process that reads a turn from stdin and writes NDJSON events to stdout is a valid agent.
- Agent doesn't know about the platform. Platform concerns (delivery, rate limiting) are the bridge's job.
- Session IDs are opaque. The bridge stores and forwards them; the agent owns their meaning.
- One turn per process. Long-lived sessions and mid-turn cancellation are out of scope by design.
type:is not part of this spec. Built-in shortcuts are platform extensions, not P0.
See the full spec on GitHub for design rationale and a comparison with MCP, ACP, NDJSON, SSE, LSP, and the Unix filter convention.