Last checked: 2 November 2026, against the Claude Code docs and changelog up to v2.1.314.
Short answer: claude -p exits 0 on success and non-zero when the run fails. From Claude Code 2.1.314 (30 October 2026), a failed start prints the error and exits 1 instead of exiting 0 with no output. SIGTERM gives 143. Some failures still exit cleanly, such as an MCP server skipped at startup, so a CI job also needs to read the JSON output.
The headless docs explain each flag on its own. This page turns them into a CI contract: what each exit code means, what changed in 2.1.314, what still passes when something went wrong, and a checklist that makes the job fail loudly.
What changed in 2.1.314
The 2.1.314 changelog (30 October 2026) has three entries that matter for pipelines:
- Startup failures now fail. “Fixed
claude -pexiting with code 0 and no output when startup failed; it now prints the error and exits 1.” - Resumed sessions in worktrees are sandboxed. “Fixed sandbox protections not applying to a worktree entered by
claude -p --resumeor--continue.” - A new write boundary.
permissions.blockWritesOutsideWorkingDirectoriesmakes Write, Edit and NotebookEdit refuse paths outside the working directories in every permission mode.
The same release also lowers memory use in long headless and SDK turns that run many subagents.
| Situation | Before 2.1.314 | 2.1.314 and later |
|---|---|---|
claude -p fails during startup | Exit code 0, no output | Error printed, exit code 1 |
claude -p --resume or --continue enters a worktree | Sandbox protections not applied | Sandbox protections applied |
| Write, Edit or NotebookEdit targets a path outside the working directories | No setting for this | Refused in every permission mode when permissions.blockWritesOutsideWorkingDirectories is set |
If a job of yours went green with empty output, the first fix is the version. Pin the runner to Claude Code 2.1.314 or later, and print claude --version in the log so every run records which version it used.
The exit code contract
The headless docs document only a few codes. Treat anything else as “non-zero, not enumerated in the docs”, and fail on it.
| Exit code | Meaning | Source |
|---|---|---|
| 0 | The run succeeded. Still check the JSON output (see below). | Headless docs |
| 1 | Startup failed. The error is printed. Claude Code 2.1.314 and later. | 2.1.314 changelog |
| 143 | The run was stopped with SIGTERM. The turn in progress is left unfinished with no result. | Headless docs |
| Other non-zero | The run failed. Specific codes are not enumerated in the docs. | Headless docs |
| 0, but check the JSON | mcp_server_errors is present: an --mcp-config entry was skipped. | Headless docs |
| 0, but check the JSON | plugin_errors is present: a plugin or --plugin-dir failed to load. | Headless docs |
| 0, but check the JSON | permission_denials is not empty: calls were denied because nobody could approve them. | Headless docs |
Where the error goes also matters. An invalid flag is reported to stderr before the run starts. A failure inside the run, such as missing authentication, is printed as the result on stdout. So a script that only reads stderr can miss a real failure.
A few other documented failures are worth knowing:
- Piped stdin is capped at 10MB. Over the cap, Claude Code exits with an error and a non-zero status. Write large input to a file and name the file in the prompt.
- An invalid
--json-schemaexits withError: --json-schema is not a valid JSON Schema. --max-turnsexits with an error when the limit is reached, according to the CLI reference. The exact code is not given, so treat it as any other non-zero failure.
Failing the job when the agent never started
The exit code is the first gate, not the only one. Add these checks to the step that runs Claude Code:
- Pin the version to 2.1.314 or later and log
claude --version. - Run the shell strictly. Use
set -euo pipefailso a failure inclaude -pis not hidden by a pipe intojq. - Ask for structured output.
--output-format jsonreturns the result withsession_idandtotal_cost_usd, plus a per-model cost breakdown. With--json-schema, the answer is instructured_output. If the run ends without valid structured output, that field is missing, so check for it before you use it. - Fail on an empty result or
is_erroris true. A green step with no result is a failure. - Fail on startup errors that exit cleanly. Read the
system/initevent and fail whenmcp_server_errorsorplugin_errorsis present. Both keys are omitted when there are no errors, so their presence alone is enough.
The system/init event comes from the stream output. A minimal gate looks like this:
set -euo pipefail
claude --version
claude --bare -p "$PROMPT" \
--output-format stream-json --verbose \
--allowedTools "Read,Bash(git diff *)" \
--permission-prompts none \
> run.jsonl
# Startup errors that still exit 0
jq -e -s 'map(select(.type == "system" and .subtype == "init")) | length > 0 and (first | ((.mcp_server_errors // []) + (.plugin_errors // [])) | length == 0)' run.jsonl
# Empty result or error result
jq -e -s 'map(select(.type == "result")) | last | . != null and .is_error == false and .subtype == "success" and ((.result // "") | length > 0)' run.jsonl
# Optional: fail when any call was denied
# jq -e -s 'map(select(.type == "result")) | last | (.permission_denials // []) | length == 0' run.jsonl
jq -e exits non-zero when the expression is false, so either check fails the step. Keep run.jsonl as a build artifact so you can read what happened.
This matters most in CI. When a CI runner captures stderr, Claude Code prints no warning about skipped MCP servers. The only signal is mcp_server_errors.
Field names from the Claude Code headless docs. Keys with errors are omitted when there are none.
A reproducible CI run
A CI job should behave the same on every runner. The docs give a clear route.
Use --bare. It skips auto-discovery of hooks, skills, custom commands, subagents, installed plugins, MCP servers, auto memory and CLAUDE.md. The docs call it the recommended mode for scripted and SDK calls and say it will become the default for -p in a future release. Pass what you need explicitly with --settings, --mcp-config, --agents, --append-system-prompt or --plugin-dir.
Bring an API key. Bare mode never reads OAuth credentials or the system keychain. Set ANTHROPIC_API_KEY or supply an apiKeyHelper in the --settings JSON. Amazon Bedrock, Google Cloud’s Agent Platform and Microsoft Foundry read their own credentials as usual.
Know what runs without it. Without --bare, a -p session runs the hooks in the project’s .claude/settings.json and connects the servers in its .mcp.json, even in a folder you have never trusted. There is no trust dialog and no per-server prompt. For jobs that check out a fork’s pull request, that is repository content running on your runner.
Scope the tools. --allowedTools uses permission rule syntax. Bash(git diff *) allows any command that starts with git diff. The space before * matters: Bash(git diff*) would also match git diff-index.
Pick a permission mode that fails closed. From v2.1.302, claude -p starts in Manual mode when nothing sets a permission mode. A call that needs approval is then denied unless a PermissionRequest hook or --permission-prompt-tool approves it. For a scheduled job, add --permission-prompts none (v2.1.259 or later), for example with --permission-mode auto, so anything that would have waited for a person is denied. With stream output, denials appear as permission_denied messages and in permission_denials on the result.
Some guides suggest --permission-mode bypassPermissions for CI. That removes the checks this page relies on. Start with Manual mode and a short --allowedTools list, and widen one rule at a time. For why narrow and fail closed is the right default, see Claude Code permissions should fail closed.
Sandboxed worktrees and resumed sessions
Jobs that keep a session going across steps often save session_id from the JSON output and pass it to --resume. Before 2.1.314, the changelog says sandbox protections did not apply to a worktree entered by claude -p --resume or --continue. 2.1.314 fixes that. If your jobs resume sessions in worktrees, pin to 2.1.314 or later.
permissions.blockWritesOutsideWorkingDirectories is a useful addition to a CI settings file. Be clear about its limits: the changelog names Write, Edit and NotebookEdit. It does not say it covers Bash, so a shell command can still write elsewhere. Keep Bash narrow with --allowedTools.
Resumed runs also report cost differently. With --continue or --resume, total_cost_usd is the whole conversation’s total, earlier runs included. Do not add those numbers up across steps.
GitHub Actions and scheduled jobs
The GitHub Actions docs cover anthropics/claude-code-action@v1. A few points matter for unattended runs.
- Automation mode. With a
promptinput, the action runs on any GitHub event, including a cronschedule. Results go to the workflow run log by default. - No tools by default. For a plain-text prompt, Claude has no shell or GitHub API access until you grant tools with
--allowedToolsinclaude_argsor apermissions.allowrule in thesettingsinput. - Actor checks. Before Claude starts, the action checks that the triggering user has write access (skipped for
schedule, which no user authors) and rejects bot actors unless they are listed inallowed_bots. GitHub attributes scheduled runs to a repository user, usually whoever last changed thecronline. If that is a bot, list it inallowed_bots, or the run fails. - Cost controls. Set
--max-turnsinclaude_argsand set workflow-level timeouts, such astimeout-minuteson the job.
If you use the lower-level Claude Code Base Action, it exposes conclusion (success or failure), execution_file, structured_output and session_id as outputs. Gate the next step on conclusion, and run the JSON checks above on execution_file. The Base Action enforces no trust boundaries of its own. For issues, fork pull requests or outside comments, its README points you to claude-code-action instead.
Every gate after the run can fail the job, including when Claude Code exited 0.
Stopping runs cleanly
Process supervisors and CI cancel buttons usually send SIGTERM. On SIGTERM, claude -p exits with 143, leaves the turn in progress unfinished and records no result. It kills the process tree of any running Bash command and runs SessionEnd hooks before it exits.
To end the turn properly, send SIGINT first (or call the Agent SDK’s interrupt()), then stop the process. In your gate, treat 143 as “cancelled”, not as a pass, and do not read a partial result as the answer. For stop rules that sit above a single run, see Claude Code needs a stop rule before more autonomy.
Checklist
Copy this into the pull request that adds or changes a Claude Code job.
- Claude Code pinned to 2.1.314 or later;
claude --versionprinted in the log - Shell step uses
set -euo pipefail - Job fails on any non-zero exit code; 143 treated as cancelled
-
--bareset;ANTHROPIC_API_KEYorapiKeyHelpersupplied as a secret - MCP servers, settings and plugins passed explicitly with flags
- Gate fails when
mcp_server_errorsorplugin_errorsis present - Gate fails on an empty result or
is_erroris true, or a missingstructured_outputwhen you used--json-schema -
--allowedToolsscoped, with a space before each* - Manual mode or
--permission-mode auto, plus--permission-prompts nonefor scheduled jobs; nobypassPermissions -
permission_denialsreviewed or gated -
permissions.blockWritesOutsideWorkingDirectoriesset; Bash still kept narrow -
--max-turnsand a job timeout set - Bot actors for scheduled runs listed in
allowed_bots - Untrusted input runs through
claude-code-action, not the Base Action - Output file kept as a build artifact
For the wider release checklist, see the Claude Code production checklist.
Where this fits
Headless runs load more than your prompt. Mods hooks also run under claude -p, and --plugin-dir can be blocked by managed settings. See what Claude Code mods can approve, and how to keep control before you let a CI job load plugins.
FAQ
What exit codes does claude -p return in CI?
Claude Code exits 0 on success and non-zero when a run fails. Since 2.1.314 (30 October 2026), a failed start prints the error and exits 1 instead of 0 with no output. SIGTERM gives 143. Skipped MCP servers still exit cleanly, so also check mcp_server_errors in the output.
Why did your CI job pass with no output from Claude Code?
Before 2.1.314, claude -p could exit 0 with no output when startup failed. Upgrade to 2.1.314 or later, pin the version in your pipeline, and add a check that fails the step when the result is empty. Also check mcp_server_errors and plugin_errors, which do not change the exit code.
How do you fail the job when an MCP server never loaded?
Read the system/init event from --output-format stream-json and fail when mcp_server_errors is present. The key is omitted when there are no errors. Claude Code skips invalid --mcp-config entries and exits cleanly, and prints no warning when a CI runner captures stderr, so this field is the only signal.
Should you use --bare in CI?
Yes, for most jobs. --bare skips hooks, plugins, MCP servers, skills and CLAUDE.md found on the machine or in the repo, so runs behave the same everywhere. It needs ANTHROPIC_API_KEY or an apiKeyHelper, because it never reads OAuth or the keychain. The docs say it will become the default for -p.
How do you run Claude Code on a schedule in GitHub Actions?
Use anthropics/claude-code-action@v1 with a prompt input and a schedule trigger. Grant tools with --allowedTools in claude_args, because a plain-text prompt has no shell or GitHub API access by default. If a bot last edited the cron line, list it in allowed_bots, or the run fails.
Is a resumed claude -p session in a worktree sandboxed?
Yes, from Claude Code 2.1.314. The changelog lists a fix for sandbox protections not applying to a worktree entered by claude -p --resume or --continue. If your jobs resume sessions in worktrees, pin to 2.1.314 or later, and keep Bash narrow, since the new write-boundary setting covers file tools only.