Solo Stack Kits Directory
Claude Code workflows & skills for solo consultants and freelancers

← Blog · 2026-10-31

Claude Code Headless Mode Not Working? claude -p Problems: 15 Causes and Fixes

60-second test: run claude -p "say hi" --output-format json; echo "exit: $?". A non-zero exit with an auth message in result points to causes 9 and 10. A clean answer here but failures in your real command usually means tool calls are being denied (causes 1 to 3). The answer is fine but your script can't read it is an output-format problem (causes 4 to 6).

Headless mode is what Anthropic's docs now call running the Agent SDK through the CLI: add -p (or --print) to a claude command and it runs once, non-interactively, prints the result and exits. It's the same agent loop as an interactive session, but a few things work differently:

Interactive claude Headless claude -p
Permission prompts You answer them Nobody can; calls that need approval are denied unless a rule, hook or prompt tool approves them
Starting permission mode auto on recent versions Manual (default), unless you pass --permission-mode
Output The terminal UI text by default, or json / stream-json with --output-format
Workspace trust dialog Shown for new folders Never shown, so project hooks and .mcp.json servers load without asking (unless you pass --bare)
Built-ins like /login Available Not available

Not every CLI option combines with -p: Claude Code rejects --bg, for example, with an error naming the conflict. Most other "headless doesn't work" reports come down to one of the fifteen causes below.

Permissions: 3 causes

  1. Tool calls are denied. With Claude Code v2.1.302 or later, a claude -p run with no permission mode set starts in Manual mode, and any call that needs approval is denied unless something approves it: an allow rule, a PermissionRequest hook or --permission-prompt-tool. Calls that need no approval, such as reading files in your working directories, still run. Fix it one of two ways: list what Claude may do, for example claude -p "Run the test suite and fix any failures" --allowedTools "Bash,Read,Edit", or start in auto mode with --permission-mode auto so a classifier reviews each action instead. For a locked-down CI run, the docs suggest --permission-mode dontAsk plus an exact allowlist: claude -p "run the test suite" --permission-mode dontAsk --allowedTools "Bash(npm test)" "Read".
  2. The --allowedTools rule doesn't match the command. --allowedTools uses permission rule syntax. The trailing * enables prefix matching, and the space before it matters: Bash(git diff *) allows any command starting with git diff, while Bash(git diff*) also matches git diff-index. In a run that starts in auto mode, Claude Code drops a bare Bash entry as too broad and lets the classifier judge each command instead. Don't confuse the three tool flags either: --allowedTools approves tools without prompting, --tools restricts which built-in tools exist at all ("" disables all), and --disallowedTools denies or removes them ("mcp__*" removes every MCP tool). Our keeps asking for permission guide explains how allow, ask and deny rules combine.
  3. The run waits on a permission host that never answers. If your run has a permission host, an Agent SDK app with a canUseTool callback or an MCP tool passed with --permission-prompt-tool, the run waits for that host to answer each request. When nobody is there, for example in a scheduled job, pass --permission-prompts none: anything that would prompt is denied unless a PermissionRequest hook allows it, Claude is told not to retry, and the run continues. That flag needs v2.1.259 or later; earlier versions reject it with an unknown-option error. With --output-format stream-json, denials show up as permission_denied system messages and in the final result's permission_denials list. --dangerously-skip-permissions also removes prompts, but the docs only recommend it inside a container, VM or sandbox, run as a non-root user.

Output: 3 causes

  1. The output isn't in the shape your script expects. The default --output-format is text. Use json for the result plus session ID and metadata, with the answer in the result field: claude -p "Summarize this project" --output-format json | jq -r '.result'. For output that follows a schema, add --json-schema and read structured_output. If the schema itself is invalid, claude exits with Error: --json-schema is not a valid JSON Schema; if the run ends without valid structured output (for example Claude declined), the field is simply missing, so check that it's present before you use it.
  2. stream-json doesn't stream token by token. stream-json is newline-delimited JSON, one event per line. To get tokens as they're generated, the documented command adds --verbose and --include-partial-messages: claude -p "Explain recursion" --output-format stream-json --verbose --include-partial-messages. --include-partial-messages only works with --print and --output-format stream-json. Text arrives in stream_event lines whose event.delta.type is text_delta, and the last line is a result message with the final text, cost and session metadata. Subagent text and thinking only appear with --forward-subagent-text (v2.1.211 or later).
  3. Your script treats a failure as a success, or reads an error as the answer. Claude Code exits with code 0 on success and non-zero when the run fails, so branch on the exit status. An invalid flag is reported on 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 stdout can mistake it for Claude's answer.

Input and commands: 2 causes

  1. Piped input is ignored or rejected. Headless mode reads stdin, so cat build-error.txt | claude -p 'concisely explain the root cause of this build error' > output.txt works. But piped stdin is capped at 10 MB: above that, Claude Code exits with an error and a non-zero status, so write big inputs to a file and name the path in your prompt instead. If Claude Code can't read stdin at all, for example because the process that started it closed its end, it prints a warning on stderr and continues with only the prompt from the command line.
  2. A slash command does nothing in -p. Built-in commands that only run in the terminal interface, such as /login, aren't available. User-invoked skills and custom commands do work: put /skill-name in the prompt string and Claude Code expands it before running. Some built-ins take their value as an argument instead of opening a picker, for example /model sonnet, and settings change with /config key=value such as /config thinking=false. If the skill exists but never runs, see our skill not triggering guide.

