Skip to main content
Documentation

Scheduled jobs

Run cron tasks in a per-app Durable Object.

On this page

DeepSpace apps include a per-app CronRoom Durable Object for scheduled work - digests, cleanups, periodic syncs. Tasks run on the DO's alarm, so there's no separate scheduler service to manage. You declare tasks in src/cron.ts; the SDK runs them and exposes a monitor hook.

Define tasks#

// src/cron.ts
import type { CronTask } from 'deepspace/worker'
import { buildCronContext } from 'deepspace/worker'

export const tasks: CronTask[] = [
  { name: 'heartbeat', intervalMinutes: 1 },
  { name: 'daily-digest', schedule: '0 9 * * *', timezone: 'America/New_York' },
]

export async function runTask(name: string, env: Env): Promise<void> {
  // Scope by the immutable app id — names are mutable URL leases.
  const ctx = buildCronContext(env, env.OWNER_USER_ID, `app:${env.DEEPSPACE_APP_ID}`)

  if (name === 'heartbeat') {
    await ctx.records.update('settings', 'global', { lastHeartbeat: new Date().toISOString() })
  }

  if (name === 'daily-digest') {
    const users = await ctx.records.query('users', { where: { wantsDigest: true } })
    for (const user of users) {
      const data = await ctx.integrations.call('resend/send-email', {
        to: user.data.email,
        subject: 'Your daily digest',
        text: '...',
      })
    }
  }
}
ts

Each task declares either intervalMinutes (every N minutes) or schedule + timezone (a 5-field cron expression evaluated against an IANA timezone). Declaring both, or neither, throws at DO construction time.

FieldTypeDescription
namestringUnique task name; passed to runTask.
intervalMinutesnumberFire every N minutes.
schedulestring5-field cron expression.
timezonestringIANA timezone (DST-aware).
pausedbooleanStart disabled. Toggle via useCronMonitor.

The cron context#

buildCronContext(env, ownerUserId, roomId?) returns a context that runs as the app owner - RBAC is bypassed:

PropertyTypeDescription
recordsobjectquery, create, update, delete operations.
integrationsobjectcall(endpoint, params) - proxies through the api-worker as the app owner (signed with APP_OWNER_JWT), billed to the owner.
ownerUserIdstringThe owner's user ID.

The records API differs from server actions - methods return their data directly rather than wrapping in { success, data }:

// query: returns Envelope[] (already unwrapped from the tools response)
await ctx.records.query('users', { where: { active: true }, limit: 100 })

// create / update / delete: return the raw tool-call data
await ctx.records.create('notifications', { userId, message })
await ctx.records.update('users', userId, { lastSeenAt: Date.now() })
await ctx.records.delete('notifications', notificationId)
ts

ctx.records.query accepts { where?, limit? } only - there is no orderBy/orderDir here. For richer filters, fetch from a server action instead. There is no ctx.records.get(...); fetch one row via query with a where filter on recordId or call tools.get from a server action.

Throws on failure; wrap in try/catch if you need to handle an error inline.

Worker wiring#

The scaffolded worker.ts already wires AppCronRoom:

export class AppCronRoom extends CronRoom {
  constructor(state: DurableObjectState, env: Env) {
    super(state, env, { tasks: cronTasks })
    this.env = env
  }
  protected async onTask(taskName: string): Promise<void> {
    await runCronTask(taskName, this.env)
  }
}
ts

Don't edit those bindings - add tasks to src/cron.ts and the DO picks them up at construction.

When a schedule starts running#

A Durable Object does not exist until something fetches it, and CronRoom arms its alarm inside that first fetch. So a deployed schedule that nothing has touched runs nothing - and nothing reports it. Two things close that gap, and both are already in a current scaffold:

  • The template worker.ts calls armCronRoom(c.executionCtx, c.env.CRON_ROOMS, `app:${c.env.DEEPSPACE_APP_ID}`, cronTasks) from its request path (a * middleware). The first request the worker handles after a deploy arms the schedule - once per isolate, under waitUntil so it never delays the response, and a no-op when the app declares no tasks.
  • deploy sends that first request for you: once the edge confirms the release (serving: confirmed) it fetches GET /api/auth/ok on the deployed app origin once, so the request middleware can arm the schedule before any visitor arrives. This is best effort - if the wake fails, the first real request arms it instead.

The health response proves that the app answered and gives the middleware an opportunity to arm the room. It does not prove a task has executed. Trigger the task and inspect its receipt/history when you need execution proof.

An app scaffolded before this wiring, or one whose worker.ts no longer calls armCronRoom, still arms only when a client opens the room. Add the middleware above by hand - app update has no migration guidance for it - and confirm with the run lines below rather than by waiting.

