Automation

Claude Code headless mode: run claude -p from scripts and cron

How to run Claude Code non-interactively with claude -p, get JSON or schema-checked output, set permissions for unattended runs, and schedule it with cron.

Sooner or later you want Claude Code to do a job while you're not there, such as reviewing a diff in CI or summarising last night's logs at 3am. The interactive terminal can't do that. claude -p is the same agent with no interface: it takes a prompt, does the work, prints the result and exits.

The basic call

Add -p (short for --print) to any claude command:

bash
claude -p "What does the auth module do?"

It reads stdin too, so it fits in a pipeline like any other command-line tool:

bash
cat build-error.txt | claude -p "Explain the root cause of this build error" > explanation.txt

Piped input is capped at 10MB. For anything bigger, write it to a file and name the file in the prompt.

The exit code is 0 on success and non-zero when the run fails, so scripts can branch on it. A bad flag is reported on stderr before anything starts. A failure inside the run, such as missing authentication, is printed as the result on stdout.

Choose an output format

--output-format decides what lands on stdout:

Format What you get Use it for
text The final answer as plain text (the default) Piping into a file or another command
json One JSON object with the answer and metadata Scripts that need the session ID, cost or error flag
stream-json One JSON event per line as the run happens Live progress, or watching tool calls

Here is the shape of a real json result from a one-word test call, trimmed to the fields I read in scripts:

json
{
  "type": "result",
  "subtype": "success",
  "is_error": false,
  "result": "{\"word\":\"ok\"}",
  "structured_output": { "word": "ok" },
  "session_id": "18827f20-...",
  "num_turns": 2,
  "total_cost_usd": 0.102857,
  "usage": { "input_tokens": 2, "output_tokens": 138, "cache_creation_input_tokens": 102511 },
  "permission_denials": []
}

Pull out one field with jq:

bash
claude -p "Summarise this project" --output-format json | jq -r '.result'

total_cost_usd is a client-side estimate, so it can differ a little from your bill.

Get data that matches a schema

When a script needs fields rather than prose, pass a JSON Schema with --json-schema. Claude finishes the task, then returns data that matches the schema in a separate structured_output field:

bash
claude -p "List the main function names in auth.py" \
  --output-format json \
  --json-schema '{"type":"object","properties":{"functions":{"type":"array","items":{"type":"string"}}},"required":["functions"]}' \
  | jq '.structured_output.functions'

An invalid schema stops claude before the run with Error: --json-schema is not a valid JSON Schema. If a run ends without valid output, for example because Claude declined, the structured_output field is missing altogether. Check for it before you use it:

bash
out=$(claude -p "..." --output-format json --json-schema "$schema")
echo "$out" | jq -e '.structured_output' > /dev/null || { echo "no structured output" >&2; exit 1; }

The format keyword (such as "format": "email") is accepted but not enforced.

Permissions when nobody is watching

In a -p run nobody is there to answer "Allow this command?". Since v2.1.302, a claude -p run with no permission mode set starts in Manual mode, and Claude Code denies any call that needs approval. Claude carries on without it, and you get a job that did half of what you wanted.

Reading files in the working directory and read-only commands need no approval. For everything else, give the job exactly what it needs.

Allow specific tools with --allowedTools, using permission rule syntax:

bash
claude -p "Look at my staged changes and create a commit" \
  --allowedTools "Bash(git diff *),Bash(git log *),Bash(git status *),Bash(git commit *)"

The space before * matters. Bash(git diff *) matches git diff --stat but not git diff-index, while Bash(git diff*) matches both.

Or set a baseline for the whole run with --permission-mode:

Mode In a -p run
acceptEdits File edits and common filesystem commands such as mkdir and mv go through. Other shell commands still need an allow rule
dontAsk Anything that would prompt is denied. Only allow rules and pre-approved actions run. Good for locked-down CI
auto A classifier reviews each action instead of a person

For a scheduled job, also pass --permission-prompts none (v2.1.259 or later). It tells Claude that nobody can approve a denied request, so it doesn't keep retrying, and it removes tools such as AskUserQuestion that need a person to answer.

