How to Run Claude Code in CI (GitHub Actions + Headless -p Mode)
Run Claude Code headless in CI: -p non-interactive mode, --allowedTools, API-key auth, JSON output with per-run cost and exit codes, and the honest math of it.
Run Claude Code non-interactively by passing -p with your prompt: claude -p "run the tests and fix failures" --allowedTools "Bash,Read,Edit". In a pipeline, authenticate with an ANTHROPIC_API_KEY environment variable pulled from your secrets store, add --output-format json to get a parseable result with the per-run cost in total_cost_usd, and check the exit code — 0 on success, non-zero on failure. On GitHub specifically, the official anthropics/claude-code-action@v1 wraps all of this: give it a prompt, your API key secret, and any CLI flags via claude_args.
Or skip the work: Meta-Prompt + System Prompt Architect does it in seconds →
The first time I put Claude Code in a pipeline, it failed in the most useless way possible: green checkmark, nothing done. The run finished in seconds, the job passed, and the “fix” it was supposed to commit didn’t exist. No crash, no red X — the agent had politely asked for permission to run a command, nobody on the runner existed to say yes, and it shrugged and ended its turn. That failure mode is the whole reason this guide exists: running Claude Code unattended is easy, but running it unattended and usefully takes about five flags, and the docs scatter them across three pages.
One scoping note before the flags, because these get confused constantly: this is not the same thing as scheduling Claude Code to run daily in the cloud. Routines are Anthropic-hosted cron — their machine, their sandbox, fired by a clock. CI is your pipeline: a GitHub Actions runner you configure, fired by a push or a pull request, paying with your API key, with an exit code that can block a merge. Clock-shaped work → routines. Code-change-shaped work → this guide.
Headless mode: claude -p
The whole trick is one flag. Add -p (or --print) and Claude Code runs your prompt non-interactively — no TUI, no session to attend, output to stdout like any other command:
claude -p "What does the auth module do?"
Every CLI option works alongside it. It also reads stdin, which turns Claude into a pipe-able Unix citizen — this is my favorite CI shape because the data arrives without Claude needing any tool permissions to fetch it:
git diff main | claude -p "you are a typo linter. for each typo in this diff, report filename:line and the issue. return nothing else."
(Piped stdin is capped at 10MB as of v2.1.128 — past that the process exits non-zero with a clear error. For bigger inputs, write a file and reference the path in the prompt.)
Two context facts that matter in a pipeline. First: by default, claude -p loads everything an interactive session would — hooks, MCP servers, skills, and your CLAUDE.md file. That last one is the good news: the standing rules you wrote for your repo apply to CI runs automatically, which is exactly where you want your conventions enforced by something that never gets tired. Second: when you want the opposite — a run that behaves identically on every machine, unpolluted by whatever a teammate has in their ~/.claude — add --bare. Bare mode skips all auto-discovery (including CLAUDE.md) and only honors flags you pass explicitly. Anthropic says it’s the recommended mode for scripted calls and will become the default for -p eventually; for reproducible CI it’s the right call, and you pass context back in deliberately with --append-system-prompt or --settings.
Permissions: why unattended runs die quietly
Interactive Claude asks before it acts. Headless Claude can’t ask — so anything not pre-approved is denied, and the run either routes around the denial or ends with an apology and a clean exit code. You have two levers.
--allowedTools pre-approves specific tools, using the same permission rule syntax as your settings file:
claude -p "Look at my staged changes and create an appropriate commit" \
--allowedTools "Bash(git diff *),Bash(git log *),Bash(git status *),Bash(git commit *)"
The trailing * is prefix matching — Bash(git diff *) allows anything starting with git diff. And the space before the * is load-bearing: Bash(git diff*) without it would also match git diff-index. Scope these tightly; a CI runner usually holds a checkout with push access, so “allow all Bash” is handing the model your repo.
--permission-mode sets a session-wide baseline instead of listing tools. The two useful ones for CI: acceptEdits lets Claude write and edit files without prompting (plus common filesystem commands like mkdir, mv, cp) — right for “apply the lint fixes” jobs. dontAsk denies everything not already in your permissions.allow rules or the read-only command set — right for locked-down analysis runs where the agent should look but never touch:
claude -p "Apply the lint fixes" --permission-mode acceptEdits
There is also a bypass-everything mode. Don’t reach for it in CI: a runner that holds repo write access and secrets is precisely the machine where “skip all permission checks” is a bad trade. Enumerate what the job needs; the enumeration is the security model.
Auth: an API key in a secret, nothing else
Pipelines authenticate with an API key in the environment. Store it as a repository secret, expose it on the step:
- name: Claude lint
run: git diff origin/main | claude -p "report typos as filename:line" --bare
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
Notes from getting this wrong so you don’t have to: --bare skips OAuth and keychain reads entirely, so in bare mode the env key isn’t just recommended, it’s the only Anthropic auth path. And the precedence cuts the other way on your laptop — a set ANTHROPIC_API_KEY outranks your claude.ai login, which is how a leftover exported key ends up silently billing the wrong account. I tripped exactly this while fact-checking this guide: the run failed with a low-credit error from a key I’d forgotten was set, while a perfectly good login sat ignored underneath it.
Exit codes and capturing output
A claude -p step behaves like a normal CI command: exit 0 on success, non-zero on failure. I verified both paths while writing this — a healthy run returned 0; an API error returned 1. So the simplest integration is no integration: put the command in a step and let the exit code gate the job.
For anything smarter, add --output-format json. The single JSON object you get back includes the pieces a pipeline actually wants (trimmed from a real run):
{
"type": "result",
"is_error": false,
"result": "ok",
"num_turns": 1,
"session_id": "916cb541-ab38-445f-a1c9-342bb6c5e339",
"total_cost_usd": 0.0284,
"permission_denials": []
}
Pull the text with jq -r '.result', log total_cost_usd somewhere you’ll actually look at it, and — the underrated one — check permission_denials. A run that got handcuffed by permissions can still exit 0 with is_error: false, because from Claude’s point of view it completed a turn. Non-empty denials means your allowlist is too tight for the task, and that’s a check worth failing loudly instead of shipping a no-op green build.
Two more output modes for specific shapes: --output-format stream-json (with --verbose --include-partial-messages) emits newline-delimited events for live CI logs, and --json-schema with a schema definition returns validated data in a structured_output field — the right tool when a downstream step needs fields, not prose.
The GitHub Action: anthropics/claude-code-action@v1
If your CI is GitHub Actions, you mostly don’t hand-roll the above — the official action wraps the CLI, checks out context, and posts results back to the PR or issue. Fastest setup is running /install-github-app inside Claude Code; it installs the GitHub App (Contents, Issues, and Pull requests permissions) and walks you through the API key secret. The action auto-detects its mode: give it a prompt and it runs immediately on the triggering event; omit the prompt on comment events and it responds to @claude mentions instead.
A review-every-PR workflow is this small:
name: Claude PR Review
on:
pull_request:
types: [opened, synchronize]
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
prompt: "Review this PR for bugs and security issues. Be specific: file, line, why it breaks."
claude_args: "--max-turns 10 --model claude-opus-4-8"
Everything CLI-shaped flows through claude_args — --model, --max-turns, --allowedTools, --append-system-prompt, --mcp-config. (If you’re migrating from the beta action: direct_prompt became prompt, and the old max_turns/model/allowed_tools inputs all moved into claude_args.) The action reads your repo’s CLAUDE.md too, so review standards live in the repo, not in the workflow file.
The honest cost math
Two meters run on every triggered workflow, and people budget for the wrong one.
Meter one: API tokens. Current sticker pricing puts Sonnet-class models at $3 per million input tokens / $15 per million output, Haiku-class at $1/$5, Opus-class at $5/$25. But here’s the number that surprised me when I measured it: a trivial headless run — the prompt was literally “reply with ok” — cost about $0.03, because a non-bare run loaded roughly 34,000 tokens of settings and memory context before the task even started. Your per-run floor is your context, not your task. --bare shrinks that floor; a fat CLAUDE.md raises it on every single trigger.
For a real job, do the arithmetic with your own numbers — as an illustration only: a PR review that processes ~150K input tokens (diff, surrounding files, project context) and writes ~5K output on Sonnet-class pricing runs about $0.45 + $0.08 ≈ $0.53 per PR. At 15 PRs a workday that’s ~330 runs and roughly $175 a month — noticeable but cheap next to human review time. The same job on Haiku-class pricing is about a third of that; on Opus-class, roughly double. Don’t trust my illustration, though — trust total_cost_usd. It’s in every JSON response, per run, with a per-model breakdown. Log it for a week before you decide anything.
Meter two: runner minutes. The job occupies a GitHub-hosted runner the whole time Claude is thinking. Free for public repos; private repos burn the Actions quota on your plan. An agent turn can run minutes, so this is not nothing.
The levers, in order of effect: cap iterations with --max-turns, set a workflow-level timeout-minutes so a runaway run can’t eat an hour of both meters, use GitHub’s concurrency controls so ten rapid pushes don’t fan out into ten parallel agents, and gate the trigger itself — paths filters or a label condition so docs-only PRs never wake the agent. And route by model: the always-on, high-volume lanes don’t need premium inference. I keep the heavy bulk lanes on the z.ai GLM Coding Plan — that’s a referral link, it helps fund our compute, and the plan costs the same with or without it. The same environment-variable redirect from setting up Claude Code with the GLM API works in a workflow step, since the raw CLI reads whatever endpoint the job env gives it. Cheap tokens for every-PR volume, premium tokens for the judgment calls.
Where this fits
CI is where your prompt quality stops being a preference and starts being infrastructure. An interactive session forgives a vague prompt — you see the drift and correct it. A pipeline doesn’t: the same instruction runs on every push, unwatched, against code you haven’t read yet, and whatever ambiguity it contains gets amplified a few hundred times a month at real per-run cost. Every hour I’ve put into tightening a CI prompt — the deliverable’s exact shape, the failure policy, what not to touch — has paid back in runs I never had to think about again. That’s the exact muscle Meta-Prompt + System Prompt Architect trains: turning rough intent into the tight, failure-aware, reusable instructions that an unattended runner with repo write access can be trusted to execute. Write the prompt like it’s code going to production — because in CI, that’s precisely what it is.
Frequently asked
What's the difference between running Claude Code in CI and scheduling a routine?
Who owns the machine and what pulls the trigger. A routine is a cron job that runs in Anthropic's cloud — you set a schedule, their infrastructure spins up a session, and your computer stays off. CI is your pipeline: a GitHub Actions runner you configure, triggered by your repo's events — a push, a pull request, a comment — authenticated with your API key, and its exit code can gate a merge. Use routines for time-based jobs with no repo trigger; use CI when the work should happen because code changed.
How do I authenticate Claude Code in a GitHub Actions pipeline?
Store your key as a repository secret named ANTHROPIC_API_KEY and never commit it. With the official action, pass it as the anthropic_api_key input; with the raw CLI, expose it as an environment variable on the step. One trap worth knowing: an ANTHROPIC_API_KEY in the environment outranks a claude.ai login, so a stray exported key silently decides which account pays for the run. In CI that's exactly what you want — the runner has no login, only the key.
Why does my headless run finish without doing anything?
Permissions. In interactive mode Claude asks before running a command or editing a file; on a runner there's nobody to answer, so un-approved tool calls are denied and Claude either works around them or gives up politely — and the job can still exit 0. Fix it by pre-approving what the task needs: --allowedTools with specific rules like Bash(git diff *), or a permission mode like --permission-mode acceptEdits for file edits. The JSON output has a permission_denials array — if it's non-empty, your run was quietly handcuffed.
How much does it cost to run Claude Code on every pull request?
Two meters run at once: API tokens and GitHub Actions minutes. The API side depends on model and context — a PR review that reads a real diff plus project context typically lands in the tens of cents on Sonnet-class pricing, and every --output-format json response reports its own total_cost_usd so you can measure instead of guessing. Runner minutes are free on public repos and metered on private ones. The levers that matter: --max-turns to cap iterations, a workflow timeout, concurrency limits, and only triggering on PRs that actually need review.
How do I get the result and exit code from a headless Claude Code run?
Add --output-format json and parse it. The payload includes result (the final text), is_error, session_id, num_turns, a full usage breakdown, and total_cost_usd. Pull the text with jq -r '.result'. The process exits 0 on success and non-zero on failure, so a plain claude -p step fails the job the way any other CI command would. For machine-checkable gates, add --json-schema and read the structured_output field instead of parsing prose.
Some links may be referral links, always marked. Full disclosure →