Skip to main content
Claude Code tips · Lesson 8 of 13 · 13 min

Claude Code Headless Mode: claude -p With a Pass/Fail Gate

Run Claude Code headless with claude -p: output formats, permission modes, allowedTools and dontAsk, plus a verification gate script that closes the loop.

By Matthews WongReviewed on Checked against Claude Code v2.1.301

Key takeaway

Headless mode runs Claude Code without a person at the keyboard: claude -p takes a prompt, works, prints the result and exits with a status code. Because nobody can answer prompts, you pre-approve a narrow set of tools, and you give Claude a script that exits 0 or 1 so the run proves its own work.

You will be able to

  • Run claude -p with the right output format, tool allowlist and permission mode.
  • Keep an unattended run inside a narrow set of tools, turns and spend.
  • Close the loop with a pass/fail script, and know when to plan first.
Watch it work

Step through the diagram

Press Play, or step with the arrows. Every step is written out below the diagram.

One unattended run, closed by a gate

A scheduler starts claude -p with an allowlist; Claude edits, runs a gate script that fails, fixes, reruns until it exits 0, and the process exit status goes back to the scheduler.

Reviewed on 17 October 2026 · Claude Code v2.1.301

Step 1 of 7. A scheduler starts claude -p, which cannot ask you anything. Pre-approved tools and a gate script stand in for the person who would have approved and checked.

Keys: ← → step · Home / End · K play or pause

  1. A scheduler starts claude -p, which cannot ask you anything. Pre-approved tools and a gate script stand in for the person who would have approved and checked.

  2. The job passes a prompt plus flags: a permission mode, an allowlist and a turn limit. With no person present, every choice has to be on the command line.

    claude -p "Fix the staged post, then run the validator" --permission-mode dontAsk --allowedTools "Edit" "Bash(node scripts/validate-staged-post.mjs *)" --max-turns 12 --output-format json
  3. In dontAsk mode, any call that would normally prompt is denied. Only reads, the allowlisted Edit and the one validator command run. The rest never executes.

    --permission-mode dontAskPredict the next step Claude tries a command that is not in --allowedTools, under dontAsk. What happens? (The run hangs waiting for an answer · The call is denied, with no prompt · It runs, because nobody is watching)
  4. The validator command matches the allowlist, so it runs. It checks the staged post and exits 1 with a FAIL list, because the work is not done yet.

    console.error("FAIL:\n - " + errs.join("\n - ")); process.exit(1);
  5. The failure text goes back to Claude. It edits what the list names and runs the gate again. The loop ends when the gate passes or --max-turns runs out.

    Predict the next step The gate prints FAIL and exits 1. What does Claude do next? (It stops and reports success anyway · It reads the failures, fixes them and reruns · It asks you in the terminal)
  6. On the rerun the validator exits 0. Claude writes its final answer, and claude -p prints it as one JSON object with a result field and a session id.

    claude -p "..." --output-format json | jq -r ".result"
  7. The process exits 0 on success and non-zero when the run fails, so the scheduler can branch. That means the run finished, not that the work is right: rerun the gate yourself.

    claude -p ... ; echo $?

What does claude -p do, and what does it print?

The -p (or --print) flag runs Claude Code non-interactively: it takes the prompt, does the work, prints the answer and exits. It exits with code 0 on success and non-zero when the run fails, so a script can branch on the status. A failure inside the run, such as missing authentication, is printed as the result on stdout. It reads stdin too, so cat build-error.txt piped into claude -p works, up to a 10 MB cap.

Read more (2)

--output-format picks the shape. text, the default, is plain text. json is one object with the result, the session id, metadata and a cost estimate, which jq can read: jq -r .result. stream-json is one JSON event per line, and needs --verbose, and optionally --include-partial-messages, to stream tokens. Add --json-schema to get output that follows your schema in a structured_output field. To keep going, --continue resumes the latest conversation and --resume takes a session id.

For scripts and CI the docs recommend --bare, and say it will become the default for -p in a future release. It skips auto-discovery of hooks, skills, subagents, plugins, MCP servers, auto memory and CLAUDE.md, so a run behaves the same on every machine. The price is that it never reads your login: set ANTHROPIC_API_KEY, or supply an apiKeyHelper, and pass any context you need with flags such as --append-system-prompt or --mcp-config.

