Docs menu
Durable Workflows
Runs multi-step workflows durably under the same runId; suspend/resume is safe, retry() provides declarative retries (with a journal-backed counter), and it's exposed as REST via the /workflows/:name/run endpoint.
What it's for / when to use it#
Used to model multi-step workflows that go beyond a single agent call (e.g. first create a record, then check payment, then wait for approval and send a notification). Each step is recorded in the journal separately; if the process crashes mid-flight or a step (sleep/waitFor) suspends, calling again with the same runId does NOT re-run the completed steps (replay) — it simply continues from where it left off.
Concrete scenario: in an order flow, a "wait for payment approval" step might take hours. Even if the process shuts down during that time, calling it again with the same runId returns suspended if the payment step is still pending; once the condition is met, the step actually progresses and moves on to the next steps.
Setup / import#
The workflow() builder from the @gnldev/workflow package is used to define a workflow; the @gnldev/durable createGnl registry is needed to run/register it (the Workflow class already satisfies the WorkflowLike structural interface that createGnl expects — no separate adapter is needed).
import { workflow, step, retry } from '@gnldev/workflow';
import { createGnl } from '@gnldev/durable';Step-by-step usage#
1) Define the workflow as a sequence of steps using workflow() and step(id, run):
const myWorkflow = workflow<{ email: string }>()
.then(step('onboard:create-account', async (input) => {
// ... create the account
return { ...input, accountId: 'acc_1' };
}))
.then(step('onboard:send-welcome', async (input) => {
// ... send the welcome email
return { ...input, welcomed: true };
}));2) Register the workflow by name under createGnl's workflows field:
const config: CreateGnlConfig = {
storage,
agents: { /* ... */ },
workflows: { onboard: myWorkflow },
};
const gnl = createGnl(config);3) Run it with runWorkflow, passing the same runId — even if the process is interrupted, calling again with the same runId skips completed steps and continues from where it left off:
const res = await gnl.runWorkflow('onboard', input, { runId: 'wf-1' });
// POST /workflows/onboard/run with the same runId -> resumes where it stoppedOn the REST side, this is exposed with the same contract via the POST /workflows/:name/run endpoint automatically generated by @gnldev/server — the body expects { runId?, input }; if the given runId already has a trace in the journal, the request is automatically treated as a resume (the budget gate is skipped in that case — only genuinely new work is gated). The step list of registered workflows can be introspected via GET /workflows.
4) Use retry(step, policy) to retry a step declaratively — it takes attempts (total attempts including the first run), an optional backoffMs (a fixed ms value or a function computed from the attempt index), and an optional fallback step to run once all attempts are exhausted. The wrapped step is a drop-in — it carries the same id and is used like an unwrapped step inside then/branch/parallel:
import { retry, step } from '@gnldev/workflow';
const chargeStep = retry(
step('order:charge', async (input) => chargeCard(input)),
{
attempts: 3,
backoffMs: (attempt) => attempt * 500, // 500ms, 1000ms, ...
fallback: step('order:charge-fallback', async (input) => ({ ...input, charged: false })),
},
);
const orderWorkflow = workflow<{ orderId: string }>().then(chargeStep);The attempt counter is written to the journal under the key `${runId}:wf:${id}:attempts` — on crash-resume the counter does NOT restart from zero, so the "total N attempts" guarantee holds regardless of process deaths. A step that was already exhausted in a previous run goes straight to fallback (if any) on resume — it does not retry N more times. A suspension (WorkflowSuspended, e.g. sleep/waitFor) doesn't count as a failure and propagates outward exactly as-is without consuming an attempt. fallback is journaled separately under the step's own key — if fallback runs and then crashes, it does not re-run on resume.
API reference#
workflowworkflow<Input>() — starts a workflow definition; .then(step) chains the ordered steps.
stepstep(id, run) — one journaled step. The id is what a resume matches on, so it is part of the contract, not a label.
waitForResumeTyped human-in-the-loop suspension: the workflow stops and continues from runResumable({ resume }) with the value the operator supplied.
cancelWorkflowRunDurable cancel — the decision is journaled, so a worker that comes back later still sees it.
forkWorkflowRunCopies a workflow run up to a step into a new runId, the workflow-level counterpart of forkRun.
createGnlSets up the agent + workflow registry; the returned object's runWorkflow/listWorkflows methods run/introspect workflows.
WorkflowLikeThe structural interface that can be given to createGnl's workflows field — build()/run()/optional runResumable(). @gnldev/workflow's Workflow class already satisfies this.
WorkflowMetaThe introspection shape returned by listWorkflows(): { name, steps: { id, kind }[] }.
WorkflowRunResultThe result of runWorkflow(): runId, output, suspended/paused flags, stepId/reason, and per-step outputs.
RunOptionsCall options for gnl.run()/stream() (runId, prompt/messages, context, etc.) — runWorkflow additionally uses its own { runId?, maxSteps? } option.
retry(step, policy: RetryPolicy) → Step. Wraps a step with a declarative retry policy — drop-in (same id); the attempt counter is journaled (does not reset on crash-resume), suspension doesn't consume an attempt, and fallback is journaled separately.
RetryPolicy{ attempts, backoffMs?: number | ((attempt) => number), fallback?: Step } — attempts is the total number of attempts including the first run.
RetryExhaustedErrorAll attempts were exhausted and no fallback was given — carries { stepId, attempts, cause }.
input and ctx parameters inside step functions; base logic on the journaled output rather than external (closure) state — otherwise inconsistencies can arise between steps replayed during resume and steps that genuinely re-run live.sleep/waitFor need runResumable() instead of run() — plain run() throws the suspend signal (WorkflowSuspended) as an error. createGnl.runWorkflow automatically prefers runResumable when the workflow has one.Related pages#
- Deterministic replay & crash recoveryAn interrupted run resumes deterministically from where it left off.
- Human-approved tools (guard)Guard suspends risky calls; resumed on approval.
- Automatic REST API + OpenAPI + SSETurns createGnl into a durable HTTP API + SSE stream in one line.