Every run logs one line, so deepspace logs --search cron shows the schedule working: [cron] <task> ok <ms>ms, or [cron] <task> failed <ms>ms: <message> at level error with the stack as plain text. The message is in the line on purpose - the Workers runtime renders a logged Error object as its stack frames without the message. A run that throws is a caught failure: it is a log event, not an exception event, and no invocation reports outcome: "exception" for it; see reading an event. Cron history rows (useCronMonitor's history) record the same success/failure and duration.

Monitor and trigger from the UI - useCronMonitor#

import { useState } from 'react'
import { useCronMonitor, useUser, type CronMutationResult } from 'deepspace'
import { SCOPE_ID } from '../constants'

function CronAdmin() {
  const { tasks, history, connected, canWrite, lastError, trigger, pause, resume } = useCronMonitor(SCOPE_ID)
  const { user } = useUser()
  const isAdmin = user?.role === 'admin'
  const [mutationResult, setMutationResult] = useState<string | null>(null)

  const runMutation = async (mutation: Promise<CronMutationResult>) => {
    setMutationResult('pending')
    const receipt = await mutation
    setMutationResult(receipt.ok ? 'ok' : receipt.error ?? receipt.reason)
  }

  if (!connected) return <p>Connecting…</p>

  return (
    <div>
      <h2>Tasks</h2>
      <ul>
        {tasks.map((t) => (
          <li key={t.name}>
            <strong>{t.name}</strong> - next: {t.nextRunAt}
            {isAdmin && (
              <>
                <button
                  aria-label={`Run now: ${t.name}`}
                  disabled={!canWrite}
                  onClick={() => void runMutation(trigger(t.name))}
                >
                  Run now
                </button>
                <button
                  disabled={!canWrite}
                  onClick={() => void runMutation(t.paused ? resume(t.name) : pause(t.name))}
                >
                  {t.paused ? 'Resume' : 'Pause'}
                </button>
              </>
            )}
          </li>
        ))}
      </ul>
      {lastError && <p role="alert">{lastError}</p>}
      {mutationResult && (
        <p data-testid="cron-trigger-result" aria-live="polite">{mutationResult}</p>
      )}
      <h2>History</h2>
      <ul>
        {history.map((h, i) => (
          <li key={i} data-testid="cron-history-row">
            {h.taskName} - {h.success ? 'ok' : 'failed'} - {h.durationMs}ms
          </li>
        ))}
      </ul>
    </div>
  )
}
tsx
ReturnTypeDescription
tasksCronTaskState[]Live state of each task.
historyCronHistoryEntry[]Recent runs (with success, duration, error).
connectedbooleanWebSocket connection status.
canWritebooleanRBAC gate from the server. False until the AUTH frame lands; stays false for read-only viewers.
lastErrorstring | nullMost recent room error frame.
trigger(name)(name: string) => Promise<CronMutationResult>Run onTask immediately; resolves after the run completes.
pause(name)(name: string) => Promise<CronMutationResult>Disable a task and return the server receipt.
resume(name)(name: string) => Promise<CronMutationResult>Re-enable a task and return the server receipt.

CronMutationResult is { ok: true, taskName, requestId } or { ok: false, reason, error? }, where reason is 'read_only', 'not_connected', 'unknown_task', or 'failed'. Always check the receipt when the next UI step depends on success.

Outbound calls in handlers#

runTask runs as the app owner. Use ctx.integrations.call(...) for third-party APIs (billed to APP_OWNER_JWT):

const data = await ctx.integrations.call('openai/chat-completion', {
  model: 'gpt-5.6-terra',
  messages: [{ role: 'user', content: 'Summarize today\'s activity' }],
})
ts

For autonomous LLM calls via the AI SDK:

import { createDeepSpaceAI } from 'deepspace/worker'
import { generateText } from 'ai'

const ai = createDeepSpaceAI(env, 'anthropic') // no authToken → owner pays
const { text } = await generateText({
  model: ai('claude-haiku-4-5'),
  prompt: '…',
})
ts

Testing without waiting for the schedule#

trigger(taskName) runs onTask immediately via the same task-execution path as the alarm. In tests, assert the receipt before checking history:

test('daily-digest fires when triggered', async ({ page }) => {
  await page.goto('/cron-admin')
  await page.getByRole('button', { name: /run now: daily-digest/i }).click()
  await expect(page.getByTestId('cron-trigger-result')).toHaveText('ok')
  await expect(page.locator('[data-testid="cron-history-row"]')).toBeVisible()
})
ts

Don't wait for intervalMinutes: 1 to tick in tests - it's slow and flaky. Trigger explicitly.

trigger runs onTask directly and bypasses alarm wake-up and scheduling. If you need to verify the alarm path itself, use a task with intervalMinutes: 1 and budget about 130 seconds for the tick and history row. Reserve that for cron infrastructure; app-level task logic should test through trigger.

A ready-made monitor page#

npx deepspace add cron
bash

This inserts a 1-minute heartbeat task at the generic insertion point in src/cron.ts, and installs a read-only page at src/pages/(app)/cron-log.tsx that subscribes via useCronMonitor(SCOPE_ID) and renders tasks + history. The page lands under (app)/ deliberately - that's the route group where the providers mount, which the hook needs. It does not expose trigger / pause / resume - add those yourself with the admin gating above.

Migrating a pre-CronRoom setup#

If an existing app has a cron.json, a handleCron function, or an /internal/cron route, that is the retired pattern from before the per-app CronRoom. There is no cron JSON manifest, no cron HTTP endpoint, and no centralized dispatcher - every app schedules and runs its own work in its own DO via Cloudflare alarms. Delete those artifacts and rewrite to the shape on this page:

  1. Move task declarations into src/cron.ts as CronTask[] entries.
  2. Move handler logic into runTask(name, env), using buildCronContext scoped to app: + env.DEEPSPACE_APP_ID for all record mutations and integration calls - it signs ctx.integrations.call(...) with APP_OWNER_JWT so you don't hand-roll request auth.
  3. Delete the old route and manifest. The scaffolded worker.ts wiring (AppCronRoom + /ws/cron/:roomId) replaces them.

Next steps#