Claude · 25 · Part 12 · Hands-On Automation

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.

4
Gate scripts
0
Findings on the clean repo
8
Real runs, all under $0.01
Information current as of: 2026-10-31. Runs used Claude Code 2.1.316 with the haiku model on a copy of the bookshelf project. Costs are the client-side estimates Claude Code reports, not a bill.
01
The Idea

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.

A gated headless run
triggerpush, PR, cron claude -pallowlist, mode,turn and budget caps JSON resultresult, cost,subtype, errors gate scriptcompare withground truth exit 0 or non-zero the model proposes; a script you wrote decides

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.

02
Output

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 fields of a json result · executed 2026-10-31
api_error_status duration_api_ms duration_ms fast_mode_disabled_reason fast_mode_state first_content_frame_ms is_error modelUsage num_turns permission_denials queued_turn_count result result_index safety_stops session_id stop_reason subagent_stats subtype terminal_reason time_to_request_ms total_cost_usd ttft_ms ttft_stream_ms type usage uuid

The ones a script cares about, with their values from that run:

selected fields of the same result
{ "type": "result", "subtype": "success", "is_error": false, "result": "ok", "num_turns": 1, "total_cost_usd": 0.00162882, "stop_reason": "end_turn", "permission_denials": [], "terminal_reason": "completed" }
  • result is the answer text. It is null when the run did not finish normally, as section 04 shows, so read it only after checking is_error.
  • subtype and terminal_reason say why the run ended: success and completed here.
  • total_cost_usd and num_turns let a script track spend and effort. The documentation calls the cost a client-side estimate that can differ from your bill.
  • permission_denials lists calls that were refused, which Chapter 17 used to find hook and mode denials.
03
Gate 1

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:

automation/failing-features.sh
#!/usr/bin/env bash # Asks Claude for the failing features as schema-validated JSON, then compares the answer with ground truth # read by jq. Exit 0 only if they match. Usage: automation/failing-features.sh <project-dir> # Env: MODEL (default haiku), CAP (max USD, default 0.10), GROUND_TRUTH (default <project>/feature_list.json) set -euo pipefail PROJ=${1:?project dir} TRUTH=${GROUND_TRUTH:-$PROJ/feature_list.json} SCHEMA='{"type":"object","properties":{"failing":{"type":"array","items":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string"}},"required":["id","name"]}}},"required":["failing"]}' ISOLATE=$(jq -nc --arg p "$HOME/.claude/CLAUDE.md" '{claudeMdExcludes:[$p]}') OUT=$(cd "$PROJ" && claude -p "Read feature_list.json and list the features whose status is failing." \ --model "${MODEL:-haiku}" --max-turns 4 --max-budget-usd "${CAP:-0.10}" --allowedTools "Read" \ --permission-mode dontAsk --setting-sources user --settings "$ISOLATE" \ --output-format json --json-schema "$SCHEMA" < /dev/null) echo "$OUT" | jq -e '.is_error == false and (.structured_output | type == "object")' > /dev/null \ || { echo "FAIL: no structured output"; exit 3; } GOT=$(echo "$OUT" | jq -c '[.structured_output.failing[].id] | sort') WANT=$(jq -c '[.features[] | select(.status == "failing") | .id] | sort' "$TRUTH") echo "model says: $GOT ground truth says: $WANT cost: $(echo "$OUT" | jq .total_cost_usd)" if [ "$GOT" = "$WANT" ]; then echo "MATCH"; else echo "MISMATCH"; exit 4; fi

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:

Actual output · executed 2026-10-31 · each run under one cent
$ automation/failing-features.sh <project> model says: [6,7] ground truth says: [6,7] cost: 0.00673604 MATCH exit=0 $ GROUND_TRUTH=altered.json automation/failing-features.sh <project> # altered copy: #7 marked passing model says: [6,7] ground truth says: [6] cost: 0.00275311 MISMATCH exit=4
  • 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.
04
Caps

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:

Actual output · executed 2026-10-31 · total cost of the three under half a cent
$ claude -p "Read feature_list.json and tell me how many features are failing." --max-turns 1 --output-format json ... exit=1 {"subtype":"error_max_turns","is_error":true,"terminal_reason":"max_turns","num_turns":2,"total_cost_usd":0.00172262,"result":null} $ claude -p "<same prompt>" --max-budget-usd 0.0005 --max-turns 6 --output-format json ... exit=1 {"subtype":"error_max_budget_usd","is_error":true,"terminal_reason":"budget_exhausted","num_turns":1,"total_cost_usd":0.0015897600000000002,"result":null} $ claude -p "hi" --bogus-flag exit=1 error: unknown option '--bogus-flag'
  • Both caps give a non-zero exit code and is_error: true. The subtype tells them apart: error_max_turns and error_max_budget_usd.
  • result is null. A script that pipes .result straight into a file would write the word null. Always check is_error first.
  • Caps are checked after a step, not before it. With --max-turns 1 the 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.
The cap is also your safety net while you develop. The scripts behind this guide refuse to start a run when the logged spend passes a limit, and every run carries its own --max-budget-usd. A loop that accidentally calls Claude on every file would otherwise be a bill, not a bug.
05
Permissions

Deciding in Advance What Nobody Can Approve

