04 · Deadlines, Hooks & Waiting
The three features that make a workflow feel alive: deadlines that fire on their own, hooks that run side effects on enter/exit, and wait states that pause for an external system. A support ticket with SLAs ties them together.
Workflow diagram
Features shown: time-triggered transitions (after), tick / getNextDueAt, the host-supplied clock (createInstance(id, ctx, now), dispatch(…, { now })), onEnter / onExit hooks, addWait + resolveWait.
Code
ts
import { z } from 'zod';
import { createWorkflow, type HookContext } from 'flowyd';ts
// ─── A clock we control, so the example is fully deterministic ──────────────────
const T0 = new Date('2026-06-07T09:00:00.000Z');
const at = (mins: number): Date => new Date(T0.getTime() + mins * 60_000);
const clock = (d: Date): string => d.toISOString().slice(11, 16); // HH:MM
// ─── Hooks: side effects, kept out of the transition logic itself ───────────────
const notifyOnCall = (ctx: HookContext): void => {
console.log(` ↳ [hook onEnter ${ctx.stateId}] paging on-call engineer`);
};
const stampTriaged = (ctx: HookContext): void => {
console.log(` ↳ [hook onExit ${ctx.stateId}] recording triage completion`);
};
const sendSurvey = (ctx: HookContext): void => {
console.log(` ↳ [hook onEnter ${ctx.stateId}] emailing satisfaction survey`);
};
// ─── Action payloads ────────────────────────────────────────────────────────────
const AssignSchema = z.object({ agentId: z.string() });
const VendorSchema = z.object({ vendorTicket: z.string() });
const ReplySchema = z.object({ summary: z.string() });
const ResolveSchema = z.object({ agentId: z.string(), resolution: z.string() });
// ─── Workflow ────────────────────────────────────────────────────────────────────
export const ticket = createWorkflow({ name: 'support-ticket' })
.defineAction('ASSIGN', AssignSchema)
.defineAction('ESCALATE_TO_VENDOR', VendorSchema)
.defineAction('VENDOR_REPLIED', ReplySchema)
.defineAction('RESOLVE', ResolveSchema)
.addStep('triage', { label: 'Triage', onExit: stampTriaged })
.addStep('in-progress', { label: 'In Progress' })
.addWait('awaiting-vendor', { label: 'Awaiting Vendor', externalName: 'vendor-support' })
.addStep('escalated', { label: 'Escalated', onEnter: notifyOnCall })
.addStep('resolved', { label: 'Resolved', onEnter: sendSurvey })
.setInitial('triage')
.setTerminal(['resolved'])
// Action-triggered arcs:
.addTransition({ from: 'triage', to: 'in-progress', on: 'ASSIGN' })
.addTransition({ from: 'in-progress', to: 'awaiting-vendor', on: 'ESCALATE_TO_VENDOR' })
.addTransition({ from: 'awaiting-vendor', to: 'in-progress', on: 'VENDOR_REPLIED' })
.addTransition({ from: 'in-progress', to: 'resolved', on: 'RESOLVE' })
.addTransition({ from: 'escalated', to: 'resolved', on: 'RESOLVE' })
// Time-triggered arcs (deadlines). Exactly one of `on` / `after` per arc.
// The clock for each is anchored to when its `from` state is entered.
.addTransition({ from: 'triage', to: 'escalated', after: '15m' }) // breach if not assigned
.addTransition({ from: 'in-progress', to: 'escalated', after: '4h' }) // breach if not resolved
.build();
// ─── Run ─────────────────────────────────────────────────────────────────────────
async function run() {
// ── A. Handled within SLA — the deadline never fires ──────────────────────────
console.log('=== A. Resolved inside the SLA window ===\n');
const a = ticket.createInstance('TKT-A', undefined, T0); // created at 09:00
console.log(` 09:00 created → ${a.getCurrentStates()}`);
console.log(` next deadline armed at → ${clock(new Date(a.getNextDueAt()!))} (15m triage SLA)`);
await a.dispatch('ASSIGN', { agentId: 'sam' }, { now: at(5) }); // 09:05
console.log(` 09:05 ASSIGN → ${a.getCurrentStates()}`);
await a.dispatch('RESOLVE', { agentId: 'sam', resolution: 'restarted the node' }, { now: at(40) });
console.log(` 09:40 RESOLVE → ${a.getCurrentStates()} terminal: ${a.isTerminal()}`);
// ── B. SLA breach — a scheduler ticks an idle instance and the deadline fires ──
console.log('\n=== B. Nobody picks it up → 15m deadline auto-escalates ===\n');
const b = ticket.createInstance('TKT-B', undefined, T0);
console.log(` 09:00 created → ${b.getCurrentStates()}`);
// Your scheduler stores getNextDueAt() and wakes up at that time to call tick().
const due = b.getNextDueAt()!;
console.log(` deadline due at → ${clock(new Date(due))}`);
const fired = await b.tick(at(20)); // scheduler wakes at 09:20, past the 15m SLA
console.log(` 09:20 tick() → ${fired} deadline(s) fired → ${b.getCurrentStates()}`);
// The escalated ticket can still be resolved by the on-call engineer.
await b.dispatch('RESOLVE', { agentId: 'on-call', resolution: 'mitigated' }, { now: at(25) });
console.log(` 09:25 RESOLVE → ${b.getCurrentStates()} terminal: ${b.isTerminal()}`);
// ── C. Waiting on an external system, then resuming ───────────────────────────
console.log('\n=== C. Escalate to a vendor (wait), then resume on their reply ===\n');
const c = ticket.createInstance('TKT-C', undefined, T0);
await c.dispatch('ASSIGN', { agentId: 'pat' }, { now: at(2) });
await c.dispatch('ESCALATE_TO_VENDOR', { vendorTicket: 'V-7781' }, { now: at(10) });
console.log(` 09:10 to vendor → ${c.getCurrentStates()} (status: waiting)`);
console.log(` any deadline armed? → ${c.getNextDueAt() ?? 'none (paused on the vendor)'}`);
// ...hours later the vendor replies. The host promotes the wait state, then a
// normal dispatch carries the vendor's payload into the audit trail.
c.resolveWait('awaiting-vendor', { now: at(180) });
await c.dispatch('VENDOR_REPLIED', { summary: 'firmware patch shipped' }, { now: at(181) });
console.log(` 12:01 vendor replied → ${c.getCurrentStates()}`);
await c.dispatch('RESOLVE', { agentId: 'pat', resolution: 'applied vendor patch' }, { now: at(190) });
console.log(` 12:10 RESOLVE → ${c.getCurrentStates()} terminal: ${c.isTerminal()}`);
}What to notice
- The engine never reads a clock — the host does. Every time-aware call takes an optional
now(defaulting tonew Date()). Pass explicit timestamps and a run becomes a pure function of its events: deterministic replay and tests with zero fake timers. - Two ways deadlines advance: automatically at the start of every
dispatch(so a late action can't act on a state that should already have timed out), and explicitly viatick(now)— what a scheduler calls for idle instances. PersistgetNextDueAt()as an indexed column to drive that sweep. - Hooks fire after the transition commits, in order:
onExitof exited states, thenonEnterof entered states. Keep notifications and audit writes here, out of the transition logic. - A wait state has no deadline of its own here, so
getNextDueAt()returnsnullwhile paused on the vendor.
Run it locally:
npx tsx examples/04-deadlines-hooks-wait.ts