Cron Scheduler System Architecture
Context
bot0 daemons currently only execute tasks on demand (via user chat or CLI). We need a scheduling system so agents can create recurring cron jobs that execute on specific daemons. This is separate from the existing Bytespace web-based agent_schedules system (which serves marketplace workflows). The new system lives in ctx0 (shared DB), uses Bytespace proxy for authenticated CRUD, QStash for reliable triggering, Hub for real-time dispatch, and supports multi-daemon task assignment.
Critical constraint: Schedules can ONLY target 24/7 daemons (deviceRole === 'agent'). Daily drivers (deviceRole === 'daily-driver') are never valid targets because they're not always on. However, any daemon (daily driver or 24/7) can CREATE and MANAGE schedules.
Primary flow: Agent/CLI creates schedule -> proxy -> ctx0 DB + QStash -> QStash fires webhook -> webhook creates pending execution -> webhook dispatches via Hub REST endpoint -> Hub pushes hub:schedule_execute to target 24/7 daemon via WebSocket -> daemon runs agent -> reports results to proxy.
Fallback flow (daemon offline at trigger time): Execution stays pending in DB -> daemon polls on startup/reconnect -> picks up missed executions.
Phase 1: Database Schema (ctx0)
New file: packages/ctx0/src/schema/schedules.ts
Table ctx0_schedules - Schedule definitions:
| Column | Type | Notes |
|---|---|---|
| id | uuid PK | gen_random_uuid() |
| user_id | uuid FK users | CASCADE delete |
| name | text NOT NULL | Human-readable name |
| prompt | text NOT NULL | Agent prompt to execute |
| working_directory | text | CWD for execution (nullable) |
| cron_expression | text NOT NULL | Standard 5-field cron |
| timezone | text DEFAULT 'UTC' | IANA timezone |
| target_daemon_id | uuid FK ctx0_daemons | REQUIRED — must be a 24/7 daemon |
| status | text DEFAULT 'active' | 'active' / 'paused' / 'completed' |
| qstash_schedule_id | text | For QStash cancellation |
| max_executions | integer | null = unlimited |
| execution_count | integer DEFAULT 0 | |
| description | text | Optional longer description |
| settings | jsonb DEFAULT {} | Future extensibility (model override, etc.) |
| last_executed_at | timestamptz | |
| next_execution_at | timestamptz | |
| expires_at | timestamptz | Optional end date |
| created_at | timestamptz | defaultNow() |
| updated_at | timestamptz | defaultNow() |
Note: target_daemon_id is NOT NULL — every schedule must target a specific 24/7 daemon. No "any daemon" scheduling since we can't guarantee a daily driver will be online.
Indexes: (user_id), (user_id, status), (target_daemon_id)
Table ctx0_schedule_executions - Individual execution records:
| Column | Type | Notes |
|---|---|---|
| id | uuid PK | gen_random_uuid() |
| schedule_id | uuid FK ctx0_schedules | CASCADE delete |
| user_id | uuid FK users | CASCADE delete |
| status | text DEFAULT 'pending' | 'pending' / 'dispatched' / 'claimed' / 'running' / 'completed' / 'failed' |
| claimed_by_daemon_id | uuid FK ctx0_daemons | Which daemon claimed it |
| claimed_by_device_id | text FK ctx0_devices.device_id | |
| claimed_at | timestamptz | |
| prompt | text NOT NULL | Snapshot from schedule at trigger time |
| working_directory | text | |
| session_id | uuid | Links to ctx0_sessions for the agent run |
| result_summary | text | Short result text |
| error_message | text | |
| scheduled_for | timestamptz NOT NULL | When QStash triggered |
| started_at | timestamptz | |
| completed_at | timestamptz | |
| duration_ms | integer | |
| input_tokens | integer DEFAULT 0 | |
| output_tokens | integer DEFAULT 0 | |
| created_at | timestamptz | defaultNow() |
Indexes: (schedule_id), (user_id), (user_id, status), partial index on (status, scheduled_for) WHERE status = 'pending'
Modify existing files:
packages/ctx0/src/schema/index.ts- Import + add both tables toctx0Tablespackages/ctx0/src/schema/types.ts- Add type exports:Schedule,NewSchedule,ScheduleExecution,NewScheduleExecutionpackages/ctx0/src/schema/relations.ts- Add relations for both tablespackages/ctx0/src/schema/meta.ts- Add'trigger_schedules_updated_at'toEXPECTED_TRIGGERSpackages/ctx0/src/schema/functions.ts- Addctx0_claim_schedule_execution(p_execution_id, p_daemon_id, p_device_id)function (atomic CAS:UPDATE ... WHERE status='pending' AND scheduled_for <= NOW(), returns boolean)
Phase 2: Hub Protocol + REST Dispatch Endpoint
New message type in packages/core/src/types/hub-protocol.ts
Add hub:schedule_execute to HubMessageType union and define:
interface HubScheduleExecuteMessage extends HubMessageBase { type: 'hub:schedule_execute'; executionId: string; scheduleId: string; prompt: string; workingDirectory?: string; scheduleName: string; }
Add to HubMessage union type.
New REST endpoint on Hub: apps/hub/src/index.ts
Add HTTP request handling to the existing HTTP server (which currently only serves WebSocket upgrades). Add a POST /api/dispatch route:
Request:
POST /api/dispatch
Authorization: Bearer {HUB_API_SECRET}
Content-Type: application/json
{
userId: string,
targetDeviceId: string, // Required — the 24/7 daemon's device
execution: {
id: string,
scheduleId: string,
prompt: string,
workingDirectory?: string,
scheduleName: string,
}
}
Logic:
- Validate
HUB_API_SECRETfrom Authorization header - Look up daemon in
daemonClientsmap bytargetDeviceId - Verify daemon's
userIdmatches request'suserId - Send
hub:schedule_executeWebSocket message to daemon - Return
200 { dispatched: true, deviceId }or404 { dispatched: false, error: "Daemon not connected" }
This reuses the existing HUB_API_SECRET pattern (already used for POST /api/proxy/hub/daemon-status).
Phase 3: Bytespace Proxy Endpoints
All routes use authenticateProxyRequest() for device-authenticated access.
New route files:
apps/bytespace/src/app/api/proxy/schedules/route.ts - Schedule CRUD
POST- Create schedule: validate target daemon hasdevice_role = 'agent'(reject daily-drivers with 400), insert toctx0_schedules+ create QStash cron scheduleGET- List user's schedules (joins daemon name + role for display)
apps/bytespace/src/app/api/proxy/schedules/[id]/route.ts - Single schedule ops
GET- Get schedule details (includes target daemon info)PATCH- Update schedule (status change pauses/resumes QStash; cron change recreates QStash). If changing target daemon, validate new target is 24/7DELETE- Delete schedule + delete QStash schedule
apps/bytespace/src/app/api/proxy/schedules/[id]/executions/route.ts - Execution history
GET- List executions with?limit=20&status=completedquery params
apps/bytespace/src/app/api/proxy/schedules/poll/route.ts - Daemon polling (fallback)
POST { daemonId, deviceId }- Only returns executions if requesting daemon is 24/7 (reject daily-driver poll with empty result). Find pending executions, atomically claim viactx0_claim_schedule_execution()RPC.
apps/bytespace/src/app/api/proxy/schedules/complete/route.ts - Completion reporting
POST { executionId, status, sessionId?, resultSummary?, errorMessage?, durationMs?, inputTokens?, outputTokens? }- Updates execution record + increments schedule
execution_count+ setslast_executed_at - If
max_executionsreached -> set schedule to 'completed' + delete QStash schedule
QStash Webhook:
apps/bytespace/src/app/api/webhooks/daemon-schedule/route.ts
- Verify QStash signature
- Look up schedule, validate active + not expired + not at max
- Insert
ctx0_schedule_executionsrecord withstatus = 'pending' - Look up target daemon -> get its
device_idfromctx0_daemons - Dispatch via Hub:
POST {HUB_URL}/api/dispatchwithHUB_API_SECRETauth- If Hub returns
dispatched: true-> update execution status to'dispatched' - If Hub returns
dispatched: false-> leave as'pending'(daemon offline, will poll later)
- If Hub returns
- Update
next_execution_aton schedule
Phase 4: Daemon Tool (Root Agent Only)
New file: packages/daemon/src/tools/schedule.ts
Tool name: schedule
This is a built-in tool (not a skill) — scheduling is a core agent capability like todos and plans. It's the agent's calendar.
Actions: network, create, update, list, get, pause, resume, delete, executions
Use update to edit schedules in place so existing execution logs/history remain attached to the same schedule ID.
Input schema:
action: enum [network, create, update, list, get, pause, resume, delete, executions]
name: string (required for create)
prompt: string (required for create)
cron_expression: string (required for create) - "0 9 * * *"
timezone: string (optional, default UTC)
target_daemon: string (required for create) - daemon name or ID, must be a 24/7 daemon
schedule_id: string (required for get/pause/resume/delete/executions)
name/description/prompt/cron_expression/timezone/target_daemon/working_directory/max_executions: optional for update
working_directory: string (optional)
description: string (optional)
max_executions: number (optional)
Example in-place edit:
schedule({
action: "update",
schedule_id: "2c7c3f7a-...",
cron_expression: "0 14 * * 1-5",
timezone: "America/New_York"
})
The network action
The network action is critical — it lets the agent discover available 24/7 daemons before scheduling:
- Calls
POST /api/proxy/daemons/list(already exists atapps/bytespace/src/app/api/proxy/daemons/list/route.ts) - Returns each daemon's name, role, online status, and discover profile
- Only shows daemons with
deviceRole === 'agent'as schedulable - Exposes discover data details: saved login domains, CDP availability, apps, git projects, platform
Example output:
Schedulable Daemons (24/7):
atlas [online] — macOS, 15 git projects, 3 apps
Logins: github.com, gmail.com
CDP: Chrome (port 9222)
Device: abc123...
sentinel [offline] — Linux, 8 git projects, 0 apps
Logins: github.com
CDP: none
Device: def456...
Daily Drivers (not schedulable):
jarvis [online] — macOS, 30 git projects, 12 apps
Logins: linkedin.com, github.com, notion.com, gmail.com
This allows the agent to reason about which 24/7 daemon has access to specific services (e.g., which daemon has LinkedIn saved logins and Chrome CDP for browser automation tasks).
Dependencies
Same pattern as Exa tools at packages/daemon/src/index.ts:273-276:
interface ScheduleToolDeps { getConfig: () => DaemonConfig; getSigningProvider: () => SigningProvider | null; }
Root agent only — exclude from subagents
Modify packages/daemon/src/agent/agents.ts:
- Add
'schedule'to thedeniedToolslist for all subagent types (explore, general, browser, plan) - Same pattern as
'task'which is already excluded from subagents
Modify:
packages/daemon/src/tools/index.ts- ExportcreateScheduleTool,ScheduleToolDepspackages/daemon/src/index.ts- Register increateDefaultToolRegistry()alongside Exa tools (reuseexaDeps)
Phase 5: Daemon Schedule Execution + Polling
Daemon Hub client handler: packages/daemon/src/hub/client.ts
Add hub:schedule_execute to the handleMessage switch:
case 'hub:schedule_execute': this.handleScheduleExecute(msg as HubScheduleExecuteMessage); break;
handleScheduleExecute implementation:
- Similar to
handleTaskSubmitbut no desktop progress streaming - Create session with
sessionType: 'scheduled', title[scheduled] {scheduleName} - Run agent via
Bot0Agent.run()with the schedule's prompt - On completion, call proxy
POST /api/proxy/schedules/completedirectly (viafetch+ device headers) - No
taskSourceDesktoptracking needed (no desktop involved)
New file: packages/daemon/src/schedule/poller.ts
SchedulePoller class — fallback for when 24/7 daemon was offline at trigger time:
- Only runs on 24/7 daemons (
deviceRole === 'agent'). Skips entirely for daily drivers. - Polls
POST /api/proxy/schedules/pollevery 60s - Also polls immediately on daemon startup (5s delay for boot) and on Hub reconnect
- Skips if not authenticated or previous poll still running
- For each claimed execution, runs agent (same logic as Hub client handler)
- Reports completion to proxy
Shared execution logic
Both Hub client handler and poller need identical logic. Extract to packages/daemon/src/schedule/executor.ts:
async function executeScheduledTask(opts: { executionId: string; scheduleId: string; prompt: string; workingDirectory?: string; scheduleName: string; agent: Bot0Agent; sessionManager: SessionManager | null; config: DaemonConfig; signingProvider: SigningProvider | null; }): Promise<void>
Modify:
packages/daemon/src/config.ts- AdddaemonId?: stringtoDaemonConfig(set during daemon registration)packages/daemon/src/index.ts- AddSchedulePollertoDaemonclass, start ininitialize()only if daemon is 24/7, stop incleanup()packages/daemon/src/session/manager.ts- Filter'scheduled'from resume chain ingetLatestSessionInChain()(same as'task_child')
Phase 6: Desktop UI — CronPanel
New file: packages/desktop/src/components/Terminal/CronPanel.tsx
Overlay panel (follows UsagePanel.tsx / Voice0Panel.tsx pattern) showing all schedules across the user's daemon network.
Layout:
+-----------------------------------------------------+
| Schedules & Triggers [X] |
|-----------------------------------------------------|
| [Schedules] [Executions] tab bar |
|-----------------------------------------------------|
| |
| Schedules Tab: |
| +---------------------------------------------+ |
| | * Daily Git Status Check [active] | |
| | "0 9 * * *" (daily 9am UTC) | |
| | -> atlas (24/7) | |
| | Last: 2h ago - Next: tomorrow 9am | |
| | Runs: 47 [pause] [del] | |
| |---------------------------------------------| |
| | * Dependency Audit [paused] | |
| | "0 0 * * 1" (Mondays midnight) | |
| | -> sentinel (24/7) | |
| | Last: 5d ago - Next: -- | |
| | Runs: 12 [resume] [del] | |
| +---------------------------------------------+ |
| |
| Executions Tab: |
| +---------------------------------------------+ |
| | [ok] Daily Git Status Check 2h ago 1.2s | |
| | atlas - 1.2k tokens | |
| |---------------------------------------------| |
| | [!!] Dependency Audit 5d ago err | |
| | sentinel - Error: timeout | |
| +---------------------------------------------+ |
| |
+-----------------------------------------------------+
Data fetching: Panel calls daemon IPC via window.bot0.daemon.scheduleAction(...) which routes through the daemon to proxy endpoints.
Features:
- Schedules tab: list all schedules grouped by target daemon, show status/cron/timing, pause/resume/delete actions
- Executions tab: recent execution history across all schedules, show status/daemon/duration/tokens
- Each schedule shows its target 24/7 daemon name with role badge
- Status indicators: green (active), yellow (paused), gray (completed)
- Human-readable cron descriptions (e.g., "every weekday at 9am")
Wire up sidebar button: packages/desktop/src/components/Terminal/NetworkSidebar.tsx
Change the "Schedules / Triggers" NavButton (line 426-430) onClick to call onNavigate?.('cron') or toggle the CronPanel.
Wire up panel state: packages/desktop/src/components/Terminal/Terminal.tsx
Add state and rendering for CronPanel (same pattern as showUsagePanel, showVoicePanel, etc.):
const [showCronPanel, setShowCronPanel] = useState(false);- Pass
onCronOpencallback to NetworkSidebar - Render
<CronPanel>overlay whenshowCronPanelis true
IPC bridge for desktop
Modify packages/desktop/electron/src/preload.ts:
Add scheduleAction to the window.bot0.daemon bridge (same pattern as runTask, listTaskSessions, etc.)
Modify packages/desktop/electron/src/main.ts:
Add IPC handler that forwards scheduleAction calls to daemon socket.
Modify packages/desktop/src/types/global.d.ts:
Add scheduleAction to the Bot0Daemon interface type.
Phase 7: CLI Commands (ctx0-cli)
New file: packages/ctx0-cli/src/commands/schedule.ts
Command group: ctx0 schedule
Subcommands:
ctx0 schedule list- List all schedules (table format)ctx0 schedule create -n <name> -p <prompt> -c <cron> --target <daemon-name> [-t timezone] [-d dir] [--max-runs N]ctx0 schedule get <id>- Get schedule detailsctx0 schedule pause <id>- Pause schedulectx0 schedule resume <id>- Resume schedulectx0 schedule delete <id>- Delete schedulectx0 schedule history <id>- View execution historyctx0 schedule run <id>- Manually trigger (creates pending execution)ctx0 schedule network- List available 24/7 daemons
CLI -> Daemon IPC
CLI routes through daemon IPC to reuse device signing (CLI process can't access Secure Enclave).
New file: packages/ctx0-cli/src/lib/ipc-client.ts
Simple IPC client: net.connect('~/.bot0/daemon.sock') -> write JSON + newline -> read response -> disconnect. (Pattern from packages/desktop/electron/src/daemon-client.ts)
Modify:
packages/daemon/src/ipc/types.ts- Add'scheduleAction'toIPCRequest.methodunion +ScheduleActionParamsinterfacepackages/daemon/src/ipc/server.ts- AddhandleScheduleActionhandler (calls proxy endpoints, same as schedule tool)packages/ctx0-cli/src/index.ts- RegisterscheduleCommand
Key Design Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Built-in tool, not skill | Tool | Scheduling is a core agent capability (like todos/plans). Skills are prompt templates — they can't make signed proxy calls. The agent needs direct API access. |
| Root agent only | Exclude from subagents | Only the root agent should manage schedules. Add 'schedule' to deniedTools in agents.ts. |
| 24/7 daemons only as targets | Enforce at proxy + tool | Daily drivers aren't always on. Proxy rejects create/update targeting daily-driver daemons. Poller skips on daily drivers. |
target_daemon_id NOT NULL | Required | Every schedule must target a specific 24/7 daemon. No "any daemon" — unreliable with mixed fleet. |
network action on tool | Expose daemon network | Agent needs to see available 24/7 daemons + their discover profiles to make informed scheduling decisions. Calls existing /api/proxy/daemons/list. |
| Hub push primary, polling fallback | Hybrid | Hub has real-time daemon routing. Polling catches executions missed when 24/7 daemon was temporarily offline. |
| REST endpoint on Hub | POST /api/dispatch | Bytespace webhook needs HTTP; Hub already has HTTP server + HUB_API_SECRET. |
| New message type | hub:schedule_execute | Different from hub:task_submit — no desktop source, no progress streaming. |
| Shared executor | schedule/executor.ts | Both Hub handler and poller need identical logic. |
| CLI through daemon IPC | IPC | CLI lacks hardware key access; daemon signs proxy requests. |
Implementation Order
1. Schema (ctx0) <- no dependencies
2. Hub protocol + REST endpoint <- depends on @bot0/core types
3. Proxy endpoints (Bytespace) <- depends on schema + Hub endpoint
4. Daemon tool <- depends on proxy endpoints
5. Daemon execution + polling <- depends on proxy endpoints + Hub protocol
6. Desktop UI (CronPanel) <- depends on IPC handler
7. IPC handler + CLI <- depends on daemon tool
Phases 4, 5, 6 can overlap. Phase 2 is independent from 1.
Files Created (new)
| File | Package | Purpose |
|---|---|---|
packages/ctx0/src/schema/schedules.ts | ctx0 | Table definitions |
apps/bytespace/src/app/api/proxy/schedules/route.ts | bytespace | Schedule CRUD |
apps/bytespace/src/app/api/proxy/schedules/[id]/route.ts | bytespace | Single schedule ops |
apps/bytespace/src/app/api/proxy/schedules/[id]/executions/route.ts | bytespace | Execution history |
apps/bytespace/src/app/api/proxy/schedules/poll/route.ts | bytespace | Daemon polling |
apps/bytespace/src/app/api/proxy/schedules/complete/route.ts | bytespace | Completion reporting |
apps/bytespace/src/app/api/webhooks/daemon-schedule/route.ts | bytespace | QStash webhook |
packages/daemon/src/tools/schedule.ts | daemon | Agent schedule tool |
packages/daemon/src/schedule/executor.ts | daemon | Shared execution logic |
packages/daemon/src/schedule/poller.ts | daemon | Fallback polling (24/7 only) |
packages/desktop/src/components/Terminal/CronPanel.tsx | desktop | Schedule management UI |
packages/ctx0-cli/src/commands/schedule.ts | ctx0-cli | CLI commands |
packages/ctx0-cli/src/lib/ipc-client.ts | ctx0-cli | IPC client |
Files Modified
| File | Change |
|---|---|
packages/ctx0/src/schema/index.ts | Add tables to ctx0Tables |
packages/ctx0/src/schema/types.ts | Add type exports |
packages/ctx0/src/schema/relations.ts | Add relations |
packages/ctx0/src/schema/meta.ts | Add trigger name |
packages/ctx0/src/schema/functions.ts | Add claim function |
packages/core/src/types/hub-protocol.ts | Add hub:schedule_execute message type |
apps/hub/src/index.ts | Add POST /api/dispatch REST handler |
packages/daemon/src/hub/client.ts | Handle hub:schedule_execute message |
packages/daemon/src/agent/agents.ts | Add 'schedule' to deniedTools for all subagents |
packages/daemon/src/tools/index.ts | Export schedule tool |
packages/daemon/src/index.ts | Register tool + start poller (24/7 only) |
packages/daemon/src/config.ts | Add daemonId to DaemonConfig |
packages/daemon/src/ipc/types.ts | Add scheduleAction method |
packages/daemon/src/ipc/server.ts | Add handler |
packages/daemon/src/session/manager.ts | Filter 'scheduled' from resume chain |
packages/desktop/src/components/Terminal/NetworkSidebar.tsx | Wire cron button onClick |
packages/desktop/src/components/Terminal/Terminal.tsx | Add CronPanel state + rendering |
packages/desktop/electron/src/preload.ts | Add scheduleAction to bridge |
packages/desktop/electron/src/main.ts | Add IPC handler for scheduleAction |
packages/desktop/src/types/global.d.ts | Add scheduleAction type |
packages/ctx0-cli/src/index.ts | Register schedule command |
Verification
- Schema:
pnpm --filter @bot0/ctx0 build && pnpm db:status— verify new tables detected - Hub:
pnpm --filter @bot0/hub build— verify REST endpoint compiles - Proxy:
pnpm --filter @bot0/bytespace build— verify routes typecheck - Daemon:
pnpm --filter @bot0/daemon build— verify tool + poller + Hub handler compile - Desktop:
pnpm --filter @bot0/desktop dev— verify CronPanel renders, sidebar button works - CLI:
pnpm --filter @bot0/ctx0-cli build— verify schedule command registers - 24/7 enforcement: Try creating schedule targeting daily-driver -> verify 400 rejection
- E2E test: Create schedule via daemon tool targeting 24/7 daemon -> verify QStash fires -> Hub dispatches -> daemon executes -> completion reported -> CronPanel shows result
- Offline test: Create schedule -> 24/7 daemon offline -> verify stays 'pending' -> daemon reconnects -> poller picks up
- Network discovery: Agent calls
schedule({ action: "network" })-> verify only 24/7 daemons shown as schedulable with discover profiles