wirldocs
The wirl npm package is not published yet, so the npx wirl … commands on these pages will not run today. Until it ships, connect your agent to the hosted endpoint: the first command in the Quickstart.

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 in wirl schedules, wirl run and 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 to UTC. 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 to 0.
  • 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's name.
  • x-wirl-run — this run's id, useful in your own logs to line up with wirl run or the Overview.
  • x-wirl-attempt1 on the first try, 2 or 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.