# Headless and CI | Claude for Real Work

> Running Claude Code with nobody watching: one-shot invocations, output a script can parse, a mode that cannot hang, and a review that fails a build.

Source: https://karakabakov.com/courses/claude-for-real-work/headless-and-ci/

---

[&larr; Claude for Real Work](https://karakabakov.com/courses/claude-for-real-work/)

# Headless and CI

Lesson 26 of 28 · 12 min · Updated August 21, 2026

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 `-p` run. 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.ts` with 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.

[&larr; Previous

## Plugins](https://karakabakov.com/courses/claude-for-real-work/plugins/) [Next &rarr;

## Catching its mistakes](https://karakabakov.com/courses/claude-for-real-work/catching-mistakes/)

[&larr; All lessons](https://karakabakov.com/courses/claude-for-real-work/) [Next lesson &rarr;](https://karakabakov.com/courses/claude-for-real-work/catching-mistakes/)