ToolWhat 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-indexYes, in every run
--permission-mode dontAskDeny everything that would otherwise prompt; calls covered by your allow rules still run. Suited to locked-down CIYes, in every run
--permission-mode acceptEditsWrites files without prompting; other shell commands still need a ruleNo
--permission-prompts noneFor unattended runs: anything that would prompt is denied and Claude is told not to retry. Needs 2.1.259 or laterNo
--bareSkips hooks, skills, plugins, MCP, memory and CLAUDE.md discovery; recommended for CI; needs an API keyNo

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.

06
Gate 2

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).

automation/check-init.sh
#!/usr/bin/env bash # CI gate: start a one-turn session with the given extra claude flags and fail if a plugin or MCP config did not load. # Usage: automation/check-init.sh <project-dir> [claude flags...] e.g. --plugin-dir ./plugins/bookshelf-kit # Reads the system/init event of a stream-json run (documented fields: plugin_errors, mcp_server_errors). set -euo pipefail PROJ=${1:?project dir}; shift ISOLATE=$(jq -nc --arg p "$HOME/.claude/CLAUDE.md" '{claudeMdExcludes:[$p]}') RAW=$(cd "$PROJ" && claude -p "Reply with the single word ok." --model haiku --max-turns 1 --max-budget-usd 0.05 \ --permission-mode dontAsk --setting-sources user --settings "$ISOLATE" --output-format stream-json --verbose "$@" < /dev/null 2>/dev/null) INIT=$(echo "$RAW" | jq -c 'select(.type == "system" and .subtype == "init")' | head -1) PERR=$(echo "$INIT" | jq -c '.plugin_errors // []') MERR=$(echo "$INIT" | jq -c '.mcp_server_errors // []') echo "plugin_errors: $PERR" echo "mcp_server_errors: $MERR" if [ "$PERR" != "[]" ] || [ "$MERR" != "[]" ]; then echo "FAIL: configuration did not load"; exit 1; fi echo "PASS: everything loaded"

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:

Actual output · executed 2026-10-31 · three one-turn runs, about a cent in total
$ automation/check-init.sh <project> --plugin-dir plugins/bookshelf-kit plugin_errors: [] mcp_server_errors: [] PASS: everything loaded exit=0 $ automation/check-init.sh <project> --plugin-dir plugins/does-not-exist plugin_errors: [{"plugin":"inline[0]","type":"path-not-found","message":"Path not found: plugins/does-not-exist","path":"plugins/does-not-exist"}] mcp_server_errors: [] FAIL: configuration did not load exit=1 $ automation/check-init.sh <project> --mcp-config {"mcpServers":{"bad":{"url":"https://example.invalid/mcp"}}} # url without type plugin_errors: [] mcp_server_errors: [{"name":"bad","type":"url_missing_type","message":"Skipped — MCP server \"bad\" has a \"url\" but no \"type\"; add \"type\": \"http\" (or \"sse\" / \"ws\") to this entry"}] FAIL: configuration did not load exit=1

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.

07
Gate 3 · Free

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:

Actual output of automation/lint_claude_config.py · executed 2026-10-31 · no Claude calls
$ automation/lint_claude_config.py . (excluding the deliberately broken demos) 0 finding(s) exit=0 $ automation/lint_claude_config.py skills/_broken blank-line-demo/SKILL.md: first line is not "---" (frontmatter must start on line 1) typo-demo/SKILL.md: missing "description" typo-demo/SKILL.md: unknown skill key "descripton" (it would be ignored silently) 3 finding(s) exit=1 $ automation/lint_claude_config.py <chapter-12-plugin> .mcp.json: server "bookshelf-features": script path "mcp/feature-server.mjs" is relative, so it resolves against the process working directory, not the plugin folder; use ${CLAUDE_PLUGIN_ROOT}/... 1 finding(s) exit=1
  • 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 descripton key 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:

.github/workflows/lint-claude-config.yml
name: Lint Claude configuration on: push: pull_request: jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Lint skills, agents, hooks, MCP and plugin files run: python3 -I automation/lint_claude_config.py . --exclude _broken --exclude samples - name: Shell scripts parse run: for f in $(git ls-files '*.sh'); do bash -n "$f" || exit 1; done - name: MCP server parses run: node --check mcp/feature-server.mjs - name: Hook scripts behave (no Claude calls) run: | mkdir -p /tmp/proj && cd /tmp/proj && git init -q && git -c user.name=ci -c user.email=ci@example.invalid commit -q --allow-empty -m init "$GITHUB_WORKSPACE/hooks/test_hooks.sh" /tmp/proj
Status of that workflow. I dry-ran each of its four steps locally and they pass. The workflow file itself has not yet run on GitHub: GitHub runs it only after the repository is pushed, and I will not claim a green check I have not seen.
Not covered here. Chapter 11 showed a workflow that uses Anthropic's 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.
08
Summary

Key Takeaways, Exercises and Sources

∑ Automation: what to remember
  • claude -p is a command: exit code, JSON result, and no one to approve anything, so decide permissions and caps in advance.
  • Check is_error before reading result. 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_errors and mcp_server_errors yourself.
  • Lint the files first. It is free, and it catches the mistakes that cost the most to debug.
Exercises

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.

Sources (read on 2026-10-31; paraphrased)
  1. 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's plugin_errors and mcp_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/.