cron-scheduler-system-architecture.md

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:

ColumnTypeNotes
iduuid PKgen_random_uuid()
user_iduuid FK usersCASCADE delete
nametext NOT NULLHuman-readable name
prompttext NOT NULLAgent prompt to execute
working_directorytextCWD for execution (nullable)
cron_expressiontext NOT NULLStandard 5-field cron
timezonetext DEFAULT 'UTC'IANA timezone
target_daemon_iduuid FK ctx0_daemonsREQUIRED — must be a 24/7 daemon
statustext DEFAULT 'active''active' / 'paused' / 'completed'
qstash_schedule_idtextFor QStash cancellation
max_executionsintegernull = unlimited
execution_countinteger DEFAULT 0
descriptiontextOptional longer description
settingsjsonb DEFAULT {}Future extensibility (model override, etc.)
last_executed_attimestamptz
next_execution_attimestamptz
expires_attimestamptzOptional end date
created_attimestamptzdefaultNow()
updated_attimestamptzdefaultNow()

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:

ColumnTypeNotes
iduuid PKgen_random_uuid()
schedule_iduuid FK ctx0_schedulesCASCADE delete
user_iduuid FK usersCASCADE delete
statustext DEFAULT 'pending''pending' / 'dispatched' / 'claimed' / 'running' / 'completed' / 'failed'
claimed_by_daemon_iduuid FK ctx0_daemonsWhich daemon claimed it
claimed_by_device_idtext FK ctx0_devices.device_id
claimed_attimestamptz
prompttext NOT NULLSnapshot from schedule at trigger time
working_directorytext
session_iduuidLinks to ctx0_sessions for the agent run
result_summarytextShort result text
error_messagetext
scheduled_fortimestamptz NOT NULLWhen QStash triggered
started_attimestamptz
completed_attimestamptz
duration_msinteger
input_tokensinteger DEFAULT 0
output_tokensinteger DEFAULT 0
created_attimestamptzdefaultNow()

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 to ctx0Tables
  • packages/ctx0/src/schema/types.ts - Add type exports: Schedule, NewSchedule, ScheduleExecution, NewScheduleExecution
  • packages/ctx0/src/schema/relations.ts - Add relations for both tables
  • packages/ctx0/src/schema/meta.ts - Add 'trigger_schedules_updated_at' to EXPECTED_TRIGGERS
  • packages/ctx0/src/schema/functions.ts - Add ctx0_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:

