Skip to content

Authoring Workflows

Writing a workflow is writing an async function. The rules below are the places where that intuition breaks down — mostly because your function can be stopped and resumed weeks later, on a different machine.

defineWorkflow(options, execute). The name, version, and input schema go in options; the logic goes in the async execute(flow) function, and whatever it returns becomes the result of the run.

A deployed version is frozen. Change the behaviour by publishing a new version — runs already in flight keep executing the version they started on.

Validate the input before the first step runs. Use any Standard Schema validator; the SDK ships Valibot if you don’t already have a preference.

Bad input means the run never starts. That’s deliberate: you would otherwise end up with a half-executed process whose events describe work you didn’t want done.

When a run resumes, IdentityFlow rebuilds its state by replaying the recorded events. That only works if your code produces the same answer every time.

So, in workflow code — not inside a step, but in the function body itself — do not read the clock, generate random values, or call the network or a database. Anything non-deterministic belongs inside a step, whose result is recorded once and replayed from the record afterwards.

Two habits follow from this: name every step, always the same way, and make each step safe to run twice.

The event stream is append-only. A restart resumes from what was recorded; it never edits an earlier event. See Errors and Retries for what that means for calls that leave the process.

flow.start() launches a child run. Give every child step a stable name, for the same reason as above.

When independent steps can run at the same time, use the ordinary Promise methods — there is no special API. IdentityFlow tracks the Promise returned directly by flow.dialog(), flow.sleep(), flow.do(), flow.request(), and flow.start(), and the method you pick decides what happens to the ones still running when the combined Promise settles.

Promise.all, Promise.allSettled, Promise.any, and Promise.race work the way you already expect: same ordering, same rejections. What IdentityFlow adds is that cancellation and suspension survive a restart.

MethodWhen the combined Promise settlesActive direct Flow Step Promises
Promise.allevery input fulfills, or the first input rejectscanceled after an ordinary rejection
Promise.allSettledevery input has a business outcomenever canceled
Promise.anythe first input fulfills, or every input rejectscanceled after the first fulfillment
Promise.racethe first input ordinarily fulfills or rejectscanceled after that first settlement

One distinction is worth pausing on, because it is the thing people get wrong. A step that is waiting — for a person, for a timer — is not a step that failed. Engine suspension cancels no sibling: it pauses the whole run until the waiting step can continue, and it never shows up as a rejected Promise.

When one of the Promise methods above does cancel a step, that step is persisted as CANCELED before you see the result.

Use Promise.all when later work needs every result. Result order follows input order. An ordinary rejection cancels the other active direct Flow Step Promises because the combined Promise can no longer fulfill.

const [recipient, role, locale] = await Promise.all([
flow.do('load recipient', () => ({ id: flow.params.recipientId })),
flow.do('load role', () => ({ id: flow.params.roleId })),
Promise.resolve('en'),
]);

The plain Promise.resolve() in that list behaves like any other Promise. The engine only knows about the two flow.do() Promises; it will never report the plain one as canceled.

Use Promise.allSettled when each item may succeed or fail independently. Inspect every rejected result and turn it into a deliberate business outcome. allSettled never cancels another member.

const assignmentResults = await Promise.allSettled(
flow.params.assignments.map((assignment, index) =>
flow.do(`apply assignment ${index}`, () => {
if (assignment === 'blocked') throw new Error('Assignment is blocked');
return assignment;
}),
),
);
const failedAssignments = assignmentResults.flatMap((result, index) =>
result.status === 'rejected' ? [flow.params.assignments[index]] : [],
);

Suspension travels as control flow, so it never turns up among the settled results. This is why a broad catch around the whole composition is a bad idea — it swallows the signal that pauses the run. If you catch an AbortError somewhere to clean up, rethrow it.

Use Promise.any when any one success is enough. The first success wins and IdentityFlow cancels the still-running steps. If everything rejects you get the normal AggregateError — no winner is invented.

const firstApproval = await Promise.any([
flow.do('manager approval', () => 'manager'),
flow.do('owner approval', () => 'owner'),
]);

Use Promise.race when the first outcome decides, success or failure alike. Pass the step Promises directly if you want the losers reliably canceled.

const firstDeadline = await Promise.race([
flow.sleep('short deadline', '15 minutes', 'short'),
flow.sleep('long deadline', '1 hour', 'long'),
]);

Both members here are step Promises, so the loser is written as CANCELED before firstDeadline is handed back to you.

Cancellation is cooperative for flow.do(), flow.request(), and flow.start(): IdentityFlow signals your callback and waits for it to stop somewhere safe.

What it cannot do is undo work that already left the process. Cancellation cannot roll back an external effect, so the system you called still has to cope with a repeat.

One gotcha: only the Promise a step returns directly carries its cancellation information. The moment you wrap it — .then(), .catch(), .finally(), a Promise subclass, a thenable of your own — the engine loses track of it.

The exact type signatures are in Workflow API, quoted from the source.