Schedules
What a schedule is#
A schedule is a cron job wirl fires into your app on your behalf. You declare it in wirl.json; there is nothing to install, no separate worker to run, and no clock of your own to keep — the control plane's clock checks every schedule across every app and fires the ones that are due.
Declaring one#
{
"app": "job-board-sync",
"server": "server.mjs",
"schedules": [
{ "name": "poll-jobs", "cron": "0 9 * * 1-5", "timezone": "America/Los_Angeles", "timeout": "2m", "retries": 1 }
]
}
Every field but name and cron has a default:
name— 1-40 lowercase letters, digits or dashes. Identifies the schedule inwirl schedules,wirl runand the dashboard; unique within the app.cron— a standard five-field cron expression (minute, hour, day of month, month, day of week).timezone— an IANA zone, e.g.America/Los_Angeles. Defaults toUTC.cron's fields are read in this zone, including across a daylight-saving change.timeout— how long a fire may run before wirl gives up on it:"30s"to"15m". Defaults to"5m". See below.retries— 0 to 3 further attempts after a failure. Defaults to0.overlap—"skip"(the default) leaves a fire alone if the previous one for this schedule is still running;"allow"starts another anyway. See below.flow: a flow the app declares underflows. The schedule then starts a run of that flow at each time due instead of posting to its path. See Starting a flow.
An app may declare up to 10 schedules.
What your app receives#
At the time due, wirl sends your app a POST to a path reserved for this:
if (url.pathname.startsWith('/__wirl/schedule/')) {
const name = url.pathname.slice('/__wirl/schedule/'.length);
// ... do the work ...
return new Response('checked 40 postings');
}
The request is authenticated the same way every other request to your app is — wirl signs it, your app does not need to check anything to know it is legitimate — and it carries no body. Alongside the usual x-wirl-app-id and x-wirl-org, it carries:
x-wirl-schedule— the schedule'sname.x-wirl-run— this run's id, useful in your own logs to line up withwirl runor the Overview.x-wirl-attempt—1on the first try,2or higher on a retry.
x-wirl-user-id, x-wirl-user-email, x-wirl-user-name and x-wirl-role are present but empty: nobody is there, so there is nobody to name. Nothing fires a schedule on a clock under wirl dev: it prints a curl -X POST command for each one that does not start a flow, and a request you send to this path arrives the same way, with x-wirl-run set to dev.
Whatever your response body says, up to 1 KB, becomes that run's note. wirl run and the wirl_run MCP tool print it after the outcome, and wirl run --json carries it as note; wirl schedules --json carries the last run's as lastRun.note; and the app's Overview shows it beside each of the app's last ten runs. Plain wirl schedules and the wirl_list_schedules tool print the last run's outcome without it. A 2xx response is ok; anything else is failed, with the status code kept alongside it. A note is for the people who can deploy the app, as its logs are: anyone else who can open the Overview sees each run's outcome, a failure's status code included, and no note.
Timeouts and the fifteen-minute ceiling#
wirl aborts a fire that outruns its timeout, marking the run timed_out — a schedule can declare at most 15 minutes ("15m"), because that is the longest a request to your app is allowed to take at all.
Work that takes longer than that does not fit in one fire; the fix is to chunk it across several. Keep a cursor in env.DB — the last id processed, an offset, a page token — and have the fire do one bounded slice of the work per invocation, moving the cursor forward before it returns:
const cursor = await env.DB.prepare('select value from sync_state where key = ?').bind('jobs_cursor').first();
const page = await fetchNextPage(cursor?.value ?? null);
await env.DB.prepare('insert into sync_state (key, value) values (?, ?) on conflict (key) do update set value = excluded.value')
.bind('jobs_cursor', page.nextCursor)
.run();
return new Response(`processed ${page.items.length}, cursor now ${page.nextCursor}`);
A */5 * * * * schedule then works through an arbitrarily large job a page at a time, and every page is its own run with its own note — so if item 40,000 breaks something, the Overview points at the run that broke, not a 4-hour fire that failed somewhere in the middle.
A flow does the same with less to keep: the cursor rides in the run's state, each step is one slice, and a schedule can start the run each night (below).
Starting a flow#
A schedule that names a flow starts a run of it at each time due, instead of posting to /__wirl/schedule/<name>:
{
"flows": [{ "name": "import", "step_timeout": "2m" }],
"schedules": [{ "name": "nightly", "cron": "0 2 * * *", "flow": "import" }]
}
- The run starts with
nullas its input, and each of its steps is a request of its own, as Flows says. Atimeouton such a schedule is refused: each step has its flow'sstep_timeout, and a run as a whole has no time limit. - Each time due starts one run, keyed
schedule:<name>:<the time it was due>, so a fire tried again finds the run its first try started rather than starting a second. No other start may use a key beginningschedule:. - The fire is recorded
okas soon as the run has started, with the notestarted run <id>. A start wirl refuses is recordedfailed, with the refusal's sentence as its note, and tried again asretriessays. - Under
overlap: "skip", the default, a fire is skipped while a run this schedule started is still unfinished. A run of the same flow started any other way does not count. wirl run <schedule>starts a run the same way, keyed on its own fire, so it never takes the place of the next one the clock makes. With--preview <name>, it starts the run on that preview, and posts nothing.wirl devdoes not run flows, so it prints nocurlfor such a schedule. It says so, and points at the flow's owncurlfor its first step, and atwirl run <schedule> --preview <name>to start a run on a preview.
Retries and overlap#
A failed or timed-out fire retries only when the schedule declares retries and the trigger was the clock, not a person: a manual wirl run is watched by whoever asked for it, so it never queues a retry behind their back. Retries wait longer each time — about a minute, then five, then fifteen — rather than immediately, so a flaky dependency gets room to recover instead of being hammered.
overlap decides what happens when a fire is due while the previous one for the same schedule is still running. The default, "skip", leaves it alone and records that run as skipped — the right choice for anything that assumes only one copy of itself runs at a time, such as the cursor pattern above. "allow" starts the new one anyway, for work that's fine running concurrently with itself.
Only one manual run of a schedule goes at a time; cron occurrences still follow the overlap rule. A manual run that is still going counts as running for the overlap rule, so the next cron occurrence of that schedule is skipped under skip.
Seeing runs#
Every fire — cron or manual — is a run, shown newest first on the app's Overview, alongside its trigger and outcome, and its note for people who can deploy the app. wirl schedules (and the wirl_list_schedules MCP tool) shows each schedule's next scheduled time and its last run's outcome without opening the dashboard:
$ wirl schedules
poll-jobs Weekdays at 09:00 America/Los_Angeles last ok 2m ago next in 3h
A time of day is shown with its zone, UTC included, in wirl schedules, the tool and the Overview: a schedule with no timezone reads Every day at 02:00 UTC. An interval such as Every 15 minutes holds no time of day, and names no zone when it is UTC. Under --json, words leaves UTC out, as it always has, and timezone names the zone.
A run that fails is also written to the app's audit log, so it shows up wherever the rest of an app's activity does.
Testing with wirl run#
Waiting for the clock to confirm a schedule works is slow. wirl run <schedule> fires it immediately and waits for the outcome, the same as the wirl_run MCP tool:
$ wirl run poll-jobs
Queued poll-jobs (run a1b2c3).
ok in 4.1s: checked 40 postings
A non-ok outcome — failed, timed_out — prints the same way and exits 1, so it fails a script or a CI step the way any other broken command would:
$ wirl run poll-jobs
Queued poll-jobs (run d4e5f6).
failed (502) after 1.0s
After a rollback#
Schedules belong to the app, not the deployment: rolling back does not rewrite them. If the older code lacks a handler for a schedule, its runs fail until you redeploy or remove the schedule.