Chapter 20. Claude Code Headless Mode, CI, and Scheduled Runs
Print mode is how Claude Code runs a job with nobody at the keyboard:
claude -p takes one prompt, runs one request, prints the
response, and exits. Add --output-format json and the run
comes back as one result object carrying session_id,
total_cost_usd, token usage,
is_error, and terminal_reason. I don’t have a
night crew. The 20-plus live surfaces I run get one operator, and that
operator sleeps, so any sweep, review, or check that happens between
midnight and morning has to run without me. Claude Code covers that gap
in three layers: print mode for anything a shell script can trigger, a
GitHub Action for anything a repo event can trigger, and schedulers for
the work that runs on a clock. The claims below come from two sources,
the official docs and the 2.1.211 binary installed on my Mac, and where
the two disagree I say so, because the binary is the one running your
job.
claude -p, and when print mode turns itself on
claude -p "summarize the failing tests" runs one
non-interactive request, prints the response, and exits. The long form
is --print. Print mode is also implied: with stdout piped,
claude --output-format json hi on my machine emitted a
single JSON object and exited, no -p anywhere on the line.
The same non-TTY detection skips the workspace trust dialog, which the
help text ties to -p or stdout not being a TTY, so a cron
job cannot wedge on a prompt it can’t answer.
The same help text records a nastier property: settings files that fail validation are ignored in print mode, with no error dialog. Your pipeline runs on default settings and tells you nothing. Validate settings.json as its own step if the job depends on it.
Output comes in three formats, and the flag is strict about them:
error: option '--output-format <format>' argument 'nonsense' is invalid. Allowed choices are text, json, stream-json.
--input-format accepts text and
stream-json. Piped stdin has a cap: 10MB as of v2.1.128,
and exceeding it exits non-zero with an error.
What –output-format json returns, and the field that lies
--output-format json wraps the run in a single result
object. The docs promise total_cost_usd, a per-model cost
breakdown, a session_id, and a modelUsage
field reporting the model that answered. On my machine the envelope’s
top-level keys, captured from a run that failed auth, were:
type, subtype, is_error,
api_error_status, duration_ms,
duration_api_ms, num_turns,
result, stop_reason, session_id,
total_cost_usd, usage,
modelUsage, permission_denials,
terminal_reason, fast_mode_state,
uuid. Inside usage sit the token counts, the
cache-creation split (ephemeral_1h_input_tokens,
ephemeral_5m_input_tokens), service_tier,
inference_geo, iterations, and
speed. Success-path shapes I have not verified.
That error run is also where the field lies. It reported
"subtype":"success" next to "is_error":true
and "terminal_reason":"api_error", with
stop_reason set to "stop_sequence". A script
that greps for success will pass a broken run. Gate on
is_error and terminal_reason, not on
subtype.
When the output has to be machine-shaped,
--json-schema '<JSON Schema>' combined with
--output-format json returns the conforming value in the
response’s structured_output field, with the plain text
still in result. An invalid schema exits with
Error: --json-schema is not a valid JSON Schema. The
format keyword ("format": "email") is accepted
but treated as annotation, not enforced. Version matters here: before
v2.1.205, an invalid schema was ignored without a word, and any schema
containing format counted as invalid. Pin the CLI version
in CI, or the same YAML produces different behavior on different
runners.
stream-json: the flags it requires and the events it emits
stream-json is the format for consuming events as they
happen, and it enforces its pairings with exact errors:
Error: When using --print, --output-format=stream-json requires --verbose
Error: --include-partial-messages requires --print and --output-format=stream-json.
Error: --input-format=stream-json requires output-format=stream-json.
Error: --replay-user-messages requires both --input-format=stream-json and --output-format=stream-json.
Token-level streaming needs all three of
--output-format stream-json, --verbose, and
--include-partial-messages. The last line of the stream is
a result message carrying the final text, cost, and session
metadata, so a consumer can treat the stream as a progress feed and
still parse one authoritative line at the end.
The stream has structure worth knowing before you write a parser. In
a normal run the first event is a system message with
subtype init, reporting the model, tools, MCP servers,
plugins, plugin_errors, and an optional
capabilities array (values like
interrupt_receipt_v1, which requires v2.1.205). Retryable
API errors surface as system messages with subtype
api_retry, carrying attempt,
max_retries, retry_delay_ms, and an
error field drawn from a fixed list:
authentication_failed, oauth_org_not_allowed,
billing_error, rate_limit,
overloaded, invalid_request,
model_not_found, server_error,
max_output_tokens, unknown. Subagent messages
carry the spawning tool call’s ID in parent_tool_use_id,
and main-conversation messages carry null there, which is
how you demultiplex. --forward-subagent-text (v2.1.211 or
later, env form CLAUDE_CODE_FORWARD_SUBAGENT_TEXT) adds
subagent text and thinking blocks to the stream.
Slow consumers used to lose data. Before v2.1.208 a large piped
response could truncate the final result line; the exit
drain wait was capped at about two seconds until v2.1.214, which scales
it with queued output up to a 30-second cap.
When a print-mode run ends, and what it kills on the way out
| Version | Print-mode change |
|---|---|
| v2.1.128 | piped stdin capped at 10MB |
| v2.1.163 | background shells terminated about five seconds after the final result |
| v2.1.182 | background-subagent wait capped at ten minutes by default |
| v2.1.205 | --json-schema validates its input; slash commands take
arguments in -p |
| v2.1.208 | fixed truncation of the final result line on slow
pipes |
| v2.1.214 | exit drain wait scales with queued output, capped at 30 seconds |
Two of those deserve prose. A background Bash shell started during a
-p run is terminated about five seconds after the final
result returns and stdin closes; before v2.1.163 a never-exiting
background process held the invocation open with no limit, a hung job
eating runner minutes. Background subagents and workflows get the
opposite treatment: print mode waits for them, capped at ten minutes by
default from v2.1.182. CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS
adjusts the ceiling, and 0 removes it.
Cancellation is clean. SIGTERM to a claude -p run aborts
the in-progress turn, terminates the process tree of any running Bash
command, runs SessionEnd hooks, and exits with code 143, so
a CI timeout kill still fires your cleanup hooks.
–bare, and where a CI run gets its credential
--bare skips auto-discovery of hooks, skills, plugins,
MCP servers, auto memory, and CLAUDE.md, and the docs state it will
become the default for -p in a future release. My binary’s
help adds local detail: it also skips LSP, plugin sync, attribution, and
background prefetches, sets CLAUDE_CODE_SIMPLE=1, and
skills still resolve when invoked by name as /skill-name.
In bare mode the model gets Bash, file read, and file edit, nothing
else, and auth must come from ANTHROPIC_API_KEY or an
apiKeyHelper inside the JSON passed to
--settings, because bare mode reads neither OAuth state nor
the keychain. That is the right shape for CI: the run does not depend on
whatever CLAUDE.md sits in the checkout, and the credential is the one
you provisioned, not one it found.
Provisioning that credential is its own step, and I have a receipt
for why. A claude child process launched from inside one of
my own Claude Code sessions did not inherit a usable login:
claude auth status --json reported
loggedIn: false, and every API-bound command died with
Failed to authenticate: OAuth session expired and could not be refreshed.
The interactive login on the parent machine is not ambient. For scripts
and CI, claude setup-token generates a long-lived OAuth
token (it requires a Claude subscription), consumed via
CLAUDE_CODE_OAUTH_TOKEN. Tokens from it make model requests
and nothing more; they cannot establish Remote Control sessions, which
is the correct blast radius for a credential living in a secrets
store.
Permissions for a job with nobody watching
An unattended run cannot answer a permission prompt, so the
permission mode is the whole security posture.
--permission-mode dontAsk denies anything not covered by
permissions.allow or the built-in read-only command set,
and it denies AskUserQuestion, connector tools an org set
to ask, and MCP tools marked
requiresUserInteraction even when an allow rule matches.
acceptEdits auto-approves file writes plus
mkdir, touch, mv, and
cp; other shell commands still need an
--allowedTools entry or a permissions.allow
rule.
--allowedTools uses prefix matching, and one space
changes the match set: Bash(git diff *) scopes to
git diff invocations, while Bash(git diff*)
also matches git diff-index. The flag has an
--allowed-tools alias, and
--disallowedTools/--disallowed-tools set deny
rules.
Now the disagreement I promised. The CLI reference lists
--permission-mode accepting default,
acceptEdits, plan, auto,
dontAsk, bypassPermissions,
manual. I fed my 2.1.211 binary a bogus value and read the
validator’s own list back:
error: option '--permission-mode <mode>' argument 'nonsense' is invalid. Allowed choices are acceptEdits, auto, bypassPermissions, manual, dontAsk, plan.
Six values, no default; manual occupies
that slot. A workflow that passes --permission-mode default
will fail to parse on this binary even though the docs bless the value.
Test the flag against the version your runner installs.
Two caps belong in any unattended invocation.
--max-budget-usd <amount> stops the session at a
dollar ceiling, and --fallback-model takes a
comma-separated list tried in order, retrying the primary at the start
of each user turn. Both are documented as print-only, but the constraint
is soft: on my binary, --fallback-model,
--max-budget-usd, and --no-session-persistence
without --print do not error, they fall through to normal
startup. Only the stream-json family enforces its pairings. Acceptance
of a flag is not proof it did anything.
Resuming a session between script steps
Multi-step scripts need continuity, and the envelope provides it:
session_id comes back in the JSON result. The flags that
consume it: --resume/-r resumes by ID or name,
--continue/-c loads the most recent
conversation in the current directory, --session-id sets
the ID up front and must be a UUID
(Error: Invalid session ID. Must be a valid UUID.),
--fork-session resumes into a new session ID instead of
reusing the original, and --no-session-persistence (print
mode only) keeps a run off disk when there is nothing worth
resuming.
review=$(claude -p "review the diff on this branch" --output-format json)
sid=$(printf '%s' "$review" | jq -r '.session_id')
claude -p "now write the fix plan" --resume "$sid" --output-format jsonIn-session commands work in print mode too, with values passed as
arguments: /model sonnet, /effort,
/fast, /color, and /rename accept
the value inline as of v2.1.205, /config key=value works
from v2.1.181, and terminal-only commands such as /login
are unavailable.
Running Claude Code in GitHub Actions
The action is anthropics/claude-code-action@v1. Its v1.0
release cleaned house: the beta mode input is gone
(auto-detected now), direct_prompt became
prompt, and max_turns, model,
custom_instructions, allowed_tools, and
disallowed_tools moved into a single
claude_args string as --max-turns,
--model, --append-system-prompt,
--allowedTools, and --disallowedTools, with
claude_env replaced by a settings JSON. The
action’s configuration converges on the same CLI flags this chapter
covers.
The input list is short: prompt,
claude_args, plugin_marketplaces
(newline-separated Git URLs), plugins (newline-separated
names), anthropic_api_key, github_token,
trigger_phrase, use_bedrock,
use_vertex. The default trigger_phrase is
@claude, which is the one-person-CI trick in a single
input: a mention in an issue or PR comment becomes a work order that
executes while I do something else. Inside the action
--max-turns defaults to 10, a sane leash for a bot with
write access.
Setup is one command from an interactive session:
/install-github-app installs the Claude GitHub App and
walks through workflow and secret setup, and v2.1.187 added a Skip for
now option that stops after App installation. The App wants Contents,
Issues, and Pull requests at read and write, and the documented workflow
permissions block is:
permissions:
contents: write
pull-requests: write
issues: write
id-token: writeSecrets follow the provider. The documented names are
ANTHROPIC_API_KEY, APP_ID, and
APP_PRIVATE_KEY; Bedrock uses
AWS_ROLE_TO_ASSUME; Google Cloud’s Agent Platform uses
GCP_WORKLOAD_IDENTITY_PROVIDER and
GCP_SERVICE_ACCOUNT, with Vertex workflows also setting env
vars ANTHROPIC_VERTEX_PROJECT_ID,
CLOUD_ML_REGION, and
VERTEX_REGION_CLAUDE_4_5_SONNET.
/loop and the cron tools: scheduling from a local session
/loop is the fastest way to put a session on a clock,
and its behavior splits on what you give it. An interval plus a prompt
runs that prompt on a fixed cron schedule. A prompt with no interval
runs self-paced: Claude picks each next wakeup, between one minute and
one hour. An interval alone, or a bare /loop, runs a
built-in maintenance prompt, replaceable by loop.md:
.claude/loop.md in the project takes precedence over
~/.claude/loop.md, content past 25,000 bytes is truncated,
and edits apply on the next iteration. Interval units are
s, m, h, and d, with
seconds rounded up to the nearest minute. A self-paced loop ends when
Claude calls the ScheduleWakeup tool with
stop: true (v2.1.202 or later); an iteration that neither
reschedules nor stops gets one fallback wakeup about 20 minutes later,
and Esc clears a pending wakeup.
Underneath sit three scheduling tools: CronCreate,
CronList, CronDelete. Tasks get 8-character
IDs and a session holds up to 50 at once. CronCreate takes
standard five-field cron
(minute hour day-of-month month day-of-week) with
*, single values, */N steps, A-B
ranges, and comma lists; day-of-week runs 0 (or
7) for Sunday through 6 for Saturday. The
extended syntax you may carry over from other crons is absent: no
L, no W, no ?, no
MON or JAN aliases. Times are local, and
day-of-month plus day-of-week match on either field, vixie-cron
semantics.
The scheduler’s timing model will surprise a CI habit. Recurring
tasks fire up to 30 minutes after the scheduled time, or up to half the
interval for sub-hourly tasks, with the offset a pure function of the
task ID; one-shot tasks scheduled for :00 or
:30 can fire up to 90 seconds early. Recurring
session-scoped tasks expire seven days after creation: one final fire,
then they delete themselves. The scheduler checks each second, enqueues
due tasks at low priority, fires only between turns, and does not catch
up on missed fires. A closed laptop means skipped runs, not queued
ones.
Version gates and switches round this out. As of v2.1.196 a scheduled
fire executes only skills Claude may invoke on its own, so built-in
commands (/permissions, /model,
/clear), skills with
disable-model-invocation: true (the bundled
/verify and /code-review among them), skills
withheld by skillOverrides or a Skill deny
rule, and MCP prompts like /mcp__github__list_prs arrive as
plain text instead of executing. The task list lives in the project’s
.claude directory, and from v2.1.216 scheduling errors out
if that directory or the task file is a symlink.
CLAUDE_CODE_DISABLE_CRON=1 shuts the scheduler off
wholesale, cron tools and /loop included. On Amazon
Bedrock, Claude Platform on AWS, Google Cloud’s Agent Platform, and
Microsoft Foundry, /loop without an interval runs on a
fixed ten-minute schedule, loop.md is not read, and a bare
/loop prints the usage message.
Cloud routines: scheduled runs with the laptop closed
Routines are the cloud tier, in research preview, and unlike everything above they do not need my machine on. The tradeoff is enumerable:
| Surface | Minimum interval | Needs |
|---|---|---|
| Cloud Routines | 1 hour | nothing local |
| Desktop scheduled tasks | 1 minute | machine on |
/loop |
1 minute | machine on, session open |
Create them at claude.ai/code/routines or with /schedule
in the CLI (alias /routines; subcommands list,
update, run). The CLI creates scheduled
triggers only; API and GitHub triggers are added on the web. Schedule
presets are hourly, daily, weekdays, and weekly, with custom cron via
/schedule update, and the one-hour minimum is enforced: a
denser cron expression is rejected. One-off runs auto-disable after
firing, marked Ran, and do not count against the daily routine run
cap.
The API trigger turns a routine into a webhook target:
POST https://api.anthropic.com/v1/claude_code/routines/<routine_id>/fire
Authorization: Bearer <token>
anthropic-beta: experimental-cc-routine-2026-04-01
anthropic-version: 2023-06-01
Content-Type: application/json
An optional text field rides in the body, and the
response reports type: "routine_fire" plus a
claude_code_session_id and
claude_code_session_url. The text does not
arrive as an instruction. It lands wrapped in a
<routine-fire-payload> block labeled as untrusted
data, and the routine’s saved prompt has to reference the payload for
the run to act on it, which is the right default for an endpoint anyone
holding the token can hit.
GitHub triggers cover two event categories, Pull request and Release,
with actions such as pull_request.opened and
pull_request.closed. PR filters run on Author, Title, Body,
Base branch, Head branch, Labels, Is draft, and Is merged, through
operators equals, contains, starts with, is one of, is not one of, and
matches regex, where the regex tests the entire field value.
Routines run with no permission-mode picker and no approval prompts,
so the compensating controls are structural. Pushes land only on
branches prefixed claude/ unless “Allow unrestricted branch
pushes” is enabled per repository, and in the default Trusted cloud
environment an outbound request to a non-allowlisted host fails with
HTTP 403 and the header
x-deny-reason: host_not_allowed.
One availability trap: /schedule hides itself. It
returns Unknown command: /schedule when you are
authenticated with a Console API key or a cloud provider, or when
DISABLE_TELEMETRY, DO_NOT_TRACK,
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, or
DISABLE_GROWTHBOOK is set. It requires a claude.ai
subscription login, and ANTHROPIC_API_KEY,
ANTHROPIC_AUTH_TOKEN, or an apiKeyHelper takes
precedence over that login, so a stray key exported in a shell profile
is enough to make the command vanish.
Which layer to use for which job
Repo events go to the GitHub Action, where a @claude
mention or a pull_request.opened trigger picks up work the
moment it exists. Clock work splits by machine state: sub-hourly checks
run in a local /loop while I am at the desk, and anything
that must run at 3 a.m. becomes a routine, because the cloud does not
care whether my laptop lid is open. The overhead is small; the docs
price background functionality (conversation summarization for
--resume, status commands like /usage) at
under $0.04 per session in tokens even when idle. One economic detail
worth planning around: prompt cache lives one hour on a subscription and
five minutes on an API key or once usage credits kick in, so a scheduled
run spaced wider than the cache lifetime pays cold-cache input prices on
each fire.
The work still gets reviewed. I read the results in the morning instead of producing them at night.