Schedule Claude subagents to run recurring tasks. Each job spawns claude -p as a cold worker, scores every run by pattern-matching its log, and auto-resolves prior failures when jobs stabilise.
Part of The Agent Crafting Table — standalone agent system components for Claude Code.
flowchart TD
A([cron-runner.js\ndaemon starts]) --> B[Load jobs.json]
B --> C{Hot-reload\nwatcher active}
C -->|every 60s| D[tick]
C -->|jobs.json\nchanged| B
D --> E[Check trigger\nfiles]
E -->|trigger file\nfound| F[Run job\nimmediately]
E -->|no trigger| G[cronMatches?\nschedule + tz]
G -->|no match| D
G -->|match| H{preCommand\ndefined?}
H -->|yes| I[Run preCommand\n15s timeout]
I -->|exit 0| J[Generate\nbrief]
I -->|non-zero\nexit| D
H -->|no| J
J --> K{runner type?}
K -->|shell| L[Spawn /bin/sh\nwith shellCommand]
K -->|model\ndefault| M[Spawn\nclaude -p]
L --> N[Stream stdout\nto log file]
M --> N
N --> O{HEARTBEAT_OK\nin output?}
O -->|yes| P[✅ success]
O -->|no| Q[⚠️ partial / fail]
P --> D
Q --> D
- Silent breakage — without structured scoring, broken cron jobs fail invisibly for weeks. The HEARTBEAT_OK convention +
cron-score.jsflips this. - Cold subagent problem —
claude -pworkers have no memory.cron-brief.jsprepends a short context header so they know the date and what's in flight. - "Re-fixing" resolved failures —
cron-resolve.jsmarks failures as resolved after 2 consecutive successes so automated patchers don't re-propose the same fix forever. - Patch regression —
cron-eval.jsblocks any prompt patch that removes known success markers or doesn't address the target failure mode.
src/
cron-runner.js # Scheduler daemon — ticks every minute, spawns claude -p
cron-brief.js # Context brief generator — prepended to every subagent prompt
cron-score.js # Log classifier — structured outcomes per run
cron-resolve.js # Auto-resolver — marks failures fixed after 2+ successes
cron-eval.js # Pre-flight gate — validates prompt patches before apply
examples/
jobs.json # Example job definitions
cron-eval.json # Example eval ruleset
start-cron.sh # Auto-restart wrapper
assets/
architecture.md # System design notes
- Node.js 18+
claudeCLI on$PATHand authenticated (API key or Max subscription)- No database, no network services
All five scripts are standalone — no npm install needed.
mkdir -p crons
cp examples/jobs.json crons/jobs.jsonEdit to add your jobs. Each job needs at minimum:
{
"id": "my-daily-report",
"name": "Daily Status Report",
"enabled": true,
"schedule": "0 9 * * *",
"tz": "America/New_York",
"timeoutSeconds": 300,
"message": "Check the system status and write a brief report to memory/daily-notes/. Reply HEARTBEAT_OK when done."
}Reply HEARTBEAT_OK when done. at the end of every prompt is the success signal. Jobs that don't include it will always score as partial/no_heartbeat.
node src/cron-runner.js
# or use examples/start-cron.sh for auto-restart on crash
bash examples/start-cron.shSchedule a daily job that runs scoring + resolution:
node src/cron-score.js --days=1
node src/cron-resolve.js --applySee the third entry in examples/jobs.json for how to wire this as a self-healing job.
flowchart LR
subgraph Gate["Pre-Spawn Gate"]
direction TB
P1{preCommand\nexits 0?}
P2[Skip this tick]
P1 -->|non-zero| P2
end
subgraph Brief["Context Brief"]
direction TB
B1{skipBrief?}
B2[Prepend date,\nactive threads,\nminimalBoot flag]
B1 -->|false| B2
B1 -->|true| B3[Raw prompt only]
end
subgraph Spawn["Spawn Worker"]
direction TB
S1{runner}
S2["claude -p\n--dangerously-skip-permissions"]
S3["/bin/sh -c\nshellCommand"]
S1 -->|model| S2
S1 -->|shell| S3
end
Gate -->|exit 0| Brief
Brief --> Spawn
Spawn -->|stdout| LOG[(crons/logs/\nYYYY-MM-DD-id.log)]
| Variable | Default | Description |
|---|---|---|
WORKSPACE_DIR |
cwd | Root for jobs.json, crons/logs/, data/ |
CRON_TZ |
UTC |
Default timezone for jobs without explicit tz |
CRON_MODEL |
sonnet |
Default Claude model for subagents |
DISCORD_POST |
— | Path to a node <script> <channel> <message> helper for timeout/error alerts |
| Field | Required | Description |
|---|---|---|
id |
yes | Stable slug or UUID. First 8 chars used as log filename prefix. |
name |
yes | Human label for logs. |
enabled |
yes | Boolean. |
schedule |
yes | 5-field cron (min hour dom month dow). Supports , - /. |
tz |
no | Per-job timezone override. |
timeoutSeconds |
no | Default 300. SIGTERM at timeout, SIGKILL +5s. |
message |
yes | Prompt handed to claude -p. End with Reply HEARTBEAT_OK when done. |
model |
no | Per-job model override (e.g. opus, haiku). |
skipBrief |
no | Skip context brief prepend. |
discordChannel |
no | Channel ID for error/timeout alerts via DISCORD_POST. |
preCommand |
no | Shell command to run before spawning. Non-zero exit skips the job silently. Use to gate jobs on external state ("only run when there's something to do"). |
runner |
no | Set to "shell" for deterministic jobs that don't need a model. Spawns /bin/sh with shellCommand instead of claude -p. |
shellCommand |
conditional | Required when runner: "shell". The shell command to run. |
minimalBoot |
no | If true, prepends MINIMAL_BOOT_MODE: skip-l1 sentinel to the prompt. Pairs with a CLAUDE.md "Minimal-boot escape hatch" section that skips L1 file reads — saves ~5-10k tokens per run for one-shot mechanical jobs. |
The preCommand, runner: "shell", and minimalBoot fields exist for one reason: stop spinning Claude when you don't need to.
node src/cron-runner.js --dry-runSimulates one tick without spawning anything. Lists which jobs would fire (schedule match or trigger file present) and which are skipped because they're disabled. Useful for verifying schedule + timezone before going live.
Drop an empty file at crons/triggers/<jobId>.trigger — the daemon picks it up on next tick (within 60s), fires the job, and deletes the trigger file. No daemon restart needed.
node src/cron-runner.js --run my-daily-reportFires once, blocks until the subagent exits, then exits. Does not start the scheduler.
These live under WORKSPACE_DIR and are not shipped:
crons/jobs.json— you author thiscrons/logs/YYYY-MM-DD-<id-prefix>.log— per-run logs, append-onlycrons/triggers/— manual trigger drop zonecrons/cron-runner.pid— daemon lock file (auto-cleared if owner PID is dead)data/cron-performance.json— rolling 30-run scoring window per jobdata/running-jobs.json— currently-running jobs withstartedAt
cron-score.js reads logs and classifies each run:
- success — log contains
HEARTBEAT_OK - partial — ran but no success marker (e.g. timed out mid-task)
- fail — exit non-zero, spawn error, or matched a known failure pattern
Failure modes (e.g. timeout, module_not_found, script_error) are matched from log content.
flowchart TD
L[(crons/logs/)] -->|read recent runs| SC[cron-score.js]
SC -->|classify each run| PF[(data/cron-\nperformance.json)]
PF -->|rolling 30-run\nwindow per job| RS[cron-resolve.js]
RS --> RQ{2+ consecutive\nsuccesses since\nlast failure?}
RQ -->|yes| MR[Mark failure\nas resolved]
RQ -->|no| KEEP[Leave open]
PF -->|failure detected| PA[Patch agent\nreads performance.json]
PA --> PROP[Proposes prompt\npatch]
PROP --> EV[cron-eval.js\nvalidates patch]
EV --> EQ{Eval result}
EQ -->|exit 0 — passes| APPLY[Apply patch\nto jobs.json]
EQ -->|exit 1 — blocked| BLOCK[Reject: removes\nsuccess markers or\ndoesn't fix failure]
EQ -->|exit 2 — skip| SKIP[Skip: unrelated\nto this failure]
APPLY --> L
cron-runner.jsspawnsclaude --dangerously-skip-permissions. Only schedule jobs whose prompts come from trusted sources you control.- The subagent runs with the same filesystem access as the cron daemon. Use a dedicated low-privilege OS user, a container with bind-mounted workspace, or a restricted
WORKSPACE_DIRto limit blast radius. - Logs are plain text and may contain sensitive output. Restrict
crons/logs/withchmod 700if needed.