← 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 inresultpoints 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
- Tool calls are denied. With Claude Code v2.1.302 or later, a
claude -prun with no permission mode set starts in Manual mode, and any call that needs approval is denied unless something approves it: an allow rule, aPermissionRequesthook 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 exampleclaude -p "Run the test suite and fix any failures" --allowedTools "Bash,Read,Edit", or start in auto mode with--permission-mode autoso a classifier reviews each action instead. For a locked-down CI run, the docs suggest--permission-mode dontAskplus an exact allowlist:claude -p "run the test suite" --permission-mode dontAsk --allowedTools "Bash(npm test)" "Read". - The
--allowedToolsrule doesn't match the command.--allowedToolsuses permission rule syntax. The trailing*enables prefix matching, and the space before it matters:Bash(git diff *)allows any command starting withgit diff, whileBash(git diff*)also matchesgit diff-index. In a run that starts in auto mode, Claude Code drops a bareBashentry as too broad and lets the classifier judge each command instead. Don't confuse the three tool flags either:--allowedToolsapproves tools without prompting,--toolsrestricts which built-in tools exist at all (""disables all), and--disallowedToolsdenies or removes them ("mcp__*"removes every MCP tool). Our keeps asking for permission guide explains how allow, ask and deny rules combine. - The run waits on a permission host that never answers. If your run has a permission host, an Agent SDK app with a
canUseToolcallback 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 aPermissionRequesthook 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 aspermission_deniedsystem messages and in the final result'spermission_denialslist.--dangerously-skip-permissionsalso removes prompts, but the docs only recommend it inside a container, VM or sandbox, run as a non-root user.
Output: 3 causes
- The output isn't in the shape your script expects. The default
--output-formatistext. Usejsonfor the result plus session ID and metadata, with the answer in theresultfield:claude -p "Summarize this project" --output-format json | jq -r '.result'. For output that follows a schema, add--json-schemaand readstructured_output. If the schema itself is invalid,claudeexits withError: --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. stream-jsondoesn't stream token by token.stream-jsonis newline-delimited JSON, one event per line. To get tokens as they're generated, the documented command adds--verboseand--include-partial-messages:claude -p "Explain recursion" --output-format stream-json --verbose --include-partial-messages.--include-partial-messagesonly works with--printand--output-format stream-json. Text arrives instream_eventlines whoseevent.delta.typeistext_delta, and the last line is aresultmessage with the final text, cost and session metadata. Subagent text and thinking only appear with--forward-subagent-text(v2.1.211 or later).- 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
- 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.txtworks. 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. - 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-namein 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=valuesuch as/config thinking=false. If the skill exists but never runs, see our skill not triggering guide.
Authentication: 2 causes
- It works in your terminal but fails in CI or a script. Your terminal uses your
/logincredentials; a CI runner or container has none. For CI, set eitherCLAUDE_CODE_OAUTH_TOKENto a token fromclaude setup-token(lasts one year, needs a Pro, Max, Ultra, Team or Enterprise plan, and can only make model requests) orANTHROPIC_API_KEYto a Claude Console key. Watch the precedence: Claude Code checksANTHROPIC_AUTH_TOKEN, thenANTHROPIC_API_KEY, thenapiKeyHelper, thenCLAUDE_CODE_OAUTH_TOKEN, then your/loginsubscription. In-p, anANTHROPIC_API_KEYis 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. Rununset ANTHROPIC_API_KEYto fall back to your subscription. Login problems on your own machine are covered in our login not working guide. --barecan't authenticate. Bare mode never reads OAuth credentials or the system keychain, and it ignoresCLAUDE_CODE_OAUTH_TOKEN. With--bare, setANTHROPIC_API_KEYor supply anapiKeyHelperin the--settingsJSON. Amazon Bedrock, Google Cloud's Agent Platform and Microsoft Foundry keep reading their own provider credentials as usual.
Configuration and MCP: 2 causes
- The run behaves differently on another machine, or your setup is missing. Without
--bare,claude -ploads hooks, skills, custom commands, subagents, plugins, MCP servers, auto memory andCLAUDE.mdfrom 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.jsonservers. That's why a teammate's hooks or a repository's MCP config can change a headless run. Add--barefor 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-promptor--append-system-prompt-file,--settings,--mcp-config,--agentsand--plugin-dir. - MCP tools are missing. With
--mcp-configand-p, Claude Code waits for pending servers before the first turn, but only up to theMCP_TIMEOUTstartup timeout (30 seconds by default). Entries that fail validation, such as aurlentry with notype, are skipped and the run still exits cleanly. TheWarning: 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-jsonand check thesystem/initevent:mcp_serverslists each server with its status, andmcp_server_errorslists skipped entries, so a CI gate can fail on a non-empty array. Add--strict-mcp-configto 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
- The run stops early with an error.
--max-turnslimits 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-usdstops 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 withBudget limit reached. - The run doesn't exit, or ends without a result. After Claude's turn, a
-prun 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 withCLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS(0waits with no cap). When stderr is a terminal you'll see a line starting withWaiting 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. --continueor--resumepicks the wrong conversation, or none.--continueloads the most recent conversation in the current directory. A plain interactiveclaude --continueskips sessions created withclaude -por the Agent SDK, whileclaude -p --continueincludes them. With several runs going, capture the ID instead:session_id=$(claude -p "Start a review" --output-format json | jq -r '.session_id'), thenclaude -p "Continue that review" --resume "$session_id", which finds the session in any project on the machine. A run started with--no-session-persistenceis never saved and can't be resumed,--session-idonly accepts a valid UUID, and--fork-sessiongives 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-prompton 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.