Driving Claude Code headlessly with stream-json
Updated July 27, 2026 · 9 min read
With --input-format stream-json and --output-format stream-json, Claude Code becomes a bidirectional JSONL process you can drive from any language. The interesting part is the control channel: permission requests arrive as control_request events and you answer them with control_response, which is what makes a custom approval UI possible.
Verified against Claude Code 2.1.220
This is the protocol BubbleCode itself uses, so everything here is load-bearing in a shipping app rather than reconstructed from docs. It is not a stable public API — expect it to move between releases and pin a version if you build on it.
claude -p prints a response and exits. That's fine for scripting a one-shot task, and useless if you want a long-lived session you can talk to. The streaming JSON mode is the other thing: a subprocess you keep open, write JSONL to on stdin, and read JSONL from on stdout.
Starting the process
claude -p \
--input-format stream-json \
--output-format stream-json \
--permission-mode default \
--permission-prompt-tool stdio-p/--print— non-interactive. Required for the stream formats.--input-format stream-json— realtime streaming input on stdin, rather than a single prompt argument.--output-format stream-json— realtime streaming events on stdout, one JSON object per line.--permission-prompt-tool— routes permission prompts to your process instead of a terminal UI. It isn't listed inclaude --help; it's the SDK-facing flag, and it's the one that makes the whole thing interesting.
Useful companions: --include-partial-messages for token-level streaming, --replay-user-messages to have your own messages echoed back for acknowledgement, --session-id <uuid> to pin the session ID, --resume <id> to continue an existing one, and --max-budget-usd to cap spend.
It uses the existing login
A subprocess driven this way authenticates the same way your terminal sessions do — the user's Claude Code login. No API key, no proxy, no separate billing. That's the main practical reason to shell out to the CLI rather than calling the API directly.
Sending a message
Write one JSON object per line to stdin. A user turn looks like this:
{
"type": "user",
"message": {
"role": "user",
"content": [{ "type": "text", "text": "Refactor the auth module" }]
}
}Newline-terminated, no pretty-printing. The process stays alive between turns, so this is a conversation rather than a series of invocations.
What comes back
| `type` | Meaning |
|---|---|
system | Lifecycle. subtype: "init" carries the resolved model and the session_id — capture that, it's your handle for resuming later. |
stream_event | Incremental token deltas, when --include-partial-messages is on. Wrap the inner event object. |
assistant | A complete assistant message. message.content is an array of blocks; type: "tool_use" blocks tell you which tool it's invoking and with what input. |
user | Echoed tool results. Blocks of type: "tool_result" with an is_error flag — the cheapest place to notice a failing command. |
control_request | The control channel. This is where permission requests arrive. |
result | End of turn, with duration and cost. Also how a failed --resume surfaces: an is_error result with no preceding init. |
The permission handshake
This is the part worth the article. When the agent wants to use a tool that needs approval, you get a control_request with subtype: "can_use_tool":
{
"type": "control_request",
"request_id": "req_01H...",
"request": {
"subtype": "can_use_tool",
"tool_name": "Bash",
"description": "Run the test suite",
"input": { "command": "npm run test", "description": "Run the test suite" },
"permission_suggestions": [
{ "type": "addRules", "rules": [{ "toolName": "Bash", "ruleContent": "npm run test *" }] }
]
}
}The session is now blocked. Nothing else will happen until you write a control_response carrying the same request_id:
{
"type": "control_response",
"response": {
"subtype": "success",
"request_id": "req_01H...",
"response": { "behavior": "allow", "updatedInput": { "command": "npm run test" } }
}
}Three shapes of answer:
- Allow —
{ "behavior": "allow", "updatedInput": <the input> }. Echo the input back; you can also modify it, which is how you'd implement 'yes, but with--dry-run'. - Deny —
{ "behavior": "deny", "message": "..." }. The message goes to Claude, so a specific reason lets it adapt rather than retry the same thing. - Allow always — allow, plus
updatedPermissionsset to thepermission_suggestionsfrom the request. That's how a 'don't ask again' button works: the CLI proposes the rule, you hand it back.
A minimal driver
import json, subprocess, threading, queue
proc = subprocess.Popen(
["claude", "-p",
"--input-format", "stream-json",
"--output-format", "stream-json",
"--permission-mode", "default",
"--permission-prompt-tool", "stdio"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
text=True, bufsize=1,
)
def send(obj):
proc.stdin.write(json.dumps(obj) + "\n")
proc.stdin.flush()
def approve(request_id, tool_input):
send({"type": "control_response", "response": {
"subtype": "success",
"request_id": request_id,
"response": {"behavior": "allow", "updatedInput": tool_input},
}})
send({"type": "user", "message": {
"role": "user",
"content": [{"type": "text", "text": "Run the test suite and fix what fails"}],
}})
for line in proc.stdout:
line = line.strip()
if not line:
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
continue # partial line; buffer in real code
kind = event.get("type")
if kind == "system" and event.get("subtype") == "init":
print("session:", event.get("session_id"))
elif kind == "control_request":
req = event.get("request", {})
if req.get("subtype") == "can_use_tool":
print("wants:", req.get("tool_name"), req.get("input"))
# ---- this is where a human decides ----
approve(event["request_id"], req.get("input", {}))
elif kind == "result":
print("done:", event.get("total_cost_usd"))
breakBuffer properly
Reading line-by-line is fine for a demo and wrong in production — long tool outputs arrive split across reads. Accumulate into a buffer and split on newlines, keeping the trailing partial. Every implementation hits this.
Things that bite
- The process blocks on you. A
control_requestyou never answer means the session sits there forever. If your UI can crash or lose focus, you need a story for what happens to in-flight requests — that latency is the whole cost model. - Failed resume is quiet.
--resumewith a stale ID produces anis_errorresult with noinitevent, then exits. Track whether you ever sawinitto tell the difference between 'resumed' and 'never started'. - Every session is a real process. Ten background sessions are ten
claudeprocesses with ten context windows. Start them lazily rather than restoring everything at launch. - Don't auto-approve to make it smooth. If you're building on this, the value is putting a human in front of the decision faster — not deciding for them. A relay that quietly allows things is a
bypassPermissionsyou didn't tell the user about.
What it's good for
Custom approval UIs, primarily. Once permission requests are structured events rather than terminal prompts, you can put the decision anywhere — a menu bar, a phone, a Slack message, a floating bubble that renders over fullscreen video. That last one is BubbleCode: each chat is one of these subprocesses, and the bubble is the approval surface.
It's also the right substrate for CI integration where you want structured events rather than scraped text, for multi-agent orchestration with real per-session state, and for anything that needs cost and duration per turn from the result event.
What it isn't is a stable public API. Field names have changed between releases. If you ship on it, pin a CLI version, and check claude --version at startup so you fail loudly rather than mysteriously.
FAQ
How do I run Claude Code programmatically?
Launch it with `claude -p --input-format stream-json --output-format stream-json` and talk to it over stdin/stdout with newline-delimited JSON. The process stays alive across turns, so it's a session rather than a series of one-shot invocations.
How do permission requests work in headless Claude Code?
They arrive on stdout as an event with type "control_request" whose request has subtype "can_use_tool", carrying the tool name, its input, and suggested permission rules. You reply on stdin with a "control_response" containing the same request_id and a behavior of "allow" or "deny". The session blocks until you answer.
Does driving Claude Code as a subprocess need an API key?
No. The CLI uses the user's existing Claude Code login, so a wrapper app inherits that authentication and bills against the same subscription rather than API rates.
How do I resume a headless Claude Code session?
Capture session_id from the system event with subtype init, then pass it to --resume on a later launch. If the ID is stale you'll get an is_error result with no init event and the process will exit, so track whether init arrived.
Is the stream-json protocol a stable API?
No. It's the SDK-facing surface and field names have changed across releases. Pin a CLI version and check `claude --version` at startup if you build on it.
Keep reading
Claude Code permission modes, explained
The six modes the CLI accepts, what each one stops asking about, and how to pick one for unattended runs.
Why Claude Code stalls waiting for permission
A blocked agent isn't working. The arithmetic on notification latency, and the four fixes ranked by what they cost you.
How to run multiple Claude Code agents in parallel
Worktrees, --bg background agents, tmux, separate clones — what each costs, and the bottleneck they all share.