Blogs / Technical

Claude Code headless mode: from claude -p to nightly runs

Claude Code headless mode in production: permission allowlists, JSON output with cost per run, cron and launchd scheduling, and the failure modes we hit.

12 min readAgentsAutomationInfrastructure
Scheduled runclaude -p
Claude Code headless mode: claude -p becomes a nightly 04:00 scheduled command

Claude Code headless mode is the non-interactive way to run the agent: you pass claude -p with a prompt, it does the work, prints the result to stdout, and exits with a code your script can branch on. That single flag turns an interactive assistant into a command you can pipe, schedule, and build automation around. We run headless Claude Code on a schedule every day, so beyond the basics we'll cover what the intro posts skip: permission allowlists that aren't --dangerously-skip-permissions, output a script can parse, and the ways a scheduled run actually fails.

The mechanics

What does headless mode actually do?

Headless mode runs one complete agent session with no terminal UI: claude -p reads the prompt, uses its tools until the task is done, prints the final result, and exits. The official docs treat it as the CLI face of the Agent SDK, so the same loop, tools, and context management you get interactively also power a print-mode call.

Figure 01 / print modeLive trace
$ claude -p "summarize yesterday's CI failures" --output-format json
{
"type": "result",
"subtype": "success",
"duration_ms": 184203,
"num_turns": 42,
"result": "Two flaky tests, one real regression in auth.",
"total_cost_usd": 1.87,
"session_id": "a91f8c2e-…"
}
$ echo $? # 0
One claude -p invocation: prompt in, structured JSON result out, including the run's estimated cost.

Because it behaves like a normal Unix command, stdin and stdout do what you'd expect. You can pipe a build log in and redirect the explanation out, the way you'd use grep or jq in a pipeline.

Pipe data through a runbash
cat build-error.txt | claude -p 'explain the root cause of this build error' > analysis.txt

Piped input is capped at 10 MB, and past that the CLI exits with a clear error and a non-zero status. For bigger inputs you write the content to a file and reference the path in the prompt instead. Exit codes are the contract that makes scripting possible: a successful run exits 0, a failed one exits non-zero, and a SIGTERM from a supervisor ends the process with code 143 after aborting the turn and cleaning up child processes.

Output formats are where headless mode stops being a toy. --output-format json wraps the result with metadata including the session ID, usage, and total_cost_usd with a per-model cost breakdown, so every invocation reports what it spent. Both cost figures are client-side estimates that can differ from your actual bill, but they're exactly what you want for spotting a job that quietly doubled in price. When your code needs to consume the answer without regex, add --json-schema with a JSON Schema definition and the response carries a structured_output field conforming to it.

Parse the JSON resultbash
claude -p "Summarize this project" --output-format json | jq -r '.result'

The third format, stream-json, emits newline-delimited events as the run progresses, and combined with --verbose and --include-partial-messages it delivers tokens as they're generated. The last line of the stream is always a result message carrying the final text, cost, and session metadata. That makes it the right format when a run feeds a dashboard or a log pipeline rather than a one-shot script.

Safety

How do you keep a headless run safe without skipping every permission?

You allowlist the exact tools the run needs with --allowedTools and let everything else be denied, which is the middle ground most write-ups never mention. A print-mode session starts in Manual permission mode on every plan, and since nobody is there to click approve, a tool call outside your allowlist simply fails instead of executing. That default works in your favor: the failure mode is a blocked action in the log, not an agent doing something you never sanctioned.

Figure 02 / allowlistLive trace
Bash(git add:*)allow
Bash(git commit:*)allow
Bash(gh pr create:*)allow
WebFetchallow
Bash(git push origin main:*)deny
Read(./.env)deny
Permission rules with prefix matching: the run gets exactly the git commands it needs and nothing else.

The allowlist uses the same permission rule syntax as settings.json, including prefix matching, so a commit job can be scoped to precisely the git commands it needs.

