harness -p: one prompt, one turn, then exit
-p runs one turn for a script, a pipe or another agent, and exits with a code the caller can trust.
Flags
-p [TEXT], also--promptor--print. With no value, the prompt is stdin.- Works on the root and on
run, with everyrunflag. --output-format text|json|stream-json, defaulttext.--verbose: the live transcript on stderr, even if stderr is not a terminal.- Exit 2:
--verbosewithout-p;--output-formatwithout-por--input-format stream-json;--output-formatwith-p --mock(a mock run already prints JSON; a conversation takesstream-jsonbeside--mock); an empty prompt, after stdin is joined. - For a session a program holds open — many turns, gates it answers — see a conversation below.
Stdin
- Read only if stdin is not a terminal.
cat log | harness -p "why does this fail?": the message is stdin, a blank line, then the value. With a value,-pwaits up to 3 s for stdin’s first byte or end of input, then runs without stdin and says so on stderr. Redirect from/dev/nullto skip the wait.git diff | harness -p: stdin is the prompt, with no time limit. No value and stdin a terminal: exit 2.- Over 8 MiB, not UTF-8, or unreadable (a directory, a device error): exit 2, before a session is made. Unreadable
prints
harness: could not read stdin: <error>. A closed stdin (<&-) is no input.
Text output
| stdout | stdout gets | stderr gets |
|---|---|---|
| a terminal | the live transcript | notices and errors |
| a pipe or file | the answer and a newline, if the turn answered | notices and errors; the transcript too if stderr is a terminal or with --verbose |
A turn answered if the session ended cleanly with outcome completed or tool_failed; the answer is the
model’s last response text. When a reply hit the output cap and the harness continued it, the answer is the text
of every try in that last streak, joined in order with no separator. Calls that failed on the way are then named on stderr:
harness: 2 tool calls failed in this turn: read_file, bash (approval refusals have their own line). Otherwise
stdout is empty and stderr says why: the session’s failure, else the words of a blocked prompt, else the model’s
error (a 429, a bad key), else a “did not complete” line. Trust stdout exactly when the exit code is 0:
out=$(harness -p "summarise CHANGELOG.md" 2>log) || { cat log; exit 1; }
The output cap
A reply that stops at the output cap with no tool call is continued, as in an interactive session
(config.md): up to 3 tries in all, so a -p run can make two more model
calls than it used to, each counted in model_calls and usage. The third try runs one thinking level lower
when the second was all thinking. The ↻ The reply hit the output limit. Continuing (2/3). line goes with the
transcript, so it shows on a terminal and with --verbose, and not on a piped run with a quiet stderr. A turn
still cut after the third try ends truncated (exit 1, no answer). In stream-json each try is its own
reasoning and assistant records; result holds the joined answer. harness eval continues the same way:
a case that used to end truncated can now finish, and output_contains reads the turn’s answer, the joined
text, not only the last reply.
Approvals
- Somebody can answer if stdin is a terminal, the prompt is not from stdin, and the transcript is on a terminal.
Gated calls then ask, as in
harness run. - Nobody can answer otherwise. A gated call is denied; the model gets “I’m not at a terminal to approve this, so
it did not run. Don’t retry it; finish with what you can and say what you could not do.” as its result, and the
turn goes on. Stderr gets
harness: refused <tool> (needs approval, nobody to ask): <summary>. --approve-alland--awaywork as inharness run. An--awayrefusal is a failed call: the answer prints and stderr names the call.
Hooks
-p runs the hooks in your user config (hooks.md); --no-hooks turns them off.
- The banner’s
hooks: …line and the root session’s hook warnings go to stderr (a sub-agent’s hook warnings show on the panel only). A warning readsharness:where the panel’s has its warning sign:harness: hook no-force-push is failing (timed out after 10 s), so the calls it covers are blocked. Fix it, set on_failure = "continue", or restart with --no-hooks.A conversation (--input-format stream-json) prints them the same way onrunandresume, a sub-agent’s included. - A call a hook blocks is a failed call: the model reads why, the turn goes on, and stderr names it with
the other failed calls. It is not in
denied, which lists calls that needed approval. Instream-jsonit is atool_resultwithstatusfailed; there is no separate hook record. - A prompt a hook blocks never reaches the model: exit 1,
outcomeprompt_blocked,is_errortrue. Text output prints nothing on stdout andharness: prompt blocked by hook "keywords": <reason>on stderr, or for a hook that failedharness: prompt blocked: hook "keywords" failed (timed out after 5 s).errorin the JSON formats is the same text withoutharness:.
Exit codes
| Code | Meaning |
|---|---|
| 0 | the turn answered, even if a tool call failed (outcome tells which in JSON) |
| 1 | no answer (model_failed: a model error, or two replies in a row with no text and no tool call; max_iterations; interrupted; truncated; prompt_blocked: a prompt hook blocked the prompt), or a failure after start (a missing key, a failed session, an unwritable stdout) |
| 2 | a refusal or argument error (bad flags, a refused stdin, an empty prompt) |
Ctrl-C and SIGTERM interrupt the turn at its next checkpoint; the turn ends interrupted (or model_failed with
error: "interrupted: " when the signal lands on the model call), exit 1.
A failed write to a piped or redirected stdout (a full disk, a closed pipe) fails the run in every format: stderr
gets harness: could not write to stdout: <error>, exit 1. In the JSON formats a failed turn’s error precedes
that line on stderr. stream-json writes nothing after the failed line, then exits 1. Not covered: a terminal that
closes mid-run.
--output-format json
One object on stdout when the run ends, nothing else. Stderr gets notices, errors, the transcript with --verbose,
and failed calls as in text output (stream-json likewise). After the session starts, a failure goes in error,
not on stderr. Neither JSON format runs the background-colour probe ([ui] probe).
{"type":"result","v":1,"session_id":"01M4JAEP0CST02MMP62FGKJ823","outcome":"tool_failed","is_error":false,"result":"I could not run the tests: running commands needs approval here.","error":null,"model":"openai:gpt-5.6-sol","duration_ms":4210,"model_calls":2,"usage":{"input_tokens":3100,"output_tokens":700,"cached_input_tokens":null,"reasoning_tokens":210},"denied":[{"tool":"bash","summary":"cargo test"}]}
| Field | Meaning |
|---|---|
type, v | "result"; the format version, 1 |
session_id | the session; its log is <session_id>.jsonl in the sessions folder |
outcome | completed, tool_failed, model_failed, max_iterations, interrupted, truncated or prompt_blocked; null if the turn never ended |
is_error | false exactly when the turn answered |
result | the answer; null if is_error |
error | set exactly when is_error: the session’s failure, else a blocked prompt’s words, else the model’s error, else the “did not complete” line |
model | the session’s model |
duration_ms | turn start to end; 0 if it never ended |
model_calls | the turn’s model responses |
usage | the turn’s token totals; cached_input_tokens null if no reply gave a cache count, reasoning_tokens 0 if none did |
denied | calls in the session that needed approval and did not run (nobody to ask, denied at a question, refused under --away), each with its approval summary’s first line; includes calls inside a script; a child agent’s stay in its own log |
A failure before the session exists (bad flags, a missing key, a refused stdin) goes to stderr only, with its usual exit code. After that, exactly one result object prints, even if the stream closes early or the session fails after the turn.
--output-format stream-json
One complete JSON record per line, flushed as the session writes it, never a streaming delta. The last line is the
result object. Reasoning is always included, whatever [ui] thinking says.
{"type":"init","v":1,"session_id":"01M4JAEP0CST02MMP62FGKJ823","model":"openai:gpt-5.6-sol","mode":"manual","cwd":"/home/me/project","harness_version":"0.12.0"}
{"type":"user","text":"run the tests"}
{"type":"reasoning","text":"**Running the tests**\n\nI'll start with cargo test."}
{"type":"tool_use","id":"7EXJS7R3G160QVGQFXGZVM875N","tool":"bash","input":{"command":"cargo test"}}
{"type":"approval","id":"7EXJS7R3G160QVGQFXGZVM875N","tool":"bash","decision":"denied","reason":"I'm not at a terminal to approve this, so it did not run. Don't retry it; finish with what you can and say what you could not do."}
{"type":"tool_result","id":"7EXJS7R3G160QVGQFXGZVM875N","tool":"bash","status":"failed","error":"denied by approval gate: cargo test. The user said: I'm not at a terminal to approve this, so it did not run. Don't retry it; finish with what you can and say what you could not do."}
{"type":"assistant","text":"I could not run the tests: running commands needs approval here."}
{"type":"result","v":1,"session_id":"01M4JAEP0CST02MMP62FGKJ823","outcome":"tool_failed","is_error":false,"result":"I could not run the tests: running commands needs approval here.","error":null,"model":"openai:gpt-5.6-sol","duration_ms":4210,"model_calls":2,"usage":{"input_tokens":3100,"output_tokens":700,"cached_input_tokens":null,"reasoning_tokens":210},"denied":[{"tool":"bash","summary":"cargo test"}]}
type | Fields |
|---|---|
init | v, session_id, model, mode, cwd, harness_version; in a conversation also commands, resumed and, when the session has client tools, client_tools (their names, in order) |
user | text, kind (message, or steer for a steer that landed) |
reasoning | text: the reasoning blocks, joined by a blank line; omitted if empty |
assistant | text; omitted if empty |
tool_use | id, tool, input |
tool_result | id, tool, status (ok or failed), output or error |
approval | id, tool, decision, reason (when given) |
approval_request | a conversation only: id, tool_use_id, tool, kind, reason, escape, summary, input, choices, turn_grant_skips_kind; a sub-agent’s also session, agent |
response | a conversation only: id, command, ok, then disposition or data, or error (code, message) |
compacted | trigger (automatic, manual; null in an old log), pre_tokens (the last turn reply’s whole prompt before it; null under -p) |
result | as --output-format json, plus turn_id and prompt_id; in a conversation also cost |
idpairs atool_resultorapprovalwith itstool_use; it is never null. A call inside ascripthas notool_useline: itsapproval(a conversation only) carries the emptyiditsapproval_request’stool_use_iddid. A sub-agent’stool_uselines are not printed: itsapprovalcarries thetool_use_idof itsapproval_request.decision:allowed_once,allowed_for_turn,allowed_for_session(old logs only),denied,denied_and_stopped,timed_out,refused(under--away).- A call cut off by the reply’s output cap is never requested, so has no line; the model sees the failure and retries.
- Every line is one JSON object with U+2028 and U+2029 written as
\u2028/\u2029, so a reader that splits on Unicode line separators (Python’sstr.splitlines(), Node’sreadline) still sees one line per object. - Not projected: a child agent’s records (its
agentcall’stool_useandtool_resultcarry its work), or ascript’s calls and their approvals (deniedstill lists them).
The contract: types and fields are only ever added; consumers ignore unknown ones; removing or redefining one
bumps v. The same holds for a conversation’s commands: fields are only added, and a command type is never
removed.
A conversation: --input-format stream-json
harness run --input-format stream-json holds a session open for a program: stdin takes one JSON command per
line, stdout prints the stream-json lines above, every gate as an approval_request, and one result per
turn. harness resume <id> --input-format stream-json continues a session the same way. Stdin must be a pipe;
-p and --verbose cannot be combined with it, and --output-format other than stream-json is
refused (exit 2 each; stdout is already the whole transcript). --mock <file> is accepted
(below). Every other run flag keeps its meaning:
--approve-all means no approval_request is ever written; --away puts the first task away (its gates are
refused, get_state.away is true), and away being one task’s, the next prompt’s gates ask.
The first line on stdout is init, with commands (what this build takes) and resumed. Then, for every command,
exactly one response — written before anything the command causes:
{"type":"response","id":"fix-1","command":"prompt","ok":true,"disposition":"started"}
{"type":"response","id":"s9","command":"steer","ok":false,"error":{"code":"no_turn","message":"no turn is running; send prompt"}}
| command | fields | what happens | disposition |
|---|---|---|---|
prompt | text | starts a turn; during a turn, or a compaction sent while idle, it waits and is the next turn, never a steer | started, queued |
steer | text | joins the running turn at its next landing point, as a message typed mid-turn does; shown as user with kind: "steer", or dropped if the turn ends first | accepted |
abort | turn_id or prompt_id (optional, not both; null is absent) | interrupts the running turn at its next checkpoint; with a turn_id that is not the running turn’s, nothing; with a prompt_id, takes that prompt back from the queue, or interrupts the turn it opened; a prompt started and its turn not yet begun is interrupted at once, ahead of any command sent after the abort | interrupting, dequeued, idle, stale |
approval | id, decision, reason (optional) | answers the approval_request with that id; decision is one of its choices | answered |
compact | — | compacts the context; during a turn, after it; a quit cuts one sent while idle | done, with data.compacted (trigger, pre_tokens, as on its compacted line, which comes first) when this attempt wrote one, and no data when it declined; queued; error quitting when the session ended first. A queued compaction’s compacted line is never another compact’s data |
get_state | — | data: session_id, model, thinking, mode, away, turn ("idle" or {id, prompt_id}), open_requests, queued, context (last_prompt_tokens, window, percent, auto_compact_at_percent; the first three null until known) | — |
away | on | /away or its end, for the running task and every running sub-agent, or the next task; get_state.away says what was set, though the log records it at the next gate or the next task’s start | set |
client_tool_result | id, content, is_error | answers a client_tool_call (below) | answered |
quit | — | ends the running turn, then the session | quitting |
id is yours, echoed on the response and, for a prompt, on its turn’s result as prompt_id; null is no
id, and one that is not a string is refused (bad_argument) and not echoed. Errors never end the session: parse (not a JSON object; the command is parse), unknown_command, bad_argument (the message
names the field), no_turn, no_such_request, unknown_call, quitting (any command after quit), too_long (a line over
8 MiB, skipped whole), refused (away {"on": true} where the panel refuses /away; the message is the panel’s
line).
Approvals. Every gate the panel would ask about — the root’s and a sub-agent’s — is written as an
approval_request: id (what approval names; the approval.requested.v1 record’s own), tool_use_id (its
tool_use line; empty for a call inside a script), tool, kind (gate, protected, removal,
decode_and_run, credential, escape, mcp, client_tool, judge_flag, judge_unavailable), reason, escape,
summary, input (the call’s arguments), choices (allow_once, allow_for_turn, deny, deny_and_stop; a
full escape, an mcp call and a client_tool call offer no allow_for_turn, and answering one with it is bad_argument) and turn_grant_skips_kind (allow_for_turn then covers
the turn’s other calls of the tool, never another call of this kind). A sub-agent’s carries session and agent
too; answer it the same way. The answer’s reason reaches the model as The user said: …, and the decision prints
as today’s approval line — a sub-agent’s with the same session and agent.
kind follows the first that applies: the gate’s own reason (protected, removal, decode_and_run,
credential, or escape for a full escape); the judge (judge_flag, judge_unavailable), whatever the call asks
to escape to; an escape the judge did not decide (escape, with escape.kind network or outside); mcp;
client_tool; else gate.
A full escape (outside_sandbox: true) is never asked of a program by default: a conversation runs under
escape = "network_only" as --approve-all does, so the sandbox block tells the model no full escape is on offer,
network: true is still offered (asked in manual, judged in auto), and the program on the other end — which may
be a model with no person behind it — can never let a command out of the sandbox. --allow-escapes (on run and
resume, with --input-format stream-json only) restores escape = "ask" for a client whose requests a person
answers; a full escape then arrives as kind escape with escape.kind outside and no allow_for_turn. It is refused with --approve-all, which answers nothing. A
crash resume keeps the rule its log recorded: a program resuming a session recorded as ask must pass
--allow-escapes, and the flag on a session recorded as network_only is refused rather than ignored (exit 2, one
line each). An MCP call’s once-only approval is unchanged.
An approval_request nobody answers is timed out at [away] timeout (default 15 min; off waits for ever; a
session started with --options takes approval_timeout instead) with the panel’s consequences. Where /away
may start (approvals required, the sandbox on or external, and the mode’s ask holding neither bash nor
exec), the task goes away: the call is refused, the turn goes on, every later gate of that task is refused with
no request, and away ends with the turn. Elsewhere the turn ends interrupted. away {"on": true} puts the
running or the next task away under the same rule, else it is answered refused. {"on": false} ends an away
task.
Turns. One result per turn, the same object as above for that turn alone, plus turn_id, prompt_id and
cost; nothing else ends a turn. is_error says whether the turn answered. cost prices the turn’s model calls
as harness session prices a session: usd (the provider’s own figure where it reports one, else the catalog’s
list price; null when nothing could be priced), priced, unpriced and free (calls to mega) counts, bound
("upper" when every call was priced and none from the catalog past 200K input tokens, else null) and basis
(reported, list_price, mixed, or null). The root session’s calls since the last result: a compaction inside
the turn counts, and so does one between turns, in the next turn’s cost.
Ending. Closing stdin means “no more input”: the running turn finishes, the prompts still queued run in order,
each with its result, and the session ends, exit 0. A gate opened after that has nobody to answer it, so it is
refused at once with -p’s reason (the approval line carries it, decision: "denied") and the turn goes on.
quit and the first Ctrl-C (or SIGTERM) are the stop: the running turn is interrupted and prints its result
(outcome: "interrupted", or model_failed with error: "interrupted: " when the interrupt lands on the model
call), the queued prompts are dropped, the session ends, exit 0. A request still open is closed by that interrupt
as the panel’s Esc closes one: recorded as a denial with no reason and printed as approval with
decision: "denied", before the result (abort does the same to the turn’s open request). A second Ctrl-C or
SIGTERM exits at once, exit 1, without the result. Exit 1 also means a failure after the start (an unwritable
stdout, a failed session); exit 2 a refused flag.
# one turn, from a shell: closing stdin lets it finish
printf '%s\n' '{"type":"prompt","id":"1","text":"list the tests in tests/"}' \
| harness run --input-format stream-json --approve-all | jq -c 'select(.type=="result") | {prompt_id, outcome, result, cost}'
# two turns, the second steered
{
printf '%s\n' '{"type":"prompt","id":"1","text":"list the tests in tests/"}'
sleep 20
printf '%s\n' '{"type":"prompt","id":"2","text":"run them"}'
sleep 2
printf '%s\n' '{"type":"steer","text":"use pytest -x"}'
} | harness run --input-format stream-json --approve-all | jq -c 'select(.type=="result") | {prompt_id, outcome, result}'
The whole wire, both directions, is written down in
protocol/stream-json.schema.json (JSON Schema 2020-12; $defs/command is
what a program sends, $defs/line what the harness prints). The code’s lists (COMMANDS, LINE_TYPES,
ERROR_CODES in crates/cli/src/headless.rs), the schema and the conformance transcripts in sdk/conformance/
are held equal by a test, so a line added without its schema entry fails the build.
A client that answers gates reads each approval_request and writes {"type":"approval","id":…,"decision":…};
jq -c --unbuffered on stdout and a coprocess on stdin are enough. A complete one in Python, with a scenario per
feature group run against a real model, is examples/stream-json-client.
Client tools
A session started with --options can carry tools the program runs. When one is ready to run (after its gate),
the harness prints client_tool_call between the call’s tool_use and tool_result lines:
{"type":"client_tool_call","id":"7EXJS7R3G160QVGQFXGZVM875N","name":"search_trains","input":{"to":"Leeds","day":"2026-11-06"},"turn_id":"01M4…"}
{"type":"client_tool_result","id":"7EXJS7R3G160QVGQFXGZVM875N","content":[{"type":"text","text":"3 trains: 07:40, 09:10, 11:15"}],"is_error":false}
{"type":"client_tool_cancel","id":"7EXJS7R3G160QVGQFXGZVM875N","reason":"timed_out"}
idis the call’stool_useid (and itsapproval_request’stool_use_id). Answer once withclient_tool_result, a command like any other (oneresponse).contentis a list of{"type":"text","text":…}blocks and at most one{"type":"image","media_type":…,"data":<base64>}block; a plain string is one text block.is_error: trueis the result the model reads as a failed call. An image withis_error, two images, or an invalid image isbad_argumentand leaves the call open (answer it again, as an error). A late, repeated or settledidisunknown_call.client_tool_cancelis printed when the harness stops waiting, always before the call’stool_resultand the turn’sresult:reasonisinterrupted(anabort),quitting(quit, the first SIGINT or SIGTERM),timed_out(the tool’stimeout) orinput_closed(stdin ended with the call open). Cancelling does not undo what the program already did.- With no program to answer (stdin closed, a panel or REPL resume) a call ends at once with the error “no
program is attached to run
<name>”; if writing stdout failed, “the program’s connection failed”; past its timeout, “timed out after<t>”. - A tool with
ask: true, and every client tool inplanmode, first sends anapproval_requestof kindclient_toolwith the once-only choicesallow_once,deny,deny_and_stop. A client tool is never judged and never covered by a grant.--approve-allskips the gate. - A result line may be at most 8 MiB, as every line; a sender keeps its image and text under that.
Options: --options <file>
run --options <path> and resume <id> --options <path> read one JSON object (at most 1 MiB) and start the
session the way an SDK does (sdk.md): isolated from the machine’s own setup unless the file turns
a read back on. Only with --input-format stream-json; elsewhere it is exit 2. An unknown field, a wrong type
(JSON null included), an invalid value, or a field that repeats a flag given on the same command line is
exit 2 with one line naming the field, before anything is written.
Fields: model, thinking, mode (manual, auto, plan; default manual), sandbox (on, off,
external; default on), builtin_tools (the ceiling for the session’s life; [] offers none; to call an MCP
server mcp must be in it), client_tools (definitions: name, description, input_schema, ask,
timeout; at most 128), system_prompt ({"replace": …} or {"append": …}), project_instructions,
user_config, skills, agents, hooks (each default false; hooks needs user_config), mcp_servers
(the [mcp.servers] table’s shape), approval_timeout (default "15m", at most "24h", or "off"),
allow_escapes and max_iterations. What each switch suppresses is in sdk.md.
On a resume of an options session, the mode, builtin_tools, system_prompt, the isolation switches and
sandbox come from the log (giving one, or --sandbox, is refused); client_tools must equal the log’s,
and is required when the log has any; the MCP servers come from the log, and mcp_servers, if given, must
equal them, every one unchanged and no other (the SDK never gives it); model, thinking, max_iterations,
approval_timeout and allow_escapes may be given. resume --input-format stream-json needs a session id.
--mock in a conversation
run --mock <file> --input-format stream-json (and resume) is a live conversation whose model is the mock
file: no key, no network, the mode, sandbox, options and client tools real. --output-format stream-json beside
it is accepted. A sub-agent call ends with the error “sub-agents are not available in a mock conversation”. A
resumed segment answers from the start of the file it is given. Without --input-format, run --mock is the
ungated event dump as before.