Claude Code Headless Mode (claude -p): Script It With JSON Output, Resume Sessions, Cap Spend and Measure What It Costs Your Plan

Claude Code headless mode as a scripting interface: parse claude -p JSON with jq, fail on exit codes, resume sessions, scope tools, and measure plan usage.

Horizontal bar chart titled 'Six reports: claude -p drains the 5-hour window faster'. Six indigo bars show reported ratios versus an interactive client: kler, Team plan, 2.83x; harry924, Max 20x, about 2.5x; achapla, Pro, about 2.1x; Z3l20, Pro, about 2.0x; qvarker, Max, about 1.8x; futin, account logs, 1.30x. A dashed line marks 1x, the same rate.Horizontal bar chart titled 'Six reports: claude -p drains the 5-hour window faster'. Six indigo bars show reported ratios versus an interactive client: kler, Team plan, 2.83x; harry924, Max 20x, about 2.5x; achapla, Pro, about 2.1x; Z3l20, Pro, about 2.0x; qvarker, Max, about 1.8x; futin, account logs, 1.30x. A dashed line marks 1x, the same rate.
Every reporter in issue #97074 measured claude -p using the 5-hour window faster than an interactive client, from 1.30x to 2.83x. These are reporters' own measurements on different plans, models and comparison clients, not an Anthropic statement. Source: anthropics/claude-code issue #97074 and its comments, Sep 25 to Oct 30, 2026.

Your pre-commit hook calls claude -p, gets nothing back, and lets the commit through. Your overnight batch finishes and you wake up to an empty 5-hour window. Both problems come from treating Claude Code headless mode as a black box. It is a command-line program with an exit status, a JSON payload, resumable session IDs, permission flags and a cost estimate, and a script can check every one of them. This guide is for developers on a Claude Pro or Max plan, or on a Console API key, who call Claude Code from shell scripts, git hooks or CI. You will end up with a copyable bash wrapper that reviews a staged git diff, fails on every kind of error and logs each run, plus a usage ratio measured on your own account.

The flags and fields come from Anthropic’s Run Claude Code programmatically page, the CLI reference, the authentication docs and the permissions docs. The commands are documented syntax, not output captured for this article. Where the guide describes an expected result, it is what those pages say you should see. Tables marked Illustrative are models: replace their numbers with your own.

Why the 5-hour window belongs in your script

On Oct 25, 2026, developer Frostfire posted on X a warning for anyone running Claude Code headless on a Max plan: a new bug report found that claude -p used about 1.8× more of the 5-hour limit than the interactive CLI on the same workload, with the same or fewer tokens. The report is anthropics/claude-code issue #97074, opened Sep 25 by qvarker. In a controlled A/B on Claude Code 2.1.282 with Opus 5, the headless arm got about $1.6 of API-equivalent work per 1% of the window, and the interactive TUI about $3.0. Session transcripts record headless runs with the entrypoint sdk-cli and the TUI as cli.

Other commenters reproduced the direction on different plans: about 2.0× on Pro, about 2.5× on Max 20x against both the TUI and the Desktop app, and 2.83× on a Team plan against the VS Code extension’s label. The independent claude-p-limit-bench repository measured about 2× on Pro with Claude Code 2.1.312 on Windows: $0.22 of work per 1% of the window through claude -p, against $0.43 to $0.48 interactively.

Horizontal bar chart titled 'Six reports: claude -p drains the 5-hour window faster'. Six indigo bars show reported ratios versus an interactive client: kler, Team plan, 2.83x; harry924, Max 20x, about 2.5x; achapla, Pro, about 2.1x; Z3l20, Pro, about 2.0x; qvarker, Max, about 1.8x; futin, account logs, 1.30x. A dashed line marks 1x, the same rate.Horizontal bar chart titled 'Six reports: claude -p drains the 5-hour window faster'. Six indigo bars show reported ratios versus an interactive client: kler, Team plan, 2.83x; harry924, Max 20x, about 2.5x; achapla, Pro, about 2.1x; Z3l20, Pro, about 2.0x; qvarker, Max, about 1.8x; futin, account logs, 1.30x. A dashed line marks 1x, the same rate.
Every reporter in issue #97074 measured claude -p using the 5-hour window faster than an interactive client, from 1.30x to 2.83x. These are reporters' own measurements on different plans, models and comparison clients, not an Anthropic statement. Source: anthropics/claude-code issue #97074 and its comments, Sep 25 to Oct 30, 2026.