Authentication: 2 causes

  1. It works in your terminal but fails in CI or a script. Your terminal uses your /login credentials; a CI runner or container has none. For CI, set either CLAUDE_CODE_OAUTH_TOKEN to a token from claude setup-token (lasts one year, needs a Pro, Max, Ultra, Team or Enterprise plan, and can only make model requests) or ANTHROPIC_API_KEY to a Claude Console key. Watch the precedence: Claude Code checks ANTHROPIC_AUTH_TOKEN, then ANTHROPIC_API_KEY, then apiKeyHelper, then CLAUDE_CODE_OAUTH_TOKEN, then your /login subscription. In -p, an ANTHROPIC_API_KEY is always used when present, without the approval prompt an interactive session shows, so a leftover key from a disabled or expired organization breaks headless runs even though you're signed in. Run unset ANTHROPIC_API_KEY to fall back to your subscription. Login problems on your own machine are covered in our login not working guide.
  2. --bare can't authenticate. Bare mode never reads OAuth credentials or the system keychain, and it ignores CLAUDE_CODE_OAUTH_TOKEN. With --bare, set ANTHROPIC_API_KEY or supply an apiKeyHelper in the --settings JSON. Amazon Bedrock, Google Cloud's Agent Platform and Microsoft Foundry keep reading their own provider credentials as usual.

Configuration and MCP: 2 causes

  1. The run behaves differently on another machine, or your setup is missing. Without --bare, claude -p loads hooks, skills, custom commands, subagents, plugins, MCP servers, auto memory and CLAUDE.md from the working directory and ~/.claude, just like an interactive session, and in an untrusted folder it still runs the project's hooks and connects its .mcp.json servers. That's why a teammate's hooks or a repository's MCP config can change a headless run. Add --bare for the same result on every machine (the docs recommend it for scripts and CI and say it will become the default for -p). The flip side: in bare mode nothing is auto-loaded, so pass what you need explicitly with --append-system-prompt or --append-system-prompt-file, --settings, --mcp-config, --agents and --plugin-dir.
  2. MCP tools are missing. With --mcp-config and -p, Claude Code waits for pending servers before the first turn, but only up to the MCP_TIMEOUT startup timeout (30 seconds by default). Entries that fail validation, such as a url entry with no type, are skipped and the run still exits cleanly. The Warning: 1 MCP server skipped due to invalid config: line appears only when you run the command in a terminal; when a CI runner captures stderr, nothing is printed. Use --output-format stream-json and check the system/init event: mcp_servers lists each server with its status, and mcp_server_errors lists skipped entries, so a CI gate can fail on a non-empty array. Add --strict-mcp-config to use only the servers from --mcp-config. For servers that fail to connect at all, see our MCP server not connecting guide.

Limits, exits and sessions: 3 causes

  1. The run stops early with an error. --max-turns limits agentic turns in print mode and exits with an error when the limit is reached; with no flag there is no limit, so a low value copied from an example can cut a task short. --max-budget-usd stops the run once the estimated API spend reaches the cap (the estimate is client-side and can differ from your bill), and spawning another subagent past it fails with Budget limit reached.
  2. The run doesn't exit, or ends without a result. After Claude's turn, a -p run stays open to wait for background work it started, such as background commands, subagents or Monitor watches. By default it stops idle waiting after 10 minutes and drops the partial result; change that with CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS (0 waits with no cap). When stderr is a terminal you'll see a line starting with Waiting for background work to finish. If a supervisor kills the run with SIGTERM, Claude Code exits with code 143 and records no result for the unfinished turn; send SIGINT first to end the turn cleanly.
  3. --continue or --resume picks the wrong conversation, or none. --continue loads the most recent conversation in the current directory. A plain interactive claude --continue skips sessions created with claude -p or the Agent SDK, while claude -p --continue includes them. With several runs going, capture the ID instead: session_id=$(claude -p "Start a review" --output-format json | jq -r '.session_id'), then claude -p "Continue that review" --resume "$session_id", which finds the session in any project on the machine. A run started with --no-session-persistence is never saved and can't be resumed, --session-id only accepts a valid UUID, and --fork-session gives a resumed run a new session ID. One more surprise: system prompt flags are recorded on a conversation's first request, so a different --append-system-prompt on a resumed run only takes effect after compaction or in a new conversation (or with --system-prompt-snapshot off, v2.1.257 or later).

Running it in GitHub Actions?

The Claude Code GitHub Action passes CLI options through claude_args, so the same flags apply there, for example claude_args: "--max-turns 5" or --allowedTools. For the action's own setup, triggers and secrets, see Claude Code GitHub Action not working.


Facts on this page come from Anthropic's Claude Code docs on running Claude Code programmatically, the CLI reference, permission modes and authentication. This guide isn't affiliated with Anthropic.

Made by Solo Stack Kits. Once your headless runs work, keep them on a short leash. Start with our free Claude Code safety hooks, or get the Claude Code Power Config Pack with permission presets and 8 tested safety hooks, $6 with the launch code while it lasts.

Want it set up already? Claude Code Power Config Pack

Fixed it? Skip the next one with a tested Claude Code setup you can drop into any repo.

One install command that backs up your settings first. Checked against the official Claude Code docs. MIT licensed.

Launch price: $6 instead of $12 (code LAUNCH50, first 25 buyers, until Nov 10, 2026).

Get the Config Pack: $6 launch priceOr try 2 of the hooks free

Free on Gumroad: block-dangerous-bash and protect-secrets with a settings snippet. Enter $0; Gumroad asks for an email to deliver the zip.

Prefer to build it yourself? Use the free Claude Code tools, no sign-up.