Headless and CI
Headless mode is Claude Code with no session, no prompts and no person, run from a script
with claude -p. You have been using it for the checks in this course since lesson 11. This
lesson is what it is for.
claude -p "How many test files are in this repo?"
One prompt in, one answer out, exit. Everything else here is what to add so the answer is something a script can use.
Permissions, but in reverse
In -p mode the permission mode is Manual by default, so anything that is not pre-approved
gets refused rather than asked about. You saw that in lesson 18: without an allow rule for
npm test, the answer was “I need your permission to run npm test” and nothing ran.
That is the wrong shape for automation. The right one is dontAsk, where nothing runs
unless it is on an explicit list:
claude -p "run the test suite and report the failures" \
--permission-mode dontAsk \
--allowedTools "Bash(npm test)" "Read"
Two things are true at once: the run cannot hang waiting for input, and it cannot do anything you did not name. Every other mode is either interactive or too permissive to leave running on a build machine.
--dangerously-skip-permissions also exists and it is exactly what it says. It belongs in a
container that has nothing in it, and nowhere else.
Output a script can read
--output-format json gives you the answer plus the metering:
claude -p --output-format json "..." | jq -r '.result, .total_cost_usd, .num_turns'
That is what lesson 17 and lesson 22 measured with. There is also stream-json, which emits
each message as it happens, for anything that needs to show progress.
Better than either, when the output has a shape: make Claude fill it in.
claude -p --json-schema '{"type":"object","properties":{
"verdict":{"type":"string","enum":["pass","concerns"]},
"findings":{"type":"array","items":{"type":"string"}}},
"required":["verdict","findings"]}' "..."
{"verdict":"pass","findings":[]}
No parsing prose, no “sometimes it adds a preamble”, no regex. If you are piping a result into anything, use a schema.
Hand it the input
The other half is stdin. Anything you would ask Claude to go and fetch, you can pipe in instead:
git diff main...HEAD | claude -p "Review this diff."
That is faster, cheaper, and it removes a tool from the equation entirely. A run that needs no tools cannot be surprised by a permission rule, cannot read the wrong file, and does the same thing on your laptop and on a build machine that has no git history.
Prefer this over allowing a tool wherever you can. If the script already knows what Claude needs to see, show it.
A review that fails the build
Putting it together. This is a real gate: it reviews the branch against main and exits
non-zero if the review has concerns.
#!/usr/bin/env bash
set -euo pipefail
BASE="${1:-main}"
SCHEMA='{"type":"object","properties":{
"verdict":{"type":"string","enum":["pass","concerns"]},
"findings":{"type":"array","items":{"type":"object","properties":{
"file":{"type":"string"},"line":{"type":"integer"},"issue":{"type":"string"}},
"required":["file","line","issue"]}}},
"required":["verdict","findings"]}'
diff=$(git diff "$BASE...HEAD")
if [ -z "$diff" ]; then echo "No changes against $BASE."; exit 0; fi
result=$(printf '%s' "$diff" | claude -p --permission-mode dontAsk \
--json-schema "$SCHEMA" \
"Review this diff. Report only real problems: a behaviour change with no test, a change
the commit message does not describe, or a bug. Empty findings if it is fine.")
echo "$result" | jq -r '.findings[] | "\(.file):\(.line) \(.issue)"'
[ "$(echo "$result" | jq -r '.verdict')" = "pass" ]
Run against a branch that fixed the paging bug cleanly, it prints nothing and exits 0.
Now put a second commit on that branch called “Tidy up paginate”, which adds one line
capping pageSize at 100, and run it again:
src/util/paginate.ts:18 The 'Tidy up paginate' commit message implies a pure cleanup, but
this line silently caps pageSize at 100, a real behaviour change. It is undocumented in the
commit message and has no test covering the clamp. Both call sites pass config.pageSize
from an env var with no upper-bound validation elsewhere...
Exit 1. It caught a behaviour change hidden behind a tidying message, found both call sites, and noticed the environment variable. That is a review worth having on every branch, and it is eighteen lines of bash.
What is a headless run bad at?
Anything ambiguous. A headless run cannot ask you a question. If the task has a decision in it, the run will pick one, and you will find out later.
Anything expensive left unbounded. --max-budget-usd caps the spend on a run. Use it on
anything that fires automatically, because a loop that costs four cents is fine and the same
loop on every push to every branch is a bill.
Being the only reviewer. A gate that fails the build on findings has to be right almost always, or people learn to re-run it until it passes. Start it as a comment rather than a gate, watch it for a fortnight, and promote it only if you find yourself agreeing with it.
Long autonomous work. A one-shot run with no person in it is not the place for a multi-hour task. If it goes wrong at minute three, it goes wrong for the rest of it.
On a build machine, remember what else loads.
CLAUDE.md, project skills and hooks from the checked-out repository all apply in a-prun. That is usually what you want, and it is also why a pull request that adds a.claude/directory deserves a read.
Your turn
Build the gate above as review.sh in your stockroom clone, on the branch from lesson 19.
chmod +x review.sh
./review.sh main
echo "exit: $?"
Check 1: on the branch with only the paging fix on it, the script prints nothing and exits 0.
Now give it something to find. Add a line to paginate() that changes behaviour, commit it
with a message that does not mention the change, and run the gate again:
git commit -am "Tidy up paginate"
./review.sh main
echo "exit: $?"
Check 2: the script names
src/util/paginate.tswith a line number, says the commit message does not describe the change, and exits 1. If it passes, your prompt is too vague about what counts as a problem: “review this diff” gets you a summary, and the wording above gets you a verdict.
Recap
claude -p is one prompt, one answer, exit. --permission-mode dontAsk with
--allowedTools is the shape for automation: it cannot hang and it cannot exceed the list.
--output-format json gives you the metering. --json-schema gives you a result with a
shape, which is what to use whenever a script reads the output.
Pipe the input in rather than letting it go and fetch things. A run with no tools is the same run everywhere.
Bound the spend with --max-budget-usd, and let an automated review comment for a fortnight
before it is allowed to fail a build.
Next: the mistakes that get through all of this, including the one in stockroom that no test catches.