2.9 · Headless Mode, CI, and Pipelines

2.9 · 30 min

Headless Mode, CI, and Pipelines

Everything so far has assumed you, a terminal, and a conversation. But the review discipline you built in this part only scales if it runs when you are not there: on every PR, on a schedule, inside a pipeline. Claude Code was built for this from the start. The same binary that runs your interactive sessions is a command-line tool that reads stdin, writes stdout, and exits with a status code, which means everything you know about shell pipelines applies to an agent.

The concept: one flag, no conversation

Add -p (or --print) to any claude command and it runs non-interactively: execute the prompt, print the result, exit. No session UI, no back-and-forth.

claude -p "What does the auth module do?"

Every CLI option works with -p, and three families matter for pipelines:

Output formats. --output-format takes text (default), json, or stream-json. The json format wraps the run in an envelope with fields your script can rely on: result (the text), is_error, num_turns, session_id, total_cost_usd, and a per-model cost breakdown. That cost field means every scripted invocation is self-accounting; pipe it into your logs and you have per-job spend tracking for free. stream-json emits one JSON object per line as events happen and requires --verbose; add --include-partial-messages to get token-level deltas for live display.

Stdin. Non-interactive mode reads piped input like any Unix tool:

cat build-error.txt | claude -p "Explain the root cause of this build failure" > explanation.md

Continuation. Runs are stateless by default: each -p starts fresh. When a pipeline genuinely needs multi-step state, capture the session and resume it:

session_id=$(claude -p "Start a review" --output-format json | jq -r '.session_id')
claude -p "Continue that review" --resume "$session_id"

Bare mode: reproducibility for CI

Here is the trap in putting claude -p in CI naively: it loads the same context an interactive session would. CLAUDE.md, hooks, plugins, MCP servers, auto memory, whatever exists in the working directory and ~/.claude. On your machine that is your rig; on a runner it is whatever the checkout and image happen to contain, and your teammate's hook or a committed .mcp.json changes the behavior of "the same" command.

--bare turns all of that off. Per the docs, it skips auto-discovery of hooks, skills, plugins, MCP servers, auto memory, and CLAUDE.md; only flags you pass explicitly take effect. Auth must come from ANTHROPIC_API_KEY (bare mode skips OAuth and keychain reads), and you feed back exactly the context you want: --append-system-prompt-file for standing instructions, --settings for config, --mcp-config for servers, --allowedTools for permissions.

The position this course takes: interactive sessions inherit your rig; CI invocations declare theirs. Use --bare in anything automated, and pass context explicitly. Reproducibility is a verification property, and Part 3 will lean on it hard.

In practice: a diff reviewer that emits JSON

Piped input, one prompt, a declared output contract. This is the shape of most useful headless tools:

review-diff.shbash
#!/usr/bin/env bash
# Review the diff against main; emit machine-readable verdict.
set -euo pipefail

git diff main...HEAD | claude --bare -p "You are reviewing a diff.
Respond with ONLY a JSON object, no prose, no code fences:
{
\"verdict\": \"approve\" | \"request_changes\",
\"issues\": [{ \"file\": string, \"line\": number | null,
             \"severity\": \"blocker\" | \"nit\", \"note\": string }]
}
Flag only correctness and security problems, not style." \
--output-format json > review-envelope.json

# Envelope sanity, then extract the model's JSON from .result
jq -e '.is_error == false' review-envelope.json > /dev/null
jq -r '.result' review-envelope.json | jq . > review.json

echo "cost: $(jq -r '.total_cost_usd' review-envelope.json) USD"
jq -e '.verdict == "approve"' review.json

Two layers of JSON, and the distinction teaches the CLI's honest limit. The outer envelope (is_error, total_cost_usd) is guaranteed by --output-format json. The inner contract, your verdict and issues shape, is enforced only by the prompt; the CLI does not validate the model's text against a schema. Prompted contracts hold most of the time, and "most of the time" is exactly what jq -e is there to catch: if the model wrapped its JSON in prose, the parse fails, the script exits nonzero, and your pipeline treats it as the failure it is. When you need the contract guaranteed rather than prompted, that is schema-validated structured output, and it lives in the Agent SDK: Lesson 2.11.

Note also what the reviewer cannot do: with --bare and no --allowedTools, it has no pre-approved tools and nothing gets granted mid-run, because there is no human to answer a permission prompt. For a reviewer fed its input on stdin, that is not a limitation, it is the design: a read-nothing, write-nothing critic.

Into GitHub Actions

For GitHub automation you could script raw claude -p on a runner, but the maintained path is the official action, anthropics/claude-code-action@v1. Setup is one command in an interactive session: /install-github-app installs the GitHub App on your repo and walks you through adding the workflow and the ANTHROPIC_API_KEY secret. (You need repo admin rights; Bedrock and Google Cloud users follow the manual path in the docs.)

