---
title: "Schedules"
description: "Run an agent on a cron cadence, either a fire-and-forget prompt or a handler that hands work off to a channel."
---

A schedule starts the agent on its own clock instead of waiting for an inbound message. Use one for daily digests, data syncs, cleanup sweeps, heartbeats, or anything that should fire on a cadence. Each one is a single file under `agent/schedules/` carrying a cron expression. Schedules are root-only, so declared subagents cannot have a `schedules/` directory.

The name comes from the path under `schedules/` (`agent/schedules/billing/sweep.ts` → `"billing/sweep"`), and nested directories are fine.

## `defineSchedule`

Every schedule provides a `cron` and exactly one of `markdown` or `run`:

```ts
interface ScheduleDefinition {
  cron: string;
  markdown?: string; // fire-and-forget prompt (task mode)
  run?: (args: ScheduleHandlerArgs) => Promise<void> | void; // handler
}

interface ScheduleHandlerArgs {
  receive: CrossChannelReceiveFn; // hand the work off to a channel
  waitUntil: (task: Promise<unknown>) => void; // keep the cron task alive past return
  appAuth: SessionAuthContext; // pre-built app principal
}
```

`defineSchedule` is a type-level pass-through. The compiler is what enforces the one-of rule.

`cron` is a standard 5-field string (`minute hour day-of-month month day-of-week`) with minute granularity. On Vercel, each schedule becomes a Vercel Cron Job, and Vercel evaluates the expression in UTC, so `"0 9 * * 1-5"` fires at 09:00 UTC on weekdays. `eve dev` never fires schedules on their cron cadence. A built app served with `eve start` does run production scheduled tasks. To trigger one while iterating in dev, use the dispatch route below.

## Markdown form (fire-and-forget)

This is the minimal schedule. eve runs the agent on the prompt and throws away the output, though the agent can still call tools, write to backends, and log along the way. We call this task mode. A task-mode session runs to completion or fails, and cannot park to wait for a person or an OAuth sign-in.

```ts title="agent/schedules/heartbeat.ts"
import { defineSchedule } from "eve/schedules";

export default defineSchedule({
  cron: "*/5 * * * *",
  markdown: "Pull open Linear issues and POST a summary to the metrics endpoint.",
});
```

You can write the same thing as a plain `.md` file: its frontmatter takes `cron` and nothing else, and the body is the prompt.

`agent/schedules/cleanup.md`:

```md
---
cron: "0 0 * * 0"
---

Sweep stale workflow state.
```

## Handler form (`run`)

Use a handler when the schedule needs to deliver to a channel, branch on conditions, or compute its arguments at fire time. The handler is in full control. It has no channel of its own, so it passes the work to one with `receive`.

```ts title="agent/schedules/critical-alerts.ts"
import { defineSchedule } from "eve/schedules";

import slack from "../channels/slack.js";

export default defineSchedule({
  cron: "* * * * *",
  async run({ receive, waitUntil, appAuth }) {
    waitUntil(
      receive(slack, {
        message: "Check for new critical alerts. Report only when there are any.",
        target: { channelId: "C0123ABC" },
        auth: appAuth,
      }),
    );
  },
});
```

The agent does not have to deliver a message on every run. When a prompt makes delivery conditional, as in the alert check above, eve tells the agent how to finish successfully without sending anything to the channel. Frequent polling schedules do not need a separate filter or delivery setting.

- `receive(channel, { message, target, auth })`: starts a session on another channel. Same contract as a route handler's `args.receive`.
- `waitUntil(promise)`: extends the cron task's lifetime so the parked session and any in-flight fetches settle before the task ends. Wrap the `receive` call in it.
- `appAuth`: the app principal (`{ authenticator: "app", principalId: "eve:app", principalType: "runtime" }`). Pass it as `receive(..., { auth: appAuth })` for work the agent does on its own behalf.

A handler-form session runs on the same durable runtime engine as any other session, so it can park (durably suspend), for instance when the channel handoff is waiting for a Slack reply. Only markdown task mode is barred from waiting.

## Trigger a schedule while iterating

The dev server mounts a one-shot dispatch route that fires a schedule by name, out of band, exactly once. Since `eve dev` never runs schedules on their cron cadence, this is how you trigger one without waiting for the next production tick.

```sh
curl -X POST http://localhost:3000/eve/v1/dev/schedules/heartbeat
# -> { "scheduleId": "heartbeat", "sessionIds": ["..."] }
```

`:scheduleId` is the path-derived schedule name (`agent/schedules/heartbeat.ts` → `heartbeat`; URL-encode the `/` in nested names). It runs the exact dispatch path the production cron handler uses and returns the started session ids as JSON, so you can subscribe to each one's [stream](./concepts/sessions-runs-and-streaming) at `GET /eve/v1/session/:sessionId/stream`. An unknown id comes back `404` with `availableScheduleIds`, listing the schedules the app actually defines.

The route is dev-only. Production builds never mount it, and it needs no auth since the dev server is local-only.

## On Vercel

Hosted Vercel builds turn every `defineSchedule(...)` into a Vercel Cron Job, with each `cron` written as an entry in `.vercel/output/config.json`. Vercel evaluates these expressions in UTC. Confirm discovery under **Settings → Cron Jobs** and watch execution history under **Observability → Cron Jobs**. Per-run logs land under **Observability → Logs**.

## Self-deployed hosts

Production builds register schedules as Nitro scheduled tasks. On Vercel, Nitro's Vercel preset wires those task registrations into Vercel Cron for you. Outside Vercel, the standard `eve build && eve start` path serves Nitro's Node output and starts Nitro's schedule runner, so the tasks fire on their cron cadence while that process is running.

The gotcha is custom hosting. If you adapt the generated output to a process manager, container platform, or Nitro preset that only serves HTTP and does not start Nitro's scheduled task runner, the schedule definitions still compile, but they will not fire automatically. In that case, run eve through `eve start`, use a host that supports Nitro scheduled tasks, or trigger the same work from your own scheduler through an authenticated route, channel handoff, or application-specific job runner. The dev dispatch route above is only for `eve dev`; production builds do not mount it.

## What to read next

- [Channels](./channels/overview): deliver schedule output to users.
- [Sessions, runs & streaming](./concepts/sessions-runs-and-streaming): inspect a schedule run.
