wispwisp
Primitives

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) runs fn exactly 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.sleep calls should be side-effect-free — it may run again on replay. Put side effects (an API call, a database write) inside a ctx.step.
  • A Store, Queue, or Bucket call 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.sleep calls at the top level of the handler.

Not yet supported:

  • ctx.parallel, ctx.map, or waiting on an external callback.
  • ctx.step/ctx.sleep calls nested inside if/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 (no ctx.step after it) doesn't run — nothing re-invokes the function once the final state is a Wait with no Task after it. End a workflow with ctx.step if 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.

On this page