A workflow is a plain function. Each hop.step() wraps one side effect — a charge, an email, an API call. Between steps you can branch, loop, throw, and sleep, in your language, with your libraries.
There is no workflow DSL to learn and no state machine to draw. If you can write a function, you have already learned the API.
export async function checkout(hop, order) {
await hop.step("reserve_inventory", () =>
inventory.hold(order.sku));
await hop.step("charge_card", () =>
stripe.charge(order));
await hop.sleep("refund_window", "30d");
await hop.step("ship_order", () =>
carrier.create(order));
await hop.step("notify_customer", () =>
email.send(order.user, "shipped"));
}
When a step completes, its result is committed to the journal before execution continues. The frame stores the return value, the attempt count, and an idempotency key — so a replay knows the step already ran and what it returned.
Sleeps are frames too. A 30-day wait costs no worker, no thread, no open connection — the run simply has a wake time, and nothing needs to be running until it arrives.
When a worker dies, its runs are reassigned. The new worker re-executes the function from the top — but every hop.step() that already has a committed frame returns the recorded result instead of running again. Execution fast-forwards to the exact point of death, then proceeds for real.
worker_03 reserve_inventory ✓ ── charge_card ✓ ── ship_order ✗ SIGKILL │ journal survives ▼ │ worker_07 reserve_inventory ○ ── charge_card ○ ─── ship_order ✓ attempt 2 ── notify_customer ✓ (replayed in 3ms — no side effects re-run, charge ch_8x2 returned from frame 1)
A committed frame never re-executes. An in-flight step retries with the same idempotency key, so your provider sees one charge either way.
New code picks up old runs mid-process. Versioned steps let you change a workflow without stranding the runs already inside it.
Any historical run can be replayed locally against the recorded frames — step through what production actually did, not what the logs imply.