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 json

In-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: write

Secrets 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.