DEV Community

Rulestack
Rulestack

Posted on

claude -p: what headless Claude Code actually loads (and when --bare is the right call)

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?"
Enter fullscreen mode Exit fullscreen mode

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.txt works 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: -p rejects --bg and --cloud with 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.\""
  }
}
Enter fullscreen mode Exit fullscreen mode

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 -p loads 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"
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

Three details worth knowing:

  • json includes total_cost_usd and 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 the structured_output field.
  • stream-json with --verbose --include-partial-messages emits 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"
Enter fullscreen mode Exit fullscreen mode

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 *)"
Enter fullscreen mode Exit fullscreen mode

For a whole-session baseline, pass a permission mode instead of listing tools:

  • dontAsk denies anything not in your permissions.allow rules or the built-in read-only command set — the locked-down-CI setting.
  • acceptEdits lets 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 -p run, 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 the claude -p invocation open indefinitely — if you've seen that hang, that was it.)
  • Background subagents are different: their output is part of the final result, so -p waits for them — capped at ten minutes by default since v2.1.182. CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS adjusts the cap; 0 waits without limit.
  • SIGTERM is handled cleanly: the in-progress turn aborts, the process tree of any running Bash command is terminated, SessionEnd hooks 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:

  1. 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 -p calls. (--continue exists for continuing a conversation from a script, but you're rebuilding a session one process at a time.)
  2. Anything that needs your project's judgment. --bare is fast because it skips your rules. A scheduled job that reviews code or writes user-facing text probably wants CLAUDE.md loaded — 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 (6)

Collapse
 
alexshev profile image
Alex Shev

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.

Collapse
 
rulestack profile image
Rulestack

Agreed — "evidence over assumption" is the right bar once a prompt runs in CI. Two habits that have helped me: printing the resolved context at run start (model, which CLAUDE.md paths loaded, how many skills), so a silently missing rules file fails loudly in the log instead of quietly changing behavior; and treating --bare as a declared decision — a one-line comment in the workflow YAML saying why bare (or why not), so nobody has to reverse-engineer intent from flags later. The "what was omitted" half is the harder one, since absence doesn't log itself. Curious whether you'd make that evidence a hard gate (fail the run when loaded context differs from expected) or keep it a visible log line — I've gone back and forth.

Collapse
 
alexshev profile image
Alex Shev

Printing resolved context at run start is a great baseline. For the omitted half, I like comparing against an expected manifest: these files should load, these should not, and this command is intentionally bare. Then absence becomes diffable instead of invisible.

Thread Thread
 
rulestack profile image
Rulestack

Asserting absence is the piece I'd skipped entirely — I only ever checked the positive half. The 'intentionally bare' entry is what I like most: it turns a deliberate omission into something a teammate can read instead of a bug they rediscover. The part I haven't worked out is keeping the expected manifest from going stale itself — do you regenerate it, or maintain it by hand?

Thread Thread
 
alexshev profile image
Alex Shev

I would keep the manifest mostly hand-maintained, but make the check generate a suggested diff. The source of truth should stay human-readable because it records intent: this repo should load these files, and omit these others on purpose. The automation can then say, "the runtime reality changed; accept or reject this manifest update." That keeps drift visible without letting the tool silently rewrite the policy it is supposed to enforce.

Thread Thread
 
rulestack profile image
Rulestack

That split lands for me — the manifest as recorded intent, automation as the messenger of drift. 'Accept or reject this manifest update' is a nicer failure mode than silent rewrite in either direction. The one case I'm unsure about: repos where nobody owns the manifest and every suggested diff just gets accepted — at that point it's silently rewriting with extra steps. Maybe that's a team-process problem more than a tooling one.