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: '...',
})
}
}
}
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.
| Field | Type | Description |
|---|---|---|
name | string | Unique task name; passed to runTask. |
intervalMinutes | number | Fire every N minutes. |
schedule | string | 5-field cron expression. |
timezone | string | IANA timezone (DST-aware). |
paused | boolean | Start disabled. Toggle via useCronMonitor. |
The cron context#
buildCronContext(env, ownerUserId, roomId?) returns a context that runs as the app owner - RBAC is bypassed:
| Property | Type | Description |
|---|---|---|
records | object | query, create, update, delete operations. |
integrations | object | call(endpoint, params) - proxies through the api-worker as the app owner (signed with APP_OWNER_JWT), billed to the owner. |
ownerUserId | string | The 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)
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)
}
}
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.tscallsarmCronRoom(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, underwaitUntilso it never delays the response, and a no-op when the app declares no tasks. deploysends that first request for you: once the edge confirms the release (serving: confirmed) it fetches the template'sGET /api/auth/okhealth route once, so the schedule is armed before any visitor arrives. This is best effort - if the wake fails, the first real request arms it instead.
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 { useCronMonitor, useUser } from 'deepspace'
import { SCOPE_ID } from '../constants'
function CronAdmin() {
const { tasks, history, connected, canWrite, trigger, pause, resume } = useCronMonitor(SCOPE_ID)
const { user } = useUser()
const isAdmin = user?.role === 'admin'
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 onClick={() => trigger(t.name)}>Run now</button>
<button onClick={() => (t.paused ? resume(t.name) : pause(t.name))}>
{t.paused ? 'Resume' : 'Pause'}
</button>
</>
)}
</li>
))}
</ul>
<h2>History</h2>
<ul>
{history.map((h, i) => (
<li key={i}>
{h.taskName} - {h.success ? 'ok' : 'failed'} - {h.durationMs}ms
</li>
))}
</ul>
</div>
)
}
| Return | Type | Description |
|---|---|---|
tasks | CronTaskState[] | Live state of each task. |
history | CronHistoryEntry[] | Recent runs (with success, duration, error). |
connected | boolean | WebSocket connection status. |
canWrite | boolean | RBAC gate from the server. False until the AUTH frame lands; stays false for read-only viewers. |
trigger(name) | (name: string) => void | Fire onTask immediately - same code path as the alarm. Fire-and-forget. Silently no-ops when canWrite is false. |
pause(name) | (name: string) => void | Disable a task. Silently no-ops when canWrite is false. |
resume(name) | (name: string) => void | Re-enable a task. Silently no-ops when canWrite is false. |
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' }],
})
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: '…',
})
Testing without waiting for the schedule#
trigger(taskName) runs onTask immediately via the same code path as the alarm. Use it in tests:
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.locator('[data-testid="cron-history-row"]')).toBeVisible()
})
Don't wait for intervalMinutes: 1 to tick in tests - it's slow and flaky. Trigger explicitly.
The one exception: trigger runs onTask directly and bypasses the alarm machinery. If what you need to verify is the alarm path itself - the DO waking up and firing on schedule - use a task with intervalMinutes: 1 and budget about 130 seconds for the tick to fire and the history row to arrive. Reserve that for verifying cron infrastructure; app-level task logic always tests through trigger.
A ready-made monitor page#
npx deepspace add cron
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:
- Move task declarations into
src/cron.tsasCronTask[]entries. - Move handler logic into
runTask(name, env), usingbuildCronContextscoped toapp:+env.DEEPSPACE_APP_IDfor all record mutations and integration calls - it signsctx.integrations.call(...)withAPP_OWNER_JWTso you don't hand-roll request auth. - Delete the old route and manifest. The scaffolded
worker.tswiring (AppCronRoom+/ws/cron/:roomId) replaces them.
Next steps#
- Worker cron reference -
CronRoom,CronTask,buildCronContext. - Server actions - privileged on-demand operations.
- External APIs - calling third-party APIs from your worker.