Roasbeef
## Problem Substrate's Stop hook and Claude Code's `/loop` skill (CronCreate/CronList/CronDelete) are fundamentally incompatible today. Substrate's Stop hook long-polls for 9m30s and always returns `decision: "block"` to keep the agent alive for mail. This prevents `/loop`'s cron scheduler from ever firing, because the session never reaches the idle state where scheduled tasks get dispatched. ### Current Flow (Broken) ``` Session completes → Stop hook fires → Substrate blocks (9m30s poll) → Substrate re-injects "check mail" prompt → Session runs again → Stop hook fires → Substrate blocks again → 🔄 loop never fires cron tasks ``` ### How /loop Works (Claude Code v2.1.97) 1. `/loop` invokes the `CronCreate` built-in tool to register recurring tasks in a session-scoped scheduler (up to 50 tasks, 3-day expiry, jitter) 2. The CLI scheduler checks `scheduled_tasks.json` periodically 3. When a task is due, the scheduler injects a prompt into the session 4. The task fires, session completes, and the cycle repeats At the assistant worker level (`@anthropic-ai/claude-agent-sdk/assistant`), scheduling is built into the daemon loop: ```typescript scheduling?: { dir: string; // reads <dir>/.claude/scheduled_tasks.json every 10s horizonMs?: number; // look-ahead window (default ~10s) leadMs?: number; // spawn lead time (~5s before fire) } ``` --- ## Proposed Solution: Substrate as Schedule-Aware Stop Hook The key insight is that Substrate already owns the Stop hook and has a long-running poll loop — it can incorporate cron awareness directly into that loop. ### Design During the Stop hook's 9m30s poll cycle, Substrate checks both: 1. Incoming mail (existing behavior) 2. Pending scheduled tasks from `.claude/scheduled_tasks.json` When a scheduled task is due within the next `horizonMs` (~15s), Substrate returns `decision: "approve"` to unblock the session and let the CLI's cron scheduler handle it. After the cron task completes, the next Stop hook invocation resumes normal Substrate behavior. ``` Stop hook fires → Start poll loop: Check mail (non-blocking) → if mail, block with mail prompt Check scheduled_tasks.json → if task due within 15s, approve (yield to cron) Sleep 10s, repeat After 9m30s with nothing: approve ``` ### Implementation Details **Reading the Schedule File:** ```go type ScheduledTask struct { ID string `json:"id"` CronExpr string `json:"cron"` // 5-field cron expression Skill string `json:"skill"` // Skill/command to run Args string `json:"args"` // Arguments NextFire time.Time `json:"nextFire"` CreatedAt time.Time `json:"createdAt"` ExpiresAt time.Time `json:"expiresAt"` } ``` Substrate's Stop hook reads `<cwd>/.claude/scheduled_tasks.json` (if it exists) and checks each task's `nextFire` against `time.Now() + horizonMs`. **Priority:** - Mail takes priority over cron (mail = human/agent communication) - Cron takes priority over idle (don't hold the session idle when work is due) - Substrate stop hook continues to block if neither mail nor cron is pending ### Sequence Diagram ``` Claude Session Substrate Stop Hook Cron Scheduler | | | |--- Stop event ------->| | | |-- check mail -------->| | | (no mail) | | |-- check schedule ---->| | | task due in 12s | | | | |<-- approve -----------| | | | | |--- idle state ------->| | | | |<------------- inject cron prompt -------------| | | |--- execute task -----> | | | |--- Stop event ------->| | | |-- check mail -------->| | | 1 new message | |<-- block (mail) ------| | ``` --- ## Stretch Goal: Built-in Substrate Scheduled Tasks Beyond /loop compatibility, Substrate could offer its own scheduling primitive: ### Why - Agents often need recurring work: "check deploy status every 5m", "run tests every 30m", "sync upstream daily" - Currently requires the agent to set up /loop, which conflicts with Substrate - Substrate already has a database, agent identity, and lifecycle management ### Possible Approaches **A) Via Claude Code Sessions**: Substrate manages scheduled tasks that fire by injecting prompts into CC sessions. This leverages the existing session infrastructure and cron format from CC v2.1.97. ```bash substrate schedule create --session-id "$CLAUDE_SESSION_ID" \ --cron "*/5 * * * *" \ --skill "/test-forge" \ --args "--coverage" substrate schedule list --session-id "$CLAUDE_SESSION_ID" substrate schedule delete <id> --session-id "$CLAUDE_SESSION_ID" ``` **B) Via Mail**: Scheduled tasks are self-addressed mail messages with a `deliver_at` timestamp. When the time comes, Substrate delivers the mail and the agent processes it via normal mail flow. ```bash substrate send --session-id "$CLAUDE_SESSION_ID" \ --to self \ --subject "Scheduled: Run tests" \ --body "/test-forge --coverage" \ --deliver-at "2026-04-08T23:00:00Z" \ --recurring "*/5 * * * *" ``` **C) Hybrid**: Substrate stores the schedule in its DB, and the Stop hook checks both the Substrate schedule table AND `.claude/scheduled_tasks.json` (for /loop compat). This way both Substrate-native schedules and /loop schedules coexist. ### Recommendation Start with the /loop compatibility fix (schedule-aware Stop hook), then build toward option C (hybrid) where Substrate has its own schedule table but also respects CC's `scheduled_tasks.json`. --- ## Tasks - [ ] Read and parse `.claude/scheduled_tasks.json` in the Stop hook - [ ] Add cron-awareness to the Stop hook poll loop (yield when task due) - [ ] Add `substrate schedule create/list/delete` CLI commands - [ ] Add `scheduled_tasks` table to Substrate DB - [ ] Integrate Substrate schedules into the Stop hook poll loop - [ ] Test /loop + Substrate coexistence end-to-end