You wire claude -p into a script, it works, and then the usage numbers make no sense: a one-line prompt cost as much as a whole working session. We hit this running Claude Code on a schedule — a cold claude -p in our repo root burned roughly 150,000 tokens before doing any work, because print mode loads everything an interactive session would.
This post covers what -p actually loads, the --bare flag that fixes the cost problem, and the permission and output flags that matter once Claude Code runs unattended.
What -p does
-p (long form: --print) runs any claude command non-interactively: one prompt in, one result out, process exits.
claude -p "What does the auth module do?"
The scripting basics behave the way you'd hope:
- Exit codes: 0 on success, non-zero on failure, so CI can branch on it. An invalid flag is reported to stderr before the run starts; a failure inside the run (like missing auth) is printed as the result on stdout.
-
stdin is read:
cat build-error.txt | claude -p 'explain the root cause' > out.txtworks like any Unix tool. Piped stdin is capped at 10MB (since v2.1.128) — past that you get a clean error and a non-zero exit, so write big inputs to a file and reference the path instead. -
Incompatible flags fail loudly:
-prejects--bgand--cloudwith an error naming the conflict.
A package.json script that uses Claude as a typo linter over your diff:
{
"scripts": {
"lint:claude": "git diff main | claude -p \"you are a typo linter. for each typo in this diff, report filename:line on one line and the issue on the next. return nothing else.\""
}
}
The part nobody budgets for: -p loads your entire setup
Here's the line from the docs that explains our 150k-token bill:
Without it,
claude -ploads the same context an interactive session would, including anything configured in the working directory or~/.claude.
That means every scripted call pays for auto-discovery of:
- hooks
- skills
- plugins
- MCP servers
- auto memory
-
CLAUDE.md(all of them in the load chain, not just the repo one)
If your repo has a long CLAUDE.md, a skills directory, and a couple of MCP servers — normal for a mature setup — a "quick scripted question" silently ships all of that as context on every invocation. Our measured ~150k tokens per cold call wasn't a bug. It was -p faithfully reproducing our interactive environment for a job that needed none of it.
--bare: start from nothing, add back what you need
--bare skips that auto-discovery entirely — no hooks, no skills, no plugins, no MCP servers, no auto memory, no CLAUDE.md:
claude --bare -p "Summarize README.md" --allowedTools "Read"
Two properties make it the right default for CI:
1. Reproducibility. A hook in a teammate's ~/.claude or an MCP server in the project's .mcp.json won't run, because bare mode never reads them. Same call, same behavior, on every machine.
2. Explicit credentials. Bare mode never reads OAuth credentials or the system keychain. For the Anthropic API you set ANTHROPIC_API_KEY (or supply an apiKeyHelper via --settings). Amazon Bedrock, Google Cloud's Agent Platform, and Microsoft Foundry keep reading their own provider credentials as usual. No surprise dependence on whoever logged in last.
In bare mode Claude still has Bash, file read, and file edit tools. Everything else is opt-in via flags:
| To load | Use |
|---|---|
| System prompt additions |
--append-system-prompt, --append-system-prompt-file
|
| Settings | --settings <file-or-json> |
| MCP servers | --mcp-config <file-or-json> |
| Custom agents | --agents <json> |
| A plugin |
--plugin-dir <path>, --plugin-url <url>
|
The docs note that --bare is the recommended mode for scripted and SDK calls and will become the default for -p in a future release. If your scripts depend on CLAUDE.md rules or hooks being active during -p runs, that's worth knowing now: the day the default flips, those scripts change behavior. Decide per script — "needs my rules" vs "needs a clean room" — and write the flag explicitly.
Structured output for scripts
--output-format controls the response shape:
claude -p "Summarize this project" --output-format json | jq -r '.result'
Three details worth knowing:
-
jsonincludestotal_cost_usdand a per-model cost breakdown. If you run scheduled jobs, log it — this is how you notice a 150k-token cold start without waiting for the dashboard. -
--json-schema(with--output-format json) makes the response conform to a JSON Schema you provide; the structured part lands in thestructured_outputfield. -
stream-jsonwith--verbose --include-partial-messagesemits token-level events as JSON lines, if you're building progress UI on top.
Permissions when nobody's there to click "allow"
Headless runs can't answer permission prompts, so you pre-approve:
claude -p "Run the test suite and fix any failures" \
--allowedTools "Bash,Read,Edit"
You can scope Bash approvals to specific command patterns instead of blanket access:
claude -p "Look at my staged changes and create an appropriate commit" \
--allowedTools "Bash(git diff *),Bash(git log *),Bash(git status *),Bash(git commit *)"
For a whole-session baseline, pass a permission mode instead of listing tools:
-
dontAskdenies anything not in yourpermissions.allowrules or the built-in read-only command set — the locked-down-CI setting. -
acceptEditslets Claude write files without prompting and auto-approves common filesystem commands (mkdir,touch,mv,cp). Other shell commands and network requests still need an allow rule — otherwise the run aborts when one is attempted.
Timing gotchas that only show up in CI
These are the ones that produce "works locally, hangs in CI" tickets:
-
Background Bash tasks are killed ~5 seconds after the final result. If Claude starts a dev server or watch build during a
-prun, that shell gets terminated about five seconds after the result is delivered and stdin closes. (Before v2.1.163, a never-exiting background process held theclaude -pinvocation open indefinitely — if you've seen that hang, that was it.) -
Background subagents are different: their output is part of the final result, so
-pwaits for them — capped at ten minutes by default since v2.1.182.CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MSadjusts the cap;0waits without limit. -
SIGTERM is handled cleanly: the in-progress turn aborts, the process tree of any running Bash command is terminated,
SessionEndhooks still run, and the exit code is 143. Your supervisor can kill a stuck run without leaving orphans.
When -p is the wrong tool
Two honest boundaries from using this in production:
-
Iterative work. If you'll react to the answer with a follow-up, an interactive session with its warm context beats a chain of cold
-pcalls. (--continueexists for continuing a conversation from a script, but you're rebuilding a session one process at a time.) -
Anything that needs your project's judgment.
--bareis fast because it skips your rules. A scheduled job that reviews code or writes user-facing text probably wantsCLAUDE.mdloaded — which means paying the context cost deliberately, not avoiding it.
The pattern that's worked for us: --bare + explicit --allowedTools + --output-format json for mechanical jobs, full-context -p only where the rules genuinely earn their tokens, and total_cost_usd logged everywhere so drift shows up in a week instead of a quarter.
I maintain Rulestack — tested rule packs, skills, and templates for Claude Code, Cursor, and Codex.
Daily notes on AI coding agents on Bluesky: @ai-shop.bsky.social
Top comments (1)
The headless path is where instruction loading stops being theoretical. If a command is used in CI or automation, I want explicit evidence of what context loaded, what was omitted, and whether bare mode was intentional.