Scoped allowlistbash
claude -p "Look at my staged changes and create a commit" \
  --allowedTools "Bash(git diff *),Bash(git log *),Bash(git status *),Bash(git commit *)"

The space before the asterisk matters more than it looks: Bash(git diff *) allows any command starting with git diff, while Bash(git diff*) would also match git diff-index. Once rules stabilize we move them into the project's .claude/settings.json, so every scheduled job shares one reviewed allowlist instead of five slightly different command lines.

--permission-mode sets a session baseline when individual rules get tedious. acceptEdits lets Claude write files and run common filesystem commands such as mkdir and mv without prompting, which suits a job whose whole purpose is editing. dontAsk flips the posture for locked-down CI, denying anything not covered by your allow rules or the built-in read-only command set. And --dangerously-skip-permissions, which is an alias for bypassPermissions mode, belongs only inside a disposable sandbox, because on your own machine it hands a scheduled process unattended write access to everything you can touch.

There's a second safety layer that gets far less attention. Without --bare, a claude -p call loads everything an interactive session would: hooks, skills, plugins, MCP servers, auto memory, and CLAUDE.md from the project and your home directory. The docs are explicit that a print-mode session shows no workspace trust dialog. It runs the hooks in a project's .claude/settings.json even in a folder you've never trusted, and connects the MCP servers in its .mcp.json without the usual per-server approval. Adding --bare skips that auto-discovery, which keeps a cloned repo's configuration from executing on your box and makes runs reproducible across machines. In bare mode the CLI never reads OAuth credentials or the keychain, so you set ANTHROPIC_API_KEY in the environment; Anthropic calls bare mode the recommended way to script and plans to make it the default for -p in a future release.

Scheduling

How do you run Claude Code on a schedule?

A scheduled run is the same claude -p command wrapped in a small script that cron, launchd, or your CI fires at a fixed time. The script sources the environment, changes into the repo, runs the prompt with an explicit allowlist, and appends stdout and stderr to a log you can read the next morning.

Figure 03 / the scheduleLive trace
Mon04:00
Tue04:00
Wed04:00
Thu04:00
Fri04:00
Sat09:12
Sun04:00

Sat: machine asleep at 04:00, launchd fired the missed job on wake. cron would have skipped it.

A week of nightly runs: launchd fires the missed Saturday job on wake instead of skipping it.
Nightly cron jobbash
# crontab -e: draft the nightly report at 03:00
0 3 * * * cd /srv/reports && ./run-agent.sh >> logs/agent.log 2>&1

On macOS we use launchd instead, and the reason sits verbatim in the launchd.plist man page: cron skips job invocations when the computer is asleep, while launchd starts a missed StartCalendarInterval job the next time the machine wakes, coalescing several missed windows into one event. For an agent meant to run at 3am on a laptop that's usually closed at 3am, that one property separates a job that runs most days from a job that never runs at all.

Longer workflows chain runs together with sessions. --continue picks up the most recent conversation, and --resume takes a session ID, which you can capture from the JSON output of an earlier run. You can also shape a run without touching its prompt: --append-system-prompt layers standing instructions on top of the default system prompt. That's where we put output conventions and the reminder that the run is unattended, so the individual job prompts stay short.

Chained sessionsbash
session_id=$(claude -p "Start the review" --output-format json | jq -r '.session_id')
claude -p "Now write up the findings" --resume "$session_id"

Our own nightly agent works exactly this way: a scheduler fires a wrapper script, the wrapper sources credentials and calls the agent headless, and the agent does its work and opens a pull request that a human reviews before anything ships. A prompt file plus an allowlist plus a log file is genuinely all the infrastructure a first scheduled agent needs, and one week of a real nightly job will teach you more than any amount of reading about one.

Reliability

What breaks when nobody's watching?

Scheduled runs fail in four recurring ways: they hang, they miss their window, they get blocked by a permission, or they spend more than you expected. We've hit all four running our own nightly jobs, and none of them announce themselves; you find out from a log line the next day. That's why every mitigation here is something you set up before the first run.