The default workflow answers @claude mentions in issues and PRs. For an unconditional review job on every PR, the workflow is small:

.github/workflows/claude-review.ymlyaml
name: Claude PR Review
on:
pull_request:
  types: [opened, synchronize]

jobs:
review:
  runs-on: ubuntu-latest
  permissions:
    contents: read
    pull-requests: write
  steps:
    - uses: actions/checkout@v4
      with:
        fetch-depth: 0
    - uses: anthropics/claude-code-action@v1
      with:
        anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
        prompt: |
          Review this PR's diff for correctness and security issues only.
          Post findings as a PR comment with file and line references.
          If nothing is wrong, say so in one line.
        claude_args: |
          --max-turns 10

The prompt input is your dispatch; claude_args passes CLI flags through, so everything from this lesson (--model, --allowedTools, --append-system-prompt) works in CI unchanged. The API key comes from GitHub Secrets, never hardcoded. Scope the workflow's permissions block to what the job does: a reviewer needs contents: read and pull-requests: write, not blanket write access.

Where it breaks

No human in the loop means no permission prompts. Interactively, a risky tool call stops and asks you. Headless, there is nobody to ask: the call either matches a pre-approval (--allowedTools, settings) or fails. People discover this as mysterious CI hangs or refusals, then reach for the biggest hammer, --dangerously-skip-permissions, on a runner that has repo write access and secrets in scope. Do not. Declare the minimal tool list the job needs, or give it none and feed everything on stdin like the reviewer above.

Prompted contracts drift. The inner-JSON pattern fails occasionally: a model that wants to be helpful adds "Here is the review:" above the JSON. Your script must treat unparseable output as a failed run, not silently pass. jq -e and set -euo pipefail are load-bearing in the example, not decoration.

Cost has no natural brake. An interactive session has you watching it. A workflow on pull_request: synchronize runs on every push to every open PR, and a retry loop around a flaky job multiplies that. Cap work with --max-turns in claude_args, watch total_cost_usd from the JSON envelope, and give scheduled jobs a budget review after the first week. Boring advice that has saved real money.

Lablab-2-9Build the JSON diff reviewer

Goal: Ship a headless reviewer script whose output a pipeline can parse, then wire the PR review workflow into a GitHub repo.

Prereqs: the agentic-practice repo pushed to GitHub, jq installed, an Anthropic API key (the Action needs one even if your local Claude Code uses a subscription).

  1. Create a branch in the practice repo and make a small, deliberately imperfect change: for example, a function that mutates its input array while claiming to be pure.
  2. Create review-diff.sh with the script from this lesson. Make it executable and run it on your branch.
  3. Inspect all three outputs: review-envelope.json (find total_cost_usd and num_turns), review.json (did the model catch your planted issue?), and the script's exit code (echo $?).
  4. Break the contract on purpose: edit the prompt to remove the "ONLY a JSON object" constraint and add "Explain your reasoning first." Re-run and watch where the script fails. Restore the prompt.
  5. Wire the Action: run /install-github-app in an interactive session in the repo, add the ANTHROPIC_API_KEY secret when prompted, then commit the claude-review.yml workflow from this lesson.
  6. Open a PR from your branch and watch the Actions tab: the job should run and post a review comment on the PR.

Verify

  • ./review-diff.sh exits 0 on a clean diff and nonzero when the review parses to request_changes (or fails to parse), and you can state which happened on your branch.
  • review.json is valid JSON matching the contract, and the planted bug appears in issues.
  • Your sabotage run in step 4 failed at the jq parse, not silently.
  • The PR shows a comment from the Claude workflow run, and the run is green in the Actions tab.
>Troubleshooting
  • Local script fails immediately with an auth error: --bare ignores your OAuth login; export ANTHROPIC_API_KEY in the shell running the script.
  • jq: error ... cannot be parsed as JSON: the model broke the inner contract. Print jq -r '.result' to see what came back; tighten the prompt ("no code fences" catches the most common wrapper) and re-run.
  • Action fails with a permissions error: check the workflow's permissions block and confirm the Claude GitHub App is installed on the repo (/install-github-app reports status when re-run).
  • Action never triggers: the workflow file must be on the repository's default branch before PR events fire it.

Knowledge check

Knowledge check

Q1Your CI job runs claude -p and behaves differently on the runner than the identical command does on your laptop. What is the most likely cause, and the fix?
Q2With --output-format json, which guarantee do you actually have about the review contract JSON your prompt requested?
Q3A teammate proposes running the nightly triage job with --dangerously-skip-permissions because it keeps stalling on tool approvals. What is the right response?
Q4You want token-by-token streaming from a headless run to drive a live progress display. Which invocation is correct per the docs?

Sources