Not every agent runs in response to an HTTP request. Daily reports, nightly syncs, hourly data refreshes — these tasks need to fire on a schedule. Anvil’s defineSchedule and Scheduler let you define background tasks with a standard cron expression. Scheduled agents run under the same tracing, cost-governor, and guardrail machinery as your request-driven agents, so you get full observability with no extra setup.
Defining a scheduled task
Create a schedule.ts file in your background tasks directory and export a defineSchedule descriptor as the default export:
import { defineSchedule } from 'anvil/schedule';
export default defineSchedule({
name: 'daily-report',
cron: '0 9 * * *', // every day at 9:00 AM (local time)
async run(ctx) {
// ctx.now — the Date the task fired
// ctx.trace — an open trace span, if a tracer is configured
console.log('Generating daily report for', ctx.now.toDateString());
await generateDailyReport(ctx.now);
},
});
defineSchedule is a thin identity function that types the descriptor — it does no registration itself. Tasks are registered with a Scheduler instance at startup.
ScheduledTask fields
| Field | Type | Description |
|---|
name | string | Unique task identifier. Used in logs, traces, and idempotency tracking |
cron | string | 5-field cron expression (minute hour dom month dow) |
run | (ctx: TaskContext) => unknown | Promise<unknown> | The task function |
TaskContext
Every run function receives a TaskContext:
| Field | Type | Description |
|---|
now | Date | The time at which the task fired |
payload | unknown | For scheduled tasks, this is undefined. Trigger tasks (see Triggered Agents) receive event payloads here |
trace | TraceHandle | undefined | An open trace span for this run, if a tracer is configured on the Scheduler |
The Scheduler
Scheduler manages a list of registered tasks and fires them when their cron expression matches.
Creating a Scheduler
import { Scheduler } from 'anvil/schedule';
import { tracer } from './observability.js'; // optional
const scheduler = new Scheduler({
tracer, // optional: wraps each run in a trace
onError: (name, err) => { // optional: defaults to console.error
logger.error({ task: name, err }, 'Scheduled task failed');
},
});
scheduler.add(task)
Register a ScheduledTask with the scheduler. Returns this for chaining.
import dailyReport from './tasks/daily-report/schedule.js';
import weeklyDigest from './tasks/weekly-digest/schedule.js';
scheduler.add(dailyReport).add(weeklyDigest);
scheduler.tick(now)
Fire all tasks whose cron expression matches now. Each task runs at most once per minute — calling tick multiple times with the same minute is idempotent. Returns a Promise<string[]> of the task names that ran.
const ran = await scheduler.tick(new Date());
console.log('Tasks that ran:', ran);
You rarely need to call tick() directly. Use scheduler.start() to poll automatically.
scheduler.start(intervalMs?)
Poll on an interval, calling tick(new Date()) each time. Defaults to once per minute (60_000 ms). Returns a stop function — call it during graceful shutdown.
const stop = scheduler.start(); // poll every 60 seconds
// In your shutdown handler:
process.on('SIGTERM', () => {
stop();
process.exit(0);
});
Pass a custom interval for testing or sub-minute schedules:
// Poll every 10 seconds (useful if you need near-real-time scheduling)
const stop = scheduler.start(10_000);
Auto-discovering tasks with loadBackgroundTasks
For larger projects, use loadBackgroundTasks(dir) to automatically discover and load all schedule.ts (and trigger.ts) files in a directory tree. Each file’s default export is imported and registered automatically.
import { loadBackgroundTasks, Scheduler } from 'anvil/schedule';
const { schedules, triggers } = await loadBackgroundTasks('./src/tasks');
const scheduler = new Scheduler({ tracer });
for (const task of schedules) {
scheduler.add(task);
}
const stop = scheduler.start();
loadBackgroundTasks recursively walks dir, finds files named schedule.ts, schedule.js, schedule.mts, or schedule.mjs, and imports their default export. The task’s name field defaults to its folder path if the descriptor’s name is empty.
Organize tasks by feature: src/tasks/reports/daily/schedule.ts, src/tasks/sync/inventory/schedule.ts. loadBackgroundTasks finds them all, and the folder path becomes the default task name.
Cron expression syntax
Anvil’s scheduler uses standard 5-field cron syntax in local time:
┌─── minute (0–59)
│ ┌─── hour (0–23)
│ │ ┌─── day of month (1–31)
│ │ │ ┌─── month (1–12)
│ │ │ │ ┌─── day of week (0–6, 0 = Sunday)
│ │ │ │ │
* * * * *
| Expression | Meaning |
|---|
* * * * * | Every minute |
0 * * * * | Every hour on the hour |
0 9 * * * | Every day at 9:00 AM |
0 9 * * 1 | Every Monday at 9:00 AM |
0 0 1 * * | First day of every month at midnight |
0 0 * * 0 | Every Sunday at midnight |
*/15 * * * * | Every 15 minutes |
0 9,17 * * 1-5 | 9:00 AM and 5:00 PM on weekdays |
Full startup example
// src/server.ts
import { createApp } from 'anvil';
import { loadBackgroundTasks, Scheduler } from 'anvil/schedule';
import { tracer } from './observability.js';
const app = createApp();
// ... route definitions ...
// Load and start background tasks
const { schedules } = await loadBackgroundTasks('./src/tasks');
const scheduler = new Scheduler({ tracer });
for (const task of schedules) scheduler.add(task);
const stop = scheduler.start();
const server = Bun.serve({ fetch: app.fetch });
process.on('SIGTERM', () => {
stop();
server.stop();
});
Scheduled tasks run in the same process as your HTTP server by default. For long-running or resource-intensive tasks, consider running the scheduler in a separate worker process to avoid blocking request handling.