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 -p exiting 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 --resume or --continue.”
  • A new write boundary. permissions.blockWritesOutsideWorkingDirectories makes 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.

SituationBefore 2.1.3142.1.314 and later
claude -p fails during startupExit code 0, no outputError printed, exit code 1
claude -p --resume or --continue enters a worktreeSandbox protections not appliedSandbox protections applied
Write, Edit or NotebookEdit targets a path outside the working directoriesNo setting for thisRefused 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 codeMeaningSource
0The run succeeded. Still check the JSON output (see below).Headless docs
1Startup failed. The error is printed. Claude Code 2.1.314 and later.2.1.314 changelog
143The run was stopped with SIGTERM. The turn in progress is left unfinished with no result.Headless docs
Other non-zeroThe run failed. Specific codes are not enumerated in the docs.Headless docs
0, but check the JSONmcp_server_errors is present: an --mcp-config entry was skipped.Headless docs
0, but check the JSONplugin_errors is present: a plugin or --plugin-dir failed to load.Headless docs
0, but check the JSONpermission_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-schema exits with Error: --json-schema is not a valid JSON Schema.
  • --max-turns exits 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:

  1. Pin the version to 2.1.314 or later and log claude --version.
  2. Run the shell strictly. Use set -euo pipefail so a failure in claude -p is not hidden by a pipe into jq.
  3. Ask for structured output. --output-format json returns the result with session_id and total_cost_usd, plus a per-model cost breakdown. With --json-schema, the answer is in structured_output. If the run ends without valid structured output, that field is missing, so check for it before you use it.
  4. Fail on an empty result or is_error is true. A green step with no result is a failure.
  5. Fail on startup errors that exit cleanly. Read the system/init event and fail when mcp_server_errors or plugin_errors is 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.

Annotated Claude Code output showing the fields a CI gate should check

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 prompt input, the action runs on any GitHub event, including a cron schedule. 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 --allowedTools in claude_args or a permissions.allow rule in the settings input.
  • 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 in allowed_bots. GitHub attributes scheduled runs to a repository user, usually whoever last changed the cron line. If that is a bot, list it in allowed_bots, or the run fails.
  • Cost controls. Set --max-turns in claude_args and set workflow-level timeouts, such as timeout-minutes on 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.

CI pipeline for a headless Claude Code run, showing each gate that fails the job: actor checks, exit code, startup errors in JSON, empty result, then pass

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 --version printed in the log
  • Shell step uses set -euo pipefail
  • Job fails on any non-zero exit code; 143 treated as cancelled
  • --bare set; ANTHROPIC_API_KEY or apiKeyHelper supplied as a secret
  • MCP servers, settings and plugins passed explicitly with flags
  • Gate fails when mcp_server_errors or plugin_errors is present
  • Gate fails on an empty result or is_error is true, or a missing structured_output when you used --json-schema
  • --allowedTools scoped, with a space before each *
  • Manual mode or --permission-mode auto, plus --permission-prompts none for scheduled jobs; no bypassPermissions
  • permission_denials reviewed or gated
  • permissions.blockWritesOutsideWorkingDirectories set; Bash still kept narrow
  • --max-turns and 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.