Routines

Routines are local scheduled jobs running headless claude -p through the Go runner bin/routine, under launchd on macOS and Task Scheduler on Windows.

The runner is cmd/routine and internal/routinerun. Generic claude -p mechanics (flags, auth options, stream-json): claude -p mechanics. Author a new routine with /skillroutine-create (routine route).

What runs

Schedules are local time.

RoutineScheduleGroup → branchPrompt and outputBudget
smine-nightly03:00smine-nightly → claude-routines/smine-nightly-<date>-proposals and -batch, from mainOne dispatcher session: /smine --nightly --stages [style],batch,memory,context,permissions,acdsl,problems,implement,consolidate,[judge] (smine-nightly).$300 per run (ROUTINE_MAX_BUDGET_USD, workflow agents included)
skill-eval-railroad-reviewnone (Run Now)skill-eval-railroad-review → claude-routines/skill-eval-railroad-review-<date>One dispatcher session: /skillroutine-ab railroad-review variant: model:sonnet. Baseline: railroad-review's eval/definition.json (opus, high; repo smine via the registry ~/.claude/smine/repos.json; armUsd per side); variant: the same with sonnet. Both sides run as claude -p roots via go run ./cmd/skilleval arm. The A/B dir evals/railroad-review/ab-<date>-<hash6>/ is committed on the dated branch.No dispatcher cap; each side's arms at the preset armUsd
backlog-fdesigndispatch-onlyown group → claude-routines/backlog-fdesign-<date>/fdesign --nightly + the dispatched item: plan persisted to its plan dir, no approval, no /fimplement$25
backlog-ideadispatch-onlyown group/idea --nightly + item: idea/idea.md record$10
backlog-conceptdispatch-onlyown group/concept --nightly + item: concept/concept.md, escalations held OPEN$15
backlog-fexploredispatch-onlyown group/fexplore --nightly + item: design/exploration.md$20
backlog-skillroutine-createdispatch-onlyown group/skillroutine-create --nightly + item: plan in its plan dir, skill or routine files built, bootstrap after merge$25
spec-driftSun 04:00own group/spec-drift --nightly cap: 40: reports/spec-drift/<date>.md$20
smine-budgethourly at :07none: no worktree, branch or group lockNone (kind budget, no claude cell). Archives transcripts and captures peek, ccusage and context into the usage store ~/.claude/smine/usage; writes a result row only when it took a session or a source failed.No claude spend; 30-minute deadline (ROUTINE_TIMEOUT_S 1800)

routine.json and the Backlog board

Each routine dir carries routine.json.

FieldMeaning
kindsingle, smine-nightly or budget: the runner flow.
groupOptional; lowercase letters, digits, -. Collapses the routine with its siblings on the Routines page (smine, backlog, skill-eval); without one it stands alone.
promptIts leading /skill is the skill the routine declares; a budget routine has none and declares no skill.

The config server's Backlog board dispatches an item as Run Now of the routine declaring the item's route skill. The route args, B-NNN: <title>, the board's extra prompt and the item description are appended to the routine's prompt, ROUTINE_EXTRA_PROMPT between them. An item whose skill no routine declares shows ROUTINE MISSING until one is authored with /skillroutine-create routine; a single routine is routine.json plus plist, no code.

Groups and dated branches

Each group owns one worktree, ~/.cache/claude-routine/worktrees/<group>, and one lineage of dated branches claude-routines/<group>-<date>; -2, -3, … tell same-day runs apart. smine-nightly differs (below).

  1. Pick the base

    Every run mints a fresh branch, never reusing one: on the newest un-merged dated branch (the chain tip), so runs stack linearly, or on main when none survives.

  2. Sync with main

    A chain-based run first syncs: a clean git merge main, or on conflicts an unattended /merge-resolve claude run in the worktree, gated by a clean tree and a conflict-free git merge-tree --write-tree main HEAD. A sync that cannot reach a mergeable state fails the run, chain untouched.

  3. Run under the group lock

    Chain members (routines sharing a ROUTINE_GROUP) serialize on it; different groups run concurrently, never touching each other's worktrees or branches.

  4. Commit locally

    Output is committed on the dated branch: no push, no PR. Every result is mergeable into main as of run start; a conflict at accept time only means main moved since.

