Durable workflows#
Multi-step agent processes that survive a restart: each step's state is snapshotted to the same storage adapter memory uses, so a run suspended waiting for a human resumes exactly where it stopped — in another process, hours later.
import { createWorkflow, createStep, createWorkflowRunner } from '@janux/agent';Defining one#
const interview = createWorkflow({
id: 'onboarding',
initialState: (input) => ({ answers: {}, region: (input as any)?.region }),
steps: [
createStep({
id: 'ask-region',
run: ({ state, resumeData, suspend }) => {
if (resumeData) {
state.answers.region = resumeData;
return;
}
suspend({ question: 'Which region do you operate in?' });
},
}),
createStep({
id: 'decide',
run: ({ state, requestContext }) => {
state.answers.decision = decide(state.answers, requestContext);
},
}),
],
});createWorkflow(def) and createStep(step) are identity functions — they exist purely to give you inference on TState. WorkflowDef is { id, initialState(input), steps }.
The step bag#
| Field | What it is |
|---|---|
state |
The run's state. Mutate it — it's what gets snapshotted |
resumeData |
What the caller passed to resume(). undefined on the first pass through the step |
suspend(payload) |
Stops the run here and surfaces payload to the caller |
requestContext |
Per-run context (auth, locale, org) handed in at start/resume |
The if (resumeData) … else suspend(…) shape is the whole human-in-the-loop pattern: the step runs twice, once to ask and once to record.
Running it#
const runner = createWorkflowRunner(storage);
const started = await runner.start(interview, { region: 'EU' }, { userId });
// { runId, status: 'suspended', state, suspendPayload: { question: '…' } }
const next = await runner.resume(interview, started.runId, 'Germany', { userId });
// advances from the suspending step; status: 'done' when the last step finishesRunResult is { runId, status: 'suspended' | 'done', state, suspendPayload? }. Both calls take the definition — resume needs it to replay the steps, and it refuses a run that belongs to another workflow (workflow_mismatch) or one storage has never seen (unknown_run). Between the two calls the run lives only in storage — with createPgStorage a deploy, a crash or a different instance answering the next request all keep working, which is exactly what an in-memory conversation state cannot do.
Where this fits#
Use a workflow when a process has stages with human checkpoints: an onboarding interview, a compliance review, an approval chain. For a single guarded action, an intent with guard: 'confirm' is simpler — the proposal is the checkpoint (intents and guards).
Snapshots are keyed by runId through saveSnapshot/loadSnapshot/deleteSnapshot, so any custom adapter implementing those three methods gets durability for free.
Related: Agent memory & storage · Agent rate limiting · The agent and your copilot