Figure 04 / the morning afterLive trace
04:00:00 run started · nightly-report
04:00:04 permissions loaded: 11 allow / 3 deny
04:00:41 drafting report, 7 sections
04:39:58 tool call blocked: Bash(curl …) denied by rule
04:41:07 exit 124 · killed by timeout after 2467s
07:45:00 notify: FAILED nightly-report, see log
Unattended failures only exist in the log: timestamps and exit codes are the whole forensic record.

Hangs used to be the nastiest. When a run starts a background process, say a dev server or a watch build, older versions of the CLI would wait for it to exit, which for a server is never. Since v2.1.163 leftover background shells are terminated about five seconds after the final result is printed, and since v2.1.182 the wait for a stuck background subagent is capped at ten minutes by default. We still wrap every scheduled invocation in timeout with a generous ceiling, because a hard outer bound turns any unknown failure into a known one with an exit code. Transient API errors, on the other hand, are handled for you: when a request fails with a retryable error the CLI retries it, and in stream-json output it emits a system event named api_retry carrying the attempt number, the delay until the next try, and an error category. A monitoring pipeline can tell a rate limit from a real outage without parsing prose.

A missed window is usually the machine rather than the tool: the laptop was asleep, cron skipped the invocation, and no error exists anywhere because nothing ever started. Moving macOS schedules to launchd covers sleep, and logging a timestamp at the top of the wrapper script proves whether the job fired at all. Permission failures are quieter still, because in Manual mode a denied tool call doesn't kill the run; the agent can finish with a partial result and still exit 0. The log shows what got blocked, and the fix is extending the allowlist deliberately, one reviewed rule at a time, rather than reaching for the skip-everything flag the first time something is denied.

Cost drift is the failure people notice last. --max-turns puts a hard cap on how many agentic turns a print-mode run can take, which is your defense against a job looping on a task it can't finish. Parsing total_cost_usd out of the JSON result into your log produces a per-run cost series for free, and it's worth watching, because spend usually creeps for boring reasons: a growing repo, a longer prompt, or tool schemas quietly inflating the context. We measured that last one directly when a single MCP tool's schema reached an estimated 54,000 tokens, and wrote up how we cut MCP tool-schema tokens by 97% after finding it.

The ceiling

When does cron stop being enough?

Cron stops being enough when the machine has to stay on, when the agent needs real isolation, or when you need spend enforced before the run instead of reported after it. Those are three separate walls, and most teams hit them in that order.

Figure 05 / past the laptopLive trace
RunScheduleCostStatus
nightly articledaily 04:00$1.84ok
index sweepdaily 01:30$0.12ok
optimize passsun 02:00$2.10ok
content syncdaily 07:00$0.12failed
Once runs move off a personal machine, the schedule, the isolation boundary, and the spend cap live in one place.

The first wall is the physical machine itself. launchd catching up after sleep works for a job where 9am is as good as 3am, but the machine still has to wake eventually, and a laptop in a bag over a long weekend runs nothing. The moment a run must happen on time whether or not you're around, you're shopping for an always-on computer, and a personal machine doing double duty as agent infrastructure is how you end up afraid to reboot.

The second wall is blast radius. A headless agent on your own machine works inside your real home directory, with your real credentials, next to every project you care about. An allowlist narrows what it can do, but the honest reading of --dangerously-skip-permissions is that it's only defensible when the environment is disposable, and your laptop isn't. We've argued before that agent applications are not agent infrastructure, and execution is where that split bites: a per-run sandbox that's created clean and destroyed afterwards turns a bad run into a shrug instead of an incident.

