
Claude Code in CI: headless with claude -p, --bare and GitHub Actions
Add -p (or --print) to any claude command and Claude Code runs non-interactively: it reads stdin and prints a result. Every CLI flag still works. Pair it with --allowedTools to auto-approve tools, --output-format json for cost and session data, and --bare for reproducible CI. That one flag turns Claude Code into a scriptable, CI-friendly tool.
What does claude -p do, and how do I run Claude Code in CI?
Run Claude Code in CI by adding -p (or --print) to any claude command: it reads stdin, does the work, prints a result, and exits — non-interactively, no REPL. Every CLI flag still works. Pair -p with --allowedTools, --output-format json, and --bare, and Claude Code becomes a scriptable, CI-friendly tool you drop into a shell script or GitHub Actions job.
I configure Claude Code every day, and the shift from “a thing I chat with” to “a binary I pipe into” happened the moment I internalized that -p is just a mode toggle on the exact same CLI. The session you’d drive by hand becomes a step in a shell script, a package.json task, or a GitHub Actions job. If you’re new to the tool, start with what Claude Code is and how to use it, then come back to automate it.
One prerequisite: headless mode still needs a real account behind it — Pro, Max, Team, Enterprise, or Console, or a provider like Bedrock, Vertex, or Foundry. -p changes how you invoke Claude Code, not whether you’re authenticated.
claude -p: run Claude Code non-interactively (and every flag still works)
The whole feature is one flag:
claude -p "summarize the changes in this repo since the last tag"
No TUI, no prompt loop. Claude Code reads your prompt (and any piped stdin), runs to completion, prints the result to stdout, and returns an exit code. That’s it. Because it’s the same CLI, everything composes the way you’d expect from a Unix tool — redirects, pipes, $(...) capture, exit-code checks in a script.
The mental model that unlocks the rest of this post: -p doesn’t give Claude Code a reduced feature set. It gives the full set with the interactive shell removed. So --model, --allowedTools, --output-format, --append-system-prompt, --continue, permission modes — all work under -p. The rest of this guide is choosing which of those flags you need for scripts and CI.
Auto-approve tools with --allowedTools and permission-rule syntax
Interactively, Claude Code asks before it edits a file or runs a command. In a script there’s no human to say yes, so you pre-authorize the exact tools you’re comfortable with:
claude -p "fix the failing test in src/parser.ts" \
--allowedTools "Read,Edit,Bash"
That grants the Read, Edit, and Bash tools with no prompt. But Bash alone is a broad grant. --allowedTools uses permission-rule syntax, so you can scope a tool down to specific invocations with prefix matching:
claude -p "review the staged diff and flag risky changes" \
--allowedTools "Read,Bash(git diff *)"
One trap worth burning into memory: the space before the * is load-bearing. Bash(git diff *) and Bash(git diff*) are different rules — the first matches git diff followed by arguments, the second matches commands starting with the literal string git diff. Get the spacing wrong and your allow rule silently won’t match, and the run stalls waiting for an approval that never comes. Least privilege is the whole game here: grant the narrowest set of prefixed commands your task actually needs.
--output-format: grab result, session_id, and total_cost_usd
By default -p prints plain text — great for a human reading a terminal, useless for a machine that needs to branch on the outcome. Three formats:
# text — the default; just the answer
claude -p "explain this stack trace"
# json — structured envelope with metadata
claude -p "explain this stack trace" --output-format json
# stream-json — newline-delimited events as they happen
claude -p "explain this stack trace" \
--output-format stream-json --verbose --include-partial-messages
The json format is what CI wants. It wraps the answer in an envelope carrying the result, a session_id, per-run metadata, and — the field every finance-conscious team asks about — total_cost_usd, plus a per-model cost breakdown. So you can log exactly what each run cost:
cost=$(claude -p "triage this issue" --output-format json | jq -r '.total_cost_usd')
echo "This run cost \$$cost"
stream-json emits newline-delimited JSON events as the run progresses — pair it with --verbose --include-partial-messages and parse the stream with jq when you want live progress in a long CI job instead of one final blob. And if you’re routing work across models to control spend, the same cost fields make runs easy to compare.
--json-schema: force schema-conforming output into structured_output
Parsing free text with regex is how automation breaks. When you need a specific shape back, hand Claude Code a JSON Schema and it returns conforming data in a dedicated field:
claude -p "classify this issue by severity and area" \
--output-format json \
--json-schema '{
"type": "object",
"properties": {
"severity": { "type": "string", "enum": ["low", "medium", "high"] },
"area": { "type": "string" }
},
"required": ["severity", "area"]
}'
--json-schema must be used together with --output-format json. The conforming object lands in the structured_output field of the envelope, so your script reads a guaranteed shape instead of hoping a sentence parses:
severity=$(claude -p "classify severity" \
--output-format json \
--json-schema '{"type":"object","properties":{"severity":{"type":"string"}},"required":["severity"]}' \
| jq -r '.structured_output.severity')
This is the single biggest reliability upgrade for CI glue. A schema turns “the model probably said high” into a field you can if-branch on without apology.
--bare: skip hooks, skills, plugins, MCP, and CLAUDE.md for reproducible CI
Here’s the failure mode nobody warns you about: your automation works on your laptop and behaves differently in CI, because your laptop quietly loads a CLAUDE.md, some hooks, plugins, and an MCP server the CI runner doesn’t have. Same command, different context, different result. Not reproducible.
--bare fixes it by skipping auto-discovery entirely — no hooks, no skills, no plugins, no MCP servers, no auto memory, no CLAUDE.md:
claude -p "run the typo linter over this diff" --bare \
--allowedTools "Read"
In bare mode, only the flags you pass explicitly take effect, so you get the same result on every machine — exactly the property CI needs. Two things to know: it’s the recommended mode for scripted and SDK calls, and it will become the default for -p in a future release, so writing --bare today is writing forward-compatible scripts. And because bare mode skips local login discovery, auth must come from the environment: set ANTHROPIC_API_KEY (or your provider credentials).
export ANTHROPIC_API_KEY="sk-ant-..."
claude -p "review the diff" --bare --allowedTools "Read,Bash(git diff *)"
Default -p (auto-discovery on)
- Loads CLAUDE.md, hooks, skills, plugins
- Connects configured MCP servers
- Pulls in auto memory
- Result depends on the machine's local config
- Great for your laptop, unreliable in CI
--bare (recommended for scripts/CI)
- Skips all auto-discovery
- Only the flags you pass take effect
- Same result on every machine
- Auth via ANTHROPIC_API_KEY or provider creds
- Will become the default for -p
If your task genuinely needs a data source, connect it deliberately rather than relying on auto-discovery — see connecting an agent to your data with MCP and, before you wire anything into CI, how to secure MCP servers.
Pipe data through Claude Code and add a lint:claude script
Because -p reads stdin, Claude Code slots into any pipeline like grep or jq. Feed it a build log, redirect the answer to a file:
cat build-error.txt | claude -p 'explain the root cause of this build error' > output.txt
One limit to respect: piped stdin is capped at 10 MB. For anything larger, write to a file and point Claude Code at the path instead of streaming it in.
The pattern I reach for most is a linter wired into package.json, so a single npm run lint:claude runs it locally and in CI identically:
{
"scripts": {
"lint:claude": "git diff main | claude -p \"you are a typo linter — list only real typos in this diff, one per line, nothing else\""
}
}
That’s a real reviewer that costs cents and never gets bored. Swap the prompt and it becomes a changelog drafter, a migration checker, or a docstring auditor — same plumbing.
--continue and --resume: capture the session_id and keep going
Scripts sometimes need more than one turn — do a thing, inspect the result, ask a follow-up with full context. --continue picks up the most recent conversation; --resume with a session_id continues a specific one by id. In CI, where “most recent” is ambiguous across parallel jobs, always resume by id. Capture it from the JSON envelope:
session_id=$(claude -p "start the security review of this PR" \
--output-format json | jq -r '.session_id')
# ...later, same context, a targeted follow-up
claude -p "now check only the auth changes for missing rate limits" \
--resume "$session_id"
Capturing session_id and resuming by it is the difference between a one-shot call and a multi-step workflow that keeps its memory. If you’re building something more ambitious than a single reviewer, this is the primitive underneath building an autonomous agent with Sonnet.
--append-system-prompt: turn a one-off call into a reviewer persona
A generic prompt gives generic output. --append-system-prompt adds standing instructions on top of Claude Code’s default behavior, so you can shape one call into a specialist without rewriting the whole thing every time:
git diff main | claude -p "review this diff" --bare \
--allowedTools "Read" \
--append-system-prompt "You are a security reviewer. Focus only on injection, authz gaps, secret leakage, and unsafe deserialization. Report findings as a terse bullet list with file:line. Say 'no issues' if clean."
Now the same review command consistently wears a security hat, and every CI run reviews to the same standard. It’s the CLI counterpart to the specialization you’d otherwise build with subagents, hooks, and slash commands — lighter weight, perfect for a single automated step.
Permission modes, --max-turns, and --max-budget-usd for locked-down CI
--allowedTools says what’s allowed; permission modes set the default posture for everything else. Two matter for automation:
# acceptEdits — auto-approve edits and common fs commands (mkdir/touch/mv/cp)
claude -p "apply the codemod across src/" --permission-mode acceptEdits
# dontAsk — locked down: deny anything not in your allow rules or the read-only set
claude -p "review, don't modify" --permission-mode dontAsk --allowedTools "Read,Bash(git diff *)"
acceptEdits is for jobs that need to write — it auto-approves edits plus common filesystem commands like mkdir, touch, mv, and cp. dontAsk is what I default to for untrusted contexts like PR review: it denies anything not in your allow rules or the built-in read-only set, so an unexpected tool request fails closed instead of hanging. Pair either with hard ceilings so a runaway run can’t rack up cost or loop forever:
claude -p "triage this issue" --bare \
--permission-mode dontAsk \
--allowedTools "Read" \
--max-turns 6 \
--max-budget-usd 0.50
- Choose a permission modedontAsk for review/triage, acceptEdits for jobs that must write
- Scope --allowedTools tightlyPrefixed rules like Bash(git diff *) — least privilege
- Cap the run--max-turns and --max-budget-usd so nothing loops or overspends
- Run --bare with the key in envANTHROPIC_API_KEY set, reproducible, fails closed
Wire it into GitHub Actions and GitLab CI
Anthropic ships official GitHub Actions and GitLab CI/CD support, so you can automate PR review, issue triage, and per-PR code review without building the harness yourself. The recipe is the same everywhere: store the key as a CI secret, run --bare for reproducibility, grant least-privilege tools.
Here’s a minimal GitHub Actions step that reviews every pull request:
name: claude-pr-review
on: [pull_request]
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Review the diff
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
git diff origin/${{ github.base_ref }}...HEAD \
| claude -p "Review this diff for correctness and security. Terse bullets, file:line." \
--bare \
--permission-mode dontAsk \
--allowedTools "Read" \
--append-system-prompt "You are a strict senior reviewer." \
--output-format json \
| jq -r '.result'
The GitLab shape is identical — ANTHROPIC_API_KEY as a masked CI/CD variable, the same claude -p --bare invocation in a script: block. Two rules keep it safe: never grant write tools on untrusted PR code (dontAsk plus Read keeps review read-only), and log total_cost_usd from the JSON so spend stays visible.
That’s the whole arc. -p makes Claude Code headless, --allowedTools and permission modes make it safe, --output-format json makes it observable, and --bare makes it reproducible — the four flags that turn a chat tool into CI infrastructure. If you’re weighing this against an IDE-bound workflow, I compared the two in Claude Code vs Cursor. The authoritative reference is the headless docs and the CLI reference — verify any flag against those before you ship it.