bash
claude -p "Update the dependency pins and run the tests" \
  --permission-mode auto --permission-prompts none

To see what was blocked, read permission_denials in the JSON result. A PermissionRequest hook can also approve calls in a run.

--dangerously-skip-permissions turns every check off. The CLI's own help recommends it only for sandboxes with no internet access, and I agree: on a machine with your credentials on it, use allow rules.

Make runs cheap and repeatable

Without extra flags, claude -p loads everything an interactive session would: your hooks, skills, plugins, MCP servers and CLAUDE.md files. On my machine that is a lot. The test call above cost about $0.10 to reply with one word, because it wrote around 102,000 tokens of my setup into the prompt cache first.

--bare skips all of that auto-discovery and starts faster. The docs recommend it for scripts and CI, and say it will become the default for -p in a future release. Bare mode never reads your login or keychain, so set ANTHROPIC_API_KEY first (Amazon Bedrock, Google Cloud and Microsoft Foundry keep using their own credentials). Anything you still need goes in by flag: --append-system-prompt, --settings, --mcp-config, --agents or --plugin-dir.

bash
claude --bare -p "Summarise README.md" --allowedTools "Read"

Without --bare, a -p run also loads a project's .claude/settings.json hooks and .mcp.json servers with no trust dialog. Only run it in directories you trust.

Three more flags help with scheduled calls. My nightly job always passes the first, and the other two when a call asks for them:

  • --no-session-persistence keeps one-off runs out of your session history, so they can't be resumed.
  • --max-budget-usd 2 stops the run once estimated spend reaches the cap. Spend can go past it, so leave headroom.
  • --tools "" removes every tool, for calls that only need to read the prompt and answer.

Run it from cron

Cron starts jobs with a minimal environment, so a script that works in your terminal often fails at 3am because claude isn't on PATH. Set PATH and the working directory yourself:

bash
#!/usr/bin/env bash
set -euo pipefail
export PATH="$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin"
cd "$HOME/projects/my-app"
log="$HOME/logs/claude-nightly-$(date -u +%F).json"

git log --since=yesterday --stat | claude -p \
  "Summarise yesterday's commits for a changelog. Plain text, under 15 lines." \
  --output-format json --no-session-persistence --max-budget-usd 1 \
  --permission-mode dontAsk --permission-prompts none > "$log"

jq -e '.is_error == false' "$log" > /dev/null || echo "claude run failed, see $log" >&2

Then schedule it:

text
0 3 * * * $HOME/bin/claude-nightly.sh

My own nightly job follows this pattern. A crontab entry at 03:00 runs a wrapper script that sets its own PATH and starts the real work in a detached tmux session, so I can attach and watch it if I'm up. The Python behind it calls claude -p with --output-format json, --no-session-persistence and --strict-mcp-config, and adds the usage numbers from each result to a daily token budget. Once the budget is spent, it makes no more calls that night. Text output carries no usage figures, so that budget depends on the JSON format.

Questions

Does claude -p use my subscription or an API key?

A normal claude -p run uses the same login as your interactive sessions. With --bare, it never reads that login, so you need ANTHROPIC_API_KEY in the environment or an apiKeyHelper passed with --settings.

Can I continue a headless conversation?

Yes. Capture the ID with jq -r '.session_id' from a JSON result and pass it to claude -p "next step" --resume "$session_id", or use --continue for the most recent conversation. This doesn't work for runs started with --no-session-persistence.

Why did my script finish without doing the work?

Usually a permission denial. In a -p run, any call that needs approval is denied unless an allow rule, permission mode or hook approves it. Check permission_denials in the JSON output, then add the narrowest --allowedTools rule that covers the call.

Sources

Field notes

Get the next guide by email.

Practical notes on getting more out of Claude Code: skills, hooks, MCP and agent workflows, plus what changes in each Overclaud release. Free, by email.

No spam. Unsubscribe any time. Privacy.