> ## Documentation Index
> Fetch the complete documentation index at: https://anvil.thatdevguy.in/llms.txt
> Use this file to discover all available pages before exploring further.

# Scheduled Agents: Run Background Tasks on a Schedule

> Use defineSchedule and Scheduler to run background agent tasks on a cron schedule with full tracing, cost-governor, and guardrail support.

Scheduled tasks run without an HTTP request, but under the same tracing, cost-governor, and guardrail machinery as request-driven routes — background work isn't a governance blind spot.

## Defining a scheduled task

Create a `schedule.ts` file anywhere under a background-tasks directory (conventionally `server/schedule/`):

```ts server/schedule/nightly-report/schedule.ts theme={null}
import { defineSchedule } from 'anvil-sdk/schedule';

export default defineSchedule({
  name: 'nightly-report',
  cron: '0 6 * * *', // 5-field cron: minute hour day-of-month month day-of-week
  run: async (ctx) => {
    // ctx.now — the Date this run fired
    // ctx.trace — the trace span opened for this run, if a tracer is configured
    await sendNightlyReport();
  },
});
```

`defineSchedule` is an identity function — it exists purely for type inference and discovery, the same pattern as `defineAgent`.

<ParamField body="name" type="string" required>
  Unique task name. Defaults to the folder path if omitted and the task is loaded via `loadBackgroundTasks`.
</ParamField>

<ParamField body="cron" type="string" required>
  A 5-field cron expression (`minute hour day-of-month month day-of-week`). Supports `*`, comma lists (`1,15`), ranges (`9-17`), and step values (`*/15`).
</ParamField>

<ParamField body="run" type="(ctx: TaskContext) => unknown | Promise<unknown>" required>
  The task body. Receives a `TaskContext` with `now`, `payload` (unused for scheduled tasks), and `trace`.
</ParamField>

## Running the scheduler

```ts theme={null}
import { Scheduler, loadBackgroundTasks } from 'anvil-sdk/schedule';
import { Tracer, SqliteTraceStore } from 'anvil-sdk/trace';

const tracer = new Tracer(await SqliteTraceStore.open('.anvil/traces.db'));
const { schedules } = await loadBackgroundTasks('server/schedule');

const scheduler = new Scheduler({ tracer });
schedules.forEach((task) => scheduler.add(task));
scheduler.start(); // polls every minute; each due task runs once, wrapped in a trace
```

`loadBackgroundTasks(dir)` walks the directory tree, imports every `schedule.ts` file's default export, and derives each task's name from its folder path if `name` wasn't set explicitly.

### `Scheduler` API

<ResponseField name="add(task)" type="(task: ScheduledTask) => this">
  Register a task. Chainable.
</ResponseField>

<ResponseField name="tick(now)" type="(now: Date) => Promise<string[]>">
  Run every task due at `now`. Idempotent per calendar minute — calling `tick` twice within the same minute only fires each task once. Returns the names of the tasks that ran.
</ResponseField>

<ResponseField name="start(intervalMs?)" type="(intervalMs?: number) => () => void">
  Poll on an interval, default `60_000`ms. Returns a stop function that clears the interval.
</ResponseField>

Each task run is wrapped in a trace span named `schedule <name>`, tagged with the task's cron expression. A task that throws is caught, the span closes with status `error`, and the failure is passed to `onError` (default: `console.error`) — one failing task never stops the scheduler or affects other tasks.

<Note>
  `Scheduler` runs in-process on a `setInterval` — it fires as long as your server process is alive. For multi-instance deployments, either run the scheduler on a single dedicated instance, or add your own leader-election/locking around `tick()` so the same task doesn't fire once per instance.
</Note>

## Testing a schedule without waiting

Call `tick()` directly with a specific `Date` to simulate a firing time in tests, instead of waiting for the real clock:

```ts theme={null}
const scheduler = new Scheduler();
scheduler.add(nightlyReportTask);

const ran = await scheduler.tick(new Date('2026-01-01T06:00:00'));
// ['nightly-report']
```

## See also

* [Triggered agents](/background/triggered-agents) — the event-driven counterpart, fired by webhooks and queue messages instead of a clock
* [Observability](/observability/tracing) — every scheduled run appears in the same trace dashboard as request-driven agents
* [Durability & safety](/safety/durable-execution) — checkpoint long-running scheduled agent loops so a crash mid-run resumes cleanly

