claude -p: the flag that turns Claude Code into a build step, and what it costs
- claude-code
- agent-ops
- coding-agents
claude -p is Claude Code without the chat. You give it a prompt, it does the work with
its tools, and it prints the answer to stdout. The long form is --print, which the CLI
reference describes as "Print response without interactive mode". There is no terminal
approval UI.
I run it from three jobs. A dispatcher starts and resumes other agent sessions. A daily
reporting job sends one prompt and reads plain text back. A drafting job writes drafts,
and on 5 September 2026 it got back a run that said success and contained nothing,
which is why this post spends a section on checking output.
The flag belongs to Claude Code, the harness that carries most of the work on this site. That review scores the tool as a whole and lists headless runs among the reasons to pick it. This post is the flag itself, and the bill that comes with it. Flags and quotes come from Claude Code's docs, listed at the end with the dates I read them; the scripts are quoted from their source.
If you are changing the model behind an API integration, the Sonnet 5.5 launch analysis covers its request changes and progress messages.
What is headless mode on Claude?
Headless mode on Claude Code is claude -p. The docs page for it is
Run Claude Code programmatically, and it
pitches -p as the command-line face of the Agent SDK, "the same tools, agent loop,
and context management that power Claude Code". The first example on the page fixes a
bug:
claude -p "Find and fix the bug in auth.py" --allowedTools "Read,Edit,Bash"
The --allowedTools part matters more than the prompt. It lets the run read, edit and
use the shell without waiting for approval. One catch from the same page: start the run
in auto mode and a bare Bash entry gets dropped, so the classifier judges every
command instead. Leave the flag off and you get whatever the permission mode allows.
More on that below.
Checking what came back is harder than it looks. The exit code is 0 on success and non-zero on failure, but a failure can land in two places. A bad flag goes to stderr before anything runs. A failure inside the run, say missing authentication, comes back as the result on stdout, and a wrapper that only watches stderr never sees it.
Then there is the 5 September run. My drafting job asked for a draft and got this back, from the metadata I kept:
{
"subtype": "success",
"is_error": true,
"num_turns": 1,
"usage": {
"output_tokens": 24000,
"output_tokens_details": { "thinking_tokens": 24000 }
}
}
Read the first two fields together. subtype is success. is_error is true. The
process exited non-zero as well. Anything that trusts subtype alone would pass an
empty draft to the next step. My wrapper reads the exit code first and is_error
second. I would add a third check, one line: does result actually contain text? The
24000 tokens all went to thinking, which gets its own section further down.
What does "headless" mean in AI and in programming?
Programmers used the word first. A headless browser renders pages with no window; a
headless CMS has no front end of its own. With an agent, the missing piece is the
approval screen. The program sends the task and reads the answer, and nothing in a
terminal asks you to allow a command. You can still watch stdout scroll by. And if you
attach a permission host, an Agent SDK callback or a --permission-prompt-tool, the run
waits for that host to answer its permission requests. A plain unattended CLI run has no
host, so its permissions need settling in advance.
What is headless mode execution?
One execution is one claude -p process, prompt to exit. Nobody is watching it, so the
brakes go on before it starts. The
CLI reference lists two that work only
in print mode: --max-turns caps agentic turns, and --max-budget-usd stops the run
once API spend passes a dollar amount. The permission mode is the third. My two scripts
set no turn or budget limit today. If they ever move off the laptop, a budget flag is
the second change on the list in the verdict.
What --output-format json returns
My reporting job reads plain text, the default, because it wants prose. The dispatcher
asks for --output-format json, because it has to come back to a session later. It
reads two fields: result for the text and session_id for the next --resume.
The same object reports total_cost_usd and a per-model cost breakdown, and the docs
say that lets a script track spend without opening the usage dashboard. Two warnings
come with it. The figures are client-side estimates and can differ from the bill. And
after --continue or --resume they cover the whole conversation, earlier runs'
spend included, so summing them across resumed runs counts the same spend twice.
Getting the text out takes one jq call, straight from the docs:
claude -p "Summarize this project" --output-format json | jq -r '.result'
There is a third format, stream-json. It prints one JSON event per line: tokens as
they arrive if you add --verbose --include-partial-messages, then a final result
line with text, cost and session metadata. For a CI gate the docs point at the
system/init event, which carries the session metadata and a plugin_errors field. It
comes first in the stream unless a plugin install or a SessionStart or Setup hook
runs before it.
The docs also show a build log piped in:
cat build-error.txt | claude -p 'concisely explain the root cause of this build error' > output.txt
My reporting job passes its prompt the same way, through stdin, for a Windows reason that comes up with its code below. So the stdin rules apply to it. There is a 10 MB cap, and over it the run exits non-zero; the docs suggest writing large input to a file and putting the path in the prompt. If stdin can't be read at all, Claude Code prints a warning to stderr and runs with the prompt from the command line. On Windows, before v2.1.211, that same case crashed or quit silently.
--json-schema: structured output you can validate
result is still free text, even inside JSON. If the next step needs fields, add
--json-schema with a JSON Schema next to --output-format json. The validated object
comes back on its own, in structured_output. The docs' example pulls function names
out of a file:
claude -p "Extract the main function names from auth.py" \
--output-format json \
--json-schema '{"type":"object","properties":{"functions":{"type":"array","items":{"type":"string"}}},"required":["functions"]}'
Two things to watch. A broken schema now fails the run with Error: --json-schema is not a valid JSON Schema and the validator's message. Before v2.1.205 it was silently
ignored, so on an older install a job could run with no validation at all. And
format, as in "format": "email", is only an annotation. Claude Code doesn't enforce
it, so it won't reject a bad address.
Only my drafting job uses the flag. The dispatcher needs nothing past result and
session_id, and the reporting job wants prose. It is the contract from
What is function calling, pointed at the
answer instead of a tool.
claude --bare: what it skips and what it costs
--bare is the one flag in this post that changes who pays. Per the headless page, it
shortens startup by skipping auto-discovery of hooks, skills, custom commands,
subagents, plugins, MCP servers, auto memory and CLAUDE.md. Without it, claude -p
loads what an interactive session would, including anything configured in the working
directory or ~/.claude.
Everything on that list is startup content in the
context window, so a bare run also starts
smaller. The list is also most of the
agent harness around the model; a bare run keeps the
loop and the Bash, file read and file edit tools. The docs give repeatability in CI and
scripts as the reason to want that: a hook in a teammate's ~/.claude or an MCP server
in a project's .mcp.json does not run, because bare mode never reads them. Whatever
context you still need comes back through flags:
| To load | Flag |
|---|---|
| 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> |
One thing still gets through: a directory you add with --add-dir brings in the skills from its .claude/skills/ folder, though not its .claude/commands/ or .claude/agents/.
Then the sentence that decides where a job can run: "In bare mode, Claude Code
never reads OAuth credentials or the system keychain." On the Anthropic API the
key comes from ANTHROPIC_API_KEY or an apiKeyHelper in the --settings
JSON. The docs recommend --bare for scripted and SDK calls and say it will become the
default for -p in a future release.
My jobs don't use --bare. Per the docs, a -p run without it executes the hooks in a
project's .claude/settings.json and connects the servers in its .mcp.json, even in a
folder you never trusted. No trust dialog, no per-server approval prompt. On my own
machine I want exactly that: the
PreToolUse hook that reads every shell command in my
interactive sessions reads the commands of my headless runs too, because I never
pass --bare. In a repository I did not write, the same behavior is the reason to pass
it.
Permissions when nobody is at the keyboard
In a plain unattended run nobody answers a permission prompt in a terminal, so
approvals have to be decided before the run starts. If nothing sets a permission mode, a
-p run takes Claude Code's built-in starting mode, and per the docs that can be
auto, so pass the mode you want rather than find out which one you got. Anything still
unresolved is denied instead of asked when no permission host is attached; with a host,
the run waits for its answer, as the --permission-prompts paragraph below explains.
Three flags do the settling.
--allowedTools names tools that run without a prompt, using permission rule
syntax. The docs' commit example shows the detail that bites:
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 *)"
The trailing * turns each entry into a prefix match, so Bash(git diff *) allows any
command that starts with git diff. The space before the * matters: per the docs,
Bash(git diff*) would also match git diff-index.
--permission-mode sets a baseline instead of a list. auto puts a classifier in
front of most actions, and dontAsk denies every call that would otherwise
prompt. What Manual mode never asks about still runs under dontAsk, such as
file reads in your working directories and the read-only command set, and so does
anything your --allowedTools entries or permissions.allow rules cover. acceptEdits writes
files and runs common filesystem commands such as mkdir, mv and cp without
prompting. Apart from the read-only command set, other shell commands and network
requests still need an --allowedTools entry or a permissions.allow rule.
--permission-prompts says who answers. The default, host, hands each prompt to
an Agent SDK callback or a --permission-prompt-tool and waits for it. With
none the run never consults the host: anything a permission rule or a
PermissionRequest hook does not resolve is denied, Claude is told nobody can
approve it and not to retry, and the run continues. A -p run with no host
denies those requests either way, so there the flag's work is the no-retry
instruction. It also removes tools that need a human answer, AskUserQuestion
among them, and requires v2.1.259 or later.
Sessions, limits and the way a run ends
--continue loads the most recent conversation in the current directory, and
--resume with a session ID picks a specific one. The CLI reference adds a
wrinkle for scheduled jobs: an interactive --continue skips background
sessions, sessions created by claude -p or the Agent SDK, and sessions whose
first prompt was /loop. claude -p --continue includes the -p, SDK and
/loop ones. The docs' loop
captures the ID from the JSON and feeds it back:
session_id=$(claude -p "Start a review" --output-format json | jq -r '.session_id')
claude -p "Continue that review" --resume "$session_id"
Since v2.1.223 the second command can run from any directory: Claude Code finds the session by ID in any project on the machine.
Three rules about endings, from the headless page as I read it on 15 October 2026.
A background command the main conversation started, such as a dev server or a
watch build, keeps the run open until the command exits or reaches its time limit.
Claude then takes one more turn with the outcome. The page I read on 4 September
said such a task was killed about five seconds after the final result, so check
this against the version you run. Background subagents
and workflows keep the run open too, because their result is part of the output.
After ten minutes of continuous idle waiting, by default, Claude Code stops what is
still running (CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS, 0 for no cap). The docs
name exceptions to that cap: a running main-conversation command, and self-paced
/loop wakeups in a run whose prompt was passed as text rather than with
--input-format stream-json.
And SIGTERM exits with code 143 and leaves the turn unfinished, with no result
recorded; SessionEnd hooks still run. A later resume leaves that turn as it is,
and your next prompt drives the conversation. To have the resume continue the
interrupted turn, set CLAUDE_CODE_RESUME_INTERRUPTED_TURN=1. To end the turn
cleanly instead, send SIGINT before you stop the process.
For events into a running Claude Code session, check the channel server and sender controls as well as the lifetime of the receiving session.
When claude -p spends every output token on thinking
The empty run from 5 September, shown near the top of this post, ended that way because
of thinking. Its metadata puts both output_tokens and thinking_tokens at 24000:
every output token went to thinking, and none was left for the draft. The model was
claude-sonnet-5, reached through the sonnet alias. The JSON priced the run
at $0.39. On a subscription that number is an equivalent, not a charge.
My notes from that day say the positive MAX_THINKING_TOKENS I had set was ignored. I expected a cap. On this model it is not one. The
environment variables page says it
outright: "For a positive value, Claude Code ignores the number itself on
adaptive reasoning models".
Two changes fixed my draft stage. I set MAX_THINKING_TOKENS=0 for it. I added
--effort low for Sonnet.
Check which model your alias resolves to before you copy the first change. The same page says 0
disables thinking "except on Opus 5.5, Sonnet 5.5, Haiku 5.5, and the Fable
models, which can't have thinking turned off". On those models I would start with effort. You can also raise CLAUDE_CODE_MAX_OUTPUT_TOKENS to give
the text more room. That has a price. The docs say a higher value "reduces the
effective context window available before" auto-compaction triggers.
Why that job runs with --safe-mode and not --bare
The job runs on my subscription login. That rules out --bare, which never
reads OAuth credentials. So the call uses --safe-mode. The
CLI reference describes it as
a start "with all customizations disabled". CLAUDE.md, skills, plugins, hooks
and MCP servers do not load. The part I needed comes next: "Authentication,
model selection, built-in tools, and permissions work normally".
I add two more flags. --tools "" disables the built-in tools.
--strict-mcp-config ignores every MCP configuration I did not pass in. The writer gets a prompt and a JSON schema. It gets no tools and none of my customizations.
Two things to know before you copy this. Managed settings still apply in safe mode. My own hooks do not load. Hooks set by managed policy still do. So this job is the exception to what I said above about my hook reading headless runs. With no tools there is no command for it to read.
Two automations of mine that call claude -p
The first is a dispatcher for agent sessions. Its worker command is a Python list:
[claude_exe, "-p", prompt, "--model", model, "--output-format", "json"]
model defaults to haiku. The comment says why: a fresh -p without --model
takes the account's default model, about $0.40 a run by that comment's estimate.
My cost rule puts 80 percent of tasks on the cheapest tier. The resume path is a
second list:
[claude_exe, "-p", "--resume", sid, prompt, "--output-format", "json"]
No --model there, because a resumed session inherits the model it started with,
and deliberately no --dangerously-skip-permissions; the comment calls that the
safety envelope. The dispatcher caps itself at three concurrent processes, queue
runs and workers counted together. It also appends its own run events to the
session's event file, because a headless run writes no stop and end events of its
own. Its tests never spawn the real binary: the dispatcher takes its spawn
function as a dependency, and the tests pass a fake process in.
The second is a daily reporting job. Its whole call is:
proc = subprocess.run(
["claude", "-p", "--output-format", "text"],
input=prompt.encode("utf-8"), capture_output=True,
timeout=CLAUDE_TIMEOUT_S, shell=(sys.platform == "win32"),
)
The prompt goes on stdin rather than into the argument list. The call runs with
shell=True on Windows, and cmd.exe caps a command line at 8,191 characters per
Microsoft's own note on the limit, so a long prompt has nowhere else to go. A
non-zero exit raises with the first 500 characters of stderr. The job caps
itself at three proposals per run, and its header comment gives headless on my
subscription as the reason the model call costs nothing extra.
Four rules came out of those two files. Pass --output-format json whenever a
program reads the result, because session_id is what lets you continue. Pass
--model on every fresh run. Prefer --resume with a captured ID over
--continue. Leave --bare off on a machine whose hooks you trust.
The cost model: subscription or key
The docs say two things: plain -p loads the same context as an interactive
session, and --bare "doesn't use your subscription login", so on the Anthropic
API it needs ANTHROPIC_API_KEY or an apiKeyHelper. Amazon Bedrock, Google
Cloud's Agent Platform and Microsoft Foundry keep reading their own provider
credentials. Nothing on the
headless page prices a plain -p run separately from an interactive one.
My notes add the ladder. On the laptop, plain -p runs as my logged-in session, so
a scheduled job there is the cheapest rung. A job that moves to a server or CI
needs a key, a per-call cost and a secret living on that server; --bare makes
the run reproducible and the trade explicit. One more note about the rungs above
the laptop: an MCP server that authenticates through an interactive login may not
be available there, so check what each step depends on first.
Does Codex have a headless mode?
For Codex I read two pages on 4 September 2026. One was the CLI overview. The other was the non-interactive mode page it links to. I read nothing else, and on 15 October 2026 I reread the non-interactive page for the status of --full-auto.
| Agent | Non-interactive mode | What the docs say |
|---|---|---|
| Claude Code | claude -p | This post. --output-format, --json-schema, --bare, --allowedTools, --max-turns, --max-budget-usd. |
| Codex CLI | codex exec | The overview says "Use Codex interactively or call codex exec from repeatable workflows and pipelines" and links to a non-interactive mode page. That page documents --json for a JSON Lines event stream, --output-schema for a schema-conforming final response, and -o / --output-last-message for writing the final message to a file. On 4 September it called --full-auto a deprecated compatibility flag; on 15 October it says the flag is no longer supported and points scripts to --sandbox workspace-write or --approve-for-me. |
The surface matches. Both have a script entry point, a JSON switch and a schema switch. The defaults differ. codex exec starts in a read-only sandbox. It takes --ignore-user-config to skip $CODEX_HOME/config.toml. It takes --ignore-rules to skip the user's and the project's execpolicy .rules files. claude -p without --bare runs a repository's hooks and connects its MCP servers. It shows no trust dialog for either. --bare is the flag that opts out.
The verdict
claude -p gives a script the whole agent. That means tools, loop, hooks and memory. Only the person who answered the prompts is gone. The docs supply one flag per human job:
--allowedTools and --permission-mode for approvals, --max-turns and
--max-budget-usd for stopping, --json-schema for reading the answer,
--resume for memory between runs. --bare strips the rest of your setup for a
repeatable result and moves the run from your login to a key.
If either of these two scripts moves to a server, the first change is --bare
and a key, the second is a budget flag, because a machine that runs without me should
stop without me.
Sources, read on 4 September 2026:
Run Claude Code programmatically and the
CLI reference in Claude Code's
docs, source of every flag, version number and quoted or paraphrased sentence above, except in the section on thinking and the passages on the auto-mode exception for --allowedTools, the starting permission mode, dontAsk, cost on resumed sessions, background work at exit and SIGTERM, which follow the headless page as read on 15 October 2026. The section on thinking quotes the CLI reference and the environment variables page as read on 11 October 2026; the
Codex CLI overview and its
non-interactive mode page, the latter reread on 15 October 2026 for --full-auto;
Microsoft's command prompt line string limitation
for the 8,191-character cap. The two automations are quoted from their source
files on my machine.