You scripted with claude -p. You added --output-format json. Your parser still breaks in ways the docs do not explain.
The structured output guide covers the two-layer parsing problem and how to write prompts that return clean JSON. This guide covers what breaks before you even get to parsing — authentication failures, hangs, output contamination that prompt engineering alone cannot fix, and architectural limits that are closed as “not planned.”
Five failure modes, each with a diagnosis command and a copy-paste fix.
Failure 1: ANTHROPIC_API_KEY Overrides Your Max Plan Login
Symptom: claude -p exits with rc=1. Error message mentions credits, billing, or authentication even though your Max subscription is active.
Root cause: Claude Code checks ANTHROPIC_API_KEY in the environment before falling back to the OAuth session from your Max plan login. If that variable is set — even from an unrelated project months ago — it takes priority. A depleted or invalid key fails the invocation, regardless of your subscription status.
This is by design, not a bug. The priority order is: ANTHROPIC_API_KEY > OAuth keychain session.
Diagnosis:
echo $ANTHROPIC_API_KEY # If non-empty, this is likely your problem
# Confirm the key is the issue by unsetting it and retrying
env -u ANTHROPIC_API_KEY claude -p "hello" --output-format json --max-turns 1
Fix 1 — Unset for the invocation (bash):
env -u ANTHROPIC_API_KEY claude -p "your prompt" --output-format json
Fix 2 — Strip the variable in a Python subprocess:
import os
import subprocess
import json
def run_claude(prompt: str, timeout: int = 120) -> dict:
"""
Run claude -p without ANTHROPIC_API_KEY so OAuth session is used.
Preserves the rest of the environment unchanged.
"""
env = {k: v for k, v in os.environ.items() if k != "ANTHROPIC_API_KEY"}
result = subprocess.run(
["claude", "-p", prompt, "--output-format", "json", "--max-turns", "1"],
capture_output=True,
text=True,
encoding="utf-8",
timeout=timeout,
env=env,
)
if result.returncode != 0:
raise RuntimeError(f"claude rc={result.returncode}: {result.stderr.strip()}")
return json.loads(result.stdout)
Fix 3 — CI/CD: always pin the key explicitly:
# GitHub Actions — never inherit ANTHROPIC_API_KEY from runner environment
- name: Run Claude analysis
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
claude -p "your prompt" --output-format json
Explicitly setting the variable in the step environment overrides any inherited shell value, ensuring the correct key is always used regardless of runner configuration.
Failure 2: —bare Mode Silently Ignores ANTHROPIC_API_KEY
Symptom: Even with ANTHROPIC_API_KEY set, claude -p --bare returns {"is_error":true,"result":"Not logged in · Please run /login"}.
Root cause: The --bare flag is designed for lean CI/CD pipelines — it skips plugin loading, hook execution, and LSP setup to reduce per-invocation token consumption by approximately 2.5x. Its --help documentation states:
Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via —settings (OAuth and keychain are never read).
In practice, as of mid-2026, --bare ignores ANTHROPIC_API_KEY regardless of how it is supplied (GitHub Issue #60155, May 2026).
Reproduce:
# Fails with --bare even with a valid key
ANTHROPIC_API_KEY=sk-ant-api03-... claude -p --bare --max-turns 1 --output-format json "hello"
# {"is_error":true,"result":"Not logged in · Please run /login"}
# The same key works without --bare
ANTHROPIC_API_KEY=sk-ant-api03-... claude -p --max-turns 1 --output-format json "hello"
# {"is_error":false,"result":"Hi there!"}
All three documented auth paths for --bare fail identically: environment variable, --settings with apiKey, and --settings with apiKeyHelper.
Workaround:
Drop --bare. Accept the ~2.5x token cost increase.
# Instead of:
claude -p --bare --max-turns 1 --output-format json "prompt"
# Use:
claude -p --max-turns 1 --output-format json "prompt"
Issue #60155 was closed as a duplicate of an upstream fix. There is no timeline for the resolution. If token cost is a concern, combine --max-turns 1 with a focused, single-task prompt — this limits multi-step reasoning and keeps costs predictable without relying on --bare.
Failure 3: The Process Hangs — No Built-in Timeout
Symptom: claude -p starts, produces no output, and never exits. The pipeline hangs indefinitely.
Root cause: Claude Code has no configurable idle timeout for the -p flag. If a tool call takes longer than expected, the process waits indefinitely (GitHub Issue #7497).
Two common triggers:
WebSearch: A single WebSearch invocation takes 2–5 minutes under normal conditions. A prompt that implicitly triggers three searches may require 10+ minutes. The hang is silent — no output, no heartbeat, no partial results.
Large Bash operations: find across millions of files, grep over large log archives, or shell commands with unbounded output can stall the process without producing a timeout error.
Diagnosis: Add --verbose to see tool calls in real time. If the process is hanging on a tool call, you will see the tool invocation but no completion event.
# Watch which tool is hanging
claude -p "your prompt" --output-format stream-json --verbose 2>&1 | \
python3 -c "
import sys, json
for line in sys.stdin:
try:
event = json.loads(line)
if event.get('type') in ('tool_use', 'tool_result', 'assistant'):
print(event.get('type'), str(event)[:120])
except json.JSONDecodeError:
pass
"
Fix — Unix timeout wrapper:
# 480 seconds (8 min) for prompts that use WebSearch
timeout 480 claude -p "research this topic" --output-format json
echo "Exit code: $?" # 124 = timed out, 0 = success
Fix — Python subprocess with timeout:
import subprocess
import json
TIMEOUT_SECONDS = 480 # Adjust based on expected tool usage
try:
result = subprocess.run(
["claude", "-p", prompt, "--output-format", "json"],
capture_output=True,
text=True,
encoding="utf-8",
timeout=TIMEOUT_SECONDS,
)
if result.returncode == 0:
envelope = json.loads(result.stdout)
print(envelope["result"])
else:
print(f"Error (rc={result.returncode}): {result.stderr.strip()}")
except subprocess.TimeoutExpired:
# The process is still running — subprocess.run already killed it
raise RuntimeError(
f"claude -p timed out after {TIMEOUT_SECONDS}s. "
"Consider reducing prompt scope or increasing timeout."
)
Timeout sizing guidelines:
| Prompt type | Recommended timeout |
|---|---|
| No external tools, simple extraction | 120s |
| Bash tool on medium codebase | 240s |
| WebSearch (1–2 calls expected) | 480s |
| Multi-turn with WebSearch | 900s |
| Unknown — start conservative | 300s |
If you see timeout exiting with 124 regularly, add --verbose to identify which tool is slow, then either scope the prompt to avoid that tool or increase the ceiling.
Failure 4: Output Contamination — Thinking Tokens and Preamble in the Result
Symptom: --output-format json is in use. The outer envelope parses fine. But envelope["result"] contains thinking tokens, introductory sentences, or markdown before the JSON you asked for.
Root cause: Claude Code’s --output-format json wraps the entire response in a JSON envelope — it does not constrain what the model returns inside that envelope. When Claude decides to reason before answering, the result field may begin with <thinking>...</thinking> blocks, or with sentences like “Here is the analysis you requested:” before the JSON object.
The structured output guide addresses this with explicit prompt instructions (“Return ONLY a JSON object with no other text”). That approach works most of the time. When you need a mechanically verifiable contract — one that lets you detect and fail fast on violations — the XML output contract pattern is more reliable.
The XML output contract:
Instead of instructing Claude to return bare JSON, define a required output tag:
claude -p '
Analyze the function signatures in the code below and classify each one.
OUTPUT FORMAT CONTRACT — This is strict:
Your entire response MUST be exactly this structure and nothing else.
Do not write any text, reasoning, or explanation outside the <output> tag.
<output>
{"functions": [{"name": "...", "type": "...", "complexity": "low|medium|high"}], "total": N}
</output>
Code to analyze:
---
'"$(cat src/api.py)"'
---
' --output-format json
Parser that enforces the contract:
import json
import re
import subprocess
def run_with_output_contract(
prompt: str,
timeout: int = 120,
tag: str = "output",
) -> dict:
"""
Run claude -p with an XML output contract and extract the inner JSON.
Raises ValueError if the output tag is missing (contract violated).
Raises RuntimeError on non-zero exit code.
"""
result = subprocess.run(
["claude", "-p", prompt, "--output-format", "json"],
capture_output=True,
text=True,
encoding="utf-8",
timeout=timeout,
)
if result.returncode != 0:
raise RuntimeError(f"claude rc={result.returncode}: {result.stderr.strip()}")
envelope = json.loads(result.stdout)
raw = envelope.get("result", "")
# Extract content between <tag>...</tag>
pattern = rf"<{re.escape(tag)}>\s*(.*?)\s*</{re.escape(tag)}>"
match = re.search(pattern, raw, re.DOTALL)
if not match:
raise ValueError(
f"Output contract tag <{tag}> missing. "
f"Got: {raw[:300]!r}"
)
return json.loads(match.group(1))
Why this works better than prompt instructions alone:
- The
<output>boundary is mechanically verifiable — you either find the tag or you raise immediately - Thinking tokens appear before the tag and are automatically excluded without any special parsing
- A missing tag tells you exactly what went wrong: the model broke the contract, not the JSON parser
- The pattern works with any tag name; use
<result>,<json>, or a project-specific tag to avoid collisions
When to use it: If you are parsing envelope["result"] more than three retries deep, or if your downstream system receives corrupted data silently, the XML contract approach is worth the prompt overhead.
Failure 5: MCP Connectors Are Unavailable in Headless Mode
Symptom: claude mcp list shows your MCP servers as connected. But in claude -p output, the tools are missing — the model falls back to only its 17 core tools (Read, Write, Edit, Bash, Glob, Grep, etc.).
Root cause: MCP servers configured via the claude.ai web interface (OAuth-authenticated HTTP connectors) are not loaded in non-interactive mode. The system/init event in --output-format stream-json output reveals this directly: "mcp_servers": [], regardless of what claude mcp list reports (GitHub Issue #26364).
Affected in headless mode:
- claude.ai HTTP-based MCP connectors (Slack, Granola, Rube, etc.)
- Project-type MCP servers configured via the claude.ai UI
- Any connector that requires an active browser session for OAuth authentication
Not affected (these work in headless mode):
- MCP servers defined in
.mcp.jsonat project or user level - Locally-running MCP servers with static credentials
Diagnosis:
# Check the mcp_servers field in the init event
claude -p "hello" --output-format stream-json --verbose 2>/dev/null | \
head -1 | \
python3 -c "
import sys, json
init = json.load(sys.stdin)
servers = init.get('mcp_servers', [])
print(f'MCP servers available in headless mode: {len(servers)}')
for s in servers:
print(f' - {s}')
"
Fix — Migrate to local .mcp.json configuration:
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/absolute/path/to/project"]
},
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_TOKEN": "${GITHUB_TOKEN}"
}
},
"slack": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-slack"],
"env": {
"SLACK_BOT_TOKEN": "${SLACK_BOT_TOKEN}",
"SLACK_TEAM_ID": "${SLACK_TEAM_ID}"
}
}
}
}
Reference local config in your headless invocation:
GITHUB_TOKEN=ghp_... SLACK_BOT_TOKEN=xoxb-... \
claude -p "your prompt" \
--output-format json \
--mcp-config /absolute/path/to/.mcp.json
Important: Issue #26364 was closed as “not planned.” Anthropic’s stated reason is that OAuth-based connectors require an interactive session for the initial authentication handshake. This is an architectural constraint with no planned resolution. If your automation depends on Slack, Gmail, or Calendar MCP tools via claude.ai connectors, local credential configuration is the only path forward.
Quick Reference: Diagnosis Table
| Symptom | Likely Cause | Diagnosis Command | Fix |
|---|---|---|---|
| rc=1, auth/credits error | ANTHROPIC_API_KEY set in env | echo $ANTHROPIC_API_KEY | env -u ANTHROPIC_API_KEY claude -p ... |
"Not logged in" with --bare | Bug #60155: --bare ignores API key | Add --verbose; see auth error | Drop --bare |
| Process never exits | No timeout; tool call hung | --output-format stream-json --verbose | timeout 480 claude -p ... |
Thinking/preamble in result | Output contamination | Inspect envelope["result"] raw | XML output contract + regex extractor |
mcp_servers: [] in init event | claude.ai connectors unavailable | Check stream-json init event | Move servers to .mcp.json |
FAQ
Why does claude -p return rc=1 even when I have a Max plan subscription?
When ANTHROPIC_API_KEY is set in your shell environment, Claude Code uses it instead of your Max plan OAuth session. If that key has no remaining credits or is invalid, the invocation fails with rc=1 even though your subscription is active. Run echo $ANTHROPIC_API_KEY to check. Fix: env -u ANTHROPIC_API_KEY claude -p ....
How do I stop ANTHROPIC_API_KEY from overriding my Max plan login in a Python subprocess?
Pass a filtered environment to subprocess.run():
env = {k: v for k, v in os.environ.items() if k != "ANTHROPIC_API_KEY"}
subprocess.run(["claude", "-p", prompt, "--output-format", "json"], env=env, ...)
This strips the variable from the child process only, leaving your shell environment intact.
How long should my timeout be when claude -p uses WebSearch?
A single WebSearch call can take 2–5 minutes. For prompts that may trigger 1–2 searches, 480 seconds is a reasonable starting point. For multi-turn sessions with WebSearch, use 900 seconds. Use --verbose with --output-format stream-json to observe tool timing and tune accordingly.
What is the XML output contract pattern?
Instead of asking Claude to “return only JSON”, you define a required XML wrapper tag around the expected output: <output>{...your JSON...}</output>. After receiving the response, you extract only what is between the tags with a regex. This is more reliable than text instructions alone because the boundary is mechanically verifiable, thinking tokens appear before the tag and are automatically excluded, and a missing tag signals a contract violation clearly — before a JSON parser is involved.
Why are my MCP tools missing in claude -p?
MCP servers configured via the claude.ai web UI require an interactive session for OAuth authentication. They are not loaded in headless mode. The stream-json system/init event will show "mcp_servers": [] even if claude mcp list shows them as connected. Move your MCP server configuration to a local .mcp.json file and reference it with --mcp-config. This is a known architectural limitation (Issue #26364, closed as not planned).
Does —bare mode work with ANTHROPIC_API_KEY?
No — this is a confirmed bug as of mid-2026 (Issue #60155). The --bare documentation states that ANTHROPIC_API_KEY is the only supported auth method in that mode, but in practice --bare ignores the key entirely. The issue is closed as a duplicate of an upstream fix in progress. Workaround: drop --bare.
Can I resume a failed claude -p session?
Yes, if the session was persisted to disk before failing. Capture the session_id from the --output-format json envelope or the system/init event in --output-format stream-json output. On the next invocation, pass --resume <session_id>. Sessions are not persisted when --no-session-persistence is used.
Related Articles
- claude -p JSON Output Breaks in Production — Fix It with Structured Output — the two-layer parsing problem,
--output-format json, and Python retry wrappers - Claude Code Print Mode and stream-json: Automate Everything with -p — complete reference for print mode flags, stream-json event types, and CI/CD patterns
- Automated PR Reviews with Claude Code Headless Mode — end-to-end GitHub Actions workflow using claude -p for automated code review
- Claude Code Secrets Management with 1Password CLI — managing ANTHROPIC_API_KEY and MCP server credentials without putting them in shell profiles
Keep API Keys Out of Your Automation Scripts
The root cause of Failure 1 above — a stale ANTHROPIC_API_KEY leaking into subprocess environments — is part of a broader problem with secret management in automation scripts. Keys set in .bashrc or .zshrc get inherited by every subprocess, every CI runner that sources your dotfiles, and every script that calls os.environ. 1Password CLI’s op run injects secrets only into the process that needs them and removes them when that process exits — so a key you need for one tool never ends up in the environment of a different tool running in the same pipeline.