Skip to content

Signals & waitForEvent

const approval = defineWaitStep({
type: 'await-approval',
outputSchema: z.object({ approved: z.boolean() }),
dependencies: { draft },
});
const wf = buildWorkflow({ type: 'publish', inputSchema, steps: { draft, approval, publish } });
// …elsewhere, when the webhook/human responds:
await engine.resumeStep(workflowId, 'approval', { approved: true });

The step suspends (waiting) once its dependencies complete — its handler never runs — until resumeStep delivers the event payload, which becomes its output. → examples/08-wait-for-event.ts

Whatever you pass becomes the step’s output verbatim — the engine does not validate it against outputSchema on the way in. Validation happens on the way out, when a dependent step reads it through ctx.deps:

const publish = defineStep({
dependencies: { approval },
handler: async (ctx) => {
ctx.deps.approval.approved; // typed, and parsed against approval's outputSchema here
},
});

So a malformed webhook body doesn’t fail at resumeStep — it fails the dependent step, as a non-retryable validation error. Validate at your HTTP boundary if you want a 400 instead.

Idempotency and the states that ignore a resume

Section titled “Idempotency and the states that ignore a resume”

resumeStep is safe to call more than once. It returns { ok: true } and does nothing when:

  • the step is not waiting (already resumed, already failed, or still pending because its dependencies haven’t completed — a resume that arrives early is dropped, not queued)
  • the workflow is not pending or running (already completed, failed, or cancelled)

It returns an error only when the workflow or the step key doesn’t exist. That means a re-delivered webhook is a no-op, but so is a resume that arrives before the step is ready — if your event can beat the DAG, persist it and replay after the step.waiting event.

A waiting step has no timeout. It is not swept by recoverStuckWorkflows, which only looks at steps stuck in running, so a workflow awaiting an event that never arrives waits indefinitely. Two ways to bound it:

  • Cancel it. engine.cancelWorkflow(id) marks waiting steps skipped and finishes the workflow as cancelled.
  • Race it with a sleep. Add a sleep step on a parallel branch and have its downstream step check whether the approval landed. The DAG is static, so there is no built-in “first one wins” — you model the timeout as a step.

waiting folds to the display state running in the public view, so a UI shows it as in-flight rather than as its own state. Watch the step.waiting and step.resumed events if you need to show “awaiting approval” specifically.