OutcomeCommit subject
Non-zero exitStill committed, prefixed [failed].
smine-nightly run whose summary reports failed stagesThe pipeline finished, so the subject names those stages instead: [stage-failed: style, implement]. Exit 70 all the same.
Salvaged leftover worktree[salvaged], on its own branch (below).

Publish, the nightly publish, the fleet worker's land and leftover-worktree salvage strip ACDSL projection blocks before staging, so a tree projected with acdsl project -file still passes the pre-commit guard.

You wantDo
Accept the whole chainMerge the newest branch locally.
Discard a runDelete its branch.
Accept part of the chainMerge a middle branch. Branches at or below it prune next run; the tip chain, still containing their work, carries on.

What the runner deletes

Never with force, never produced work unarchived.

WhatHandling
Nightly branchesgit branch -D: a -proposals branch (never an ancestor of main) once every change on it is picked, rejected or dropped; a -batch branch once its records are on main; the -landing scratch branch. Before each -D the branch's diff against main goes into the trash plus a journal line with its sha; a failed archive keeps the branch.
discardDeletes a ref only while it still names its base's commit (update-ref -d <ref> <sha>).
Foreign dir under the group pathA directory git does not list as a worktree: moved to <worktree root>/.stale-archive/<stamp>-<group>; the run goes on.
WorktreesPlain git worktree remove.
Worktree left by a refused publishCommitted as [salvaged] on its own branch at the next create, only then removed. If salvage or removal fails, the run exits 70 and the worktree stays.
Dated branches merged into mainPruned with git branch -d.
Chain whose newest branches hold only [failed] or [salvaged] commitsSkipped, not deleted: the next run stacks on the newest branch with reviewable output (or main); the skipped ones stay for you to review, merge or delete. A [stage-failed: …] commit is reviewable output, not skipped.
Cap

ROUTINE_MAX_OPEN_BRANCHES (unset by default) caps a group's open un-merged dated branches. At the cap a run creates no branch, logs to stderr and exits 0: merge one first.

smine-nightly

The exception to the chain: every night cuts its own branches from main; the open-branch cap never applies.

  1. Cut the proposals branch

    claude-routines/smine-nightly-<date>-proposals from main. A run finding no unmined session in peek's window ends before cutting and drops nothing.

  2. Run the pipeline

    One /smine dispatcher session. Style runs only if a plan or backlog-store change landed since the last style run; judge runs unless ROUTINE_ORCHESTRATE=0. Miners write change tasks; implement runs /fimplement per task, one commit per change; consolidate makes every change commit pick cleanly onto main alone.

  3. End on the marker

    When /smine writes run-complete.json in the group's steer dir: per-stage rows, the judge's verdict per change commit, the run verdict.

  4. Land the session records

    At publish the runner commits them on claude-routines/smine-nightly-<date>-batch and lands them on main at once with smine-agentwt pick; a refused pick retries at the next night's start. So the Sessions page shows yesterday's records every morning. The next night restores sessions/ from the newest open batch into its files, never the index.

  5. Land the changes

    On a judge-accepted run with auto-merge on, the runner picks every ready change onto a scratch branch, moves main only if all applied, then deploys. Nothing is pushed; no agent touches the main checkout.

