Deadlines
A review nobody gets to is a process that stops. Put a <Deadline> in a stage to remind someone, hand the work to someone else, or close the session when time runs out.
Try it
An expense claim with a reminder and an escalation in the playground runs in your browser. Skip to … in the toolbar moves the clock forward to the next deadline.
tsx
<Stage name="review" actor="role:manager">
<Field name="decision" type="select" required options={…} />
{/* Two business days in: remind them. The stage stays open. */}
<Deadline after="2bd">
<Action id="remind_manager" type="email" to="{{manager_email}}" subject="Still waiting: {{description}}" />
</Deadline>
{/* Five business days in: finance decides instead. */}
<Deadline after="5bd" goTo="escalation" />
</Stage>
<Stage name="claim" actor="role:employee">
…
{/* A claim nobody submits for 30 days is closed. */}
<Deadline after="30d" expire />
</Stage>A deadline can do three things:
| What happens | |
|---|---|
| Actions only | A reminder: the actions inside run, and the stage stays open. You can set several. |
goTo="stage" | The stage is left unsubmitted (stage_expired), and the session moves to that stage. Any actions inside run first. |
expire | The session ends with status expired. After that, the engine accepts no answers and no submits. |
When the clock runs
- The clock starts when the stage is entered. Each round gets new clocks, so a claim that was sent back and resubmitted gives the manager a fresh two days. See Loop-backs and rounds.
- A deadline fires once per round, and only while the stage is open. Once someone submits the stage, its remaining deadlines never fire.
- No deadline fires while the session is waiting on an action (a KYC check, a slow webhook). That action has its own
timeoutSeconds, and deadlines are checked again once it resolves. - The engine looks for due deadlines every minute. A deadline fires within about a minute of its time.
A goTo target is entered even if its renderIf is false. That is how an escalation stage can stay off the normal path: give it a renderIf that is false once the manager has decided, and only the deadline takes the session there.
Durations and business days
after | |
|---|---|
30m, 4h | minutes, hours |
2d | calendar days (48 hours) |
3bd | business days: weekdays that are not holidays, at the same local time of day |
Business days depend on where the person is. A Friday in Zagreb is not a Friday in Los Angeles. Give the session a time zone and your holidays when you start it:
ts
await fetch(`${ENGINE}/api/v1/workflows/expense_claim_v1/sessions`, {
method: 'POST',
headers: { authorization: `Bearer ${API_KEY}`, 'content-type': 'application/json' },
body: JSON.stringify({
role: 'role:employee',
timeZone: 'Europe/Zagreb', // IANA name; default UTC
holidays: ['2026-12-25', '2026-12-26'], // local dates, besides Saturdays and Sundays
}),
})Entered Friday at 16:00 in Zagreb, 3bd is due Wednesday at 16:00 there. Clock changes are taken into account. There are no built-in national calendars: you send the holidays you observe.
Seeing what is coming
useDeadlines()gives the current stage's pending deadlines with their due times, so you can show "reminder goes out Wednesday".GET /workflows/:workflowId/sessions/:sid(API key) lists them asdeadlines, with the session'stimeZoneandholidays.- The ledger records each firing as
timer_fired { stage, deadline, round }, followed by what it did.
The engine fires them, within a minute of when they fall due. The playground can skip ahead to the next one, to see a reminder or an escalation without waiting days.
Rules
- A deadline's actions are never
blocking, because nothing waits for them. Deploy refuses a blocking one. aftermust be a duration, and agoTotarget must exist. A deadline needs at least one of: actions,goToorexpire. It cannot have bothgoToandexpire.- Put
<Deadline>directly in a<Stage>or one of its<Step>s. Its<Action>s take noon.