Scheduled tasks run without an HTTP request, but under the same tracing, cost-governor, and guardrail machinery as request-driven routes — background work isn't a governance blind spot.

## Defining a scheduled task

Create a `schedule.ts` file anywhere under a background-tasks directory (conventionally `server/schedule/`):

```ts server/schedule/nightly-report/schedule.ts theme={null}
import { defineSchedule } from 'anvil-sdk/schedule';

export default defineSchedule({
  name: 'nightly-report',
  cron: '0 6 * * *', // 5-field cron: minute hour day-of-month month day-of-week
  run: async (ctx) => {
    // ctx.now — the Date this run fired
    // ctx.trace — the trace span opened for this run, if a tracer is configured
    await sendNightlyReport();
  },
});
```

`defineSchedule` is an identity function — it exists purely for type inference and discovery, the same pattern as `defineAgent`.

<ParamField body="name" type="string" required>
  Unique task name. Defaults to the folder path if omitted and the task is loaded via `loadBackgroundTasks`.
</ParamField>

<ParamField body="cron" type="string" required>
  A 5-field cron expression (`minute hour day-of-month month day-of-week`). Supports `*`, comma lists (`1,15`), ranges (`9-17`), and step values (`*/15`).
</ParamField>

<ParamField body="run" type="(ctx: TaskContext) => unknown | Promise<unknown>" required>
  The task body. Receives a `TaskContext` with `now`, `payload` (unused for scheduled tasks), and `trace`.
</ParamField>

## Running the scheduler

```ts theme={null}
import { Scheduler, loadBackgroundTasks } from 'anvil-sdk/schedule';
import { Tracer, SqliteTraceStore } from 'anvil-sdk/trace';

const tracer = new Tracer(await SqliteTraceStore.open('.anvil/traces.db'));
const { schedules } = await loadBackgroundTasks('server/schedule');

const scheduler = new Scheduler({ tracer });
schedules.forEach((task) => scheduler.add(task));
scheduler.start(); // polls every minute; each due task runs once, wrapped in a trace
```

`loadBackgroundTasks(dir)` walks the directory tree, imports every `schedule.ts` file's default export, and derives each task's name from its folder path if `name` wasn't set explicitly.

### `Scheduler` API

<ResponseField name="add(task)" type="(task: ScheduledTask) => this">
  Register a task. Chainable.
</ResponseField>

<ResponseField name="tick(now)" type="(now: Date) => Promise<string[]>">
  Run every task due at `now`. Idempotent per calendar minute — calling `tick` twice within the same minute only fires each task once. Returns the names of the tasks that ran.
</ResponseField>

<ResponseField name="start(intervalMs?)" type="(intervalMs?: number) => () => void">
  Poll on an interval, default `60_000`ms. Returns a stop function that clears the interval.
</ResponseField>

Each task run is wrapped in a trace span named `schedule <name>`, tagged with the task's cron expression. A task that throws is caught, the span closes with status `error`, and the failure is passed to `onError` (default: `console.error`) — one failing task never stops the scheduler or affects other tasks.

<Note>
  `Scheduler` runs in-process on a `setInterval` — it fires as long as your server process is alive. For multi-instance deployments, either run the scheduler on a single dedicated instance, or add your own leader-election/locking around `tick()` so the same task doesn't fire once per instance.
</Note>

## Testing a schedule without waiting

Call `tick()` directly with a specific `Date` to simulate a firing time in tests, instead of waiting for the real clock:

```ts theme={null}
const scheduler = new Scheduler();
scheduler.add(nightlyReportTask);

const ran = await scheduler.tick(new Date('2026-01-01T06:00:00'));
// ['nightly-report']
```

## See also

* [Triggered agents](/background/triggered-agents) — the event-driven counterpart, fired by webhooks and queue messages instead of a clock
* [Observability](/observability/tracing) — every scheduled run appears in the same trace dashboard as request-driven agents
* [Durability & safety](/safety/durable-execution) — checkpoint long-running scheduled agent loops so a crash mid-run resumes cleanly
