---
title: "Durable workflows"
description: "Write a flow that takes minutes or weeks as one async function, with each step journaled and never run twice after a crash, restart or deploy."
section: "interfaces"
status: "Preview · ships as a beta API"
spec: "workflows"
canonical: "https://mainsdk.dev/docs/interfaces/workflows"
updated: "2026-10-28"
---

# Durable workflows

> Write a flow that takes minutes or weeks as one async function, with each step journaled and never run twice after a crash, restart or deploy.

Status: Preview · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/workflows · Updated: 2026-10-28

`workflows` runs a multi-step flow as one async function, journaling each step so it resumes after a crash, restart or deploy. Use it when work has several side effects or must wait.

A builder loads a user, sends a welcome email, waits three days, waits for the user to activate, and asks a person to approve credits. Finished steps are not run again. For one side effect in the background, use [`jobs`](https://mainsdk.dev/docs/interfaces/jobs).

## At a glance

- Status: Preview · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/workflows.md
- Category: Events, jobs and webhooks

Calls:

- `workflow(name, opts, fn)`
- `step(name, fn, opts?)`
- `sleep(duration)`
- `waitFor(type, opts)`
- `approve(req)`
- `workflows.start(wf, input, opts?)`
- `workflows.get(runId)`
- `workflows.list(opts?)`
- `workflows.cancel(runId, opts)`
- `new Workflows(opts)`
- `workflows.register(workflow)`

In main.yaml:

```yaml
workflows:
  driver: db
  retries: { max: 3, backoff: exponential, initial: 1s }
  max-step-result: 256KB
  lease: 30s
  keep: 30d
```

Guarantees, whatever the driver:

- A finished step never runs again on resume
- A run resumes on the version it started on
- Step results and workflow inputs never appear in run history


## Drivers

Pick one in main.yaml with `workflows.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built on the app's database | `db` | yes |  |
| Local (in memory in tests, a journal folder in development) | `local` |  |  |
| Inngest | `inngest` |  |  |
| Temporal | `temporal` |  |  |
| Vercel Workflow | `vercel-workflow` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-WORKFLOW-0009` | check | A duration is not a valid `<n><s|m|h|d>` value, or is over 365d |
| `MAIN-WORKFLOW-0010` | check | A clock, random or network call (`Date.now`, `new Date()`, `Math.random`, `crypto.randomUUID`, `http.*`, `fetch`) in a workflow function outside a step |
| `MAIN-WORKFLOW-0011` | check | The `local` driver in production |
| `MAIN-WORKFLOW-0012` | check | Two workflow definitions with the same name and version |
| `MAIN-WORKFLOW-0001` | runtime | `step`, `sleep`, `waitFor` or `approve` called outside a workflow run (500) |
| `MAIN-WORKFLOW-0002` | runtime | On resume, the code reached a step the journal doesn't have in that place (code changed under a live version); the run stops as `stuck` |
| `MAIN-WORKFLOW-0003` | runtime | No registered code for the run's workflow version; the run stops as `stuck` |
| `MAIN-WORKFLOW-0004` | runtime | A step result or workflow input is not JSON, or is larger than `max-step-result` |
| `MAIN-WORKFLOW-0005` | runtime | A step failed after its last retry; the run is `failed` |
| `MAIN-WORKFLOW-0006` | runtime | No run with that id in the current tenant (404) |
| `MAIN-WORKFLOW-0007` | runtime | The journal store is unreachable, so the run was not started (503, retryable) |
| `MAIN-WORKFLOW-0008` | runtime | Cancel of a run that has already finished (409) |
| `MAIN-WORKFLOW-0013` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| workflow() and step() with a journal | 1 | 0.3 | `workflows#contract` |
| Resume from the journal after a crash or deploy | 1 | 0.3 | `workflows#replay` |
| Finished steps return their journaled result | 1 | 0.3 | `workflows#idempotent-steps` |
| Per-step retries with backoff | 1 | 0.3 | `workflows#retries` |
| sleep and waitFor without holding a worker | 1 | 0.3 | `workflows#timers-events` |
| approve for a person's yes | 1 | 0.3 | `workflows#approve` |
| Versioned runs and stuck detection | 1 | 0.3 | `workflows#versions` |
| Cancel and inspect runs | 1 | 0.3 | `workflows#cancel` |
| Run history in mainsdk logs | 1 | 0.3 | `workflows#visibility` |
| Tenant isolation for runs | 1 | 0.3 | `workflows#tenant` |
| Database driver, with local and file for development | 1 | 0.3 | `workflows#drivers` |
| Checks for clocks, randomness and network calls outside steps | 1 | 0.3 | `workflows#checks` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```ts title="app/workflows/onboarding.ts"
import { workflow, step, sleep, waitFor } from "mainsdk/workflows";
import { s } from "mainsdk/schema";

// Your own code. Steps return JSON.
declare function loadUser(userId: string): Promise<{ id: string; email: string }>;
declare function sendWelcome(email: string): Promise<{ sent: boolean }>;
declare function sendNudge(email: string): Promise<{ sent: boolean }>;

export const onboarding = workflow(
  "onboarding",
  { version: 1, input: s.object({ userId: s.string() }) },
  async ({ userId }) => {
    const user = await step("load-user", () => loadUser(userId));        // your code; returns JSON
    await step("welcome", () => sendWelcome(user.email));                // runs once, even after a crash
    await sleep("3d");                                                   // parks the run; holds no worker
    const activated = await waitFor("user.activated", { match: { userId }, timeout: "7d" });
    if (!activated) await step("nudge", () => sendNudge(user.email));
    return { activated: activated !== null };
  },
);
```

Start a run from anywhere in the app:

```ts title="app/api/signup/route.ts"
import { workflows, type WorkflowDef } from "mainsdk/workflows";

declare const onboarding: WorkflowDef<{ userId: string }>;   // exported from app/workflows/onboarding.ts
declare const user: { id: string };                          // the person who just signed up

const { runId } = await workflows.start(onboarding, { userId: user.id }, { idempotencyKey: `onboarding:${user.id}` });
```

`start` writes the run and returns its `runId`; no step runs first. The same `idempotencyKey` returns the same run. A workflow function must be deterministic outside its steps: put every clock, random and network call inside a `step`. On resume the function runs again from the top and each finished step returns its journaled result.

## Configuration

```yaml title="main.yaml"
workflows:
  driver: db                    # db | file | local
  retries: { max: 3, backoff: exponential, initial: 1s }
  max-step-result: 256KB        # JSON size of one step result and of a run's input
  lease: 30s                    # how long a worker holds a run before another may take it
  keep: 30d                     # finished runs are deleted after this
```

| Key | What it does |
| --- | --- |
| `driver` | Where the journal lives. `db` stores it in the app's database and is the default when the app has a `db` section. `file` keeps it under `.mainsdk/` and is the default otherwise; `local` keeps it in process |
| `retries` | The default retry policy for each step (3 retries, exponential, 1s to start). A step can override it |
| `max-step-result` | The largest JSON a step may return (default 256KB) |
| `lease` | How long a crashed worker holds a run before another takes it (default 30s) |
| `keep` | How long finished runs stay in the journal (default 30d) |

A workflow name matches `^[a-z0-9][a-z0-9._-]{0,63}$` and its version is a positive whole number (`MAIN-WORKFLOW-0013` otherwise). A `duration` is `<n><s|m|h|d>` and at most 365 days.

## What you get

- Each step's result is journaled, so a flow resumes at the step it reached and finished steps never run twice.
- Per-step retries with backoff, set once in `main.yaml` and overridable on a step.
- Waits for a duration or for an event, and approval by a person.
- The same `idempotencyKey` returns the same run.
- Inspect, list and cancel runs, and change a workflow safely by version.

## Examples

### Retry one step, not the whole flow

```ts
import { step } from "mainsdk/workflows";

declare function grantCredits(userId: string, credits: number): Promise<{ granted: number }>;
declare const user: { id: string };

await step("grant", () => grantCredits(user.id, 5), { retries: { max: 2 } });
```

Only `grant` retries, with `backoff: "exponential"` unless you pass `"fixed"` and an `initial` wait. The steps before it return their journaled results. A step that fails its last attempt fails the run with `MAIN-WORKFLOW-0005`.

### Ask a person to approve

```ts
import { approve, step } from "mainsdk/workflows";

declare function grantCredits(userId: string, credits: number): Promise<{ granted: number }>;
declare const user: { id: string };

const ok = await approve({ action: "credits.grant", risk: "write", spend: "$5", reason: "Welcome credits", timeout: "2d" });
if (ok.approved) await step("grant", () => grantCredits(user.id, 5));
```

`approve` puts the request in the approval queue of [`agents`](https://mainsdk.dev/docs/interfaces/agents) and parks the run until a person says yes (`ok.approved` is `true` with `by` and `approvalId`), or until the timeout (`{ approved: false, reason: "timeout" }`). A cancelled run returns `reason: "cancelled"`. An agent cannot approve its own request. Nothing is held while it waits. A person approves with `agents.approve(approvalId, { kind: "user", id })`; `agents.pendingApprovals()` lists what is waiting.

### Wait for an event

`waitFor(type, { match, timeout })` parks the run until `events.publish()` sends an event of that type whose `data` contains every `match` field. It returns the CloudEvent, or `null` at the timeout.

```ts
import { events } from "mainsdk";

await events.publish("user.activated", { userId: "u_1" });   // resumes the run that waits for this user
```

### Inspect, list and cancel

```ts
import { workflows } from "mainsdk/workflows";

declare const runId: string;                                 // from workflows.start()

const run = await workflows.get(runId);                      // state, currentStep, history, output, error
const waiting = await workflows.list({ name: "onboarding", state: "waiting" });
await workflows.cancel(runId, { reason: "user closed the account" });
```

`state` is `queued`, `running`, `sleeping`, `waiting`, `completed`, `failed`, `cancelled` or `stuck`. `history` lists each step's name, kind (`step`, `sleep`, `waitFor` or `approve`), attempt and times. It never includes step results or inputs. `cancel` stops a run that has not finished; on a finished run it fails with `MAIN-WORKFLOW-0008`.

### Change a workflow safely

A run keeps the version it started on. To change the steps, register a new version and keep the old one registered while runs are on it. New runs start on the highest registered version.

```ts
import { workflow, workflows } from "mainsdk/workflows";

export const digestV1 = workflow("digest", { version: 1 }, async () => { /* old steps */ });
export const digestV2 = workflow("digest", { version: 2 }, async () => { /* new steps */ });
workflows.register(digestV1);   // `start` registers a definition too
workflows.register(digestV2);
```

If the code reaches a step the journal doesn't have, the run is `stuck` with `MAIN-WORKFLOW-0002`. If no code is registered for a run's version it is `stuck` with `MAIN-WORKFLOW-0003`, and it resumes once you register it.

## Errors and checks

| Code | When | Fix |
| --- | --- | --- |
| `MAIN-WORKFLOW-0001` | `step`, `sleep`, `waitFor` or `approve` was called outside a workflow run | Call it inside the function you pass to `workflow()`, started with `workflows.start()` |
| `MAIN-WORKFLOW-0002` | On resume the code reached a step the journal doesn't have (the code changed under a live version); the run is `stuck` | Keep old steps in place, or start new runs on a new version |
| `MAIN-WORKFLOW-0003` | No registered code for the run's version; the run is `stuck` | Register the version |
| `MAIN-WORKFLOW-0004` | A step result or input is not JSON, or is over `max-step-result` | Return a smaller JSON value |
| `MAIN-WORKFLOW-0005` | A step failed after its last retry; the run is `failed` | Fix the cause, then start a new run |
| `MAIN-WORKFLOW-0006` | No run with that id in the current tenant (404) | Use the `runId` from `workflows.start()` |
| `MAIN-WORKFLOW-0007` | The journal could not be reached, so the run was not started (503, retryable) | Check the database, then retry |
| `MAIN-WORKFLOW-0008` | `workflows.cancel` on a run that already finished (409) | Only a run that has not finished can be cancelled |
| `MAIN-WORKFLOW-0009` | A duration is not valid, or is over 365 days | Use `<n><s\|m\|h\|d>` up to 365d |
| `MAIN-WORKFLOW-0010` | A clock, random or network call in a workflow function outside a step (check) | Move it into a `step` |
| `MAIN-WORKFLOW-0011` | The `local` driver in production (check) | Set `driver: db` |
| `MAIN-WORKFLOW-0012` | Two definitions share a name and version | Give one a new version |
| `MAIN-WORKFLOW-0013` | A workflow name or version is invalid | Use a lowercase name and a positive whole version |

See [check codes](https://mainsdk.dev/docs/reference/check-codes) for the full list.

## Swapping drivers

| Driver | Use it for |
| --- | --- |
| `db` | Production. The journal lives in the app's database |
| `file`, `local` | Development and tests. `mainsdk check --env prod` fails on either (`MAIN-WORKFLOW-0011`) |

`inngest`, `temporal` and `vercel-workflow` are planned as later drivers.

## Testing

`createTestApp` runs workflows on a fake clock and an in-memory journal. `tick` moves the clock and lets timers fire, so a three-day sleep takes no time.

```ts title="app/workflows/onboarding.test.ts"
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { workflow, step, sleep, waitFor } from "mainsdk/workflows";
import { s } from "mainsdk/schema";

const sent: string[] = [];
const onboarding = workflow(
  "onboarding",
  { version: 1, input: s.object({ userId: s.string() }) },
  async ({ userId }) => {
    await step("welcome", () => { sent.push(`welcome:${userId}`); return { sent: true }; });
    await sleep("3d");
    const activated = await waitFor("user.activated", { match: { userId }, timeout: "7d" });
    return { activated: activated !== null };
  },
);

describe("onboarding", () => {
  it("workflows AC1 sends the welcome once, sleeps, then finishes when the user activates", async () => {
    const app = createTestApp();
    const { runId } = await app.workflows.start(onboarding, { userId: "u1" }, { idempotencyKey: "onboarding:u1" });
    await app.clock.tick("1m");
    assert.equal((await app.workflows.get(runId)).state, "sleeping");
    await app.clock.tick("3d");
    assert.equal((await app.workflows.get(runId)).state, "waiting");
    await app.events.publish("user.activated", { userId: "u1" });
    await app.clock.tick("1m");
    const run = await app.workflows.get(runId);
    assert.equal(run.state, "completed");
    assert.deepEqual(run.output, { activated: true });
    assert.deepEqual(sent, ["welcome:u1"]);
  });
});
```

## Security and privacy

- The history records step names, kinds and times. Step results and inputs stay out of it, so personal data does not leak into logs. A step's journaled result is stored in the journal, so return IDs rather than personal data where you can.
- Runs belong to one tenant. `workflows.get` and `workflows.list` see only the current tenant's runs, and every step runs inside the tenant that started the run.
- Each run has one trace id, so logs from every step and every resume join up.
- An agent never approves its own step.

## FAQ

**Workflow or job?** Use `jobs.run` for one side effect in the background; on failure the whole job retries from the top. Use a workflow when the work has more than one side effect or has to wait: only the failed step retries, and `sleep`, `waitFor` and `approve` hold no worker.

**Can I use `Date.now()` or `fetch` in the function?** Not outside a step. The function runs again on every resume, so a clock, random or network call outside a step gives a different answer each time and fails `mainsdk check` with `MAIN-WORKFLOW-0010`. Put it in a `step` and its result is journaled.

**What happens on a deploy?** Runs resume on the version they started on. Keep old versions registered until their runs finish.

## Reference

| Call | What it does |
| --- | --- |
| `workflows.register(workflow)` | Register a workflow definition, so runs can start on it and resume after a restart. `workflows.start` registers a definition too |
| `new Workflows(opts)` | The engine behind `workflows.*`. `mainsdk` builds it from the `workflows` section of `main.yaml`. `opts` takes the run and step collections, a `local`, `file` or `db` driver, the tenancy, log, timers, clock, the `jobs` and `events` interfaces, agents, an idempotency store, a `WorkGate` and the parsed config. `close()` stops its timers and listeners, and a pass that is running finishes |

## Next

- [Events and background jobs](https://mainsdk.dev/docs/interfaces/jobs): one side effect in the background, and the events `waitFor` reads.
- [Build a durable workflow](https://mainsdk.dev/docs/guides/build-a-durable-workflow): the same calls as a walkthrough.
- [Agents](https://mainsdk.dev/docs/interfaces/agents): the approval queue behind `approve`.
- [Health and status page](https://mainsdk.dev/docs/interfaces/status-page): probes and shutdown for the app that runs the workflows.