Change commitRule
TrailersSmine-Change: <kind>/<slug> and Smine-Sessions: <ids>; a judge fix carries the fixed change's session ids.
Task fileThe change's problem, evidence and fix: problem one sentence ≤ 160 runes, fix ≤ 240, at most 3 quotes ≤ 160 characters, each naming its record finding. smine-rules task-check checks it on write and before the dispatcher spawns its implementer. The runner validates it against the same schema and copies it into the run report's tasks; the page reads it there, falling back to the commit body only for a change without a record.
Proposal capOver 7 files or 420 changed lines: no proposal. Before publish the runner files it as an Icebox backlog row (commit <sha> as first evidence) and writes its rejected ref; the report row reads rejected — over the proposal cap: <n> files, <m> lines → B-NNN.
CardEvery file's added and deleted lines and the commit's totals.
Proposal eventResult
You pick it onto mainAccepted; the Proposals page's button runs smine-agentwt pick --onto main <sha>.
You Reject it on the Proposals pagerefs/smine/rejected/<sha>.
Older than SMINE_PROPOSAL_WINDOW_NIGHTS mined nights (default 2), or its dry-run pick onto main failsDropped at the next run: refs/smine/dropped/<sha>, under the Proposals page's History tab; the emptied branch is deleted.
Proposals branch fully settledDeleted at run end.

Container routines

A routine whose agent must not reach the rest of the machine runs in Docker. Keys are per routine, set in the config server's Configure form, which writes the plist's EnvironmentVariables; a budget routine starts no cell, so they mean nothing there.

KeyForm fieldDefaultEffect
ROUTINE_CONTAINERDocker container (off / on)off1 runs every chat cell of the run in a container; anything but empty, 0 or 1 stops the run, exit 78.
ROUTINE_CONTAINER_MOUNTSContainer mounts (one row per registered repo: none / read / read-write)none<repo>:ro or <repo>:rw, comma-separated, repo names from ~/.claude/smine/repos.json
ROUTINE_CONTAINER_CPUSContainer CPUs4docker run --cpus
ROUTINE_CONTAINER_MEMORYContainer memory5gdocker run --memory
TopicRule
What moves insideEvery chat cell, the /merge-resolve sync cell included, runs as docker run of the image smine-routine-cell. The runner stays on the host: locks, worktree create and sync, publish, results.jsonl, the store commit and landing never enter a container. The cell runs as your uid at your home path, so bind mounts keep their absolute path and owner; unmounted $HOME paths and /tmp are container-local. macOS and Linux only; on Windows the run exits 78.
PrerequisitesA running Docker daemon and the docker CLI. The runner adds /usr/local/bin (Docker Desktop's CLI links, missing from launchd's PATH) to its PATH and probes the daemon before the first cell, never starting it. No CLI or an unreachable daemon: exit 69.
The imageBuilt once per run, just before its first container cell (after the started row, so the routine shows running), from cmd/routine/container/Dockerfile in the main checkout. Holds the claude CLI pinned to the host's claude --version, peek-mcp at PEEK_MCP_VERSION, the deployed hook binaries plus smine-rules, smine-routine and smine-agentwt built for Linux, and the Go/Rust toolchain the make targets need. Docker's layer cache is the freshness check: an unchanged checkout costs one cached build, the first build takes minutes. Build log: image-build.log in the group's steer dir; a failed build exits 69.
Fixed limits--pids-limit 4096 per cell. At most four container cells machine-wide: each takes one of four slot lock files under ~/.cache/claude-routine/container/slots/, waiting up to 2 h, then exits 75 container slot timeout. ROUTINE_CONTAINER_ROOT overrides ~/.cache/claude-routine/container, which holds the slot locks and is never mounted.
Repo mountsA container sees its own repo's .git, worktree pool and run worktree, its repo's backlog store <store>/backlog (always, read-write), and the repos its mounts name, each at its host path. A skill writing the proposal store or running /plan-archive needs the plans-archive repo mounted read-write, registered like any repo; skills with @repos grants need the sibling repos they read. A mount only makes the path exist; the tool-policy gate still decides access.
Mount validationNames resolve against the registry before the worktree exists; an unregistered name, the run's own repo or a missing path stops the run, exit 78. The form rejects the first two on save and shows a stored name the registry lost as "(not mountable)".
What is absent insidegh, push credentials (no gitconfig; the runner passes your git identity as GIT_AUTHOR_*/GIT_COMMITTER_*), smine-heartbeat, IDE MCP servers, every macOS command. MCP is the registered peek-mcp entry, passed by flag.
NetworkA cell reaches only heartbeat's and peek-control's host ports (heartbeat.port, peek.control_port of the install record), through a second container per cell, smine-cell-<group>-firewall, whose network the cell joins without any capability. Its firewall rejects every other host-gateway port; two socat relays forward 127.0.0.1:<port> to the host. The cell mounts the deployed settings.json unchanged: Claude Code refuses HTTP hooks to private addresses, so hooks post to loopback and the relay carries them out.
Isolation worktreesThe main checkout's .git and .claude/worktrees are mounted read-write at their host paths, so a cell creates isolation worktrees in the main checkout's pool as on the host, the tool-policy gate confining each agent to its own. Heartbeat and agentwt see a container session through ~/.claude/smine/container-sessions/<pid>.json, written by the runner under the docker client's pid.
Never

Remove a running cell's -firewall container: the cell loses its network with it.

What you can adjust, and where

Most knobs are plist EnvironmentVariables keys, editable in the Configure form.

KnobWhereTakes effect
ScheduleStartCalendarInterval in <routine>/com.smine.routine.<name>.plistAfter re-bootstrap (below)
Model, budget, permission modeROUTINE_MODEL, ROUTINE_MAX_BUDGET_USD, ROUTINE_PERMISSION_MODE; optional: empty means the settings.json model and defaultMode, no cap (invocation parity, context/routines.md).Next run
Per-stage prompts, efforts, timeoutsCode in internal/routinerun (nightly.go, single.go)After make build
Prompt / wrapped skillprompt in routine.json; the smine-nightly stages build theirs in nightly.go; kind budget has none.Next run
Run backstopROUTINE_TIMEOUT_S (default 46800 s); on expiry the runner TERMs every cell and writes a 124 row.Next run
Group membershipROUTINE_GROUP=<group>, default the routine's own name.Next run; starts a new claude-routines/<group>-<date> lineage
Worktree root / branch prefixROUTINE_WT_ROOT / ROUTINE_BRANCH_PREFIX env overrides; the dated suffix is always appended.Next run
Max open branchesROUTINE_MAX_OPEN_BRANCHES for a single routine's group; empty means unlimited. The nightly ignores it: its proposals age out after SMINE_PROPOSAL_WINDOW_NIGHTS mined nights.Next run
Per-routine paramsPlist EnvironmentVariables; the Routines page's Configure form (one input per plist-declared key) rewrites the plist and re-bootstraps.After the form's re-bootstrap
Docker container, its mounts and limitsROUTINE_CONTAINER, ROUTINE_CONTAINER_MOUNTS, ROUTINE_CONTAINER_CPUS, ROUTINE_CONTAINER_MEMORY (Container routines)Next run
Run every N daysROUTINE_CADENCE_DAYS: launchd keeps its daily schedule; the runner gates on a <routine>/.cadence-stamp file, skipping until N days passed. Unset or 1: every scheduled run.Next run

Plist changes reload from the main checkout, never a worktree:

launchctl bootout gui/$(id -u)/com.smine.routine.<name>
launchctl bootstrap gui/$(id -u) <repo-root>/routines/<name>/com.smine.routine.<name>.plist

Prerequisites

Nothing runs without these three.

  1. Token

    ~/.config/claude-routine/token, non-empty, from claude setup-token (1-year OAuth; strip whitespace when pasting). Missing or empty: exit 78 before anything runs. Never cat it; check with [[ -s ]].

    Multiple accounts: labeled tokens on the Profile page (Account tokens, stored 0600 under ~/.config/claude-routine/tokens/<label>, stripped server-side, never displayed), picked per routine by the Token (account) setting, ROUTINE_TOKEN; unset means the legacy file. A selected but missing token exits 78 naming tokens/<label>, never falling back to another account. Delete a token by removing its file.

  2. Tools

    bin/routine built in the main checkout (make build; routine code changes need a rebuild), claude on PATH (the runner prepends /opt/homebrew/bin if missing), and git. The Routines component (bin/configserver -apply) copies the runner into the smine bin dir: ~/go/bin/smine-routine, on Windows %LOCALAPPDATA%\smine\bin\smine-routine.exe.

    claude-code ≥ 2.1.217 for CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS; older builds implement only CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY. The runner probes the installed binary at run start and logs a WARNING: to .err.log per configured knob it does not implement.

  3. Bootstrap

    The configserver bootstraps at startup every routine not degraded, already loaded or stopped via the UI. Stop persists through launchctl disable; Start re-enables. The configserver runs as a LaunchAgent, so a fresh login re-loads all enabled routines. Manual launchctl bootstrap (above) is only for ad-hoc tests without the server.

    Verify by exit code, not output: 0 loaded, 113 not loaded.

    launchctl print gui/$(id -u)/<label>

Observing a run

Start at results.jsonl, then stderr, then the branch.

WhereWhat it shows
<routine>/results.jsonlOne line per run: timestamp, exit_status, session_id, num_turns, total_cost_usd, the first 300 chars of the agent's result, branch (the dated branch) and, for a Backlog dispatch, item (B-NNN). A started row lands once the worktree exists; the run's next row supersedes it. First stop for "did last night work".
~/GolandProjects/agent-plans/<repo>/backlog/backlog.jsonThe live backlog store (<repo> = the main checkout's directory name). A dispatched run writes the item's link (its dated branch) and the dispatch stage once its claude stage ends, before publish; the started row carries item and branch from the start, no merge applies them. After each publish the runner commits the store dirs in the plans-archive repo. The Backlog board and item page read the item rows and poll while a run is live.
~/Library/Logs/claude-routine-<name>.out.log, .err.logWrapper stdout and stderr; read stderr first.
launchctl print gui/$(id -u)/<label>launchd state. Classify by the runs and last exit code counters, never the state string: a finished job always reports state = not running.
git log main..claude-routines/<group>-<date>The work, in the group's repo; list the lineage with git branch --list 'claude-routines/<group>-*'.
peek-mcpThe full transcript, by the session_id from results.jsonl.
~/.cache/claude-routine/worktrees/<group>/Run-scoped scratch: the .cell-pids registry lives in the group worktree, never the routine dir, and goes with the worktree. The runner's own log goes to launchd's StandardOutPath/StandardErrorPath.

Steering

TopicRule
Steer dirDurable, per group: ~/.cache/claude-routine/steer/<group>/ (root override ROUTINE_STEER_ROOT), surviving worktree teardown. Holds steer-prompt, the steer-delivered receipt, steer-log.jsonl, the completion markers run-complete (single routines) and run-complete.json (smine-nightly), and the chat pump's stream.jsonl and pump.log.
Gate accessThe steer root is a settings additionalDirectories entry, so the tool-policy gate allows the model's marker Write; an override root outside ~/.cache/claude-routine/steer must be added too. Registered repositories are never additional directories.
Deploys from a runroutine bootstrap deploys context packs after its stages; the runner publishes the style profile after a successful style stage.
Chat sessionEvery claude stage is a stream-json chat session (internal/routinerun/chat.go); the runner holds stdin open, so the run's lifetime never depends on the model's tool use.
Steer deliveryOnly at a turn boundary, as a real user message, never an interrupt (which would abort in-flight subagents); only to the top-level dispatcher session, never a subagent. A run start clears an undelivered steer; the panel then shows it dropped.
Marker cellsEnd on the model-written marker. A turn ending without it, no background task running, gets one reminder to write the marker with the content the cell's own instructions specify; the next marker-less turn end closes the cell. The last background task's end is no turn end: the harness opens a task-notification turn for it.
Result cellsmerge-resolve: end at their first turn end with no pending steer.
Stage deadlineThe pump enforces it on active time; paused (SIGSTOPped) periods no longer exhaust it.

Kill, Pause and Resume (Routines UI)

ActionEffect
KillTerminates the active run: the scheduler root's process tree (launchd on macOS, the task's routine.exe on Windows) plus the detached cell trees from .cell-pids; the server appends the 143 row to results.jsonl itself. No live run: no active run, nothing appended. The schedule stays loaded; a pending config reload applies once the run is dead. The runner exits 143 on the signal and TERMs every registered group.
Pause, Resume (container routine)Pause also freezes the run's containers (docker pause on those labelled smine.routine=<name>); Resume unfreezes them.
Kill (container routine)Unpauses the containers first; the runner then removes the cell and its -firewall container (docker rm --force) on its way out. Containers a hard-killed runner leaves are removed at the group's next cell start.

