Connect
Scheduled triggers
Attach cron schedules to any workflow and the engine starts governed runs on time — no external cron box, no forgotten crontab. Each schedule stores its own input payload, evaluates in UTC, and fires within about twenty seconds of its minute boundary.
A schedule is a small record bound to one workflow: a cron expression, a stored input object, an enabled flag, and two timestamps the engine maintains for you — last_run_at and next_run_at. An in-process loop wakes roughly every 20 seconds, finds every enabled schedule whose next fire time has arrived, and starts a run through the full engine. Everything below is the exact behaviour of that loop and the API that manages it.
The cron expression
Five space-separated fields in the standard order. All evaluation is in UTC — there is no per-schedule timezone, so convert local wall-clock times to UTC before you write the expression.
| Field | Type | Default | Description |
|---|---|---|---|
| minute | 0–59 | — | Minute of the hour. |
| hour | 0–23 | — | Hour of the day, UTC. |
| day-of-month | 1–31 | — | Calendar day of the month. |
| month | 1–12 | — | Month of the year (1 = January). |
| day-of-week | 0–7 | — | Day of week; both 0 and 7 mean Sunday, 1 = Monday. |
Field syntax
Every field accepts the same four token shapes. Within a single field you may combine them with commas — the field matches the union of all entries.
| Field | Type | Default | Description |
|---|---|---|---|
| * | wildcard | — | Every value in the field's range. |
| */n | step | n >= 1 | Every nth value counting from the field's low bound. In minute, */15 yields 0, 15, 30, 45 — the phase is always the low bound, never 'now'. |
| a-b | range | a <= b | An inclusive range. Both ends must sit inside the field's range and a must not exceed b. |
| a,b,c | list | — | A comma list. Each entry is itself a single value or a range, so 1-5,10-15 is valid. |
| 5 | value | — | A single fixed value inside the field's range. |
One subset limitation
Steps apply to the whole field only —*/n. A stepped range such as 1-10/2 is not supported and is rejected as a bad range. To step through part of a range, list the values explicitly, for example 1,3,5,7,9.Example expressions
| Field | Type | Default | Description |
|---|---|---|---|
| * * * * * | every minute | — | Fires at the top of every minute — the tightest cadence the parser allows. |
| */5 * * * * | minutes | — | Every 5 minutes: minute in 0, 5, 10, … 55. |
| */15 * * * * | minutes | — | Every 15 minutes, on the quarter hour (0, 15, 30, 45). |
| 30 * * * * | hourly | — | At 30 minutes past every hour. |
| 0 * * * * | hourly | — | On the hour, every hour. |
| 0 */2 * * * | hourly | — | Every 2 hours on the hour: 00:00, 02:00, … 22:00 UTC. |
| 0 0 * * * | daily | — | Midnight UTC, every day. |
| 0 9 * * 1-5 | weekdays | — | 09:00 UTC, Monday through Friday. |
| 0 9,17 * * 1-5 | weekdays | — | 09:00 and 17:00 UTC on weekdays — a list in the hour field. |
| */10 9-17 * * 1-5 | business hrs | — | Every 10 minutes from 09:00 to 17:50 UTC, Monday to Friday. |
| 30 6 * * 0 | weekly | — | 06:30 UTC every Sunday — 0 and 7 both mean Sunday. |
| 0 22 * * 6 | weekly | — | 22:00 UTC every Saturday (6 = Saturday). |
| 0 8 1 * * | monthly | — | 08:00 UTC on the 1st of every month. |
| 0 8 1,15 * * | monthly | — | 08:00 UTC on the 1st and the 15th. |
| 0 0 1 1 * | yearly | — | Midnight UTC on 1 January. |
Day-of-month and day-of-week together
When both the day-of-month and day-of-week fields are restricted — that is, neither is * — the schedule fires when either matches. This is standard cron, and it is a common source of surprise. When at least one of the two is *, only the other constrains the day.
| Field | Type | Default | Description |
|---|---|---|---|
| 0 9 13 * 5 | either | — | Both day fields set → 09:00 UTC on any 13th OR any Friday (not 'Friday the 13th'). |
| 0 9 * * 5 | dow only | — | Day-of-month is * → 09:00 UTC on Fridays only. |
| 0 9 13 * * | dom only | — | Day-of-week is * → 09:00 UTC on the 13th of each month only. |
Validation
Expressions are validated on create and on any cron edit. The check both parses the fields and confirms the expression fires at least once within the next year, so impossible dates like 30 February are rejected too. Failures return 400 naming the exact field and problem:
{ "detail": "Invalid cron expression: cron needs exactly 5 fields: minute hour day-of-month month day-of-week" }
{ "detail": "Invalid cron expression: minute: 75 outside 0-59" }
{ "detail": "Invalid cron expression: day-of-week: range '5-2' outside 0-7" }
{ "detail": "Invalid cron expression: hour: step must be >= 1" }
{ "detail": "Invalid cron expression: cron expression never fires within a year" }
How the scheduler runs
Scheduling is in-process. A background task in the backend's lifespan runs one tick every 20 seconds (TICK_SECONDS) on a worker thread — no Celery, no APScheduler, no external cron. Each tick loads every enabled schedule whose next_run_at is at or before the current time in a single query, then processes them in order.
- Because ticks are ~20s apart and
next_run_atalways lands on a minute boundary, a schedule fires within about 20 seconds after its boundary and never before it. Minute precision is guaranteed; sub-minute precision is not next_run_atis always the first fire time strictly in the future, found by scanning forward minute by minute up to roughly 366 dayslast_run_atis set to the tick time on every attempt, including skipped ones — it records the last time the scheduler evaluated the schedule, not the last successful run- After each attempt,
next_run_atis recomputed from the cron relative to that tick. A missed or skipped slot is not retried — the schedule advances to its next matching minute, so schedules never try to catch up on a backlog - A scheduled run is created with status
pendingand handed to the engine exactly like a manual trigger: graph validation, checkpoints, human gates, retries, cost attribution, evals, and the WebSocket event stream all apply - Each successful trigger is audit-logged as
schedule_runon the workflow, with details carrying therun_id,schedule_id, and thecronstring
Order of checks per due schedule
For every due schedule, the tick runs these checks in sequence and stops at the first that fails. Whatever the outcome, last_run_at and next_run_at are updated afterward.
| Field | Type | Default | Description |
|---|---|---|---|
| 1. run quota | skip | — | If the workspace is at its monthly run limit, the tick is skipped and logged. The schedule stays enabled and resumes when quota frees up. |
| 2. workflow exists | disable | — | If the target workflow was deleted, the schedule is disabled (enabled = false) — it will not fire again until re-enabled. |
| 3. graph valid | skip | — | The workflow graph is validated at fire time. If it has errors, the tick is skipped and logged; the schedule stays enabled so a fixed graph resumes firing. |
| 4. trigger | run | — | Otherwise a run is created, audit-logged as schedule_run, and started through the engine. |
Self-disabling
A schedule turns itself off only in two situations, and both are permanent until you re-enable it with PUT /api/schedules/{id}:
- The workflow it points at has been deleted — the scheduler cannot resolve a graph to run
- Recomputing
next_run_atfinds no fire time within a year (a defensive guard; the expression was validated at create time, so this is rare)
Schedules respect plan limits
A tick that would exceed the workspace's monthly run quota is skipped and logged, never queued. The schedule keeps its cadence and resumes firing once quota is available — typically at the start of the next calendar month (UTC) or immediately after an upgrade. See/docs/operations for the quota per plan and the 402 behaviour at every trigger surface.Managing schedules
Full CRUD lives under /api. Creating, editing, and deleting schedules require the create_edit_workflows capability (editor or admin); listing requires only view. Every schedule mutation is audit-logged.
/api/workflows/{id}/schedulesCreate a schedule on a workflow. The stored input becomes every triggered run's input. 404 if the workflow is missing, 400 if the cron is invalid. Returns 201 with the computed next_run_at.
{ "cron": "0 9 * * 1-5", "input": { "source": "daily-digest" } }
// →
{
"id": "b8d1f2a90c34",
"workflow_id": "9bc62f209c52",
"workflow_name": "Support Triage Crew",
"cron": "0 9 * * 1-5",
"input": { "source": "daily-digest" },
"enabled": true,
"last_run_at": null,
"next_run_at": "2026-07-08T09:00:00Z",
"created_at": "2026-07-07T14:21:08Z"
}
/api/workflows/{id}/schedulesEvery schedule attached to one workflow, newest first. 404 if the workflow does not exist.
/api/schedulesEvery schedule in the workspace, newest first — the fleet view. Each entry includes its workflow_name.
/api/schedules/{id}Partial update of cron, input, and/or enabled. Editing the cron re-validates it and recomputes next_run_at; re-enabling a schedule that lost its next_run_at recomputes it. 404 if the schedule is missing.
{ "enabled": false }
/api/schedules/{id}Delete the schedule. Runs it already started are unaffected. Returns 204.
Full lifecycle by curl
# create: every weekday at 09:00 UTC, with a stored input payload
$ curl -s -X POST http://localhost:8000/api/workflows/9bc62f209c52/schedules \
-H "content-type: application/json" \
-d '{"cron": "0 9 * * 1-5", "input": {"ticket": "daily digest sweep"}}'
# → 201 { "id": "b8d1f2a90c34", "enabled": true, "next_run_at": "2026-07-08T09:00:00Z", ... }
# list the whole workspace fleet
$ curl -s http://localhost:8000/api/schedules
# list schedules for just this workflow
$ curl -s http://localhost:8000/api/workflows/9bc62f209c52/schedules
# retune the cadence — recomputes next_run_at
$ curl -s -X PUT http://localhost:8000/api/schedules/b8d1f2a90c34 \
-H "content-type: application/json" \
-d '{"cron": "*/15 * * * *"}'
# pause without losing the schedule
$ curl -s -X PUT http://localhost:8000/api/schedules/b8d1f2a90c34 \
-H "content-type: application/json" \
-d '{"enabled": false}'
# remove it entirely
$ curl -s -X DELETE http://localhost:8000/api/schedules/b8d1f2a90c34 # → 204