Headless mode (claude -p)
Run Claude Code non-interactively from scripts and CI with -p, bare mode, structured and streamed output, tool pre-approval and resumable sessions.
claude -p turns Claude Code into an ordinary command-line program: prompt in, answer out, exit code at the end. It runs the same agent loop, tools and context management as the interactive app, which makes it the right tool for CI jobs, git hooks, cron scripts and anything else without a human at the keyboard. This is the CLI face of the Agent SDK; if you need callbacks, typed messages and approval handlers, use the Python or TypeScript packages instead.
claude -p "Why does test_invoice_totals fail on Mondays?" --allowedTools "Read,Grep,Bash(pytest *)"
The basics
-p (long form --print) works with most CLI flags. The ones you will reach for most are --continue/--resume, --allowedTools and --output-format. A few combinations are rejected with an error naming the conflict: --bg, and --cloud with a task description. (--cloud with a session ID plus -p is allowed and queues a follow-up into that cloud session, then exits; see Claude Code on the web.)
Exit status is 0 on success and non-zero on failure, so scripts can branch on it. Bad flags are reported on stderr before anything runs. Failures inside the run, such as missing authentication, are printed as the result on stdout.
Bare mode for reproducible runs
By default claude -p loads everything an interactive session would: CLAUDE.md, auto memory, hooks, skills, custom commands, subagents, plugins and MCP servers from the project and from ~/.claude. That is convenient on your laptop and a liability in CI, where a stray hook in someone's home directory changes behaviour.
--bare skips all of that auto-discovery and starts faster. The run behaves the same on every machine.
ANTHROPIC_API_KEY=sk-ant-... claude --bare -p "List the public functions in src/pricing.ts" --allowedTools "Read"
Warning: Without
--bare, a-prun in a folder you have never trusted still runs the hooks in its.claude/settings.jsonand connects the servers in its.mcp.json, with no trust dialog and no per-server prompt. Use--barein CI and on any checkout you did not write. See permissions for what runs before trust.
What bare mode changes
- Credentials: OAuth logins and the system keychain are never read. For the Anthropic API, set
ANTHROPIC_API_KEY(a key from the Claude Console) or provide anapiKeyHelperin--settings. Bedrock, Vertex AI and Foundry still read their own credentials. - Tools: Bash, file read and file edit are available.
- MCP: only servers you pass on the command line connect. Interactively, the automatic IDE connection is skipped unless you add
--ide. - System reminders: none are added. Claude is not told when a file it read changes on disk, and does not receive the skills list (including skills from an
--add-dirfolder). - Background tasks: none. A command that hits its timeout stops instead of moving to the background.
--add-dir: skills in that directory's.claude/skills/load; its.claude/commands/and.claude/agents/do not.
Before v2.1.286 these limits were only partial (interactive bare sessions still connected MCP servers, reminders were sent and background tasks were available).
Add back exactly what you need with flags:
| Need | Flag |
|---|---|
| Extra system prompt text | --append-system-prompt, --append-system-prompt-file |
| Settings | --settings <file-or-json> |
| MCP servers | --mcp-config <file-or-json> |
| Custom agents | --agents <file-or-json> |
| A plugin | --plugin-dir <path>, --plugin-url <url> |
Note:
--bareis the recommended mode for scripts and SDK calls and is planned to become the default for-p.
Lifecycle details
Background work when the result is ready
- Background Bash (a dev server, a watcher) is killed about five seconds after the final result is returned and stdin closes, which gives a just-finishing task time to report.
- Background subagents and workflows keep
claude -popen until they finish, since their result is part of the output. The wait gives up after 10 minutes of continuous idle waiting, stops what is still running and discards partial results. Change the ceiling withCLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS, or0for no limit. - Monitor watches are waited on until they time out (five minutes after starting, by default) or the 10-minute cap ends the wait. Claude keeps reacting to what the watch reports meanwhile.
SIGTERM
Killing a run with SIGTERM exits with code 143 and leaves the current turn unfinished with no result. To end the turn cleanly, send SIGINT (or call the SDK's interrupt()) first.
On SIGTERM, Claude Code kills the process tree of any running Bash command, runs SessionEnd hooks, and exits without starting new tools, model calls or other hooks. A command in progress is recorded as killed. A pending permission prompt is left unanswered on SIGTERM; when an SDK program closes the session, the SDK ends input first and the prompt is cancelled.
On resume, the interrupted turn stays as it was and your next prompt drives things. Set CLAUDE_CODE_RESUME_INTERRUPTED_TURN=1 to have the interrupted turn continue instead.
Deleted working directory
If the working directory disappears mid-session the run carries on. In stream-json output a warning message is emitted when a turn starts without the directory, and shell commands fail until it exists again.
Recipes
In CI, add --bare to all of these.
Pipe in, redirect out
stdin is read, so Claude composes with other tools:
kubectl logs deploy/worker --since=1h | claude -p "Group these errors by root cause, most frequent first" > worker-errors.md
Piped stdin is capped at 10MB; beyond that Claude Code exits non-zero with an error. Write large inputs to a file and name the path in the prompt. If stdin cannot be read (the parent closed its end) Claude Code warns on stderr and uses the command-line prompt alone. Before v2.1.211, that situation could crash or silently exit on Windows.
A project-specific linter
{
"scripts": {
"check:copy": "git diff origin/main -- '*.md' | claude -p \"Flag any US spellings in this diff. Output path:line and the word, one per line, nothing else.\""
}
}
Piping the diff means Claude needs no Bash permission to read it. Escaped double quotes keep the script working on Windows.
Output formats
--output-format | What you get |
|---|---|
text (default) | The response as plain text |
json | One JSON object: result, session_id, usage and metadata, plus total_cost_usd and a per-model cost breakdown |
stream-json | Newline-delimited JSON events as they happen |
Costs are client-side estimates and may differ from your bill. With --continue or --resume, the totals cover the whole conversation, earlier runs included. See cost tracking.
claude -p "One-sentence summary of this repo" --output-format json | jq -r '.result'
Schema-shaped answers
Add --json-schema to --output-format json and the validated object appears in structured_output:
claude -p "List every environment variable read in src/config/" \
--output-format json \
--json-schema '{"type":"object","properties":{"vars":{"type":"array","items":{"type":"string"}}},"required":["vars"]}' \
| jq -r '.structured_output.vars[]'
An invalid schema exits with Error: --json-schema is not a valid JSON Schema and the validator's message. format (for example "format": "email") is accepted but treated as an annotation, not enforced. Before v2.1.205 invalid schemas were silently ignored and any schema using format was rejected. More in structured outputs.
Streaming tokens
claude -p "Draft release notes from the last 20 commits" \
--output-format stream-json --verbose --include-partial-messages \
| jq -rj 'select(.type=="stream_event" and .event.delta.type?=="text_delta") | .event.delta.text'
The final line is a result message with the text, cost and session metadata. If your consumer reads slowly, Claude Code waits for queued output to drain before exiting, scaled to the backlog and capped at 30 seconds (before v2.1.214 the cap was about two seconds, which could truncate big responses). For callback-style streaming see streaming output.
Subagent messages in the stream
Messages produced by subagents, and by skills that run in a subagent, appear as assistant and user messages with a parent_tool_use_id. Main-conversation messages carry null.
A foreground subagent or forked skill starts with a user message containing its prompt or skill body. After that you get its tool_use and tool_result blocks by default. With --forward-subagent-text or CLAUDE_CODE_FORWARD_SUBAGENT_TEXT, text and thinking blocks are included too, for subagents at every nesting depth, so you can rebuild each transcript. Nested runs carry the ID of the Agent or Skill call that started them, which lets you rebuild the tree.
| Started by | parent_tool_use_id | Arrives |
|---|---|---|
| Claude calling the Agent tool | That Agent tool_use ID | Live |
| Claude calling the Skill tool for a forked skill | That Skill tool_use ID | Live |
You passing /<skill-name> as the prompt | A value beginning forked-command- | All together, in order, after it finishes |
Match the forked-command- prefix rather than the full value, as the suffix may not equal what you typed.
Minimum versions if messages seem to be missing: forwarding flags v2.1.211; every nesting depth v2.1.219; a forked skill called via the Skill tool v2.1.86 for its tool blocks and v2.1.265 for its first user message and text/thinking; subagents spawned by forked skills and forked skills inside subagents v2.1.275; forked skills started from the prompt v2.1.287.
Retry events
Before retrying a failed API request, Claude Code emits system/api_retry:
| Field | Meaning |
|---|---|
type, subtype | "system", "api_retry" |
attempt | Attempt number, from 1 |
max_retries | Retries allowed for this cause (may be below the session budget) |
retry_delay_ms | Wait before the next attempt |
error_status | HTTP status, or null when no HTTP response arrived |
no_response | Present only when no response headers arrived in time: waited_ms and retry_wait_ms. Here max_retries reflects the single retry this cause normally gets (v2.1.261+) |
error | authentication_failed, oauth_org_not_allowed, account_on_hold, billing_error, rate_limit, overloaded, invalid_request, model_not_found, server_error, max_output_tokens, cloud_credential_error or unknown |
uuid, session_id | Identifiers |
From v2.1.246, when a 401 or 403 rejects an apiKeyHelper credential, the first two retries are silent (still counted in attempt) and events start from the third.
Session metadata and CI gates
system/init reports the model, tools, MCP servers and plugins. It is the first event, except that plugin_install events (with CLAUDE_CODE_SYNC_PLUGIN_INSTALL) and hook_started / hook_progress / hook_response events from SessionStart or Setup hooks can come first. Hook events stream live (v2.1.169 to v2.1.203 batched them). It may also carry a capabilities array, such as interrupt_receipt_v1, for feature detection instead of version comparison; ignore values you do not recognise (v2.1.205+).
Fields worth failing a build on:
| Field | Contents |
|---|---|
plugins | Loaded plugins with name and path |
plugin_errors | Load failures with plugin, type, message; for a failed --plugin-dir, also the absolute path (v2.1.283+). Omitted when empty |
mcp_servers | Servers with name and status |
mcp_server_errors | --mcp-config entries rejected by validation, with name, type (such as unknown_type, url_missing_type, invalid_config, reserved_name) and message. Omitted when empty (v2.1.219+) |
Invalid --mcp-config entries are skipped and the run still exits cleanly, so check mcp_server_errors rather than the exit code. In an interactive terminal you also get a Warning: 1 MCP server skipped due to invalid config: line on stderr, but not when stderr is redirected or captured. With --mcp-config and -p, the first turn waits for pending servers up to MCP_TIMEOUT (30 seconds by default, v2.1.221+); a remote server with a cached tool list skips the wait, shows pending and connects on first use.
A gate I use in GitHub Actions:
claude --bare -p "noop" --mcp-config .ci/mcp.json --output-format stream-json --verbose \
| head -n 20 | jq -e 'select(.subtype=="init") | (.mcp_server_errors // []) | length == 0'
Plugin install events
With CLAUDE_CODE_SYNC_PLUGIN_INSTALL set, system/plugin_install events report marketplace installs before the first turn: status is started, installed, failed or completed (the first and last bracket the whole install), with name and error where relevant, plus uuid and session_id.
Permissions in unattended runs
Pre-approving tools
--allowedTools uses permission rule syntax:
claude -p "Commit the staged changes with a conventional-commit message" \
--allowedTools "Bash(git status *),Bash(git diff *),Bash(git log *),Bash(git commit *)"
The space before * matters: Bash(git diff *) matches git diff --stat but Bash(git diff*) would also match git diff-index. A bare Bash entry approves all shell commands, except in a run that starts in auto mode, where it is dropped as too broad and the classifier judges each command.
Choosing a permission mode
If nothing sets a mode, the run uses the built-in starting mode, which can be auto. Be explicit:
--permission-mode auto: a classifier reviews most actions.--permission-mode dontAsk: anything that would prompt is denied. Reads in working directories, the read-only command set and anything covered by allow rules still run.AskUserQuestion, connector tools your organisation set toaskand MCP tools markedrequiresUserInteractionare denied even if an allow rule matches. Good for locked-down CI.--permission-mode acceptEdits: file writes plus common filesystem commands (mkdir,touch,mv,cp) are approved; other shell commands and network access still need allow rules.
See permission modes for the actions no mode approves.
--permission-prompts none
For scheduled jobs with nobody watching, --permission-prompts none (v2.1.259+) stops the run waiting on a permission host, such as an SDK canUseTool callback or a --permission-prompt-tool. Anything unresolved by rules, PermissionRequest hooks or the mode is denied, Claude is told nobody can approve it and not to retry, and the run continues. Tools that need a person, like AskUserQuestion, are removed, and MCP elicitations no Elicitation hook answers are cancelled. In stream-json, denials show as permission_denied messages and are listed in the result's permission_denials.
claude -p "Bump patch versions of outdated dev dependencies and run the tests" \
--permission-mode auto --permission-prompts none
Commands inside -p
- User-invocable skills and custom commands work: put
/skill-namein the prompt. - Terminal-only commands such as
/logindo not. /model,/effort,/fast,/colorand/renametake a value (/model sonnet), and/mcpalone prints server status (v2.1.205+)./config key=valuechanges a setting, for example/config thinking=false./output-style <style>switches output style and/output-stylelists them (v2.1.269+).
Changing the system prompt
--append-system-prompt adds instructions while keeping Claude Code's defaults:
#!/usr/bin/env bash
# review-pr.sh <number>
gh pr diff "$1" | claude -p \
--append-system-prompt "You review for accessibility regressions in React components only." \
--output-format json | jq -r '.result'
--system-prompt replaces the default entirely; see the CLI reference.
Multi-step conversations
claude -p "Audit src/api for N+1 queries"
claude -p "Fix the worst three you found" --continue
claude -p "Write a short PR description of the fixes" --continue
--continue picks up the most recent conversation. From v2.1.257 it will open a finished background session but not one that is still running. For parallel pipelines, capture and pass the ID:
sid=$(claude -p "Start a dependency audit" --output-format json | jq -r '.session_id')
claude -p "Now check licences" --resume "$sid"
The ID resolves in any project on the machine (before v2.1.223 only the current project and its worktrees). --resume also accepts an absolute path to a session's .jsonl transcript.