Treat these numbers as reporter observations, not as Anthropic’s accounting. The issue is open and labelled as a bug with a reproduction, and its commenters are still asking Anthropic whether sdk-cli usage is weighted on purpose. The selected Anthropic docs don’t say. One consequence holds either way: unbounded headless turns on a subscription can spend the window faster than the same work done by hand. So the wrapper below bounds turns and spend, logs the cost estimate of every run, and the measurement section shows how to check the effect on your own plan.

Some commenters work around the gap by setting the undocumented CLAUDE_CODE_ENTRYPOINT variable to another label. Don’t. Another commenter in the same thread points out that if the weighting is intentional, relabeling could count as getting around usage limits. Measure and adapt instead.

GitHub issue 97074 in the anthropics/claude-code repository, titled as a bug report that headless claude -p (entrypoint sdk-cli) uses about 1.8x more of the 5-hour window per token than interactive cli.
The top of anthropics/claude-code issue #97074 on GitHub, the open bug report about headless claude -p (entrypoint sdk-cli) and the 5-hour window. Source: GitHub. · Original source

The second fact your script depends on is about exit codes. Claude Code v2.1.314, released Oct 30, 2026, fixed claude -p exiting with code 0 and no output when startup failed; it now prints the error and exits 1. Before that fix, a hook that checked only the exit status could pass on an empty answer. The wrapper checks both the status and the content, so it stays safe on older builds, but you still want the fix.

Check the build, the credential and jq before the first run

Five minutes of preflight saves an evening of confusing failures. Run these from the root of a test repository with at least one staged change.

claude --version                      # want 2.1.314 or later
jq --version
git diff --cached --stat              # something must be staged
claude auth status | jq -r '.authMethod'
env | grep -oE '^(ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|CLAUDE_CODE_OAUTH_TOKEN)=' \
  || echo "no credential variables exported"
  • [ ] claude --version prints 2.1.314 or later. If not, run claude update, which the CLI reference lists as the command that updates to the latest version.
  • [ ] jq --version prints a version. Every check in this guide depends on it.
  • [ ] git diff --cached --stat lists at least one file. An empty diff is a skip, not a pass, and the wrapper treats it that way.
  • [ ] claude auth status exits 0. Per the CLI reference it prints JSON, exits 1 when you are not logged in, and its authMethod field is one of none, claude.ai, oauth_token, api_key, api_key_helper or third_party.
  • [ ] The env line prints only the variables you meant to export. Any credential variable here changes who pays for the run, as the billing section explains.

Read authMethod before anything else. claude.ai means your subscription login is in use, so runs draw on the plan’s usage windows. api_key means Console billing per token. The authentication docs add an asymmetry that catches people: in an interactive session Claude Code asks once whether to use an exported ANTHROPIC_API_KEY, but in non-interactive -p mode the key is always used when present. A key you declined in the TUI still bills every script.

If you are still deciding how to schedule recurring jobs rather than how to script one, the cron and scheduling guide compares /loop, Desktop, cloud tasks and claude -p. This guide assumes you have chosen claude -p.

Pick flags with a decision table

claude -p accepts most of the CLI’s flags, and the CLI reference warns that claude --help does not list every one. These are the flags that matter for scripting, filled in for a review job. The headless page below is the reference for the output formats and session flags.

Anthropic's 'Run Claude Code programmatically' documentation page on code.claude.com.
The opening of Anthropic's 'Run Claude Code programmatically' page on code.claude.com, the reference for the -p flag, output formats and session flags used in this guide. Source: Anthropic. · Original source

