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.
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.
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.
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.
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.
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.
Bash(git add:*)allowBash(git commit:*)allowBash(gh pr create:*)allowWebFetchallowBash(git push origin main:*)denyRead(./.env)denyThe 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.
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.
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.
Sat: machine asleep at 04:00, launchd fired the missed job on wake. cron would have skipped it.
# 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.
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.
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.
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.
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.
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.
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.
-p--output-format json--include-partial-messages--allowedTools--disallowedTools--max-turns--bare--continue--resume--add-dir--append-system-promptWhat'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.
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.
