Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 --prompt or --print. With no value, the prompt is stdin.
  • Works on the root and on run, with every run flag.
  • --output-format text|json|stream-json, default text.
  • --verbose: the live transcript on stderr, even if stderr is not a terminal.
  • Exit 2: --verbose without -p; --output-format without -p or --input-format stream-json; --output-format with -p --mock (a mock run already prints JSON; a conversation takes stream-json beside --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, -p waits 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/null to 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

stdoutstdout getsstderr gets
a terminalthe live transcriptnotices and errors
a pipe or filethe answer and a newline, if the turn answerednotices 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-all and --away work as in harness run. An --away refusal 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 reads harness: 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 on run and resume, 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. In stream-json it is a tool_result with status failed; there is no separate hook record.
  • A prompt a hook blocks never reaches the model: exit 1, outcome prompt_blocked, is_error true. Text output prints nothing on stdout and harness: prompt blocked by hook "keywords": <reason> on stderr, or for a hook that failed harness: prompt blocked: hook "keywords" failed (timed out after 5 s). error in the JSON formats is the same text without harness: .

Exit codes

CodeMeaning
0the turn answered, even if a tool call failed (outcome tells which in JSON)
1no 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)
2a 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"}]}
FieldMeaning
type, v"result"; the format version, 1
session_idthe session; its log is <session_id>.jsonl in the sessions folder
outcomecompleted, tool_failed, model_failed, max_iterations, interrupted, truncated or prompt_blocked; null if the turn never ended
is_errorfalse exactly when the turn answered
resultthe answer; null if is_error
errorset 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
modelthe session’s model
duration_msturn start to end; 0 if it never ended
model_callsthe turn’s model responses
usagethe turn’s token totals; cached_input_tokens null if no reply gave a cache count, reasoning_tokens 0 if none did
deniedcalls 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"}]}
typeFields
initv, 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)
usertext, kind (message, or steer for a steer that landed)
reasoningtext: the reasoning blocks, joined by a blank line; omitted if empty
assistanttext; omitted if empty
tool_useid, tool, input
tool_resultid, tool, status (ok or failed), output or error
approvalid, tool, decision, reason (when given)
approval_requesta conversation only: id, tool_use_id, tool, kind, reason, escape, summary, input, choices, turn_grant_skips_kind; a sub-agent’s also session, agent
responsea conversation only: id, command, ok, then disposition or data, or error (code, message)
compactedtrigger (automatic, manual; null in an old log), pre_tokens (the last turn reply’s whole prompt before it; null under -p)
resultas --output-format json, plus turn_id and prompt_id; in a conversation also cost
  • id pairs a tool_result or approval with its tool_use; it is never null. A call inside a script has no tool_use line: its approval (a conversation only) carries the empty id its approval_request’s tool_use_id did. A sub-agent’s tool_use lines are not printed: its approval carries the tool_use_id of its approval_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’s str.splitlines(), Node’s readline) still sees one line per object.
  • Not projected: a child agent’s records (its agent call’s tool_use and tool_result carry its work), or a script’s calls and their approvals (denied still 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"}}
commandfieldswhat happensdisposition
prompttextstarts a turn; during a turn, or a compaction sent while idle, it waits and is the next turn, never a steerstarted, queued
steertextjoins 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 firstaccepted
abortturn_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 abortinterrupting, dequeued, idle, stale
approvalid, decision, reason (optional)answers the approval_request with that id; decision is one of its choicesanswered
compact—compacts the context; during a turn, after it; a quit cuts one sent while idledone, 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)—
awayon/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 startset
client_tool_resultid, content, is_erroranswers a client_tool_call (below)answered
quit—ends the running turn, then the sessionquitting

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"}
  • id is the call’s tool_use id (and its approval_request’s tool_use_id). Answer once with client_tool_result, a command like any other (one response).
  • content is 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: true is the result the model reads as a failed call. An image with is_error, two images, or an invalid image is bad_argument and leaves the call open (answer it again, as an error). A late, repeated or settled id is unknown_call.
  • client_tool_cancel is printed when the harness stops waiting, always before the call’s tool_result and the turn’s result: reason is interrupted (an abort), quitting (quit, the first SIGINT or SIGTERM), timed_out (the tool’s timeout) or input_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 in plan mode, first sends an approval_request of kind client_tool with the once-only choices allow_once, deny, deny_and_stop. A client tool is never judged and never covered by a grant. --approve-all skips 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.