Flag Use it when What you should see
-p / --print Any scripted call One response, then the process exits
--output-format text A human reads the output directly Plain text; nothing to parse
--output-format json A script reads the output One JSON object with result, session_id, total_cost_usd and a per-model cost breakdown
--output-format stream-json --verbose You need events as they happen, the system/init metadata or permission denials Newline-delimited JSON; the last line is the result message with cost and session metadata
--json-schema '<schema>' You need typed data, not prose A structured_output field, or no such field when the run ends without valid output
--resume <session_id> A follow-up must land on one specific earlier run The answer refers to the earlier turn; cost covers the whole conversation
--continue Manual follow-ups in a directory where nothing else is running Loads the most recent conversation in the current directory
--session-id <uuid> You want the ID before the run starts The run uses that ID; it must be a valid UUID
--no-session-persistence The prompt or input holds secrets and you will never resume The session is not saved to disk and cannot be resumed
--tools "Read,Grep,Glob" The job only needs to read Other built-in tools are unavailable
--allowedTools "Bash(git diff *)" A specific action should run without a prompt Matching calls run; everything else follows the permission mode
--disallowedTools "Bash(git push *)" A pattern must never run Matching calls are denied; a bare tool name removes the tool
--permission-mode dontAsk Locked-down CI Anything that would prompt is denied
--permission-prompts none Nobody can answer prompts (v2.1.259 or later) Prompts are denied and Claude is told not to retry
--max-turns 4 You want a hard ceiling on agentic turns Exits with an error when the limit is reached
--max-budget-usd 0.50 You want a ceiling on estimated spend The run stops once the estimate reaches the cap; spend can pass it slightly
--model sonnet You want the same model on every run The alias is used regardless of your settings or ANTHROPIC_MODEL
--bare Reproducible CI authenticated by API key Hooks, skills, plugins, MCP servers, auto memory and CLAUDE.md are skipped

A few of these choices deserve a sentence each. Use json, not text, whenever a program reads the answer: text gives you no session ID, no cost and no way to tell an error message from a review. The headless docs pair stream-json with --verbose in every example, and the CLI reference says --include-partial-messages requires stream-json, so treat --verbose as part of the stream-json recipe.

--bare is the mode Anthropic recommends for scripted calls, and the headless docs say it will become the default for -p in a future release. It changes authentication as well as discovery: bare mode never reads OAuth credentials, the keychain or CLAUDE_CODE_OAUTH_TOKEN, so it needs ANTHROPIC_API_KEY or an apiKeyHelper passed through --settings. The wrapper below leaves --bare off so it can run on a subscription. For proving that a headless run ignores state you didn’t declare, follow the headless reproducibility guide rather than re-deriving it here.

Build the wrapper: diff in, checked JSON out

The wrapper does one job: pipe the staged diff into a read-only review and refuse to report success unless three checks pass. Save it as scripts/claude-review.sh.

  1. Refuse to run when an exported API key would silently switch billing, unless you opt in with ALLOW_API_BILLING=1.
  2. Refuse to run when no login exists, using the exit status of claude auth status.
  3. Write the staged diff to a temporary file; skip cleanly when it is empty, and refuse when it nears the 10MB stdin cap.
  4. Run claude -p with JSON output, read-only tools, no prompts, a turn limit and a budget cap.
  5. Check the exit status, then that stdout parses as JSON, then that the answer is not an error and not empty.
  6. Write one tab-separated log line per run, save the session_id for follow-ups, and print the review.
#!/usr/bin/env bash
# claude-review.sh: review the staged diff with Claude Code headless mode.
# Exit codes: 0 ok or skipped, 1 run failed, 2 preflight refused, 3 findings (opt-in).
set -euo pipefail

MODEL="${MODEL:-sonnet}"
MAX_TURNS="${MAX_TURNS:-4}"
MAX_BUDGET_USD="${MAX_BUDGET_USD:-0.50}"
LOG="${LOG:-$HOME/.claude-review/runs.tsv}"
SESSION_FILE="${SESSION_FILE:-.git/claude-review-session}"

work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
mkdir -p "$(dirname "$LOG")"

status="-"
log() {  # outcome, session_id, cost
  printf '%s\t%s\t%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
    "$1" "$status" "${2:--}" "${3:--}" >> "$LOG"
}

# 1-2. Preflight. In -p mode an exported API key is always used.
if [[ -n "${ANTHROPIC_API_KEY:-}" && "${ALLOW_API_BILLING:-0}" != "1" ]]; then
  echo "claude-review: ANTHROPIC_API_KEY is set; unset it or set ALLOW_API_BILLING=1" >&2
  log "refused:api-key"; exit 2
fi
if [[ -z "${ANTHROPIC_API_KEY:-}" ]] && ! claude auth status > /dev/null 2>&1; then
  echo "claude-review: not logged in (claude auth status exited non-zero)" >&2
  log "refused:no-auth"; exit 2
fi

