Claude CodeClaude Code 2.1 · terminal and VS Code · macOS, Linux, Windows
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
32 small wins to finish your pathNext lesson →

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.

bash
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
Captured from a real run
{
  "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

diff
 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 code

The 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.

bash
python3 demo.py
Captured from a real run
first : 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

json
{
  "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.

python
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.

python
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 text

Listing what permission refused

permission_denials holds one entry per refused tool call, with the tool and its input.

python
# 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.

python
# 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

Example
"""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)

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.

A headless run is a command a script can read
promptread by the scriptA scriptor a CI jobclaude -p--allowedTools …link-shortenershorten.py, demo.pyOne JSON object--output-format json
Hover or tap a piece to see what it is and which lesson built it.
Trace a run

Pick one to watch it run, step by step.

Watching instead of waiting
--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.

Try it yourself
  • Change is_error to true in run.json and watch the exit code change.
  • Run a real headless prompt in a scratch repository with --output-format json.
PreviousAgent teams

This is what real progress feels like.