Headless mode and JSON output
Headless mode is Claude Code run as a one-off command with the -p flag, so a single prompt runs, prints its answer and exits with no interactive session.
Last updated: 28 Sep, 2026 · Claude Code
Up to now every session was interactive. The -p flag turns a prompt into a command, and a command can go in a script.
You have been using it since the installation lesson. It runs one prompt, prints the answer and exits. For automation, add two flags.
It signs in with the same login as your sessions. On a machine with no browser, such as a CI runner, run claude setup-token once on your own machine and set the token it prints as CLAUDE_CODE_OAUTH_TOKEN (it needs a paid plan), or set ANTHROPIC_API_KEY. With --bare, only the API key works.
claude -p "The demo shows a bug: after removing a link, the next code collides with an
existing one and overwrites it. Fix next_code in shorten.py so a code is never reused." \
--allowedTools "Read,Edit,Bash(python3 demo.py)" \
--output-format json{
"type": "result",
"subtype": "success",
"is_error": false,
"num_turns": 4,
"duration_ms": 17232,
"total_cost_usd": 0.0233022,
"result": "Done. `_next_index` tracks total codes generated (only increments), not current link count. Removed links no longer cause collisions.",
"permission_denials": []
}That is a real run against the project from the overview, and it fixed the bug. It took four turns, seventeen seconds and about two cents, and no action was refused. The two flags are what made it possible: --allowedTools answered the permission questions in advance, because no one was there to answer them, and --output-format json turned the whole run into one object a script can read.
What it changed
def next_code():
- n = store.count()
- return ALPHABET[n % 26] + str(n // 26)
+ global _next_index
+ code = ALPHABET[_next_index % 26] + str(_next_index // 26)
+ _next_index += 1
+ return codeThe count of links stored was the wrong thing to count, because deleting one made it go backwards. Counting codes handed out instead never goes backwards, so a code is never reused.
python3 demo.pyfirst : a0 -> https://krishnaik.in/courses second: b0 -> https://krishnaik.in/blog third : c0 -> https://krishnaik.in/about second: b0 -> https://krishnaik.in/blog
The third link is c0 now, and the second one still points where it always did. Compare that with the overview, where the last two lines were the same URL.
The JSON result
{
"type": "result",
"subtype": "success",
"is_error": false,
"num_turns": 2,
"duration_ms": 8402,
"total_cost_usd": 0.008885,
"result": "Waiting for write permission to create README.md.",
"permission_denials": [
{
"tool_name": "Write",
"tool_use_id": "toolu_01WT",
"tool_input": {
"file_path": "/tmp/linkshort/README.md",
"content": "# Link shortener\n"
}
}
],
"session_id": "eebc5636-3afb-47d4-a4f7-da64a313841d"
}This is the real object from the permissions lesson, where the write was refused. Everything a script needs is in it: whether it failed, how many turns it took, what it cost, the answer as text, and a list of anything permission stopped.
Reading it in a pipeline
A script can read that object and decide whether the run should be treated as a success. Here it is one piece at a time.
Loading the run object
The object headless mode printed goes to a file. Open it and parse it as JSON.
import json
# the object claude -p --output-format json wrote
run = json.load(open("run.json"))Reading the fields a pipeline cares about
Four fields answer whether it worked, how many turns it took, what it cost, and what it said.
print("ok: ", not run["is_error"]) # did it work
print("turns: ", run["num_turns"]) # how many turns
print("cost: ", f"${run['total_cost_usd']:.4f}") # what it cost
print("answer: ", run["result"]) # the answer as textListing what permission refused
permission_denials holds one entry per refused tool call, with the tool and its input.
# one line per action permission stopped
for denial in run.get("permission_denials", []):
tool = denial["tool_name"]
print("refused:", tool, "->", denial["tool_input"].get("file_path"))Exiting non-zero when something was refused
A denial is not an error to Claude Code. Turn it into a failing exit code so a CI job stops instead of carrying on.
# fail the job if it errored or anything was refused
status = 1 if run["is_error"] or run.get("permission_denials") else 0
print("exit: ", status)The whole script against run.json
"""Read a headless run the way a script would.
`claude -p ... --output-format json` prints one object like this.
A pipeline cares about three things: did it work, what did it
cost, and was anything refused.
"""
import json
run = json.load(open("run.json"))
print("ok: ", not run["is_error"])
print("turns: ", run["num_turns"])
print("cost: ", f"${run['total_cost_usd']:.4f}")
print("answer: ", run["result"])
for denial in run.get("permission_denials", []):
tool = denial["tool_name"]
print("refused:", tool, "->", denial["tool_input"].get("file_path"))
status = 1 if run["is_error"] or run.get("permission_denials") else 0
print("exit: ", status)ok: True turns: 2 cost: $0.0089 answer: Waiting for write permission to create README.md. refused: Write -> /tmp/linkshort/README.md exit: 1
The last line is the point. A script ends with raise SystemExit(status), so a run where something was refused exits non-zero and a CI job fails loudly rather than carrying on with work that did not happen. A denial is not an error to Claude Code, and turning it into one is your decision to make.
Pick one to watch it run, step by step.
--output-format stream-json gives you the same information as it happens, one event per line, which is what the agent-loop lesson used to show the loop. Use it when you want to watch a long run rather than wait for it.One thing to know before you script it: sessions started with -p stay out of the session picker and out of --continue, as the sessions lesson said. Keep the session id from the JSON if you want to reopen one.
Related
- Previous: Agent teams
- Next: Code Review and GitHub Actions
- Reference: Claude Code docs
- Change
is_errorto true inrun.jsonand watch the exit code change. - Run a real headless prompt in a scratch repository with
--output-format json.
This is what real progress feels like.