Substrate + Claude Code /loop compatibility and built-in scheduled tasks

#89 · open · 2 comments

View on GitHub ↗

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

Comments

m13v

the schedule-aware stop hook approach is clean. we solved a similar problem for recurring agent tasks (social media posting, repo monitoring) and the main insight was the same: the thing that owns the lifecycle loop should also own the schedule. one thing we found in practice: checking scheduled_tasks.json on a 10s interval during the 9m30s poll is fine for minute-level granularity, but file polling has a subtle race where the file can be mid-write from another process when you read it. we added a simple lockfile check, or you could use an atomic read (read into temp, validate JSON, then use). for the priority ordering (mail > cron > idle), this matches our experience exactly. the one addition that helped: when a cron task fires, buffer a few seconds before actually yielding. agents sometimes produce a burst of output right at session end that looks like completion but is actually mid-thought. the buffer lets the session cleanly finish before the scheduler takes over. the hybrid approach (Option C) for Phase 2 is the way to go. having both Substrate-native schedules and CC's scheduled_tasks.json means users don't have to pick one system.

m13v

fwiw the launchd configs we use for this are public. here's the plist that handles the cron scheduling with the priority ordering I described: https://github.com/m13v/social-autoposter/tree/main/launchd shows how StartCalendarInterval, KeepAlive, and ThrottleInterval interact in practice.