Agent schedules
Waking an agent on a schedule from inside its own class: the schedule grammar, the timezone requirement that silently breaks wall-clock schedules, how arming works after a deploy, and gating production-only work.
An agent can wake itself. Schedules are declared on the class, so they're versioned, promoted, and rolled back with the code that handles them.
Declaring a schedule
export class DigestAgent extends SpikeAgent {
override model = "anthropic/claude-haiku-4.5";
override systemPrompt = "You produce the daily support digest.";
getDefaultTimezone() { return "Europe/Istanbul"; }
override getScheduledTasks() {
if (!isProductionEnvironment(this.env)) return {};
return {
"daily-digest": {
schedule: "every day at 09:00",
prompt: "Produce today's support digest and email it to the team.",
},
};
}
}
Each firing sends prompt to the agent as a normal turn — its own session, an ordinary trace, the same tools. There's nothing special about a scheduled turn except what woke it.
The grammar
Anything outside this grammar is a deploy-time parse error:
| Form | Example |
|---|---|
every N minute(s) |
every 5 minutes — floor is every 1 minute, no sub-minute |
every N hour(s) |
every 2 hours |
every day at HH:MM |
every day at 09:00 |
every weekday at HH:MM |
every weekday at 08:30 |
every week on <days> at HH:MM |
every week on mon,thu at 17:00 |
24-hour clock. Singular unit for 1, plural otherwise (every 1 minute, every 2 minutes). Wall-clock forms accept a trailing timezone override: every day at 09:00 in Europe/Istanbul. Interval forms do not accept a timezone.
The timezone trap
Read this one twice, because the failure is silent:
A wall-clock schedule requires a timezone. Define
getDefaultTimezone()on the agent (or putin <Zone>on the schedule string). Without it, schedule reconciliation fails on every wake — the schedule never arms, and each wake logs a degraded-state error instead.
Nothing errors at deploy. sf agents lint passes. The agent works normally in conversation. The schedule just never happens, and unless you go looking for the error you'll assume the cron is fine.
Interval forms (every 2 hours) don't need a timezone.
Arming after a deploy
Schedules arm on the first wake after deploy. A freshly deployed agent with no traffic has never woken, so it has never armed its alarm — which means a brand-new schedule doesn't start on its own.
Nudge it once:
sf agents schedules trigger DigestAgent daily-digest
Then verify:
sf agents schedules # declared state, read from the code
sf agents schedules history --agent DigestAgent # actual executions
The history is the honest answer to "is this running?" — declared state only tells you what the code asks for.
Gate by environment
if (!isProductionEnvironment(this.env)) return {};
Do this by default. Every environment runs the same code, so an ungated schedule fires in staging too — and a digest that emails the team is not something you want three copies of, from three environments, at 9am.
When a non-production environment genuinely should run a schedule (a test cadence, a smoke check), opt it in explicitly rather than removing the gate.
Schedules versus routines
Both exist; they're for different things.
| Agent schedule | Routine | |
|---|---|---|
| Declared in | The agent class, getScheduledTasks() |
Platform config, sf routines |
| What it does | Wakes an agent with a prompt | Calls one of your app's HTTP endpoints |
| Use for | Work needing judgment — triage, digests, review | Deterministic jobs — syncs, cleanups, aggregation |
| Versioned with code | Yes | No |
Never create a routine to wake an agent. It's the older path, it puts half the behavior outside your code, and the agent's own schedules do the job properly. Routines remain the right tool for a cron that hits a Hono route.
For internal continuations — "check back in 30 seconds and finish this" — use this.schedule(). That's for the agent's own bookkeeping, not for user-visible recurring work.
Designing scheduled work
- Make the prompt an instruction, not a question. "Produce today's digest and email it" beats "is there anything to report?".
- Make the work idempotent. A schedule can fire twice; a digest that double-sends is a bug.
- Emit an event when it completes so there's a record whether or not anyone read the output — see Observability and costs.
- Keep the cadence honest.
every 1 minuteon a model turn is a standing bill. If you're polling for a state change, an event or a queue is usually the better shape.
Next
- Observability and costs — confirming scheduled turns ran and what they cost.
- Queues — event-driven work instead of polling.
- Environments — why the environment gate matters.
Frequently asked questions
How do I make an agent run every morning?
Declare it in getScheduledTasks() on the agent class with a schedule like 'every day at 09:00', and define a default timezone on the class. Each firing arrives as a normal turn.
Why did my schedule never fire?
Two usual causes. A wall-clock schedule without a timezone fails reconciliation on every wake and never arms. And a freshly deployed agent with zero traffic hasn't armed its first alarm yet — nudge it once with sf agents schedules trigger.
Should I use a routine to wake an agent?
No. Routines exist for calling your app's own HTTP endpoints on a cron. Agent wakes belong in the agent class, where they're versioned and deployed with the code.
How do I stop staging from running production schedules?
Gate getScheduledTasks() on the environment and return nothing outside production. Otherwise every environment fires the same work — including anything that emails customers.
Can I run a schedule by hand?
Yes: sf agents schedules trigger <AgentClass> <task-name>. That's both the manual-run verb and the way to arm a newly deployed schedule.