Claude Code Headless Mode (claude -p): Running It on a Schedule, What It Costs, and Where It Bites

Orr Yakobi
Headless mode is Claude Code run with the -p flag: it reads a task, works, prints a result to stdout and exits, with nobody watching a terminal. On 2026-09-10 SWARECO built a scheduled headless job, a twice-daily triage that reads two Slack channels, fetches public feeds through a small Ruby tool, and posts a digest back to Slack.
This article uses that job's measured runs as its evidence. It covers the flags that matter for an unattended run, what two real runs cost, and the three failure modes we hit in the first two days.
Everything about Claude Code's flags below comes from Anthropic's documentation, read on 2026-09-11. Everything about cost and behaviour comes from our own runs on Claude Code 2.1.236.
Key takeaways
claude -pturns an interactive assistant into a unit of work that a scheduler, a CI job or a shell pipeline can run. It exits 0 on success and non-zero on failure.- The flags that decide whether an unattended run is safe are
--allowedTools,--disallowedToolsand--max-turns. A denied tool is removed from the model's context, so the deny list is a policy, not a request. - Two measured runs on 2026-09-10 cost an estimated 3.38 and 2.83 USD and took 45 and 35 turns. Most of those turns were spent reading channel history, not acting.
- Without
--bare, a headless run on a Mac used the logged-in CLI's stored credentials and the claude.ai Slack connector with no extra configuration. The docs say--barewill become the default for-p, so re-test after upgrades. - The three failures were a duplicate Slack post, a hosted run that pushed to
mainbecause a hook asked it to, and a sandbox that could not reach the site it needed. Each has a one-line fix.
What Claude Code headless mode is
Adding -p (or --print) to any claude call runs it non-interactively: Claude Code processes the task, prints the result, and exits. A typical call looks like claude -p "Summarize what changed in git since yesterday". The task text can be a quoted argument or piped on stdin; piped input is capped at 10 MB.
Claude Code exits with code 0 on success and a non-zero code when the run fails, so a script can branch on the exit status. An invalid flag is reported on stderr before the run starts. A failure inside the run, such as missing authentication, is printed as the result on stdout. Not every option combines with -p; the docs say --bg is rejected.
The session is not thrown away when the result prints. Its id comes back in the JSON result, and --resume <session_id> or --continue picks the conversation up again in a later call.
The same engine is available as the Agent SDK for Python and TypeScript; claude -p is the CLI form. For a small engineering team the difference matters less than the shift it represents: the assistant becomes something you can schedule, budget and log like any other job.
The flags that matter for an unattended run
These are the flags our job uses or that the documentation names for scripted use. The Note column quotes the documentation.
| Flag | What it does | Note |
|---|---|---|
--output-format | Chooses text (default), json or stream-json. | The json form is a single result object with the text in a result field plus session_id, num_turns, duration_ms and total_cost_usd. stream-json emits newline-delimited events; the last line is a result message. Cost figures are "client-side estimates and can differ from your actual bill". |
--json-schema | Validates the output against a JSON Schema. | With --output-format json, the validated output arrives in a structured_output field. An invalid schema exits with an error. |
--allowedTools | Tools that run without an approval dialog. | Uses permission-rule syntax: Bash(git diff *) allows anything starting with git diff. The space before * matters; Bash(git diff*) would also match git diff-index. |
--disallowedTools | Deny rules. | A bare tool name such as Edit removes that tool from Claude's context entirely; * removes every tool; mcp__* removes every MCP tool. A scoped rule such as Bash(rm *) leaves the tool but denies matching calls. |
--permission-mode | The starting permission mode. | For -p the built-in starting mode is Manual on every plan. See the next section for auto, dontAsk and acceptEdits. |
--max-turns | Caps agentic turns. | Print mode only. Exits with an error at the limit. No limit by default. |
--max-budget-usd | A dollar cap on API spend. | Print mode only; subagent spend counts toward it. Requires 2.1.217 or later. |
--model | Picks the model. | An alias such as sonnet, opus or fable, or a full model name. |
--continue, --resume <session_id> | Continues a conversation. | The most recent one, or a specific one by the id from the JSON result. |
--bare | Starts without the interactive context. | Skips hooks, skills, custom slash entries, subagents, plugins, MCP servers, auto memory and CLAUDE.md. The docs call it "the recommended mode for scripted and SDK calls" and say it "will become the default for -p in a future release". |
--mcp-config | Loads MCP servers from JSON. | With -p, Claude Code waits for pending servers up to MCP_TIMEOUT, 30 seconds by default. Requires 2.1.221 or later. |
SWARECO's job runs claude -p --model sonnet --output-format json --max-turns 80 plus an allow list and a deny list. The launcher parses the last JSON line for result, num_turns, duration_ms, total_cost_usd and session_id, and writes them to a ledger.
Permissions: allow lists, deny lists and modes
The documentation names three permission modes for unattended use. auto has a classifier review most actions. dontAsk denies anything not covered by your permissions.allow rules or the read-only set, which the docs describe as useful for locked-down CI runs. acceptEdits writes files without asking and auto-approves common filesystem calls such as mkdir, touch, mv and cp. A fourth, bypassPermissions (the same as --dangerously-skip-permissions), skips every check; the docs recommend it only for sandboxes with no internet access.
SWARECO's job does not rely on a mode. It allows exactly what the task needs: Read, Grep, Glob, one Ruby tool by prefix rule, one date call, and the Slack connector's read tools. It denies Edit, Write, NotebookEdit, MultiEdit, WebFetch, WebSearch, Agent and Task outright. The run can read the repository and talk to Slack. It cannot change a file or open the web, whatever its task text says.
A --dry-run switch on the launcher moves the Slack write tools from the allow list to the deny list, so the same job can be tested with zero side effects. In that mode it prints the digest it would have sent.
The point of the deny list is that it is enforced by the tool grant, not by asking the model to behave. A denied tool is not available to be misused. That is the difference between a policy and a hope.
Credentials and MCP connectors without a terminal
Without --bare, a -p session loads the same context an interactive session would: the project's hooks in .claude/settings.json, the servers in .mcp.json, and CLAUDE.md. The docs note that it does this even in a folder you have never trusted, because a -p session shows no workspace-trust dialog and no per-server approval. That is convenient for a job you control and a risk for a repository you do not.
In bare mode Claude Code never reads OAuth credentials or the system keychain. Bare runs therefore need ANTHROPIC_API_KEY in the environment, or an apiKeyHelper.
What we observed on 2026-09-10, running -p without --bare on a Mac under launchd, on Claude Code 2.1.236: the logged-in CLI's stored credentials worked with no API key set, and the claude.ai Slack connector was available with the same tool names as an interactive session, for example mcp__claude_ai_Slack__slack_read_channel. No --mcp-config was needed.
Because the docs describe --bare as the future default, a job that depends on the interactive context should state that dependency in its own notes and re-test after each Claude Code upgrade. We make no claim here about how a subscription or an API key is billed for headless use; the only cost figure we report is the total_cost_usd estimate from the JSON result.
Scheduling on macOS with launchd, and what cron changes
Our job runs from a LaunchAgent: a plist with StartCalendarInterval entries for weekdays at 08:00 and 15:00 local time, loaded with launchctl bootstrap gui/<uid>. If the Mac is asleep at a scheduled time, launchd runs the job once when the machine wakes; it does not wake the machine.
launchd gives the job a minimal environment, so the plist uses absolute paths for both ruby and the claude binary and sets PATH, HOME and LANG in EnvironmentVariables. Standard output and error go to files under ~/Library/Logs. A cron job on Linux needs the same treatment, absolute paths and an explicit PATH, and it does not catch up runs it missed. A systemd timer can, with Persistent=true.
The job itself is a small Ruby launcher. It assembles the task text from a versioned file in the repository, so the wording is code-reviewed like anything else. It refuses to start if a pidfile shows another run alive. It kills the run after 25 minutes with SIGTERM, then SIGKILL. It writes the full output to a per-run log and appends one line per run to a TSV ledger: timestamp, mode, exit code, turns, seconds, cost estimate, session id.
One docs fact worth knowing before you write the kill logic: on SIGTERM, Claude Code exits with code 143 and leaves the turn in progress unfinished. SIGINT ends the turn instead.
What a real scheduled run costs: our numbers
Two measured runs on 2026-09-10, Claude Code 2.1.236, model sonnet, both exit code 0. Every figure is taken from the JSON result.
| Run | Turns | Seconds | Cost estimate (USD) | What it did |
|---|---|---|---|---|
| Dry run (Slack writes denied) | 45 | 370 | 3.38 | Read both Slack channels and two CSV registers, wrote nothing, printed a correct empty digest |
| Live run | 35 | 357 | 2.83 | Delivered the digest to the Slack channel; resolved one held item from a pre-fetched thread file |
The lesson is in the turns, not the dollars. Even a window with nothing new to report cost 35 to 45 turns, because most of them went to reading about 100 messages of channel history to establish state before deciding anything. Twice a day, that is roughly 6 USD a day at these estimates. The cheapest optimisation is therefore to reduce what the job reads before it decides. --max-turns is the hard stop that keeps a confused run from spending more, and --max-budget-usd caps it in dollars.
These are estimates, per the documentation, not invoices. Treat the ledger as a trend line and check it against the account's own usage view.
Three failure modes we hit, and the fixes
- The digest was posted twice. The live run sent identical text to Slack twice, 34 seconds apart. The Slack tool has no delete, so both messages stand. The cause was not visible because
--output-format jsonshows only the final result, not the tool calls. The fix is a send-once rule in the task text (read the channel for your own message before any retry) and, when debugging,--output-format stream-json, which shows every tool call as it happens. - A hosted run pushed to
main. An earlier version of the same job, running in a hosted sandbox rather than on the Mac, edited two planning documents, committed, and pushed to the main branch, because a stop hook in that environment asked it to commit and nothing forbade it. The fix is to denyEditandWriteoutright, so no hook can turn a read-only run into a writer. Repository writes from an unattended run are a policy decision, not a default. - The sandbox could not reach the site it needed. The job assumed it could open the website its triage depends on. Network policy blocked the destination, so every candidate stalled as "needs context". The fix is to probe access once and report it, never retry with a different user agent or route, and run the job on a machine that can reach what it needs.
All three surfaced in the first two days. That is the argument for a dry-run switch and a ledger before any schedule is loaded, not after.
What to hand to an unattended agent, and what to keep for people
Good headless work is reading and triaging (channels, feeds, logs), summarising, drafting something a person will approve, and checks that fail a CI job. Each of those is reversible or reviewed, and none of them needs a write to anything that matters.
Keep for people, or gate behind an approval step: deploys, repository writes, anything that sends to customers or posts publicly without an idempotency check, and anything that spends money without a cap. The duplicate Slack post above is what "posts publicly without an idempotency check" looks like in practice, and it was the mildest possible version.
SWARECO builds and runs engineering teams that ship AI-era software, and part of that work is taking companies whose products and teams predate AI tooling to the point where agentic work is safe. The tool grant, the ledger and the dry run described here are the kind of guardrails that decide whether an unattended agent is an asset or an incident. They are also the same discipline we apply to agentic coding inside a codebase, and the reason a repository's own configuration is on the threat list for any agent that opens a folder.
Conclusion
-p is the switch. The allow list and deny list are the safety. --max-turns and the ledger are the budget. Measure two runs before you load a schedule, because the numbers will tell you where the turns go, and that is where the cost and the risk both live.
SWARECO published these numbers because none of the pages ranking for this topic had any.
FAQs
What is Claude Code headless mode?
It is Claude Code run with the -p (or --print) flag. It reads a task from the argument or stdin, does the work, prints the result and exits, with exit code 0 on success and non-zero on failure. There is no interactive terminal session.
How do I run Claude Code on a schedule?
Wrap claude -p in a small launcher and give it to your scheduler: a LaunchAgent with StartCalendarInterval on macOS, or cron or a systemd timer on Linux. Use absolute paths for the binaries, set PATH and HOME explicitly, log every run, and cap it with --max-turns. Our job runs weekdays at 08:00 and 15:00 this way.
How much does a headless run cost?
Our two measured runs on 2026-09-10 reported total_cost_usd estimates of 3.38 and 2.83 USD for 45 and 35 turns. The documentation calls these client-side estimates that can differ from the bill. The cost tracks turns, and turns track how much the job reads before it acts.
Can a headless run use MCP connectors such as Slack?
Yes. Without --bare, our run had the claude.ai Slack connector available with the same tool names as an interactive session, on Claude Code 2.1.236. With --bare, pass the servers with --mcp-config and supply an API key, because bare mode does not read stored credentials.
How do I stop a headless run from editing files?
Pass --disallowedTools Edit Write NotebookEdit. A bare tool name in the deny list removes the tool from the model's context, so it cannot be called. For a locked-down CI run, the docs also name --permission-mode dontAsk, which denies anything not in your allow rules or the read-only set.
Other Articles
We build the engineering. You build the business.
If you are trying to figure out whether SWARECO is the right fit for what you are building, the best way to find out is to talk. Tell us what you have. We will be direct about what we can do and how we would approach it.









