Workflow
Durable, multi-step functions on Step Functions — write it like normal async code.
Workflow is the answer to "I need this to keep running for longer than a Lambda timeout, survive a crash partway
through, and pause without paying for the wait." It compiles to AWS Step Functions, but you write it as an ordinary
async function.
Declaring a workflow
import { Workflow } from "@vorynza/wisp";
interface CheckoutInput {
orderId: string;
amount: number;
}
export const checkout = new Workflow("checkout", async (ctx, input: CheckoutInput) => {
const payment = await ctx.step("charge", () => chargeCard(input));
await ctx.sleep("cooldown", "5 minutes");
const shipment = await ctx.step("ship", () => createShipment(payment));
return shipment;
});ctx.step(name, fn)runsfnexactly once across the whole execution — even though it may look like it's called again if the function is replayed (see How it works below), a step's real work never runs twice.ctx.sleep(name, duration)pauses for a duration like"30 seconds","5 minutes","2 hours","1 day"— handled natively by Step Functions, so the pause costs nothing and doesn't hold a Lambda invocation open.
Starting an execution
Call .start() from another function — a route, a queue consumer, wherever the triggering event happens:
export const startCheckout = api.post("/checkout", {}, async ({ body }) => {
await checkout.start({ orderId: body.orderId, amount: body.amount });
return { status: 202, body: { started: true } };
});.start() kicks off the execution and returns immediately — it doesn't wait for the workflow to finish.
How it works
Every ctx.step/ctx.sleep call in your function becomes a state in a Step Functions state machine: a Task state
per step, a native Wait state per sleep. All the Task states point at the same Lambda function — there's one
function for the whole workflow, not one per step.
When Step Functions invokes that function for a given step, the handler replays your async function from the top.
Steps that already ran return their saved result immediately, without calling your code again. The one step Step
Functions is actually asking for runs for real. This is the same execution model used by durable-workflow engines
like Temporal — it's what lets a plain async function survive a multi-hour pause without you managing any state
yourself.
In practice this means:
- Code between/around
ctx.step/ctx.sleepcalls should be side-effect-free — it may run again on replay. Put side effects (an API call, a database write) inside actx.step. - A
Store,Queue, orBucketcall made directly inside a step's callback is tracked for IAM exactly like any other function — see IAM derivation.
v0 scope
This is the newest and most constrained primitive. Supported today:
- A flat, sequential list of
ctx.step/ctx.sleepcalls at the top level of the handler.
Not yet supported:
ctx.parallel,ctx.map, or waiting on an external callback.ctx.step/ctx.sleepcalls nested insideif/for/while/try— the compiler rejects these with a clear error rather than guessing at the intended shape.- Code written after a trailing
ctx.sleep(noctx.stepafter it) doesn't run — nothing re-invokes the function once the final state is aWaitwith noTaskafter it. End a workflow withctx.stepif you need a computed return value.
What this deploys to
An AWS::StepFunctions::StateMachine (Standard, not Express — Express workflows cap execution duration at 5
minutes, which most real workflows will exceed) with its own dedicated AWS::IAM::Role, trusted by
states.amazonaws.com and scoped to invoke exactly this workflow's function. Step Functions bills per state
transition, with no idle or reserved-capacity cost — a workflow that never runs costs nothing, same as everything
else in wisp.
Any function that calls .start() gets states:StartExecution scoped to this workflow's state machine ARN, derived
automatically the same way every other cross-primitive permission is.

