System cron runner — lean headless scheduling for the Ray cron suite
Why this exists
Measured 2026-06-11 (1.7 days of Fable 5 transcripts since the 6/9 upgrade): the cron suite firing inside the always-on channels session cost ~$438 at credit prices — ~$7.8k/mo extrapolated — and 70% of it was cache reads: ~30 wakes/day each re-reading the session's ~300K-token context (5-min cache TTL means every fire is cold). Founder-approved restructure (iMessage, 2026-06-11 ~08:09 ET): (1) stop waking the big session for no-ops, (2) run cron jobs in lean headless sessions on subscription-tier models. Phase 3 (Fable dispatch gate + spend log) deferred until closer to the June 22 Fable subscription cutover.
Headless feasibility was verified empirically before migration: all 7
required MCP surfaces work in claude -p — claude.ai connectors (Gmail,
Notion, Google Calendar) AND local plugins (iMessage, Discord, Monarch, qmd).
Lean session base context is ~40K tokens vs ~300K in the channels session.
Architecture
LaunchAgent co.raydata.cron-dispatcher (every minute)
└─ cron-dispatcher.py reads manifest, matches due system jobs,
│ runs prechecks, dedups per-minute
└─ cron-job-runner.sh per-job lock, headless `claude -p /skill
│ --model <tier>`, wall-clock timeout,
│ logging, failure recording
└─ skill runs exactly as before; reports to Discord #ops /
vault / state files (output surfaces unchanged)
- Manifest (single source of truth):
~/.claude/scripts/scheduled-jobs.txtv2 format:<cron> | <runner: session|system> | <model> | <precheck> | <command>. Only/morning-prepremainsrunner=session(founder-facing brief; needs the live session's channel/thread context). Legacy 2-field lines parse as session. - Session side:
emit-cron-rearm.shv2 arms onlyrunner=sessionjobs at SessionStart, and exits immediately whenRAY_HEADLESS_CRON=1(headless runs must never arm crons — a headless probe armed all 15 on 2026-06-11 before the guard existed). - Model tiers: haiku = mechanical with NO channel-reporting duties
(graph-reingest, sync-contacts) · sonnet = everything that posts to a channel
or does moderate synthesis (vault-health, open-threads-check, inbox, board,
newsletter, youtube, curiosity, self-review, finance-pulse, cert-study) ·
opus = judgment (deep-research, improve). Model IDs map in
cron-dispatcher.pyMODEL_MAP. Sonnet floor rule: any job that must post to Discord/iMessage runs sonnet minimum — in testing, haiku ignored the MCP reply tool, attempted the post via Bash (denied), then falsely claimed the post succeeded. Reporting discipline is a sonnet-class behavior. - Prechecks (
~/.claude/scripts/prechecks/): bash-only "is there work?" gates that skip the claude run entirely. v1:inbox-nonempty.sh(process-inbox, >95% of fires skipped),first-sunday.sh(finance-pulse),vault-newer-than-graph.sh(graph-reingest). Fail-open: a missing precheck proceeds with the run. - Timeouts: default 30 min; deep-research 90 min; newsletter/youtube/improve 60 min (TIMEOUTS in dispatcher). Enforced by a kill-watchdog in the runner.
- Working directory is load-bearing: launchd spawns with cwd=
/, under which claude runs as an untrusted "/" project — no~/CLAUDE.md, no project-scoped MCP/trust config (plugins appeared "not available"), and Write/Bash permission denials. Both the runner (cd "$HOME") and the plist (WorkingDirectory=/Users/ray) force the project home. If a headless run ever reports missing MCPs or permission denials again, check cwd first. bunon PATH is load-bearing: the discord + imessage plugin MCP servers spawn viabun(plugin.mcp.json); without~/.bun/binon PATH they fail silently and channel tools are "absent" while claude.ai connectors still work (root-caused 2026-06-11 via env-bisection probes). The runner exportsBUN_INSTALL+ prepends~/.bun/bin.- Write/Edit tools are denied on
~/.claude/**in headless mode (config-dir protection; not overridable via --allowedTools). Vault + /tmp writes work normally. State files under~/.claude/state/must be written via Bash (echo/heredoc) — documented in the context shim, and already the dominant skill pattern. - Context shim: every headless prompt is prefixed with "Read
~/.claude/state/cron-runner-context.mdfirst" — that file carries the channel-ID map (Discord #ops = 1488509803964665910, founder iMessage chat_id), headless reporting rules, and the undelivered-report fallback (~/.claude/state/cron-undelivered/). Added after the first smoke test showed a fresh lean session cannot resolve "Discord #ops" — that mapping only existed in the channels session's context. - Locks: atomic mkdir per job slug in
~/.claude/state/cron-locks/; stale locks (dead PID) are reclaimed. Overlapping fires skip, logged.
Observability / feedback (founder requirement: keep access to results)
- Skills' own output surfaces are unchanged — Discord #ops posts, iMessage alerts, vault files, Notion writes, state files.
- Per-run logs:
~/.claude/logs/cron-runs/<slug>-<ts>.log(14-day retention). - Dispatcher log:
/tmp/ray-cron-dispatcher.log. - Failures (nonzero exit or
is_error:true): one line each appended to~/.claude/state/cron-failures.log;/morning-prepstep 5.6 surfaces them in the daily brief and triages. - Smoke-test hook: write a job slug to
~/.claude/state/cron-force.txt— the dispatcher fires that job at the next minute tick regardless of schedule.
Operations
- Add/change a job: edit the manifest only. System jobs are picked up at the next minute tick (dispatcher re-reads the manifest every fire); session jobs re-arm at next session start.
- Move a job back in-session: flip its runner column to
session. Done. - Disable the whole system runner:
launchctl bootout gui/$UID/co.raydata.cron-dispatcher - Backups (pre-migration state):
scheduled-jobs.txt.bak-2026-06-11,emit-cron-rearm.sh.bak-2026-06-11.
Where does a NEW scheduled task live? (decision rubric, added per founder ask 2026-06-11)
Walk top-down; first match wins:
- No model needed at all (pure script: health pings, file moves, backups)
→ user crontab or its own LaunchAgent, no claude run. Precedent:
check-public-ip-drift.sh. Never burn a model call on deterministic work. - Needs the live channels session — founder-facing synthesis that depends
on in-session context (open threads, conversation state), OR reads iMessage
via the plugin (chat.db FDA — blocked in the dispatcher tree until the
founder grants FDA to
/opt/homebrew/bin/python3) →runner=session. Current members: /morning-prep, /open-threads-check. - Everything else →
runner=system(the default). Lean headless, Max-sub models, full MCP access (claude.ai connectors + Discord + Monarch + qmd all verified working). - Future tier: cloud-shaped jobs with no local-filesystem deps (EDGAR, smart-money) → Anthropic scheduled deployments, pending the pilot decision.
Model tier: haiku = mechanical AND no channel-reporting duty · sonnet = floor for anything that posts to Discord/iMessage or does moderate synthesis · opus = judgment/writing quality. When unsure: sonnet.
Build-out checklist for a new system job
- Prove the skill works in-session first (or on-demand) before scheduling it.
- Audit dependencies: MCP servers (iMessage = session-only until FDA; all
others fine), CLIs (must be reachable from the runner PATH — currently
~/.bun/bin,~/.local/bin,/opt/homebrew/bin, system dirs), state files (~/.claude/state/**writes must use Bash, not the Write tool). - If the job frequently no-ops, write a bash-only precheck in
~/.claude/scripts/prechecks/(exit 0 = work exists). Fail-open. - Add the manifest line:
cron | system | <model> | <precheck|-> | /command. Prefer off-:00 minutes for new jobs. - If the run can exceed 30 min, add its slug to TIMEOUTS in
cron-dispatcher.py. (Slug = command lowercased, non-alphanumerics → hyphens:/process-youtube watch→process-youtube-watch.) - Smoke-test through the REAL launchd path:
echo "<slug>" > ~/.claude/state/cron-force.txt, then check~/.claude/logs/cron-runs/<slug>-*.logAND verify the output surface actually received the report — exit 0 is not proof (haiku once claimed a Discord post it never made; that's why the sonnet floor exists). - New Python tooling: use
uv(uv run,uv venv) per founder preference 2026-06-11 —Bash(uv *)is already wired into the package-security-check hook. Deliberate exception:cron-dispatcher.pystays stdlib-only on/opt/homebrew/bin/python3— it fires every minute (zero resolution overhead wanted) and that binary is the stable FDA-grant target for the chat.db unlock.
Known limitations / v2 candidates
- iMessage plugin fails in the dispatcher tree (verified on the 9:07
2026-06-11 natural fire): its MCP server reads chat.db at startup, which
requires Full Disk Access; launchd→python3 lacks the TCC grant that the
channels session's tmux-rooted tree has. Discord works (no FDA needed).
Consequences: open-threads-check is back on the SESSION runner (interim);
cert-study's rare iMessage alerts would fall back to the undelivered file.
Founder fix when wanted: grant FDA to
/opt/homebrew/bin/python3(System Settings → Privacy & Security → Full Disk Access), then flip open-threads-check tosystem | sonnetin the manifest. - open-threads-check still has no chat.db precheck (same FDA constraint) — the queued "pure-script pre-check" remains the proper fix once FDA is granted.
- check-board runs headless on sonnet: triage/report only; it should not launch multi-hour autonomous builds from a headless seat (currently moot — builds are held pending the Notion enumeration fix).
- Newsletter/youtube RSS prechecks possible but not built (1-4 fires/day each; lower value than the inbox/graph gates).
- Anthropic scheduled cloud agents (deployments) could absorb the cloud-shaped jobs (EDGAR, smart-money) entirely — separate decision, surfaced 2026-06-11.
Related
- [[2026-06-09-fable5-harness-review]] — the cost modeling that motivated this
- [[2026-05-18-open-threads-channel-state-mechanism]] — the precheck pattern's origin
- [[2026-06-10-every-how-to-get-the-most-out-of-fable-5]] — task-routing doctrine
- [[2026-06-10-stratechery-fable-5-anthropic-alignment-ai-tiers]] — the June 23 cliff