# 3. Input. Piped stdin is capped at 10MB.
git diff --cached > "$work/diff.patch"
if [[ ! -s "$work/diff.patch" ]]; then
  log "skipped:empty-diff"; exit 0
fi
if (( $(wc -c < "$work/diff.patch") > 9000000 )); then
  echo "claude-review: diff is near the 10MB stdin cap; review it in parts" >&2
  log "refused:too-large"; exit 2
fi

# 4. The run: read-only tools, nothing that prompts, bounded turns and spend.
prompt='You are reviewing a staged git diff supplied on stdin. List concrete bugs or risky changes, one per line, as path:line followed by one sentence. If there are none, reply with exactly: NO FINDINGS'

set +e
cat "$work/diff.patch" | claude -p "$prompt" \
  --output-format json \
  --model "$MODEL" \
  --tools "Read,Grep,Glob" \
  --permission-mode dontAsk \
  --permission-prompts none \
  --max-turns "$MAX_TURNS" \
  --max-budget-usd "$MAX_BUDGET_USD" \
  > "$work/run.json" 2> "$work/run.err"
status=${PIPESTATUS[1]}
set -e

# 5a. Gate 1: exit status. v2.1.314+ exits 1 when startup fails.
if (( status != 0 )); then
  echo "claude-review: claude -p exited $status" >&2
  cat "$work/run.err" >&2
  head -c 4000 "$work/run.json" >&2   # failures inside the run print on stdout
  log "fail:exit"; exit 1
fi

# 5b. Gate 2: stdout must be one JSON document.
if ! jq -e . "$work/run.json" > /dev/null 2>&1; then
  echo "claude-review: stdout is not JSON" >&2
  head -c 4000 "$work/run.json" >&2
  log "fail:not-json"; exit 1
fi

session_id="$(jq -r '.session_id // empty' "$work/run.json")"
cost="$(jq -r '.total_cost_usd // empty' "$work/run.json")"
result="$(jq -r '.result // empty' "$work/run.json")"

# 5c. Gate 3: no error flag and a non-empty answer.
# is_error is checked defensively; the headless docs do not define it.
if jq -e '.is_error == true' "$work/run.json" > /dev/null \
   || [[ -z "${result//[[:space:]]/}" ]]; then
  echo "claude-review: run reported an error or returned an empty result" >&2
  printf '%s\n' "$result" >&2
  log "fail:result" "$session_id" "$cost"; exit 1
fi

# 6. Success: log, keep the session ID, print the review.
log "ok" "$session_id" "$cost"
printf '%s\n' "$session_id" > "$SESSION_FILE"
printf '%s\n' "$result"

if [[ "${FAIL_ON_FINDINGS:-0}" == "1" && "$result" != "NO FINDINGS" ]]; then
  exit 3
fi

Diagram of the claude -p wrapper's gate path. A column of boxes runs top to bottom: Run claude -p, Exit status is 0?, stdout is valid JSON?, No error, non-empty result?, and a green Log the run, continue. Red arrows labelled non-zero, parse error and error / empty lead from the three checks to a red Stop the job box on the right.Diagram of the claude -p wrapper's gate path. A column of boxes runs top to bottom: Run claude -p, Exit status is 0?, stdout is valid JSON?, No error, non-empty result?, and a green Log the run, continue. Red arrows labelled non-zero, parse error and error / empty lead from the three checks to a red Stop the job box on the right.
The wrapper passes a run only after three checks; any failure stops the job with a non-zero exit and keeps both output streams. Illustrative wrapper logic based on Anthropic's 'Run Claude Code programmatically' docs and the Claude Code v2.1.314 release notes, Oct 30, 2026.

Why each choice is there:

  • The diff goes in on stdin. The headless docs show the same pattern for a typo linter: piping the diff means Claude doesn’t need Bash permission to read it. The docs also cap piped stdin at 10MB and exit non-zero above it, so the wrapper refuses early with a clearer message.
  • --tools "Read,Grep,Glob" plus dontAsk. Reading surrounding code is enough for a review. The CLI reference notes that on macOS, Linux and WSL the default tool set leaves out Glob and Grep, so naming them here is how you ask for them. dontAsk denies anything that would prompt, and --permission-prompts none tells Claude not to spend turns retrying a denial.
  • Two ceilings. --max-turns exits with an error at the limit, and --max-budget-usd stops the run when Anthropic’s client-side estimate reaches the cap. The CLI reference warns that spend can pass the cap, so the default leaves headroom. On a subscription the dollar figure is an estimate, but it is still a useful proxy for how much work a run did.
  • Three gates in order. The status catches startup failures, bad flags and kills. The JSON parse catches anything that printed text instead of a payload. The content check catches the case the v2.1.314 fix was about: a run that “succeeded” with nothing to say.
  • The log line. Timestamp, outcome, exit status, session_id and total_cost_usd give you an audit trail and a per-run cost series without opening any dashboard.

To run it on every commit, install it as a git hook:

mkdir -p scripts && chmod +x scripts/claude-review.sh
cat > .git/hooks/pre-commit <<'EOF'
#!/usr/bin/env bash
FAIL_ON_FINDINGS=1 exec "$(git rev-parse --show-toplevel)/scripts/claude-review.sh"
EOF
chmod +x .git/hooks/pre-commit

With FAIL_ON_FINDINGS=1, any answer other than NO FINDINGS exits 3 and blocks the commit. git commit --no-verify remains the escape hatch when you disagree with the review.

Read the result: what success and failure look like

Every run ends in one of a handful of states. Learn them once and the log becomes readable at a glance.

Situation Exit status from claude -p What stdout holds Wrapper outcome
Clean review 0 JSON with result set to NO FINDINGS ok, exit 0
Review with findings 0 JSON with findings in result ok, exit 0, or exit 3 with FAIL_ON_FINDINGS=1
Nothing staged Not run Nothing skipped:empty-diff, exit 0
Invalid flag Non-zero Nothing; the error goes to stderr before the run starts fail:exit, exit 1
Startup failure on v2.1.314 or later 1 The error is printed fail:exit, exit 1
Failure inside the run, such as missing authentication Non-zero The failure, printed as the result fail:exit, exit 1, with stdout echoed to stderr
Turn limit reached Error exit Varies fail:exit, exit 1
Killed by SIGTERM, for example by timeout 143 No result for the interrupted turn fail:exit, exit 1
Diff over the stdin cap Not run Nothing refused:too-large, exit 2
Old build exits 0 with no output 0 Empty fail:not-json, exit 1

A healthy log line looks like this, with a real ID and cost in place of the placeholders:

2026-10-31T14:02:11Z	ok	0	<session_id>	<total_cost_usd>

Two patterns in the log deserve attention. A run of fail:exit lines right after an update usually means a flag changed or a credential expired; the stderr echo tells you which. A cost column that creeps upward on similar diffs usually means the model is exploring more: tighten the prompt or lower --max-turns before raising the budget.

Resume by session ID, not by --continue

A review is often the first half of a conversation. The second half, asking for fixes, should land on the same session. The docs offer two ways to get there, and only one is safe in scripts.

--continue loads the most recent conversation in the current directory, and the CLI reference notes that claude -p --continue includes sessions started with -p and the Agent SDK. If two jobs share a working directory, the second job’s “most recent” may be the first job’s conversation. The headless docs give the rule directly: if you run multiple conversations, capture the session ID and resume that.

  1. Read the ID the wrapper saved. It came from the session_id field of the JSON payload.
  2. Resume it with the follow-up prompt and re-pass the tool and permission flags, so the follow-up is held to the same limits as the first run.
  3. Read .result and check that it refers to the findings from the first run.
  4. Read .total_cost_usd and remember that, per the headless docs, a resumed run reports the conversation’s whole total, earlier runs included.
sid="$(cat .git/claude-review-session)"
claude -p "For each finding in your review, propose the smallest fix as a unified diff. Do not edit files." \
  --resume "$sid" \
  --output-format json \
  --tools "Read,Grep,Glob" --permission-mode dontAsk --permission-prompts none \
  --max-turns 4 --max-budget-usd 0.50 \
  > followup.json
jq -r '.result' followup.json
jq -r '.total_cost_usd' followup.json   # conversation total, not just this run

The expected observation is a set of diffs that name the same files and lines as the first review. If the answer reads like a fresh start, you resumed the wrong ID.

Three related flags round this out. --fork-session with --resume creates a new session ID instead of reusing the original, which lets you try two different follow-ups from one review. --session-id assigns a UUID before the run starts, so a supervisor can record the ID even if the run is killed:

sid="$(uuidgen | tr 'A-Z' 'a-z')"
claude -p "Start the review" --session-id "$sid" --output-format json > first.json
claude -p "Try a different fix" --resume "$sid" --fork-session --output-format json \
  | jq -r '.session_id'   # a new ID, not $sid

The headless docs add that the two commands can run from different directories, because Claude Code finds a session by its ID in any project on the machine. They will not find it in a different configuration directory: the authentication docs explain that each CLAUDE_CONFIG_DIR keeps its own session history. Budget caps also behave differently on resume; the CLI reference says totals restored from earlier runs don’t count toward --max-budget-usd.

Extract structured data with --json-schema

When a script needs a list, parsing prose is fragile. --json-schema asks Claude Code to return output that matches a schema in a separate structured_output field, after the agent finishes its work. This example uses the schema from the headless docs to pull function names from a file.

  1. Write the schema and check that it is at least valid JSON with jq -e . <<< "$schema".
  2. Run with both --output-format json and --json-schema; the docs require the pair.
  3. Check that structured_output exists and holds an array before using it. The docs say a run that ends without valid structured output, for example because Claude declined, has no structured_output field at all.
  4. On failure, print .result so you can see why.
schema='{"type":"object","properties":{"functions":{"type":"array","items":{"type":"string"}}},"required":["functions"]}'
jq -e . <<< "$schema" > /dev/null || { echo "schema is not JSON" >&2; exit 1; }

claude -p "Extract the main function names from auth.py" \
  --output-format json \
  --json-schema "$schema" \
  --tools "Read" --permission-mode dontAsk --max-turns 3 \
  > functions.json || { echo "claude -p failed" >&2; exit 1; }

if jq -e '(.structured_output.functions | type) == "array"' functions.json > /dev/null; then
  jq -r '.structured_output.functions[]' functions.json
else
  echo "no structured_output; result text follows" >&2
  jq -r '.result // empty' functions.json >&2
  exit 1
fi

On success you get one function name per line, ready for while read. If the schema itself is broken, Claude Code exits with Error: --json-schema is not a valid JSON Schema followed by the validator’s diagnostic, so that failure surfaces through the exit status. One limit to remember: the docs say Claude Code accepts the format keyword, such as "format": "email", but treats it as an annotation and doesn’t enforce it. Validate formats yourself if they matter.

Scope tools for unattended runs

Nobody is at the keyboard when a hook or a CI job runs, so every permission decision has to be made in advance. The headless docs describe the default: since v2.1.302, claude -p runs start in Manual mode when nothing sets a permission mode, a call that needs approval is denied unless something approves it, and calls to tools that need no approval still run. The permissions docs list what needs no approval in Manual mode, such as file reads in the working directory and the built-in read-only command set. Pick the narrowest row that does the job.

Job Flags What runs What is refused
Read-only review --tools "Read,Grep,Glob" --permission-mode dontAsk Reads in the working directories Every edit and shell command
Commit from staged changes --allowedTools "Bash(git diff *),Bash(git log *),Bash(git status *),Bash(git commit *)" Those git subcommands Other shell commands that need approval
Apply lint fixes --permission-mode acceptEdits File writes and common filesystem commands such as mkdir and mv Other shell commands and network requests without an allow rule
Anything near a remote Add --disallowedTools "Bash(git push *)" Unchanged git push in the form Claude usually writes
No permission host Add --permission-prompts none Unchanged Prompts, with Claude told not to retry

The space in Bash(git diff *) matters. The headless docs point out that Bash(git diff*) without the space would also match git diff-index. Deny rules win over allow rules regardless of specificity, and the permissions docs are candid that a Bash rule matches command text, not the program: Bash(git push *) doesn’t stop git -C . push. Use rules to shape routine behavior and a sandbox or container for a real boundary. For the broader question of which jobs deserve unattended access at all, see the headless trust-tier guide and the restricted-mode fleet policy.

One default surprises people. Without --bare, a -p session runs the hooks in a project’s .claude/settings.json and connects the servers in its .mcp.json, even in a folder you never trusted, and it shows no trust dialog. Before running claude -p in a repository you didn’t write, the permissions docs suggest --bare, --setting-sources user, or --settings '{"disableAllHooks": true}'.

To see what was refused, switch to stream-json. The headless docs say denials appear as permission_denied system messages and that the final result message lists them in permission_denials. The last line of the stream is that result message:

claude -p "Run npm test and summarise the failures" \
  --output-format stream-json --verbose \
  --tools "Read,Grep,Glob,Bash" \
  --permission-mode dontAsk --permission-prompts none --max-turns 3 \
  | tail -n 1 | jq '.permission_denials'

In dontAsk mode npm test isn’t in the read-only command set, so you should see it listed as denied while the run still finishes. To confirm which tools a session actually loaded, read the system/init event, which the docs say reports the model, tools, MCP servers and plugins: jq -c 'select(.type == "system" and .subtype == "init") | .tools' on the same stream.

Know which meter you are spending

A headless run draws on whichever credential wins the precedence order in the authentication docs. Cloud provider variables come first, then ANTHROPIC_AUTH_TOKEN, then ANTHROPIC_API_KEY, then apiKeyHelper, then CLAUDE_CODE_OAUTH_TOKEN, then Anthropic profiles, and only then your subscription login from /login. A key exported months ago for an unrelated project sits above your plan in that list.

  • [ ] Run claude auth status | jq -r '.authMethod' in the same shell, or the same CI environment, that runs the script.
  • [ ] If you expected your plan and see api_key, run unset ANTHROPIC_API_KEY, remove the export from your shell profile, and check again. The authentication docs give unset ANTHROPIC_API_KEY as the way back to the subscription and /status as the confirmation.
  • [ ] If you meant to use Console billing, set ALLOW_API_BILLING=1 for the wrapper and consider --bare, which only authenticates by API key or apiKeyHelper anyway.
  • [ ] For CI on a subscription where no browser is available, generate a one-year token with claude setup-token and set it as CLAUDE_CODE_OAUTH_TOKEN. It requires a Pro, Max, Ultra, Team or Enterprise plan, and bare mode ignores it.
  • [ ] Read total_cost_usd correctly for your meter. The headless docs call it a client-side estimate that can differ from your bill. On an API key it approximates real charges. On a subscription the costs docs say usage is included in the plan, so the dollar figure is not a charge; it is a list-price measure of work, which is exactly what the measurement drill needs.

The environment variables guide covers where these variables can be set and how to prove which one is active. If your plan has usage credits turned on, work past the limit can become a real charge; the usage credits guide shows how to cap or disable that.

Measure the headless drain on your own plan

The reports in issue #97074 span plans, models and Claude Code versions. Your number is the one that should drive your scheduling. The drill below follows the method the reporters converged on: run the same work in both modes, alternate the order, and compare dollars of list-price work per point of the 5-hour window.

  1. Pick a fixed task that does real work, such as reading five source files and answering ten short questions, and write the prompts down. The bench repository’s runs cost about $0.60 to $0.85 each at API prices.
  2. Make batches big enough to move the meter. The window reading is a whole percentage, so a batch should move it ten points or more; the bench suggests seven runs per batch.
  3. Make sure nothing else uses the account during a batch, on any machine. That includes claude.ai chat.
  4. Strip inherited variables before starting the interactive arm. Two commenters found that an interactive claude launched from a shell that inherited CLAUDE_CODE_ENTRYPOINT=sdk-cli records sdk-cli too: use env -u CLAUDE_CODE_ENTRYPOINT -u CLAUDE_CODE_CHILD_SESSION claude.
  5. Read the 5-hour window before and about 60 seconds after each batch. In the TUI, /usage shows plan usage bars for subscribers; one commenter notes it refreshes about once a minute, hence the wait.
  6. Record cost per batch. For headless runs, sum total_cost_usd from the wrapper’s log. For the interactive arm, read the Session block’s total cost in /usage, which the costs docs say is computed locally at list price. Both are list-price estimates, so they compare fairly.
  7. Run the batches in the order headless, interactive, interactive, headless inside one window, then compute dollars per point for each mode and divide.

Reporters also read the window from a tiny headless probe. This is their method, not a documented interface, and the field path may change:

# Reporter method from issue #97074; not in Anthropic's docs. Prints the 5-hour utilization.
claude -p "reply: ok" --model haiku --output-format stream-json --verbose \
  | jq -r 'select(.type == "rate_limit_event") | .rate_limit_info.unifiedWindows.five_hour.utilization'

If it prints nothing, fall back to /usage. Here is how a filled record looks. Illustrative: these numbers are a model of the arithmetic, not a measurement.

Batch (Illustrative) Mode Runs List-price cost 5-hour window Points $ per point
A1 claude -p 7 $5.10 10% to 22% 12 0.43
B1 Interactive 7 $4.90 22% to 28% 6 0.82
B2 Interactive 7 $5.20 28% to 34% 6 0.87
A2 claude -p 7 $4.95 34% to 45% 11 0.45
Total Headless vs interactive 14 + 14 $10.05 vs $10.10 23 vs 12 0.44 vs 0.84

In this Illustrative record the interactive arm buys about 1.9× as much work per point, which would put the plan in line with the issue. Then decide:

Your measured ratio What it suggests What to change
About 1.0 Your plan and version don’t show the gap Keep headless jobs as they are; re-measure after major updates
1.3 to 2.0 Consistent with several reports Keep claude -p for short bounded jobs; move long multi-turn work to interactive sessions
Above 2.0 Consistent with the steepest reports Lower --max-turns, consider Console billing for CI, and add your numbers to the issue

If your headless jobs share a login with interactive sessions, the habits in the shared usage window guide still apply on top of this ratio.

Troubleshooting claude -p scripts

Symptom Likely cause Fix Expected observation after the fix
A pipeline passes with empty output Build older than v2.1.314, or claude -p ... | jq without pipefail hid the status claude update; use set -o pipefail or read PIPESTATUS as the wrapper does Startup failures print an error and exit 1
jq: parse error Default text output, a failure printed as text, or a whole stream-json stream parsed as one document Add --output-format json; for streams use tail -n 1 or jq -c 'select(...)' jq -e . run.json succeeds
Resume can’t find the session Wrong ID, a run with --no-session-persistence, or a different CLAUDE_CONFIG_DIR or machine Capture session_id from JSON output; resume with the same config directory The follow-up refers to the earlier turn
Every tool call is refused Manual mode in -p, no allow rule, or a rule typo such as Bash(git diff*) Add a scoped --allowedTools rule with the space before *; check system/init tools permission_denials is empty for intended actions
Unexpected API charges Exported ANTHROPIC_API_KEY is always used in -p, or --bare unset ANTHROPIC_API_KEY; check authMethod authMethod reads claude.ai
Window drained mid-batch The reported sdk-cli weighting, long multi-turn runs or subagents Lower --max-turns and --max-budget-usd; move heavy work to interactive; measure A known ratio and a log with bounded costs
The run doesn’t exit Waiting for background work, which has a 10-minute idle cap by default, or for a permission host Add --permission-prompts none; set CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS; wrap with timeout The run ends within the cap
Exit status 143 SIGTERM from timeout or a supervisor; the turn is left unfinished with no result Send SIGINT first, for example timeout -s INT, to end the turn The turn ends instead of being cut off
--max-turns error The task needs more turns than allowed Narrow the prompt before raising the limit The run finishes inside the limit

If the drain appears after an update that changed subagent behavior, check the fork-subagent migration guide: every subagent sends its own requests, and the costs docs list subagents among the usual reasons usage climbs.

Roll back and clean up

Every change in this guide is reversible. Work through the list that matches what went wrong.

  • [ ] Billing surprise: unset ANTHROPIC_API_KEY, delete the export from your shell profile or CI secrets, and confirm with claude auth status.
  • [ ] Window pressure: move heavy, multi-turn work back to interactive sessions, keep the wrapper for short reviews, and lower MAX_TURNS and MAX_BUDGET_USD.
  • [ ] Blocked commits you can’t wait on: git commit --no-verify for one commit, or rm .git/hooks/pre-commit to remove the hook.
  • [ ] Secrets in a reviewed diff: delete the wrapper’s log lines and .git/claude-review-session. Claude Code keeps transcripts locally, and the CLI reference documents claude purge [path], which deletes all local Claude Code state for a project; run it with --dry-run first. For future runs on sensitive input, add --no-session-persistence.
  • [ ] Stuck runs: wrap the call in timeout -s INT 600 so the turn ends cleanly instead of being cut off.

The command-center thesis covers the habit that makes all of this easier at scale: one place where every agent run, its cost and its outcome are visible.

Sources

YOU'RE THROUGH THIS ONE.

Keep connecting the dots.

Back to the library

Many assistants.
One place to remember.

Bring your AI sessions, context, and usage together with Automater.

Meet Automater