Guide · Updated

Claude Code headless mode: how to run claude -p in scripts and cron

How to run Claude Code headless with claude -p: the flags that matter in scripts and cron, unattended login, permissions, JSON output, and failures people hit.

Three cards joined by ink lines: a schedule, a single prompt, and a clean result with one amber dot

Claude Code's headless mode is claude -p "your prompt". The -p (or --print) flag runs one prompt without an interactive session, prints the answer to stdout and exits, so you can call it from a shell script, a git hook, cron or CI. Anthropic's docs now call this Run Claude Code programmatically and treat it as the CLI form of the Agent SDK. Below: the flags that matter, logging in from cron, what we saw when we ran it, and the failures people report. It reflects the docs and changelog as of October 2026 (Claude Code 2.1.316).

The smallest useful command

claude -p "What does the auth module do?"
cat build-error.txt | claude -p "explain the root cause of this build error" > output.txt

Piped input works, capped at 10 MB. Claude Code exits 0 on success and non-zero when the run fails. The headless docs add one catch: a failure inside the run, such as missing authentication, is printed as the result on stdout, so read the output as well as the exit code.

The flags that matter in a script

FlagWhat it does
--output-format jsonOne JSON object with result, session_id, cost and usage. stream-json (with --verbose) gives one event per line instead.
--json-schema '<schema>'Result conforms to your schema, returned in structured_output. If the run ends without valid output the field is missing, so check for it.
--allowedTools "Bash(git diff *),Read"Pre-approves tools. The space before * makes it a prefix match.
--permission-mode dontAskDenies anything not pre-approved instead of waiting for an answer.
--permission-prompts noneFor unattended runs. Denied requests tell Claude nobody can approve, so it does not retry. Needs v2.1.259 or later.
--max-turns 20Print mode only. Exits with an error at the limit. Missing from claude --help on 2.1.312, but accepted.
--max-budget-usd 2Print mode only. Stops at an estimated spend. The docs say spend can pass the cap, so leave headroom.
--bareSkips hooks, skills, plugins, MCP servers, auto memory and CLAUDE.md, so the run is the same on every machine.
--no-session-persistenceDoes not save the session to disk.
--continue, --resume <id>Follow-up prompts in the same conversation. Capture session_id from the JSON to resume a specific run.

The CLI reference lists each flag with its notes. Anthropic's best practices page shows the usual combination: --bare, an explicit --allowedTools, --permission-mode dontAsk and --max-turns in a loop over files, with the prompt asking for a short OK or FAIL reply.

Permissions changed recently. On v2.1.302 or later, a -p run with no permission mode set starts in Manual mode, where calls needing approval are denied unless a rule, hook or --permission-prompt-tool approves them. The same page says that before 2.1.302 some runs could start in auto mode instead. Pin the mode yourself and the version stops mattering.

Logging in from cron

Cron has no browser and a short PATH. Two things follow.

  1. Call claude by absolute path (which claude shows it).
  2. Give the job a credential. Per the authentication docs, claude setup-token prints a one-year OAuth token for your subscription. It does not save it. You set it as CLAUDE_CODE_OAUTH_TOKEN. That token can only make model requests, so claude.ai connectors and Remote Control are not available, though MCP servers you configure locally still work.

Order matters when several credentials exist. In the precedence list, ANTHROPIC_API_KEY comes before CLAUDE_CODE_OAUTH_TOKEN, and in -p mode the key "is always used when present". If you have a subscription and a stale key in your environment, the key wins and can fail. And --bare never reads OAuth credentials, so a bare script needs ANTHROPIC_API_KEY or an apiKeyHelper.

A wrapper script that does the checks cron will not do for you:

#!/bin/bash
export CLAUDE_CODE_OAUTH_TOKEN="$(cat ~/.config/claude-token)"
cd /home/me/app || exit 1

out=$(timeout 15m /usr/local/bin/claude -p "Summarize yesterday's failed CI runs on main" \
  --output-format json \
  --permission-mode dontAsk \
  --allowedTools "Bash(gh run list *),Bash(gh run view *)" \
  --max-turns 15)
status=$?

result=$(jq -r '.result // empty' <<<"$out")
denied=$(jq '.permission_denials | length' <<<"$out")
if [ "$status" -ne 0 ] || [ -z "$result" ] || [ "${denied:-0}" -ne 0 ]; then
  echo "$(date) claude -p failed: exit $status, $denied denied calls" >&2
  exit 1
fi
printf '%s\n' "$result"
PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin
30 9 * * 1-5 /home/me/bin/ci-digest.sh >> /home/me/logs/ci-digest.log 2>&1

The timeout is the outer guard. A SIGTERM ends a -p run with exit code 143 and no result for the current turn, and a run waits for background work it started for up to 10 idle minutes, so a wall-clock limit is cheap insurance. For the other ways to schedule Claude, see our routines and scheduled tasks guide.

What happened when we tried it

We ran these on a Mac with Claude Code 2.1.312, in a scratch repository with one commit, using our normal login.

The smallest call took 6.7 seconds and cost an estimated $0.07, mostly for writing about 17,600 tokens of context to the cache before it could answer:

claude -p "Reply with exactly: OK" --output-format json --permission-mode dontAsk
{"type":"result","subtype":"success","is_error":false,"result":"OK","num_turns":1,
 "duration_ms":4890,"total_cost_usd":0.0729,"permission_denials":[]}

Three things surprised us.

A refused call is still a success. We allowed only Read and asked Claude to run git commit --allow-empty -m probe. No commit was made, yet the process exited 0 with "subtype":"success". The only trace was in the JSON:

