Headless Claude and CI Gates
Every sample in this guide was captured with claude -p, which runs Claude Code once, without a terminal, and exits. That makes it a command you can put in a script or a pipeline. This chapter covers what such a run returns, how to cap it, how to refuse to trust its answer, how to fail a build when the setup did not load, and how to lint your Claude configuration for free. Every gate here was run for real, including the ones that are supposed to fail.
haiku model on a copy of the bookshelf project. Costs are the client-side estimates Claude Code reports, not a bill.A Command With an Exit Code
Anthropic's documentation describes the non-interactive mode as the same tools, agent loop and context management as Claude Code, available as a command for scripts and CI/CD. You pass -p with a prompt; it exits with 0 on success and a non-zero code when the run fails (Source 1). Nobody is there to answer a permission prompt, so everything a person would normally approve has to be decided in advance: which tools are allowed, which mode applies, how much it may spend.
One thing to know before automating: without the --bare flag, a -p run behaves like an interactive session. It runs the hooks in a project's .claude/settings.json and connects the servers in its .mcp.json, even in a folder you have never trusted, with no trust dialog and no approval prompt (Source 1). Chapters 22 and 23 saw the first half of that in practice: the project's hooks ran under -p. --bare skips all of that discovery and is the documented recommendation for scripted calls, but it does not use your subscription login and needs an ANTHROPIC_API_KEY, so I did not run it.
Reading the Result
--output-format takes text (the default), json (one result object with metadata) or stream-json (one event per line, used throughout Chapter 17). Here is what the json result of the smallest possible run contains, on this version:
The ones a script cares about, with their values from that run:
resultis the answer text. It is null when the run did not finish normally, as section 04 shows, so read it only after checkingis_error.subtypeandterminal_reasonsay why the run ended:successandcompletedhere.total_cost_usdandnum_turnslet a script track spend and effort. The documentation calls the cost a client-side estimate that can differ from your bill.permission_denialslists calls that were refused, which Chapter 17 used to find hook and mode denials.
Structured Output, Then Don't Trust It
Prose is hard to parse. With --json-schema plus --output-format json, the answer arrives in a structured_output field that conforms to your schema. If the run ends without valid structured output, the field is simply absent, so a script must check for it before use (Source 1). Conforming to a schema says the answer has the right shape. It says nothing about whether it is true. So the script below asks Claude for the failing features, and then checks the answer against the file with jq:
Two runs. The first compares with the real file. The second compares the same kind of answer with an altered copy in which feature #7 is marked passing, to prove the gate can say no:
- The model was right both times, [6,7]. In the second run the ground truth was changed, and the gate reported MISMATCH and exited 4. That is the behaviour you want from a gate: it cares about the comparison, not about how confident the model sounded.
- The exit codes are yours. 3 means no structured output, 4 means a mismatch. A pipeline can branch on them or just treat any non-zero as failure.
- This pattern fits questions with a checkable answer. "Which tests failed?" can be checked against a test report. "Is this design good?" cannot, and no gate script will make it so.
Turn Limits, Budgets and What a Failed Run Looks Like
Two flags put a ceiling on a run: --max-turns and --max-budget-usd. I hit both on purpose, and ran one invalid flag, to see what a script receives:
- Both caps give a non-zero exit code and
is_error: true. Thesubtypetells them apart:error_max_turnsanderror_max_budget_usd. resultis null. A script that pipes.resultstraight into a file would write the wordnull. Always checkis_errorfirst.- Caps are checked after a step, not before it. With
--max-turns 1the run reports 2 turns, and a budget of $0.0005 stopped at $0.0016. Set a cap with margin, and treat it as "about this much", not "never more". - An invalid flag fails before any API call, with a one-line message on stderr and exit 1. A failure inside a run, such as missing authentication, is printed as the result on stdout instead (Source 1).
- Killing a run: SIGTERM makes it exit with 143 and record no result for the turn in progress; SIGINT ends the turn first. I did not test either.
--max-budget-usd. A loop that accidentally calls Claude on every file would otherwise be a bill, not a bug.Deciding in Advance What Nobody Can Approve
| Tool | What it does (documented) | Used in this guide? |
|---|---|---|
--allowedTools "Read,Edit" | Auto-approve those tools. Rules use permission syntax, e.g. Bash(git diff *); the space before * matters, because Bash(git diff*) would also match git diff-index | Yes, in every run |
--permission-mode dontAsk | Deny everything that would otherwise prompt; calls covered by your allow rules still run. Suited to locked-down CI | Yes, in every run |
--permission-mode acceptEdits | Writes files without prompting; other shell commands still need a rule | No |
--permission-prompts none | For unattended runs: anything that would prompt is denied and Claude is told not to retry. Needs 2.1.259 or later | No |
--bare | Skips hooks, skills, plugins, MCP, memory and CLAUDE.md discovery; recommended for CI; needs an API key | No |
From 2.1.302 a -p run with no permission mode set starts in Manual mode, where a call that needs approval is denied unless something approves it, so a silent hang waiting for a prompt is not the failure to expect. A refused call shows up in the result's permission_denials, which is why a gate should look at it. Every sample in this guide used dontAsk plus a narrow --allowedTools, and the capstone in the next chapter shows what a denial looks like in a real task.
Fail the Build When the Setup Did Not Load
Chapter 24 proved a plugin loaded by reading the start-up event. The documentation turns that into a CI check: the event carries plugin_errors and mcp_server_errors, both omitted when empty. A bad entry does not stop the run, which "continues and exits cleanly", so without a check a broken configuration passes unnoticed (Source 1).
Three runs: the good plugin from Chapter 24, a plugin directory that does not exist, and an MCP entry with a url but no type:
The good plugin passes. The missing plugin is reported as path-not-found, with the path. The bad MCP entry is reported as url_missing_type with a message that says how to fix it. In both failing cases Claude Code itself exited cleanly; only the gate failed the build. That is the whole point of writing one.
Lint Your Claude Configuration
The cheapest check never calls Claude. Several mistakes in this guide were mistakes in files: a skill whose frontmatter did not start on line 1 (Chapter 18), a misspelled key that is ignored silently, a plugin server with a relative path (Chapter 12). A script can find all three in milliseconds. This one uses only the Python standard library:
- Clean repository: zero findings, with the intentionally broken demos excluded.
- The two broken skills from Chapter 18 are both caught, with the reason: frontmatter not on line 1, and the misspelled
descriptonkey that is "ignored silently". - The Chapter 12 plugin is flagged for the relative server path. That rule encodes a plain fact about relative paths (they resolve against the process's working directory) and my reading of how plugin servers are launched; I never ran the old file to watch it fail.
It checks: valid JSON in settings files and that every hook script they name exists and is executable; SKILL.md and agent frontmatter (first line, required keys, known keys, name matching folder); .mcp.json entries; and plugin layout. The frontmatter reader is deliberately simple, not a YAML parser, and its key lists come from the documentation read for Chapters 18 and 20, so a newer Claude Code release can make them stale. Wire it into CI next to the hook tests, which now assert their exit codes and fail the run if one is wrong:
claude-code-action to review pull requests. I did not run it: it needs a GitHub repository, an ANTHROPIC_API_KEY secret and a real pull request, and I did not read the GitHub Actions documentation page. I also did not run GitLab CI, the Agent SDK for Python or TypeScript, --bare, --permission-prompts none or SIGTERM handling.Key Takeaways, Exercises and Sources
claude -pis a command: exit code, JSON result, and no one to approve anything, so decide permissions and caps in advance.- Check
is_errorbefore readingresult. A capped or failed run has a null result. - Schemas give shape, not truth. Compare structured answers with ground truth you can compute.
- Caps are approximate. They are checked after a step, so leave margin.
- A load error does not fail a run. Gate on
plugin_errorsandmcp_server_errorsyourself. - Lint the files first. It is free, and it catches the mistakes that cost the most to debug.
Exercise 1 Beginner
Run claude -p "Reply with the single word ok." --output-format json | jq '{is_error, subtype, result, total_cost_usd}' in your own project. Then add --max-turns 1 to a prompt that needs a tool and read the same four fields.
Learning outcome: read a result and a failed result.
Exercise 2 Intermediate
Write a gate like failing-features.sh for something in your project that has a computable answer, such as the list of test classes or the version in pom.xml. Make it fail on purpose by altering the ground truth, and confirm the exit code.
Learning outcome: build a gate and prove it can fail.
Exercise 3 Advanced
Add a rule to lint_claude_config.py for something that has bitten you, for example a skill whose allowed-tools lists Bash without a pattern. Add a deliberately bad fixture and a test that the linter flags it. Then read the hooks and skills references for any key the script does not know yet.
Learning outcome: turn a past mistake into a permanent check.
- Anthropic, Run Claude Code programmatically —
-p, bare mode, output formats,--json-schema, permission modes and--allowedTools,--permission-prompts, exit codes and SIGTERM, the start-up event'splugin_errorsandmcp_server_errors. Read in full.
Executed 2026-10-31: the minimal JSON run, the schema gate (two runs), the turn and budget caps and the invalid flag, the load gate (three runs), the linter on three targets, and local dry-runs of the CI workflow's steps. Not run: --bare, --permission-prompts none, SIGTERM, GitHub Actions, claude-code-action, GitLab CI and the SDK packages. Scripts and samples are in the examples repository under automation/.