MainSDK
Search⌘K
Interfaces

Events, jobs and webhooks

Durable workflows

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.

Previewships as a beta APIPhase 1 · Core, public betaSpec workflowsFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
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.

At a glance

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

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)

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

5 drivers. Select one to see its line in main.yaml.

main.yamlBuilt on the app's database (default)
workflows:
  driver: db

Change this one line to swap providers; your calls stay the same.Your database integration

Codes

CodeWhere it appearsMeaning
MAIN-WORKFLOW-0009Checkmainsdk check, before deployA duration is not a valid <n><s|m|h|d> value, or is over 365d
MAIN-WORKFLOW-0010Checkmainsdk check, before deployA clock, random or network call (Date.now, new Date(), Math.random, crypto.randomUUID, http.*, fetch) in a workflow function outside a step
MAIN-WORKFLOW-0011Checkmainsdk check, before deployThe local driver in production
MAIN-WORKFLOW-0012Checkmainsdk check, before deployTwo workflow definitions with the same name and version
MAIN-WORKFLOW-0001RuntimeError responses and logsstep, sleep, waitFor or approve called outside a workflow run (500)
MAIN-WORKFLOW-0002RuntimeError responses and logsOn 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-0003RuntimeError responses and logsNo registered code for the run's workflow version; the run stops as stuck
MAIN-WORKFLOW-0004RuntimeError responses and logsA step result or workflow input is not JSON, or is larger than max-step-result
MAIN-WORKFLOW-0005RuntimeError responses and logsA step failed after its last retry; the run is failed
MAIN-WORKFLOW-0006RuntimeError responses and logsNo run with that id in the current tenant (404)
MAIN-WORKFLOW-0007RuntimeError responses and logsThe journal store is unreachable, so the run was not started (503, retryable)
MAIN-WORKFLOW-0008RuntimeError responses and logsCancel of a run that has already finished (409)
MAIN-WORKFLOW-0013RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
workflow() and step() with a journalPhase 10.3workflows#contract
Resume from the journal after a crash or deployPhase 10.3workflows#replay
Finished steps return their journaled resultPhase 10.3workflows#idempotent-steps
Per-step retries with backoffPhase 10.3workflows#retries
sleep and waitFor without holding a workerPhase 10.3workflows#timers-events
approve for a person's yesPhase 10.3workflows#approve
Versioned runs and stuck detectionPhase 10.3workflows#versions
Cancel and inspect runsPhase 10.3workflows#cancel
Run history in mainsdk logsPhase 10.3workflows#visibility
Tenant isolation for runsPhase 10.3workflows#tenant
Database driver, with local and file for developmentPhase 10.3workflows#drivers
Checks for clocks, randomness and network calls outside stepsPhase 10.3workflows#checks

Quick start

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:

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

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
KeyWhat it does
driverWhere 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
retriesThe default retry policy for each step (3 retries, exponential, 1s to start). A step can override it
max-step-resultThe largest JSON a step may return (default 256KB)
leaseHow long a crashed worker holds a run before another takes it (default 30s)
keepHow 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 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

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

See check codes for the full list.

Swapping drivers

DriverUse it for
dbProduction. The journal lives in the app's database
file, localDevelopment 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.

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

CallWhat 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

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet