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).
Schedules are local time.
| Routine | Schedule | Group → branch | Prompt and output | Budget |
|---|---|---|---|---|
| smine-nightly | 03:00 | smine-nightly → claude-routines/smine-nightly-<date>-proposals and -batch, from main | One 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-review | none (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-fdesign | dispatch-only | own group → claude-routines/backlog-fdesign-<date> | /fdesign --nightly + the dispatched item: plan persisted to its plan dir, no approval, no /fimplement | $25 |
| backlog-idea | dispatch-only | own group | /idea --nightly + item: idea/idea.md record | $10 |
| backlog-concept | dispatch-only | own group | /concept --nightly + item: concept/concept.md, escalations held OPEN | $15 |
| backlog-fexplore | dispatch-only | own group | /fexplore --nightly + item: design/exploration.md | $20 |
| backlog-skillroutine-create | dispatch-only | own group | /skillroutine-create --nightly + item: plan in its plan dir, skill or routine files built, bootstrap after merge | $25 |
| spec-drift | Sun 04:00 | own group | /spec-drift --nightly cap: 40: reports/spec-drift/<date>.md | $20 |
| smine-budget | hourly at :07 | none: no worktree, branch or group lock | None (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) |
Each routine dir carries routine.json.
| Field | Meaning |
|---|---|
| kind | single, smine-nightly or budget: the runner flow. |
| group | Optional; lowercase letters, digits, -. Collapses the routine with its siblings on the Routines page (smine, backlog, skill-eval); without one it stands alone. |
| prompt | Its 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.
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).
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.
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.
Chain members (routines sharing a ROUTINE_GROUP) serialize on it; different groups run concurrently, never touching each other's worktrees or branches.
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.
| Outcome | Commit subject |
|---|---|
| Non-zero exit | Still committed, prefixed [failed]. |
| smine-nightly run whose summary reports failed stages | The 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 want | Do |
|---|---|
| Accept the whole chain | Merge the newest branch locally. |
| Discard a run | Delete its branch. |
| Accept part of the chain | Merge a middle branch. Branches at or below it prune next run; the tip chain, still containing their work, carries on. |
Never with force, never produced work unarchived.
| What | Handling |
|---|---|
| Nightly branches | git 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. |
discard | Deletes a ref only while it still names its base's commit (update-ref -d <ref> <sha>). |
| Foreign dir under the group path | A directory git does not list as a worktree: moved to <worktree root>/.stale-archive/<stamp>-<group>; the run goes on. |
| Worktrees | Plain git worktree remove. |
| Worktree left by a refused publish | Committed 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 main | Pruned with git branch -d. |
Chain whose newest branches hold only [failed] or [salvaged] commits | Skipped, 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. |
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.
The exception to the chain: every night cuts its own branches from main; the open-branch cap never applies.
claude-routines/smine-nightly-<date>-proposals from main. A run finding no unmined session in peek's window ends before cutting and drops nothing.
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.
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.
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.
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 commit | Rule |
|---|---|
| Trailers | Smine-Change: <kind>/<slug> and Smine-Sessions: <ids>; a judge fix carries the fixed change's session ids. |
| Task file | The 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 cap | Over 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. |
| Card | Every file's added and deleted lines and the commit's totals. |
| Proposal event | Result |
|---|---|
You pick it onto main | Accepted; the Proposals page's button runs smine-agentwt pick --onto main <sha>. |
| You Reject it on the Proposals page | refs/smine/rejected/<sha>. |
Older than SMINE_PROPOSAL_WINDOW_NIGHTS mined nights (default 2), or its dry-run pick onto main fails | Dropped at the next run: refs/smine/dropped/<sha>, under the Proposals page's History tab; the emptied branch is deleted. |
| Proposals branch fully settled | Deleted at run end. |
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.
| Key | Form field | Default | Effect |
|---|---|---|---|
| ROUTINE_CONTAINER | Docker container (off / on) | off | 1 runs every chat cell of the run in a container; anything but empty, 0 or 1 stops the run, exit 78. |
| ROUTINE_CONTAINER_MOUNTS | Container 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_CPUS | Container CPUs | 4 | docker run --cpus |
| ROUTINE_CONTAINER_MEMORY | Container memory | 5g | docker run --memory |
| Topic | Rule |
|---|---|
| What moves inside | Every 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. |
| Prerequisites | A 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 image | Built 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 mounts | A 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 validation | Names 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 inside | gh, 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. |
| Network | A 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 worktrees | The 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. |
Remove a running cell's -firewall container: the cell loses its network with it.
Most knobs are plist EnvironmentVariables keys, editable in the Configure form.
| Knob | Where | Takes effect |
|---|---|---|
| Schedule | StartCalendarInterval in <routine>/com.smine.routine.<name>.plist | After re-bootstrap (below) |
| Model, budget, permission mode | ROUTINE_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, timeouts | Code in internal/routinerun (nightly.go, single.go) | After make build |
| Prompt / wrapped skill | prompt in routine.json; the smine-nightly stages build theirs in nightly.go; kind budget has none. | Next run |
| Run backstop | ROUTINE_TIMEOUT_S (default 46800 s); on expiry the runner TERMs every cell and writes a 124 row. | Next run |
| Group membership | ROUTINE_GROUP=<group>, default the routine's own name. | Next run; starts a new claude-routines/<group>-<date> lineage |
| Worktree root / branch prefix | ROUTINE_WT_ROOT / ROUTINE_BRANCH_PREFIX env overrides; the dated suffix is always appended. | Next run |
| Max open branches | ROUTINE_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 params | Plist 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 limits | ROUTINE_CONTAINER, ROUTINE_CONTAINER_MOUNTS, ROUTINE_CONTAINER_CPUS, ROUTINE_CONTAINER_MEMORY (Container routines) | Next run |
| Run every N days | ROUTINE_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
Nothing runs without these three.
~/.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.
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.
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>
Start at results.jsonl, then stderr, then the branch.
| Where | What it shows |
|---|---|
| <routine>/results.jsonl | One 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.json | The 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.log | Wrapper 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-mcp | The 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. |
| Topic | Rule |
|---|---|
| Steer dir | Durable, 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 access | The 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 run | routine bootstrap deploys context packs after its stages; the runner publishes the style profile after a successful style stage. |
| Chat session | Every 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 delivery | Only 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 cells | End 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 cells | merge-resolve: end at their first turn end with no pending steer. |
| Stage deadline | The pump enforces it on active time; paused (SIGSTOPped) periods no longer exhaust it. |
| Action | Effect |
|---|---|
| Kill | Terminates 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. |
Reproduce the run by hand, then read its exit code and the knobs that shaped it.
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>
| Code | Meaning | Logged as |
|---|---|---|
| 78 | Precondition: token, manifest, plist. | Token failure: a stage-less error row. Invalid manifest, plist or ROUTINE_MODEL: stderr only. |
| 75 | Group lock timeout (2 h); for a container routine also container slot timeout (no free slot in 2 h). | Stage-less error row |
| 69 | Docker unavailable, or the cell boundary failed: daemon unreachable, image build, -firewall container, firewall or relays. Row reason container: …. | Stage-less error row |
| 70 | Worktree 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 |
| 124 | Run backstop (ROUTINE_TIMEOUT_S). | Stage-less error row |
| 143 | Killed (signal). | Row appended by the server on Kill |
0, is_error: true | The agent itself reported failure. | results.jsonl |
| 0, no results line | ROUTINE_MAX_OPEN_BRANCHES reached, like the cadence skip. | open-instance limit reached in .err.log |
| Symptom | Means |
|---|---|
| "already running" | The routine's own .lock: self-overlap. |
| Group lock timeout | A 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. |
The run fails when the claude JSON envelope reports is_error or non-success, not only on a non-zero exit.
| Knob | Default | Effect |
|---|---|---|
| SMINE_MAX_PROPOSALS_MINED | 5 | Caps the change tasks each mining stage (memory, context, permissions, acdsl, problems) writes per run; passed as --cap. |
| SMINE_MAX_SESSIONS_MINED | 30 | Caps the sessions smine-batch mines per run, oldest first within peek's 13-day window, one miner agent each; passed as --limit. |
| ROUTINE_ORCHESTRATE | 1 | Adds the judge stage and gates landing. |
| ROUTINE_AUTO_MERGE | profile | Overrides the profile's auto-merge; 0 is off. |
| SMINE_DEV_MODE | profile | Overrides the profile's dev mode. |
| SMINE_AGENTS | claude,codex | Whose sessions smine-batch mines; passed as --agents. |
| SMINE_SUBAGENTS | 0 | 1 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.
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/...
Upstream docs: headless, cli-reference, sessions, auth, settings.
| Mechanism | Runs where | Survives sleep / app close | Min interval | Local files | Nightly fit |
|---|---|---|---|---|---|
| /loop (in-session) | open CLI session | no (unless backgrounded) | 1m, 7-day expiry, ≤30m jitter | yes | No: intra-session polling only |
| Desktop scheduled tasks | local, Desktop app | restart yes; needs app open and awake; 1 catch-up after wake | 1m | yes | Yes, if Desktop is reliably open |
| launchd + claude -p (chosen) | local, no app | yes | any | yes | Yes: true unattended |
| Cloud routines (/schedule) | Anthropic cloud | machine-independent | 1h | no | No for local maintenance |
| # | Stage | What happens |
|---|---|---|
| 1 | Start | claude -p 'prompt' or claude -p '/skill args'. |
| 2 | Not --bare | Loads the settings chain, CLAUDE.md hierarchy, auto-memory, skills, plugins, MCP and hooks. |
| 2 | --bare | Flags only: no hooks, skills, CLAUDE.md or .mcp.json; project .claude/skills/ stay available. Auth: ANTHROPIC_API_KEY or apiKeyHelper only. |
| 3 | Agentic loop | Hooks fire when not bare: SessionStart, UserPromptSubmit, Pre/PostToolUse, Stop, Pre/PostCompact, SessionEnd. PermissionRequest does not fire; use PreToolUse. |
| 4 | Session file | JSONL at ~/.claude/projects/<proj>/<id>.jsonl; suppress with --no-session-persistence. |
| 4 | Output | stdout: text, json or stream-json; exit 0 or non-zero. |
| 5 | Next night | --resume <id> or --continue restores the full context, which grows each run; compact via /compact in the prompt. |
| Credential | Non-bare -p | --bare | launchd |
|---|---|---|---|
| Keychain OAuth (from /login) | works interactively | ignored | No: no keychain in launchd |
| CLAUDE_CODE_OAUTH_TOKEN (claude setup-token, 1-year) | works | ignored | Yes, best for subscription. Multi-account via ~/.config/claude-routine/tokens/<label> plus per-routine ROUTINE_TOKEN. |
| ANTHROPIC_API_KEY | works | required | Yes (API billing) |
| apiKeyHelper script (+ CLAUDE_CODE_API_KEY_HELPER_TTL_MS) | works | works | Yes: vault-backed rotation |
Subscription-billed nightly jobs must run non-bare, which also gives them skills, hooks and MCP.
| Flag | Use |
|---|---|
| --output-format json | Result envelope: session_id, total_cost_usd, usage, num_turns, structured_output. |
| --json-schema <schema> | Enforced structured output. |
| --allowedTools / --disallowedTools | Claude Code's tool allowlist (above settings rules, below managed); the tool-policy gate decides first (below). |
| --permission-mode acceptEdits|dontAsk|bypassPermissions | Prompt-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-usd | Depth cap / spend cap; both exit non-zero on hit. |
| --fallback-model a,b | Ordered fallback on overload. |
| --resume <id> / --continue | Stateful 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-json | Multi-turn NDJSON protocol (below). |
| --append-system-prompt-file | Injects 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).
A driving script talks NDJSON, one message per line, to claude -p --input-format stream-json --output-format stream-json.
| # | Direction | Message |
|---|---|---|
| 1 | script → claude | {"type":"user","content":"task 1"} plus newline |
| 2 | claude → script | {"type":"system","subtype":"init","session_id":...} |
| 3 | claude → script | {"type":"assistant",...} and tool events |
| 4 | claude → script | {"type":"result","total_cost_usd":...,"num_turns":...} |
| 5 | script → claude | {"type":"user","content":"follow-up"} plus newline |
| 6 | claude → script | The next turn's events and result. |
| 7 | script → claude | EOF: the process exits. |
Envelope types: system (init, api_retry), user, assistant, result, stream_event (with --include-partial-messages, v2.1.205+).
The full schema is not officially documented (#24612 open). Pin the CLI version if you build on it.
| Concern | Handling |
|---|---|
| Transient API errors (429/529/5xx) | Auto-retried with backoff internally. |
| Auth expiry | Not retried: exits non-zero; the 1-year setup-token minimizes it. |
| Exit codes | 0 or non-zero only, no per-error map; parse stderr and the result subtype (error_during_execution, error_max_turns). |
| Run timeout | None 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+). |
| Overlap | The 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 env | Explicit PATH (/opt/homebrew/bin), HOME, no ~ expansion, StandardOut/ErrorPath logs, StartCalendarInterval. |
| Stability | DISABLE_AUTOUPDATER=1. |
| Isolation | Host 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).
The same runner and manifests run under Task Scheduler.
| Topic | Detail |
|---|---|
| Scheduler | Task 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. |
| Launcher | Task 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). |
| Install | smine-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, Resume | Pause 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. |
| Kill | Ends 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 routines | Refused: 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 messaging | Refused: heartbeat's messaging sockets are unix-only, so fleet subscriptions fail on Windows (Heartbeat § Fleets). |