How do you keep an unattended run inside safe limits?

With nobody to approve, you decide the limits up front. --allowedTools lets named tools run without a prompt, using permission rule syntax: Bash(git diff *) allows any command starting with git diff. The space before the star matters, because Bash(git diff*) would also match git diff-index. --permission-mode sets the baseline: acceptEdits writes files freely, auto has a classifier review actions, and dontAsk denies anything that would prompt, which the docs call useful for locked-down CI.

Read more (2)

Budgets bound the damage of a loop. --max-turns limits agentic turns and exits with an error at the limit, and --max-budget-usd stops the run once estimated spend reaches the amount; both are print-mode only. --permission-prompts none (v2.1.259 and later) denies whatever would prompt and tells Claude not to retry it. --dangerously-skip-permissions skips all checks, and the docs say to use it only in a container or VM with no internet access.

One trap is trust. A claude -p run never shows the workspace trust dialog, so permissions.allow rules from a project's .claude/settings.json do not apply, and Claude Code prints a warning to stderr. Hooks in project settings and servers in .mcp.json, on the other hand, still load. In a repository you did not write, start with --bare or --setting-sources user.

How does a verification loop close the run, and when should you plan first?

Claude stops when the work looks done. The best-practices guide puts it plainly: without a check it can run, looks done is the only signal, and you become the verification loop. Give it something that returns pass or fail, such as a test suite, a build exit code or a validator script, and it does the work, runs the check, reads the result and iterates until the check passes. That is the difference, in the docs' words, between a session you watch and one you walk away from.

Read more (2)

You can enforce the gate harder in three ways: ask for it in the prompt, add a Stop hook that blocks the turn from ending until a script passes, or add a verification subagent so the agent doing the work is not the one grading it. Whatever you pick, ask Claude to show evidence, the output and the command, not just to say it passed. And treat the exit code of claude -p as saying the run finished. Rerun the gate in CI, because a gate you did not run is a claim, not a result.

Plan mode is the other half, for when you are unsure of the approach. Claude researches and writes a plan but edits no source until you approve it, and --permission-mode plan starts a run in it; the docs note the same flag works with -p. If you could describe the diff in one sentence, skip the plan. The docs mention no way to approve a plan non-interactively, so use a headless plan run to get a plan out, and make the edits in a separate run.

How this site does it

The real config behind this lesson

Excerpts from the repository that builds and deploys this website, quoted as they are.

.claude/agents/blog-post-author.md:70-78
# Self-validation — do not skip

```bash
node scripts/validate-staged-post.mjs /tmp/blog-staging/agent-<slug>
```

Fix everything it reports, including warnings about content keys defined but not
referenced. Re-run until it prints `PASS`. If a cover does not exist yet the
image check will fail — say so in your report rather than inventing one.

This repo's own verification loop, written into a subagent. The agent may not report back until a script prints PASS. It is told to fix what the script reports and re-run, which is the loop the lesson describes. It runs in an interactive session today, but the same instruction would work in a claude -p prompt.

scripts/validate-staged-post.mjs:1-3
// Validate ONE staged post folder + its component before the big merge.
// Usage: node scripts/validate-staged-post.mjs /tmp/blog-staging/agent-3
// Mirrors merge-blog-staging.mjs per-post checks + SEO rules. Exit 0 = PASS.

A good gate states its contract in the first lines: what it checks, how to call it and what its exit status means. Exit 0 is PASS. That one fact is what lets a loop, a hook or a CI job treat the script as a verdict.

scripts/validate-staged-post.mjs:196-200
if (warns.length) console.log("WARN:\n - " + warns.join("\n - "));
if (errs.length) {
  console.error("FAIL:\n - " + errs.join("\n - "));
  process.exit(1);
}

The failing side. Every error is collected first and printed as a list on stderr, then the script exits 1. A list of all problems at once gives Claude a complete to-do for the next edit instead of one failure per run, and warnings go to stdout without changing the exit code.

