Skip to main content
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

FieldTypeDescription
namestringUnique task identifier. Used in logs, traces, and idempotency tracking
cronstring5-field cron expression (minute hour dom month dow)
run(ctx: TaskContext) => unknown | Promise<unknown>The task function

TaskContext

Every run function receives a TaskContext:
FieldTypeDescription
nowDateThe time at which the task fired
payloadunknownFor scheduled tasks, this is undefined. Trigger tasks (see Triggered Agents) receive event payloads here
traceTraceHandle | undefinedAn 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)
│  │  │  │  │
*  *  *  *  *
ExpressionMeaning
* * * * *Every minute
0 * * * *Every hour on the hour
0 9 * * *Every day at 9:00 AM
0 9 * * 1Every Monday at 9:00 AM
0 0 1 * *First day of every month at midnight
0 0 * * 0Every Sunday at midnight
*/15 * * * *Every 15 minutes
0 9,17 * * 1-59: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.