Cron loops → background agents: architecture change
One-line: Stop hosting the scheduled-loop suite inside the always-on channels-agent
session. Keep ONE manifest as the source of truth, compile it to the OS scheduler, and
have each scheduled fire spawn a fresh ephemeral claude --bg agent that runs one loop
and exits. The OS does the timing; background agents do the work.
Why (the problem the founder named, 2026-05-31)
Three overlapping scheduling strategies had accreted:
- SessionStart auto-arm hook (
emit-cron-rearm.sh) reads the manifest and arms all 14 loops as session-scopedCronCreatejobs on every session start. - Restart script
/loopblock (claude-channels-restart.shlines ~100-111) ALSO tmux-send-keys/loop <raw-cron> <command>for the same loops — and it never worked, because/loopexpects interval shorthand (5m/1d), not a raw 5-field cron. - Now a third option is in play: background agents (
claude --bg).
Two concrete bugs:
- Two places arm the same loops (hook + restart block) → redundant and confusing.
- The auto-arm fires on EVERY new session, including throwaway
claude --bg "ping"test agents — each then armed its own duplicate 14-job suite (observed live 2026-05-31: sessions1d54cc62,cron jobs manifest,14248c4e). Duplicate suites = duplicate founder-facing fires.
The original good intent — one centralized list of loops — should be preserved. The delivery mechanism is what changes.
Grounded findings (verified this session, not from memory)
CLI: claude v2.1.158 on the Mac Mini.
claude --bg "<prompt>"works (founder-confirmed empirically) but is undocumented inclaude --help.-n/--namesets the display name.- Lifecycle: spawn → run → idle → reaped. A dispatched bg agent stays alive idle after
its prompt, and the supervisor stops any session idle ~1h to free resources
(transcript persists, restarts on reattach). Therefore a persistent self-scheduling
/loopagent is NOT viable — it sleeps between fires, which reads as idle, so it is reaped mid-window. (This kills the "one long-lived agent per loop" idea.) - Management commands (verified to exist on this binary):
claude agents [--json] [--cwd <path>],claude attach <id>,claude logs <id>,claude stop <id>,claude respawn <id>|--all,claude rm <id>,claude daemon status|logs|stop|uninstall|run. (NOTE:claude killdoes not exist — a docs claim that failed verification.) - MCP connectors are inherited by any background agent run as user
ray— empirically confirmed twice: a headlessclaude -pfrom/tmpenumeratedclaude_ai_Notion,claude_ai_Gmail,claude_ai_Google_Calendar,monarch-money, etc. with zero flags; and a dedicated bg test (notion-mcp-test) made a live read-onlynotion-searchcall that SUCCEEDED, returning the real Task Board DB. Auth = single login-keychain OAuth (Claude Code-credentials). Caveat: after an unattended reboot the keychain is locked → need a 1Password-storedCLAUDE_CODE_OAUTH_TOKENexported by the wrapper. - THE CRUX — macOS TCC, not MCP auth. iMessage read/send needs Full Disk Access +
Automation, which are TCC grants bound to the tmux/LaunchAgent lineage the
channels-agent launches from. A
claude --bg/-pfired from bare cron is a different TCC subject with no grant → it will silently fail iMessage I/O even though thereplytool is present. Plugin reply tools are inherited with no flag; only inbound delivery needs--channels(+DISCORD_BOT_TOKENfor Discord). [Analysis from the design workflow; to be confirmed by the morning-prep canary below.]
Proposed architecture
manifest → compiled to OS scheduler → fresh claude --bg per fire.
- One source of truth (kept).
scheduled-jobs.txtstays the single place loops are managed. Extend format to<cron> | <mode> | <command>, mode ∈bg/in-session/split. - Compile, don't duplicate. A generator (
sync-loops.sh) turns the manifest into the user crontab (manifest is already cron syntax; the ip-drift cron proves crontab is restart-proof here). This single path REPLACES both the auto-arm hook (for offloaded loops) and the restart-script/loopblock. - Per-fire background agent. Each
bgcrontab line →run-loop.sh <command>→claude --bg --name <loop> --permission-mode bypassPermissions "<command>". Hardening: 1PasswordCLAUDE_CODE_OAUTH_TOKEN;flockper shared resource (overnight Notion/ledger writers can't collide); a success-sentinel the run-log validates (exit-0 lies — the 2026-05-26 silent-outage class); explicit "do NOT arm the cron suite" instruction.bypassPermissions(NOTacceptEdits, which silently denies un-allowlisted Bash like yt-dlp/ffmpeg). - Channel-coupled exceptions stay in the channels-agent (TCC reality).
open-threads-check(needs live--channelsinbound) stays in-session. iMessage-delivery loops (morning-prep, board re-prompts) use the split: bg agent gathers headless → writes brief to disk → tmux-rooted channels-agent does the iMessage send. (Or route those to Discord and they fire fully from cron.) - Auto-arm hook: retired for the bulk, name-gated for the rest. Only ever arms the 2-3 in-session loops, and only inside the channels-agent (gated on session name / marker env) → can never again arm a duplicate suite in a random bg session.
- Restart script slims down. Delete the broken
/loopsend-keys block; its only scheduling job becomes "runsync-loops.sh."
De-risking: the morning-prep canary (do FIRST, ~1 day, reversible)
One temporary crontab line firing claude --bg /morning-prep. Settles the two open unknowns:
(a) does a cron-launched bg agent inherit the TCC grant to send iMessage? (b) does the
OAuth-token export survive a locked keychain? Validates or kills the approach before any
fan-out. Do NOT canary a local-only job (it would pass while leaving the real risk untested).
If iMessage send DOES work from cron lineage, the split in #4 is unnecessary and more loops
go fully-bg.
Open decisions (founder)
- Scheduler: crontab (rec — reuses proven substrate) vs launchd (more macOS-native; re-runs jobs missed while asleep).
- Canary: green-light the 1-day morning-prep test?
- Quota: per-fire sessions are an independent-quota multiplier vs today's single shared session. OK? Heavy jobs on Sonnet to dampen?
- Cutover pace: keep the existing in-session arming live in parallel until the new substrate has a proven multi-week track record (slow, reversible).
Migration outline (after canary passes)
- Snapshot rollback:
crontab -l > ~/.claude/state/crontab.bak-<date>; back up manifest. - Write
run-loop.sh(wrapper: profile + OAuth-token export + flock + bypassPermissions + sentinel + JSON tee to~/.claude/state/loop-output/). - Validate the morning-prep split end-to-end; then one connector-only job
(
/check-board→ Discord) and one local job (/vault-health). Grep per-run JSON forpermission/denied, not just exit 0. - Add the ~9 non-channel loops to crontab via
sync-loops.sh; stagger the overnight cluster (deep-research 1:00 vs sync-contacts 1:30) + flock shared writers. - Handle the 2 iMessage-delivery loops via split (or Discord-route). Keep OFF bare cron.
- Trim the manifest to ONLY the in-session loops; name-gate the auto-arm hook to the channels-agent.
- Delete the broken
/loopblock in the restart script (keep.bak). - Add a watchdog cron: scan run-log for fired-but-no-sentinel loops; ping founder via the
channels-agent. Probe
claude mcp listfor connector OAuth drift. - Verify a FULL cycle incl. a weekend (finance-pulse/self-review Sun, improve Mon) before declaring done.
- Land script changes via branch + PR (crontab/LaunchAgents are local machine state).
References
- Design workflow run
wf_a489cb77-bc2(4 investigate agents + 3 designs + adversarial verify + synthesis); recommended Design C (minimal hybrid) with the TCC carve-out. - Empirical Notion bg-test report:
/tmp/notion-bg-test.md(call succeeded). - Verified CLI surface + management commands: this session, 2026-05-31.