What breaks

Mistakes that cost real time

  • Bash(git diff*) with no space

    The space before the star makes it a prefix match on the whole command word. Without it, Bash(git diff*) also allows git diff-index and anything else that merely starts with those letters. Write Bash(git diff *).

  • Project allow rules that never apply

    A claude -p run shows no trust dialog, so permissions.allow in the repo's .claude/settings.json is ignored and a warning goes to stderr. Pass the allowlist on the command line with --allowedTools.

  • --bare without an API key

    Bare mode never reads OAuth credentials or the keychain, so a subscription login does not work in it. Set ANTHROPIC_API_KEY or an apiKeyHelper. It also skips CLAUDE.md, hooks and MCP servers, so a script that relied on them has to pass them explicitly.

  • Trusting exit code 0 as proof

    A zero status says the run completed. It does not say the change is correct. Run the same gate yourself after the job, so a pass comes from a script and not from Claude's own summary.

  • A loop with no ceiling

    A gate that can never pass keeps a run reading, fixing and rerunning. Set --max-turns and --max-budget-usd. The turn limit exits with an error when reached, and SIGTERM ends a run with exit code 143 and no result for the unfinished turn.

Permission modes for a claude -p run
defaultReads onlyReview every action
acceptEditsReads, file edits, common filesystem commandsCode you will review later
planReads; edits blocked until a plan is approvedGetting a plan, not edits
autoMost actions, reviewed by a classifierLong hands-off tasks
dontAskReads and pre-approved tools; the rest is deniedLocked-down CI
bypassPermissionsEverythingOnly inside a container or VM
Try it yourself

Do it on your own machine

You need Claude Code, jq and a scratch repository with a TypeScript project. Start read-only, then give one run a narrow allowlist and a gate. Run these on code you can throw away.

  1. Run a read-only question and take only the text out of the JSON.

    claude -p "What does this project do? Answer in two sentences." --output-format json | jq -r ".result"
  2. Write the gate first. This one fails with a non-zero exit until the project type-checks.

    npx tsc --noEmit
    echo "gate exit: $?"
  3. Hand the gate to a locked-down run. It may edit files and run only the type-checker, for at most 12 turns.

    claude -p "Fix the type errors, then run npx tsc --noEmit until it passes." --permission-mode dontAsk --allowedTools "Edit" "Bash(npx tsc *)" --max-turns 12 --output-format json > run.json
  4. Read the answer, then run the gate yourself and branch on its exit status. Do not take the summary on trust.

    jq -r ".result" run.json
    if npx tsc --noEmit; then echo "gate passed"; else echo "gate failed"; exit 1; fi
  5. Ask a planning question in plan mode to see edits stay blocked. Then compare with the same prompt under dontAsk.

    claude -p "Plan how to add input validation to the form. Do not edit files." --permission-mode plan
Quick quiz

Three questions before you move on

Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.

  1. 1.A claude -p run under --permission-mode dontAsk tries a Bash command that is not in --allowedTools. What happens?
  2. 2.Why write Bash(git diff *) with a space before the star?
  3. 3.Your CI job ends with claude -p exiting 0. What have you actually learned?
Think it through

Answer in your head, then check

  1. 1.When would you choose --output-format stream-json over json?Show the answer

    Answer:When you want events as they happen, such as progress or tokens, instead of one object at the end. It needs --verbose, and --include-partial-messages adds token-level chunks. The last line is a result message with the final text, cost and session data.

  2. 2.Why does this repo's agent instruction say to re-run until it prints PASS?Show the answer

    Answer:Because a single run of the validator only lists problems. The loop of fix, rerun and read the result is what turns that list into done work, and the script's exit status is the signal Claude cannot talk its way past.

  3. 3.A scheduled claude -p job must not touch a repository's committed hooks or .mcp.json. What do you pass?Show the answer

    Answer:--bare, which reads no hooks, skills, plugins or .mcp.json servers from the project. Alternatively --setting-sources user, or a disabledMcpjsonServers entry for one server. Then supply what you do need with --settings and --mcp-config.

Go deeper

Related deep dives on this site