Debugging

Reproduce the run by hand, then read its exit code and the knobs that shaped it.

Run one by hand

From the main checkout, bin/routine run takes launchd's code path, the plist's EnvironmentVariables filling every key the shell leaves unset; launchctl kickstart runs it with launchd's environment instead.

bin/routine run routines/<name>
launchctl kickstart gui/$(id -u)/<label>

Exit codes

CodeMeaningLogged as
78Precondition: token, manifest, plist.Token failure: a stage-less error row. Invalid manifest, plist or ROUTINE_MODEL: stderr only.
75Group lock timeout (2 h); for a container routine also container slot timeout (no free slot in 2 h).Stage-less error row
69Docker unavailable, or the cell boundary failed: daemon unreachable, image build, -firewall container, firewall or relays. Row reason container: ….Stage-less error row
70Worktree create failed, including a registered leftover worktree that could not be salvaged or removed; the row reason carries the cause. A foreign dir at the group path is archived and the run goes on (What the runner deletes, above). A worktree vanishing mid-run fails only the publish; the run keeps its cell's status.Stage-less error row
124Run backstop (ROUTINE_TIMEOUT_S).Stage-less error row
143Killed (signal).Row appended by the server on Kill
0, is_error: trueThe agent itself reported failure.results.jsonl
0, no results lineROUTINE_MAX_OPEN_BRANCHES reached, like the cadence skip.open-instance limit reached in .err.log

Locks and leftovers

SymptomMeans
"already running"The routine's own .lock: self-overlap.
Group lock timeoutA chain sibling held claude-routines/<group> too long.
Leftover worktree under ~/.cache/claude-routine/worktrees/<group>Intentional after a refused publish: kept for inspection, commit body included; the group's next create commits it as [salvaged] and removes it unforced. Only that group's own path is ever swept, never a sibling group's.

smine-nightly knobs

The run fails when the claude JSON envelope reports is_error or non-success, not only on a non-zero exit.

KnobDefaultEffect
SMINE_MAX_PROPOSALS_MINED5Caps the change tasks each mining stage (memory, context, permissions, acdsl, problems) writes per run; passed as --cap.
SMINE_MAX_SESSIONS_MINED30Caps the sessions smine-batch mines per run, oldest first within peek's 13-day window, one miner agent each; passed as --limit.
ROUTINE_ORCHESTRATE1Adds the judge stage and gates landing.
ROUTINE_AUTO_MERGEprofileOverrides the profile's auto-merge; 0 is off.
SMINE_DEV_MODEprofileOverrides the profile's dev mode.
SMINE_AGENTSclaude,codexWhose sessions smine-batch mines; passed as --agents.
SMINE_SUBAGENTS01 also mines each session's per-subagent transcripts (peek-mcp subagent parameter); passed as --subagents. On/off, not a count: the configure widget rejects other values and the runner logs them as ignored.

In results.jsonl the stage is pipeline, the one /smine dispatcher session. The run report lists the dispatcher row (cost and turns), one row per pipeline stage from the run summary, and changes: the judge's verdict per change commit (ready, or rejected with a reason).

A missing or invalid run summary fails the run with a rejected verdict. The branch commit and the store commit (backlog dir only) happen whatever the verdict; it gates only the fast-forward of main. The style stamp routines/smine-nightly/.style-stamp (epoch, gitignored) records the last style run whose summary row is ok.

Regression tests

They cover the worktree chain, the two-night smine lineage and ledger seeding, the chat pump (steer, deadline, cancel), cells, knob mapping, and end-to-end single, nightly and landing runs against a fake claude.

