AI Briefing/Blog
Claude Code in CI·October 1, 2026·7 min read

Claude Code in practice, part 4: in CI, exit 0 isn't success

claude-code-action, claude -p with JSON, and the Agent SDK: a workflow to copy, subscription vs API, cost caps, and five checks after every run.

The exit code of claude -p is the least useful thing your CI gets back. Zero means the process ended. It doesn’t mean the agent did anything.

Sounds pedantic? Claude Code went GA in May 2025. Within three months, according to Gergely Orosz at the Pragmatic Engineer, usage grew 10x and it passed $500M in annual run-rate. A tool that sits on every laptop on the team reaches the pipeline fast. Someone comments @claude fix the test on a PR, and a GitHub job runs an agent with write access to the repo. In the first three parts, Claude sat in front of you in the terminal and you approved or blocked each step. In this part it runs alone. What you still control is what you check after it finishes.

Three doors to the same agent

There are three ways to run Claude Code without a terminal. Under the hood they all run the same loop.

To drive the same agent loop from a language other than Python or TypeScript, run the CLI as a subprocess with the -p flag and --output-format json.

— Anthropic, Agent SDK overview

So the rule is simple. On GitHub, start with the Action. In any other CI, use claude -p. Use the SDK only when your code needs to control the loop itself, for example to block a tool mid-run or to wire in an internal tool without standing up an MCP server. Don’t mix these up with Anthropic’s Code Review, which reviews every PR with no workflow at all, or with Claude Code on the web, which is a different product.

Anthropic itself separates several products that share a name. Pick a door by who runs the loop, not by the name (docs.anthropic.com)
Anthropic itself separates several products that share a name. Pick a door by who runs the loop, not by the name (docs.anthropic.com)

A minimal workflow for @claude

The short way: open claude in the repo and run /install-github-app. It installs the GitHub App, sets a secret and opens a PR with the workflow. You need gh logged in (gh auth login) and admin rights on the repo, and it only works against github.com. GitLab has its own guide.

I prefer the manual way, because you see every line before it lands. This is the workflow I’d copy:

.github/workflows/claude.yml
name: claude
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
jobs:
claude:
if: contains(github.event.comment.body, '@claude')
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
claude_args: "--max-turns 10 --allowedTools Read,Grep,Glob,Edit,Bash(npm test:*)"

Three lines here do most of the work. timeout-minutes: 15 is the real ceiling: even if Claude gets stuck in a loop, GitHub kills the job. --max-turns 10 stops the agent after ten turns. And --allowedTools defines what’s allowed, with Bash(npm test:*) instead of open Bash. One more thing: the input here is a PR comment, which is text someone else wrote. Make sure only users with write access can trigger it, and don’t run it on pull_request_target from forks when the job has secrets.

How the mechanism works
How the mechanism works

claude -p in a pipeline, and what comes back in JSON

Outside GitHub, everything starts with claude -p "..." --output-format json. Instead of free text you get back one object with the fields CI needs: subtype (success, or for example error_max_turns), is_error, num_turns, result with the text itself, session_id, duration_ms and total_cost_usd. If you want live progress in the log, --output-format stream-json emits one JSON line per event.

There’s a trap here. result is always a string. Even when you asked for “JSON only”, Claude can open with a sentence of explanation before the braces. You can trust the envelope, but whatever is inside result you parse and validate against a schema. Don’t pass it straight to json.loads.

Five checks after every run

This is what’s missing most from the workflows I see. An agent run can exit 0 without doing anything: it hit max turns, got blocked on a permission, or returned an empty answer. In our daily pipeline we once had a blocked run come back with 0 and num_turns: 0. It looked green until someone opened the output.

So after every run, five checks, in this order:

ci/run-claude.sh
#!/usr/bin/env bash
set -euo pipefail
claude -p "$PROMPT" \
--output-format json \
--max-turns 8 \
--allowedTools "Read,Grep,Glob,Edit" \
> claude-result.json || true
jq -e '.subtype == "success" and .is_error == false' claude-result.json
jq -e '.num_turns > 0' claude-result.json
jq -e '(.result | length) > 0' claude-result.json
jq -e '.total_cost_usd < 0.50' claude-result.json
npm test

jq -e returns a non-zero exit code when the expression evaluates to false or null, so every line is a gate. The || true after claude is deliberate: we don’t care what the CLI thinks of itself, we care what’s in the JSON. And the last line matters most. The best check of an agent’s work isn’t what it says in result that it did. It’s whether the tests pass on the diff it left behind.

A blocked run can come back with 0. The num_turns field in the JSON catches it before the merge. The exit code doesn't (Anthropic headless docs)
A blocked run can come back with 0. The num_turns field in the JSON catches it before the merge. The exit code doesn’t (Anthropic headless docs)

Subscription or API, and a cap that stops before the invoice

The installer asks how you pay, and there are two answers.

Subscription (Pro or Max). Run claude setup-token to get a long-lived token, store it in a secret named CLAUDE_CODE_OAUTH_TOKEN and pass it to the Action as claude_code_oauth_token. Runs come out of your subscription quota, so CI and your laptop share the same limits. For a personal repo or a team of two, that’s a good deal.

API key. ANTHROPIC_API_KEY, pay per token, and every run shows up on the bill. For a team, an org, or anything that runs on every PR, this is the right choice: separate limits, someone owns the bill, and you have something to measure. Either way the JSON returns total_cost_usd. On a subscription, treat it as a metric, not an invoice.

The caps, from innermost to outermost: --max-turns stops turns. --max-budget-usd (max_budget_usd in the SDK) stops when cumulative cost passes an amount. timeout-minutes in the workflow kills the job. And a spend limit in the Anthropic console is the last safety net. In practice, set all of them, because one day one of them will miss.

My take: on a team, use an API key per repo, not someone’s subscription token. When that person leaves, the token leaves with them, and CI fails overnight with nobody understanding why.

What to do this week

The agent in CI doesn’t need to be smarter than the one in your terminal. It needs to be checked more.

Sources

  1. Claude Code GitHub Actions - Claude Code Docs
  2. Agent SDK overview - Claude Code Docs
  3. Agent SDK overview - Claude Code Docs
  4. How Claude Code is built - by Gergely Orosz

How this was made: drafted by our agent from 4 sources, edited and approved by Koby Almog.

More terms

All terms →

AI-native engineering: writing code isn't the bottleneck anymore, verifying it is

What AI-native engineering is, what Meta and Anthropic actually did, and why the work moved from writing code to verifying it. With an AGENTS.md to copy.

Since coding: five years after Copilot, and what to change on your team on Monday

The model is the least important call when working with coding agents. Six steps: permissions, a devbox per task, AGENTS.md, proof in the PR, measurement.

Top coding: the list that went stale in 106 days

On June 8 Opus 4.8 scored 88.6%. By September 22 Claude Code ran a different model. What happened to coding agent rankings, and what to pin this week.