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.
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 and x-wirl-role are present but empty: nobody is there, so there is nobody to name.
Whatever your response body says, up to 1 KB, becomes that run's note: the line people and agents see next to its outcome in wirl schedules, wirl run, and the app's Overview. A 2xx response is ok; anything else is failed, with the status code kept alongside it.
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.
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, outcome and note. 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 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.