typescript
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:

  1. Validate HUB_API_SECRET from Authorization header
  2. Look up daemon in daemonClients map by targetDeviceId
  3. Verify daemon's userId matches request's userId
  4. Send hub:schedule_execute WebSocket message to daemon
  5. Return 200 { dispatched: true, deviceId } or 404 { 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 has device_role = 'agent' (reject daily-drivers with 400), insert to ctx0_schedules + create QStash cron schedule
  • GET - 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/7
  • DELETE - 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=completed query 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 via ctx0_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 + sets last_executed_at
  • If max_executions reached -> set schedule to 'completed' + delete QStash schedule

QStash Webhook:

apps/bytespace/src/app/api/webhooks/daemon-schedule/route.ts

  1. Verify QStash signature
  2. Look up schedule, validate active + not expired + not at max
  3. Insert ctx0_schedule_executions record with status = 'pending'
  4. Look up target daemon -> get its device_id from ctx0_daemons
  5. Dispatch via Hub: POST {HUB_URL}/api/dispatch with HUB_API_SECRET auth
    • If Hub returns dispatched: true -> update execution status to 'dispatched'
    • If Hub returns dispatched: false -> leave as 'pending' (daemon offline, will poll later)
  6. Update next_execution_at on 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 at apps/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:

typescript
interface ScheduleToolDeps { getConfig: () => DaemonConfig; getSigningProvider: () => SigningProvider | null; }

Root agent only — exclude from subagents

Modify packages/daemon/src/agent/agents.ts:

  • Add 'schedule' to the deniedTools list 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 - Export createScheduleTool, ScheduleToolDeps
  • packages/daemon/src/index.ts - Register in createDefaultToolRegistry() alongside Exa tools (reuse exaDeps)

Phase 5: Daemon Schedule Execution + Polling

Daemon Hub client handler: packages/daemon/src/hub/client.ts

Add hub:schedule_execute to the handleMessage switch:

typescript
case 'hub:schedule_execute': this.handleScheduleExecute(msg as HubScheduleExecuteMessage); break;

handleScheduleExecute implementation:

  • Similar to handleTaskSubmit but 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/complete directly (via fetch + device headers)
  • No taskSourceDesktop tracking 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/poll every 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:

typescript
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 - Add daemonId?: string to DaemonConfig (set during daemon registration)
  • packages/daemon/src/index.ts - Add SchedulePoller to Daemon class, start in initialize() only if daemon is 24/7, stop in cleanup()
  • packages/daemon/src/session/manager.ts - Filter 'scheduled' from resume chain in getLatestSessionInChain() (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 onCronOpen callback to NetworkSidebar
  • Render <CronPanel> overlay when showCronPanel is 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 details
  • ctx0 schedule pause <id> - Pause schedule
  • ctx0 schedule resume <id> - Resume schedule
  • ctx0 schedule delete <id> - Delete schedule
  • ctx0 schedule history <id> - View execution history
  • ctx0 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' to IPCRequest.method union + ScheduleActionParams interface
  • packages/daemon/src/ipc/server.ts - Add handleScheduleAction handler (calls proxy endpoints, same as schedule tool)
  • packages/ctx0-cli/src/index.ts - Register scheduleCommand

Key Design Decisions

DecisionChoiceRationale
Built-in tool, not skillToolScheduling 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 onlyExclude from subagentsOnly the root agent should manage schedules. Add 'schedule' to deniedTools in agents.ts.
24/7 daemons only as targetsEnforce at proxy + toolDaily drivers aren't always on. Proxy rejects create/update targeting daily-driver daemons. Poller skips on daily drivers.
target_daemon_id NOT NULLRequiredEvery schedule must target a specific 24/7 daemon. No "any daemon" — unreliable with mixed fleet.
network action on toolExpose daemon networkAgent 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 fallbackHybridHub has real-time daemon routing. Polling catches executions missed when 24/7 daemon was temporarily offline.
REST endpoint on HubPOST /api/dispatchBytespace webhook needs HTTP; Hub already has HTTP server + HUB_API_SECRET.
New message typehub:schedule_executeDifferent from hub:task_submit — no desktop source, no progress streaming.
Shared executorschedule/executor.tsBoth Hub handler and poller need identical logic.
CLI through daemon IPCIPCCLI 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)

FilePackagePurpose
packages/ctx0/src/schema/schedules.tsctx0Table definitions
apps/bytespace/src/app/api/proxy/schedules/route.tsbytespaceSchedule CRUD
apps/bytespace/src/app/api/proxy/schedules/[id]/route.tsbytespaceSingle schedule ops
apps/bytespace/src/app/api/proxy/schedules/[id]/executions/route.tsbytespaceExecution history
apps/bytespace/src/app/api/proxy/schedules/poll/route.tsbytespaceDaemon polling
apps/bytespace/src/app/api/proxy/schedules/complete/route.tsbytespaceCompletion reporting
apps/bytespace/src/app/api/webhooks/daemon-schedule/route.tsbytespaceQStash webhook
packages/daemon/src/tools/schedule.tsdaemonAgent schedule tool
packages/daemon/src/schedule/executor.tsdaemonShared execution logic
packages/daemon/src/schedule/poller.tsdaemonFallback polling (24/7 only)
packages/desktop/src/components/Terminal/CronPanel.tsxdesktopSchedule management UI
packages/ctx0-cli/src/commands/schedule.tsctx0-cliCLI commands
packages/ctx0-cli/src/lib/ipc-client.tsctx0-cliIPC client

Files Modified

FileChange
packages/ctx0/src/schema/index.tsAdd tables to ctx0Tables
packages/ctx0/src/schema/types.tsAdd type exports
packages/ctx0/src/schema/relations.tsAdd relations
packages/ctx0/src/schema/meta.tsAdd trigger name
packages/ctx0/src/schema/functions.tsAdd claim function
packages/core/src/types/hub-protocol.tsAdd hub:schedule_execute message type
apps/hub/src/index.tsAdd POST /api/dispatch REST handler
packages/daemon/src/hub/client.tsHandle hub:schedule_execute message
packages/daemon/src/agent/agents.tsAdd 'schedule' to deniedTools for all subagents
packages/daemon/src/tools/index.tsExport schedule tool
packages/daemon/src/index.tsRegister tool + start poller (24/7 only)
packages/daemon/src/config.tsAdd daemonId to DaemonConfig
packages/daemon/src/ipc/types.tsAdd scheduleAction method
packages/daemon/src/ipc/server.tsAdd handler
packages/daemon/src/session/manager.tsFilter 'scheduled' from resume chain
packages/desktop/src/components/Terminal/NetworkSidebar.tsxWire cron button onClick
packages/desktop/src/components/Terminal/Terminal.tsxAdd CronPanel state + rendering
packages/desktop/electron/src/preload.tsAdd scheduleAction to bridge
packages/desktop/electron/src/main.tsAdd IPC handler for scheduleAction
packages/desktop/src/types/global.d.tsAdd scheduleAction type
packages/ctx0-cli/src/index.tsRegister schedule command

Verification

  1. Schema: pnpm --filter @bot0/ctx0 build && pnpm db:status — verify new tables detected
  2. Hub: pnpm --filter @bot0/hub build — verify REST endpoint compiles
  3. Proxy: pnpm --filter @bot0/bytespace build — verify routes typecheck
  4. Daemon: pnpm --filter @bot0/daemon build — verify tool + poller + Hub handler compile
  5. Desktop: pnpm --filter @bot0/desktop dev — verify CronPanel renders, sidebar button works
  6. CLI: pnpm --filter @bot0/ctx0-cli build — verify schedule command registers
  7. 24/7 enforcement: Try creating schedule targeting daily-driver -> verify 400 rejection
  8. E2E test: Create schedule via daemon tool targeting 24/7 daemon -> verify QStash fires -> Hub dispatches -> daemon executes -> completion reported -> CronPanel shows result
  9. Offline test: Create schedule -> 24/7 daemon offline -> verify stays 'pending' -> daemon reconnects -> poller picks up
  10. Network discovery: Agent calls schedule({ action: "network" }) -> verify only 24/7 daemons shown as schedulable with discover profiles