go test ./internal/routinerun/...

claude -p mechanics

Upstream docs: headless, cli-reference, sessions, auth, settings.

Scheduling options (decision)

MechanismRuns whereSurvives sleep / app closeMin intervalLocal filesNightly fit
/loop (in-session)open CLI sessionno (unless backgrounded)1m, 7-day expiry, ≤30m jitteryesNo: intra-session polling only
Desktop scheduled taskslocal, Desktop apprestart yes; needs app open and awake; 1 catch-up after wake1myesYes, if Desktop is reliably open
launchd + claude -p (chosen)local, no appyesanyyesYes: true unattended
Cloud routines (/schedule)Anthropic cloudmachine-independent1hnoNo for local maintenance

-p run lifecycle

#StageWhat happens
1Startclaude -p 'prompt' or claude -p '/skill args'.
2Not --bareLoads the settings chain, CLAUDE.md hierarchy, auto-memory, skills, plugins, MCP and hooks.
2--bareFlags only: no hooks, skills, CLAUDE.md or .mcp.json; project .claude/skills/ stay available. Auth: ANTHROPIC_API_KEY or apiKeyHelper only.
3Agentic loopHooks fire when not bare: SessionStart, UserPromptSubmit, Pre/PostToolUse, Stop, Pre/PostCompact, SessionEnd. PermissionRequest does not fire; use PreToolUse.
4Session fileJSONL at ~/.claude/projects/<proj>/<id>.jsonl; suppress with --no-session-persistence.
4Outputstdout: text, json or stream-json; exit 0 or non-zero.
5Next night--resume <id> or --continue restores the full context, which grows each run; compact via /compact in the prompt.

Auth for headless

CredentialNon-bare -p--barelaunchd
Keychain OAuth (from /login)works interactivelyignoredNo: no keychain in launchd
CLAUDE_CODE_OAUTH_TOKEN (claude setup-token, 1-year)worksignoredYes, best for subscription. Multi-account via ~/.config/claude-routine/tokens/<label> plus per-routine ROUTINE_TOKEN.
ANTHROPIC_API_KEYworksrequiredYes (API billing)
apiKeyHelper script (+ CLAUDE_CODE_API_KEY_HELPER_TTL_MS)worksworksYes: vault-backed rotation
Consequence

Subscription-billed nightly jobs must run non-bare, which also gives them skills, hooks and MCP.

Key flags

FlagUse
--output-format jsonResult envelope: session_id, total_cost_usd, usage, num_turns, structured_output.
--json-schema <schema>Enforced structured output.
--allowedTools / --disallowedToolsClaude Code's tool allowlist (above settings rules, below managed); the tool-policy gate decides first (below).
--permission-mode acceptEdits|dontAsk|bypassPermissionsPrompt-free baseline. This repo's template disables bypass mode (disableBypassPermissionsMode), and container cells mount that settings.json read-only.
--permission-prompt-tool <mcp-tool>Programmatic permission handler (v2.1.199+): the headless answer to "ask" prompts.
--max-turns / --max-budget-usdDepth cap / spend cap; both exit non-zero on hit.
--fallback-model a,bOrdered fallback on overload.
--resume <id> / --continueStateful jobs. Also reopens a finished, failed or limit-stopped -p session, headless to continue it or from a terminal to salvage, compact or steer it.
--input-format stream-json + --output-format stream-jsonMulti-turn NDJSON protocol (below).
--append-system-prompt-fileInjects routine instructions without touching CLAUDE.md.
--settings <file> / --mcp-config <file>Per-routine config.

Permission precedence inside Claude Code: managed settings > --disallowedTools/--allowedTools > --permission-mode > settings allow/deny/ask > default. A blocked tool feeds an error back to Claude; the run continues.

Here the tool-policy gate answers every gated call first, from the settings layers and the active skill's allowed-tools; a call it denies never runs, whatever the flags (Permissions § Headless cells).

Skills: claude -p "/skill args" expands (v2.1.205+; the skill needs enable-model-invocation: true).

stream-json: local orchestration without the SDK