The third wall is that every cost number the CLI gives you arrives after the money is spent. total_cost_usd is an estimate in the result payload, not a limit, and nothing in cron stops a misbehaving job from running up the same bill again the next night. That gap is the one Orca exists in: it runs the same kind of headless agent in an isolated cloud sandbox on a schedule, metered to the micro-dollar, with the budget enforced per run rather than tallied afterwards. It's not where everyone should start, though. When your job runs on a machine that's already on all day and its worst case is a bad pull request, cron is simpler and free, and we'd pick it again for that case. A runtime earns its keep when the schedule has to hold without your laptop, when the agent needs write access you'd never grant on your own box, or when a hard per-run budget is a requirement instead of a wish.

Questions

Frequently asked questions

These are the questions we actually get about running Claude Code headless, answered from the current docs and from our own scheduled runs.

Figure 06 / the surface areaLive trace
Run
-p--output-format json--include-partial-messages
Guard
--allowedTools--disallowedTools--max-turns--bare
Continue
--continue--resume--add-dir--append-system-prompt
The whole headless surface is a handful of flags: print mode, permissions, output format, and sessions.

What's the difference between Claude Code headless mode and the Agent SDK?

They're the same engine at different depths. Running claude -p is the Agent SDK used through the CLI, with the same tools, agent loop, and context management, while the Python and TypeScript packages add programmatic control such as tool approval callbacks and native message objects. Start with the CLI, and move to a package when your integration outgrows shell scripts.

Does claude -p work with a subscription login?

Yes, a normal print-mode call uses whatever authentication your interactive sessions use. The exception is --bare, which never reads OAuth credentials or the system keychain, so bare-mode scripts need ANTHROPIC_API_KEY set in the environment or an apiKeyHelper passed through --settings.

How do I see what a Claude Code headless run cost?

Run it with --output-format json and read total_cost_usd from the result payload, which also carries a per-model breakdown. Anthropic labels both figures as client-side estimates that can differ from your actual bill, so treat them as a trend line for catching drift rather than as an invoice.

Is --dangerously-skip-permissions safe for scheduled runs?

Only when the environment is disposable. The flag is an alias for bypassPermissions mode, so a scheduled process gets unattended write access to everything the account can reach, which is defensible inside an isolated sandbox and hard to defend on the machine you work on. An explicit allowlist has covered almost every scheduled job we've run without it.

Why did my scheduled claude -p job never run?

Check whether the machine was awake before blaming the tool. cron skips invocations while the computer sleeps, and launchd's StartCalendarInterval instead fires the missed job on wake, so on macOS the fix is usually moving the schedule to launchd. Logging a timestamp at the top of your wrapper script settles whether the scheduler fired and the agent failed, or nothing started at all.

Can a headless run pick up where the last one left off?

Yes, --continue resumes the most recent conversation and --resume takes a specific session ID, which you can capture from the JSON output of the earlier run. Since v2.1.223 the ID resolves from any directory on the machine, so the follow-up doesn't have to run from the same folder.

Ship one

Where should you start tonight?

Start with one job you already do on a rhythm and wouldn't mind reading a machine's attempt at: a nightly summary of new issues, a lint pass over yesterday's diff, a draft changelog from the week's commits. Write the prompt in a file, give it the narrowest allowlist that lets it finish, run it by hand twice with --output-format json, and only then put it on a schedule with logging on both streams.

The first week will show you where your version of the four failures lives, and every mitigation in this post is cheap to add before you need it. When the job eventually outgrows the machine it runs on, you'll know exactly why, because the log will name the wall you hit. That's the honest pitch for headless mode: it's the smallest possible version of running agents in production, and small is exactly where to learn.

Source and measurement note

Every flag and behavior in this post was checked against the Claude Code documentation at code.claude.com/docs on 20 August 2026, including the headless and CLI reference pages; version notes such as v2.1.163 are quoted from those docs. The cron and launchd sleep behavior is quoted from the launchd.plist man page on macOS. Costs are discussed as mechanisms only, and we make no claim about what your runs will spend.

Run the execution plane

Build the agent. Orca handles the infrastructure.

Start with a YAML profile. Add the capabilities, compute and delivery surface your agent needs.

Sign up