# durably agent reference — core Local-first durable execution for TypeScript scripts and AI workflows. ## Documentation map - Full agent reference (standalone, all APIs): https://nikhil-verma.com/durably/llms-full.txt - Human documentation: https://nikhil-verma.com/durably/docs/ - Package: https://www.npmjs.com/package/@nikhilverma/durably - Source: https://github.com/NikhilVerma/durably - Executable examples: https://github.com/NikhilVerma/durably/tree/main/tests This is the compact, self-contained authoring reference. It covers the common `workflow → run → step → parallel → retry → test` path. The real surface is larger: see "When step and parallel are not the right tool" below for the situation-to-API map — loops, child runs, partial recovery, waits and signals, compensation, resources, engines, operations, and custom storage all live in the full agent reference. ## Fit in two lines Use durably for finite TypeScript scripts and AI workflows that must resume async effects after interruption. It records completed steps on the filesystem. It is not a distributed worker fleet, queue, cron, event bus, or transaction orchestrator; use Temporal, Restate, or DBOS for those jobs. ## Install ```bash npm install @nikhilverma/durably ``` Default state directory: `.durably/runs/`. ## Execution model The workflow function re-executes from the top on resume. Each `ctx.step()` is identified by its hierarchical position. A completed step returns its recorded result without executing its callback again. - Put network calls, model calls, subprocesses, and writes inside `ctx.step()`. - Keep branching and transformation between steps pure. - Use `ctx.now()` and `ctx.random()`, not `Date.now()` or `Math.random()`. - A completed step is replay-idempotent. Durably cannot make an interrupted external side effect atomic; give that API an idempotency key when possible. ## Complete core TypeScript API Core authoring types: ```ts type MaybePromise = T | PromiseLike type Result = | { ok: true; value: T } | { ok: false; error: E } type Budget = Partial> type Workflow = import('@nikhilverma/durably').Workflow type StandardSchemaV1 = import('@nikhilverma/durably').StandardSchemaV1 interface Advisory { code: string level: 'info' | 'warn' msg: string hint: string docs: string count: number firstAt: string } type RetryPolicy = { attempts: number backoff: 'exponential' | 'linear' | 'none' baseMs?: number maxMs?: number jitter?: boolean } type StepAttemptContext = { signal: AbortSignal stashed: unknown stash(value: unknown): Promise } type StepOptions = { name?: string retry?: RetryPolicy timeoutMs?: number compensate?: (output: Output) => MaybePromise concurrencyKey?: string concurrencyLimit?: number uses?: string } interface StepEvent { runId: string path: readonly number[] label: string status: 'running' | 'replayed' | 'ok' | 'retrying' | 'failed' attempt: number executions: number ms?: number error?: unknown } interface RunOptions { key?: string fresh?: boolean budget?: Budget dir?: string checkpointEvery?: number advisories?: 'silent' onAdvisory?: (advisory: Advisory) => void onStep?: (event: StepEvent) => void } interface WorkflowContext { readonly runId: string // current durable run readonly attempt: number // workflow attempt, starting at 1 step( fn: (attempt: StepAttemptContext) => MaybePromise, options?: StepOptions>, ): Promise> step( fn: (attempt: StepAttemptContext) => MaybePromise, options: StepOptions & { schema: StandardSchemaV1 }, ): Promise // Runs every thunk at once unless {concurrency} caps it. Child runs are // capped instead by the ENGINE's concurrency, which defaults to 4. parallel MaybePromise)[]>( thunks: Thunks, options?: { concurrency?: number }, ): Promise>>[]> now(): number random(): number log(...args: readonly unknown[]): void // Exits the WORKFLOW early: return ctx.complete(value). The loop reducer's // done(value) is the different, loop-scoped exit; see ctx.loop. complete( value: Value, ): import('@nikhilverma/durably').WorkflowCompletion } declare function workflow(): ( fn: (ctx: WorkflowContext, input: Input) => MaybePromise, ) => Workflow> declare function workflow( fn: (ctx: WorkflowContext, input: Input) => MaybePromise, ): Workflow> // Also: workflow({ name?, input: StandardSchema, output?: StandardSchema }, fn) declare function run( workflow: Workflow, input: Input, options?: RunOptions, ): Promise declare function run( workflow: Workflow, options?: RunOptions, ): Promise declare function isOk( result: Result, ): result is { ok: true; value: T } declare function partition(results: Result[]): [T[], E[]] ``` `schema` accepts Standard Schema v1 validators such as Zod, Valibot, and ArkType. Import the package's public types for shared abstractions. ## Copyable workflow ```ts import { isOk, run, workflow } from '@nikhilverma/durably' type Input = { urls: string[] } const summarize = workflow()(async (ctx, { urls }) => { const pages = await ctx.parallel( urls.map((url) => () => ctx.step( ({ signal }) => fetch(url, { signal }).then((response) => response.text()), { name: 'fetch page', retry: { attempts: 3, backoff: 'exponential', jitter: true, }, timeoutMs: 30_000, concurrencyKey: new URL(url).host, concurrencyLimit: 5, }, ) ), { concurrency: 20 }, ) const fetched = pages.filter(isOk).map((page) => page.value) if (fetched.length === 0) throw new Error('every fetch failed') return ctx.step( () => callModel({ pages: fetched }), { name: 'summarize', retry: { attempts: 2, backoff: 'linear' } }, ) }) const summary = await run(summarize, { urls: process.argv.slice(2) }) console.log(summary) ``` If the process exits after ten fetches complete, run the same command again: those ten results replay from disk and only unfinished/failed branches execute. Changing concurrency, retry, backoff, timeout, or future step implementation is compatible with resume. Changing the already-recorded step topology is not. `ctx.parallel()` returns every branch as a `Result`; throw when your workflow's own success rule is not met. ## Data that crosses the durable boundary Workflow inputs, step outputs, loop state, stash values, annotations, and the workflow result are deep-cloned as JSON plus `Date`. - `undefined` follows `JSON.stringify`: an undefined property is dropped, an undefined array element becomes `null`. `{ limit: undefined }` and `{}` are therefore the same input and resume the same run, so `field?: T` is safe. - `bigint`, symbols, functions, class instances (`Map`, `Set`, `URL`, `Error`), `NaN`/`Infinity`, and cycles throw `SerializationError` naming the offending `path`. Convert at the step boundary, not two steps later. ## Watching a long run ```ts await run(harvest, input, { key: 'nightly-harvest', checkpointEvery: 1, onStep: ({ label, status, attempt, ms }) => { if (status !== 'running') console.log(`${label} ${status} #${attempt} ${ms ?? 0}ms`) }, }) ``` `console.log` inside the workflow body re-prints on every replay; `onStep` does not, and it separates `replayed` (returned from the log) from `running`, `retrying`, `ok`, and `failed`. A listener that throws never fails the run. `durably.list({ key })` goes from the name you chose back to the run id; `KeyConflictError` names the bound run and both inputs when a key is reused with a different input. `checkpointEvery` bounds how many completed steps a crash may re-execute. A checkpoint rewrites the whole run state, so the default scales with run length: every step up to 100 steps, then every one percent of the steps so far, capped at every hundredth. Pass `1` when steps cost money. ## When step and parallel are not the right tool Everything below is in the full agent reference. Match the situation, then read that file rather than guessing an API: | Situation | Reach for | | --- | --- | | Iterate until a work list is exhausted; agent turn loops | `ctx.loop(state, reducer, { snapshotEvery, maxIterations })` — snapshots state, so resume is O(1) instead of replaying every past turn. Exit with the reducer's `done(value)`. | | Each item needs its own retries, budget, or lifecycle | `ctx.spawn` / `ctx.spawnAll` + `ctx.joinAll` | | Progress inside one long step (a stream, a partial turn) | the step's `stash(value)` / `stashed` — the only checkpoint that exists below step granularity | | Human approval or an external event | `ctx.waitFor(name, schema?, { timeoutMs })` + `durably.signal` | | Wall-clock waiting | `ctx.sleep` / `ctx.sleepUntil` | | Undo completed work when a later step fails | `compensate` on the step that did it | | Shared rate limit or circuit breaker across runs | `resource(name, ...)` + step `{ uses }` | | Cap or report spend | `budget` + `ctx.charge` + `ctx.budget.remaining` | | More runs than `FileStorage` likes, or a network filesystem | `SqliteStorage` from `@nikhilverma/durably/sqlite` | | Operating runs by id | `durably.inspect` / `list` / `retry` / `restart` / `cancel` / `pause` / `resume` | | Turning `Result[]` into values | exported `isOk` and `partition` | ## Testing ```ts import { testEngine } from '@nikhilverma/durably/test' type Selector = readonly number[] | string | { label: string; nth: number } interface TestRunOptions { crashAfter?: Selector crashInStep?: Selector budget?: Budget advisories?: 'silent' onStep?: (event: StepEvent) => void } interface TestRunResult { readonly runId: string readonly status: 'completed' | 'failed' | 'crashed' | 'waiting' | 'sleeping' readonly result: Output | undefined readonly steps: { path: readonly number[] label: string status: string executions: number attempts: number output?: unknown error?: unknown stashed?: unknown }[] readonly advisories: Advisory[] readonly error?: unknown } interface TestEngine { readonly clock: { advance(ms: number): void; now(): number } run( workflow: Workflow, input: Input, options?: TestRunOptions, ): Promise> resume( runId: string, options?: { onStep?: (event: StepEvent) => void }, ): Promise> signal(runId: string, name: string, payload: unknown): Promise } declare function testEngine(): TestEngine ``` `testEngine` provides memory storage, a fake clock, crash injection, and shadow replay that verifies stable paths with zero new executions. ## Resume and failure rules - Same workflow plus canonical input, or the same explicit `key`, finds the run. - An unfinished run resumes; a live run is joined; a completed run starts fresh. - A failed run resumes its failed step. Pass `{ fresh: true }` to start over. - `run()` returns the workflow value directly, never a metadata wrapper. - Advisories surface through stderr, callbacks, errors, state, inspection, and tests. Every `DurablyError` carries the next move in `hint` and `docs`.