"permission_denials":[{"tool_name":"Bash","tool_input":{"command":"git commit --allow-empty -m probe"}}]

That is why the wrapper above also counts permission_denials.

Some commands need no rule. With only Read allowed, git status and git log still ran in dontAsk mode, so Claude Code treated them as safe. Test an allow list with a command that changes something, or the test proves nothing.

Your own setup comes along. One reply came back in the clipped style one of us asks for in a personal CLAUDE.md. Without --bare, a -p run loads your hooks, skills and CLAUDE.md, so the same script can answer differently on a teammate's machine.

The wrapper itself ran in 8.4 seconds and printed one line. One catch on macOS: timeout is not built in. Ours is GNU coreutils from Homebrew in /opt/homebrew/bin, which cron's default PATH of /usr/bin:/bin does not include, hence the PATH line in the crontab.

We also run it in production. Briefkin runs every routine on the owner's own computer through the agent CLIs, and for Claude Code it starts, in effect:

claude -p --output-format stream-json --verbose --allowedTools "Bash(<ask command>:*),<routine's tools>"

The prompt goes in on stdin, the process is spawned detached in the routine's folder, and a routine's timeout (default 30 minutes, at most 240) kills it. The details are in our briefkin-routines.mjs hook. A few choices come from that:

  • We read stream-json rather than json, because the card should show progress while the run is going. The last event is the result line, and we read its is_error, subtype and permission_denials. A denied tool is reported back to the owner with the exact setting to add, which is better than a silent skip.
  • We do not pass --bare. A routine is supposed to see the user's skills and CLAUDE.md. If your script must behave the same everywhere, you want the opposite.
  • We rely on a wall-clock timeout, not --max-turns or --max-budget-usd. Turns are a poor proxy for time, and on a subscription the cost figure is a list price, not what anyone paid.

Real problems people hit

1. Authentication that works at the keyboard and fails in the job. In setup-token OAuth token returns 401 Invalid bearer token with claude -p, the job got Failed to authenticate. API Error: 401 Invalid bearer token while claude auth status said all was fine: the token had wrapped onto two terminal lines and only the first was copied. Compare echo -n "$CLAUDE_CODE_OAUTH_TOKEN" | wc -c with a fresh run. A different report, Headless claude -p fails "OAuth session expired and could not be refreshed", saw a launchd or cron job fail after about two seconds on macOS while an interactive session kept working. It was closed as not planned. The workaround there is the same: give the job its own CLAUDE_CODE_OAUTH_TOKEN, or an API key.

2. Exit code 0 and nothing done. Headless claude -p intermittently starts with no user prompt describes a launchd job where the model sometimes answered "I don't see a request in your message" and the process still exited 0, eight times in ten days. An immediate retry usually worked. The reporter's fix is worth copying: have the job print a sentinel last, treat exit 0 without it as a failure, and retry only runs that clearly did nothing, never ones that died halfway through. Since 2.1.314, per the changelog, a failed startup prints the error and exits 1 instead of exiting 0 silently.

3. A run that will not end. Version 2.1.163 fixed claude -p hanging forever on a background command that never exits, as Headless docs omit claude -p background-shell cleanup after final result notes, but a dev server or watcher started by the prompt still keeps the run open. Leave long-lived commands out of the allow list, and keep the outer timeout.

Our take

We use claude -p for jobs that need the files and tools on one machine we control: a digest, a triage pass, a nightly check. For anything that must run with the laptop closed, a cloud routine or GitHub Actions is the better fit, and the routines guide compares them.

Our rules, which are opinions: allow list as small as the job allows, dontAsk so a denial is a result and not a hang, an outer wall-clock timeout, and a check on the result and its permission_denials, not only the exit code. For a shared CI job, use --bare with an API key so the run is the same everywhere. For a personal box, setup-token is fine, and we would rather rotate it yearly than rely on a login that can lapse. We would not use --dangerously-skip-permissions to get a script past a permission wall; read what it does and write the allow rule instead. And if you cannot see whether a job worked, add a hook or notification before you add more jobs.

Sources

Questions

What is Claude Code headless mode?

It is claude -p "prompt" (long form --print). Claude Code runs the prompt once without an interactive session, prints the answer and exits with code 0 on success. Anthropic's docs now call it running Claude Code programmatically, and it is the CLI form of the Agent SDK.

How do I stop claude -p from waiting for permission?

Pre-approve what the job needs with --allowedTools and add --permission-mode dontAsk, which denies everything else instead of prompting. On Claude Code v2.1.259 or later, --permission-prompts none also tells Claude that nobody can answer, so it does not retry a denied call.

How do I authenticate claude -p in cron?

Run claude setup-token once on a machine with a browser, then set the printed one-year token as CLAUDE_CODE_OAUTH_TOKEN in the job's environment. If you use --bare, that token is not read, so set ANTHROPIC_API_KEY instead.

How do I get JSON out of claude -p?

Add --output-format json and read .result with jq. For output that matches a schema, add --json-schema with a JSON Schema and read .structured_output, checking first that the field exists.

Does claude -p exit non-zero when it fails?

Usually, but check the output too. A failure inside a run, such as missing authentication, is printed as the result on stdout, and a tool call it was not allowed to make does not count as a failure: we saw exit 0 with the call listed in permission_denials. Test for a non-empty result and an empty permission_denials, not only the exit status.

More guides

All guides

Know when your agents need you

Briefkin puts your Claude Code, Codex and Cursor sessions on one board: working, needs you, done or failed. Telegram pings you when one is waiting on you, and scheduled skills post their reports next to them.

Free during the open beta. No card.