A driving script talks NDJSON, one message per line, to claude -p --input-format stream-json --output-format stream-json.

#DirectionMessage
1script → claude{"type":"user","content":"task 1"} plus newline
2claude → script{"type":"system","subtype":"init","session_id":...}
3claude → script{"type":"assistant",...} and tool events
4claude → script{"type":"result","total_cost_usd":...,"num_turns":...}
5script → claude{"type":"user","content":"follow-up"} plus newline
6claude → scriptThe next turn's events and result.
7script → claudeEOF: the process exits.

Envelope types: system (init, api_retry), user, assistant, result, stream_event (with --include-partial-messages, v2.1.205+).

Warning

The full schema is not officially documented (#24612 open). Pin the CLI version if you build on it.

Robustness for launchd/cron

ConcernHandling
Transient API errors (429/529/5xx)Auto-retried with backoff internally.
Auth expiryNot retried: exits non-zero; the 1-year setup-token minimizes it.
Exit codes0 or non-zero only, no per-error map; parse stderr and the result subtype (error_during_execution, error_max_turns).
Run timeoutNone built in; the runner enforces per-stage active-time deadlines and the ROUTINE_TIMEOUT_S backstop. Background-task grace: CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS (default 10m, v2.1.182+).
OverlapThe runner's self and group locks: flock on macOS, LockFileEx on Windows.
Cost--max-budget-usd when ROUTINE_MAX_BUDGET_USD is set, plus total_cost_usd per stage in results.jsonl.
launchd envExplicit PATH (/opt/homebrew/bin), HOME, no ~ expansion, StandardOut/ErrorPath logs, StartCalendarInterval.
StabilityDISABLE_AUTOUPDATER=1.
IsolationHost routines: none; they share ~/.claude and subscription auth via CLAUDE_CODE_OAUTH_TOKEN (never CLAUDE_CONFIG_DIR), and ROUTINE_PROTECTED_PATHS refuses writes into the main checkout. Container routines: every cell in a bounded, fenced container (Container routines).

Non-bare is deliberate: skills and the subscription token need it. New routines start from routines/_templates/routine.json and routine.plist (/skillroutine-create routine).

Windows

The same runner and manifests run under Task Scheduler.

TopicDetail
SchedulerTask Scheduler tasks under \smine\ (task name = plist label). The plist stays the manifest; the config server translates StartCalendarInterval to task XML at registration. Stop disables the task, never deletes it; Start re-registers with -Force and enables. Startup reconcile (SyncAll) registers new routines and refreshes enabled ones, leaving disabled ones alone.
LauncherTask action bin\routine.exe run "<routine-dir>". The runner fills the plist env, holds the self and group locks (LockFileEx), keeps the machine awake, puts itself in a kill-on-close job and arms the ROUTINE_TIMEOUT_S backstop (default 46800s).
Installsmine-setup.exe (prebuilt payload; the wizard lays down the source tree, never a clone), or from source install.bat (the Go installer); both delegate to configserver.exe -install for the shared logic, the logon tasks and routines. Port overrides: -addr, -peek-port, -peek-control-port.
Token%USERPROFILE%\.config\claude-routine\token (or tokens\<label>).
Logs%LOCALAPPDATA%\claude-routine\logs\<label>.{out,err}.log; Task Scheduler captures nothing, the runner redirects.
Pause, ResumePause finds the run as the task's routine.exe process and suspends its whole process tree (NtSuspendProcess); Resume resumes it. A run that ended while paused resumes as a no-op.
KillEnds the routine.exe tree, then every .cell-pids tree (TerminateProcess, exit 143); the count reported is the processes ended. The task stays registered and enabled.
Container routinesRefused: a cell needs identical host and container paths and the host uid, which a Windows host cannot give. The Configure form hides the Docker rows, a posted ROUTINE_CONTAINER=1 is refused, and a run with it set exits 78.
Fleet messagingRefused: heartbeat's messaging sockets are unix-only, so fleet subscriptions fail on Windows (Heartbeat § Fleets).