Concepts

Steps

Wrap each unit of work in a step and it runs once: the result is saved, it retries on its own, and the run resumes from it after a crash.

Steps are how a workflow does durable work. Each step runs once, its result is saved, and it becomes a checkpoint the workflow can resume from. Wrap every unit of real work in a step.

Every step takes a unique id as its first argument; Duraton keys the saved result by it.

MethodReturnsPurpose
step.run(id, fn)the fn's valueRun a function once and memoize its result.
step.skip(id, reason?)voidRecord a deliberately bypassed step as terminal skipped (with an optional reason).
step.sleep(id, duration)voidPause durably for a duration.
step.sleepUntil(id, at)voidPause durably until an absolute time.
step.waitForEvent(id, opts)the event payload, or nullPause until a named event arrives or the timeout elapses.
step.poll(id, probe, opts)the ready valueRe-check an external resource until it is ready, without spending the step's retry budget.
step.runWorkflow(id, opts)the child's resultInvoke another workflow as a child run and wait for it.
step.emit(id, opts)voidPublish an event from inside a run.
step.approval(id, opts)the decisionPark the run until a human approves or rejects. See Approvals.
step.ai.*the call's resultModel calls as durable, metered steps: generate, wrap, embed, loop, infer. See AI steps.

The console renders a run's steps three ways - a list, a flow graph, and a timeline on a shared time axis, where a long sleep or waitForEvent shows as a gap and parallel steps overlap.

A run's steps as a flow graph

step.run

Run a function once and remember its result.

const triage = await ctx.step.run("triage", () => triageTicket(ticket));

The first time, the function runs and its return value is saved. On any later pass, step.run returns the saved value without running the function again. The return value is whatever your function returns (it must be JSON-serializable, since it's stored).

Put anything with a side effect or a changing result inside a step.run - API calls, database writes, payments, reading the clock. See Durable execution for why.

Recording a step's input

Pass an explicit input to record it on the step, so it shows on the console's Input tab. In TypeScript and Go the same value is also handed to the function; in Python the function reads what it needs from its closure:

const triage = await ctx.step.run("triage", { ticketId, amount: 4200 }, (input) =>
  triageTicket(input.ticketId, input.amount),
);

The recorded input shows on the step's Input tab in the console. It is optional: the bare step.run(id, fn) form captures no input (its arguments live in the function's closure, which Duraton cannot see). The structural steps below record their input automatically - a runWorkflow's child input, an emit's payload, a waitForEvent's event and timeout, a sleep's duration - so the Input tab is backed wherever a step has a meaningful input.

step.skip

Record a step you deliberately bypassed. Without it, a conditionally-omitted step simply never appears, so a stage you chose not to run is indistinguishable from one that never existed. step.skip records a terminal skipped step - with an optional reason - so the bypass is visible in the run's steps, the flow graph, and the timeline.

if (triage.priority !== "high") {
  await ctx.step.skip("page-on-call", "not high priority");
} else {
  await ctx.step.run("page-on-call", () => pageOnCall(ticket));
}

The reason is stored as the step's output. step.skip is durable and replay-safe (the id must be stable across replays), and is available in the TypeScript SDK.

step.sleep

Pause the workflow for a duration. The wait is durable: the process can restart during it and the run still wakes up on time.

await ctx.step.sleep("wait-for-settlement", "1h");

The duration is a string like "10s", "5m", "1h", or a number of milliseconds. Sleeps can be short or span days - Duraton owns the schedule, so nothing has to stay running in the meantime.

step.sleepUntil

Pause until an absolute instant rather than for a relative duration. Use it when the wake time is a fixed wall-clock target - midnight, a billing date, a scheduled send.

await ctx.step.sleepUntil("resume-on-renewal", subscription.renewsAt);
ArgumentTypeDescription
atDate | string | numberThe absolute wake time: a Date, an ISO 8601 string, or epoch milliseconds.

Reach for sleep when you mean "wait this long" and sleepUntil when you mean "wait until this moment." Computing target - Date.now() to fake an absolute wait is wrong: it reads the clock outside a step. A target already in the past wakes immediately.

step.waitForEvent

Pause until a named event arrives, or until the timeout elapses. Returns the event's data on arrival, or null on timeout.

const approval = await ctx.step.waitForEvent("await-approval", {
  event: "ticket.approved",
  timeout: "24h",
});
if (approval === null) return { status: "expired" };
OptionTypeDescription
eventstringThe event name that resumes this step.
timeoutstring | numberHow long to wait before resolving to null.
ifstringOptional CEL predicate on the event payload. The run resumes only on an event whose name matches and whose payload satisfies if.

An incoming event resumes every run waiting on that name. An event that arrives shortly before the run parks still wakes it: on parking, the step also looks back over recently-received events and resumes immediately if a matching one already arrived. This closes the race where a fast responder emits its event before the waiting run reaches its waitForEvent, so a request/response pattern never waits out its full timeout just because the reply came back first.

The look-back is bounded: only events received within a short window before the park - 60 seconds - and never older than the run itself are considered, and the same if predicate below still applies, so a stale or unrelated event of the same name never wakes the wrong run. The window is fixed platform-wide and is not a per-call, per-workflow, or per-project knob.

Correlated waits

Use if to wait for the event that belongs to this run, rather than any event of that name. The predicate uses the same CEL dialect as event trigger filters - event.name and event.data are in scope:

const payment = await ctx.step.waitForEvent("await-payment", {
  event: "payment.settled",
  timeout: "1h",
  if: `event.data.ticketId == "${ticketId}"`,
});

Without if, correlating a wait to a specific entity forces the id into the event name (payment.settled.<ticketId>), which explodes event-name cardinality. The filter keeps one stable event name and matches on the payload instead.

The if predicate is available in the TypeScript SDK today. Python and Go SDK support is on the roadmap; until then, those SDKs match on the event name only.

step.poll

Re-check an external resource until it is ready. A resource that is still provisioning is not a failure - it is a normal intermediate state - so step.poll re-checks on a fixed interval without spending the step's retry budget, and gives up after an overall deadline.

Pass a probe that reads the resource and returns its value once ready, or a "not ready yet" signal otherwise. Between checks the run is suspended durably, exactly like step.sleep - it holds no worker and survives a restart. On the first ready check step.poll resolves with the value.

Each check is two durable steps, not a free suspension: a step.run probe call plus a step.sleep gap. A short every against a long timeout produces one checkpoint pair per interval - for example every: "5s" over a timeout: "10m" is up to 120 checks, so 240 durable steps. Prefer the widest every the resource's provisioning time tolerates.

const record = await ctx.step.poll("provision", () => fetchRecordOrNull(), {
  every: "5s",
  timeout: "10m",
  until: (v) => v != null,
});

A probe that returns a value (or one that satisfies until) means ready. A probe that returns null/undefined (or one until rejects) means not ready: the run waits every and checks again. In Go the probe returns (value, ready, err), where ready is false to re-check, and Every/Timeout are time.Duration values passed on PollOpts[T].

OptionTypeDescription
everystring | numberDelay between checks - a duration string like "5s" or a number of milliseconds.
timeoutstring | numberOverall deadline for the whole wait. Once it passes, step.poll gives up.
until(value) => booleanOptional readiness predicate. When omitted, a non-null value is treated as ready.
maxChecksnumberOptional cap on the number of checks, as a safety bound.

A probe that throws is a real error, not a "not ready" signal: it retries under the normal step retry policy and, if it exhausts its attempts, fails the run. Readiness (a "not ready" return) and failure (a throw) stay fully separate, so waiting for a resource never consumes the retry budget reserved for genuine errors.

When the deadline passes

If the resource is still not ready when timeout elapses, step.poll throws PollTimeoutError and the run fails, routing to onFailure if one is declared. Giving up on a required resource is a genuine failure, so this is the default.

To treat "not ready in time" as a normal branch rather than a failure, catch it:

import { PollTimeoutError } from "@duraton/sdk";

try {
  const record = await ctx.step.poll("provision", () => fetchRecordOrNull(), {
    every: "5s",
    timeout: "10m",
  });
  return { status: "ready", record };
} catch (err) {
  if (err instanceof PollTimeoutError) return { status: "still-provisioning" };
  throw err;
}

Poll vs retry

Polling and retrying look similar but answer different questions. A retry handles a step that failed - it re-runs the same work after a backoff and spends an attempt from the step's budget each time; RetryAfterError only changes when that next attempt runs, and the run still fails once the budget is exhausted. A poll handles a resource that has not become ready yet - each check is a successful read, so it never spends the retry budget, and the wait is bounded by a wall-clock deadline instead of an attempt count. Reach for retry when a call can fail transiently; reach for poll when a call succeeds but the answer is "not yet."

step.runWorkflow

Invoke another workflow as a child run and wait for its result. The parent blocks until the child reaches a terminal state; if the child fails, the failure cascades to the parent.

const verdict = await ctx.step.runWorkflow("fraud", {
  name: "ticket.fraud-check",
  data: { ticketId },
});
OptionTypeDescription
namestringThe child workflow to invoke.
appstringOptional. Invoke the workflow in this specific app. Omit to resolve the name in the calling app first, then any other app.
runnerstringOptional. Pin the child to a specific runner within the target app.
dataunknownOptional input passed to the child as its event data.

When two apps define a workflow with the same name, set app to target one exactly:

const triage = await ctx.step.runWorkflow("triage", {
  name: "triage",
  app: "billing",
  data: { ticketId },
});

step.emit

Publish an event from inside a run. It can trigger other workflows or resume waitForEvent steps.

await ctx.step.emit("notify", {
  name: "notification.requested",
  data: { ticketId, kind: "triaged" },
});
OptionTypeDescription
namestringThe event name to publish.
appstringOptional. Deliver only to workflows in this app. Omit to deliver to every workflow that triggers on the event.
dataunknownOptional event payload.
dedupeIdstringOptional. Drops a repeat of the same event (per app) within the dedupe window - the same idempotency key POST /events accepts. Available in the TypeScript SDK.

Step ids

The first argument to every step is its id ("triage", "wait-for-settlement"). The id is how Duraton matches a step to its saved result across passes, so:

  • Keep ids stable across replays - don't compute them from changing values like timestamps, random values, or array contents. An id that changes between passes won't match the work already done, so the step runs again. This also bites when you rename a step in a new deploy while runs are in flight - see changing step ids across deploys.
  • Give distinct work distinct ids. Two different steps that happen to share an id would resolve to the same saved result.

Reusing an id (loops)

Reusing the same id is legitimate and expected - a step inside a loop runs once per iteration under one id, and that is not an error. The SDK disambiguates repeats positionally, in execution ticket: the first occurrence of an id keeps it bare and each later occurrence gets a numeric suffix - fetch-page, then fetch-page:1, fetch-page:2, and so on. Each occurrence is its own durable step with its own saved result.

for (const page of pages) {
  // "fetch-page", "fetch-page:1", "fetch-page:2", ... - one durable step per iteration
  await ctx.step.run("fetch-page", () => fetchPage(page));
}

Because the suffix is assigned by execution ticket, the loop must be replay-deterministic: it has to run the same iterations in the same ticket on every pass, or the suffixes shift and later occurrences stop matching their saved results. Drive the loop from already-durable data - the event payload or a prior step's result - not from a live source that could return a different set on the next pass.

Ordering

Steps run top to bottom, one after another. Each await completes before the next step begins, which is what lets a workflow resume at exactly the right place.

const triage = await ctx.step.run("triage", () => triageTicket(ticket));
await ctx.step.sleep("cool-off", "1h");
const refund = await ctx.step.run("refund", () => issueRefund(triage));

If this run is interrupted after triage, it resumes at the sleep; after the sleep, it resumes at refund. Completed steps are never repeated.

Parallel steps

Run independent steps concurrently - Promise.all in TypeScript, asyncio.gather in Python. Duraton discovers the whole batch in one pass and runs the branches together instead of one per round trip.

const [user, prefs, plan] = await Promise.all([
  ctx.step.run("user", () => fetchUser(id)),
  ctx.step.run("prefs", () => fetchPrefs(id)),
  ctx.step.run("plan", () => fetchPlan(id)),
]);

Each branch is still its own durable step with its own id and saved result. The workflow continues past the Promise.all only after every branch has completed - the join. Branches can mix step kinds; a parallel step.sleep or step.waitForEvent parks alongside the others, and the run wakes as each deadline arrives.

If one branch fails after exhausting its retries, the run fails (the same as Promise.all rejecting) and its still-running sibling steps are cancelled - nothing is left dangling. Use Promise.allSettled instead when you want every branch to finish regardless.

One caveat: batching is best-effort. A branch that does its own await (an un-stepped fetch, say) before calling its step.run may be discovered on the next pass rather than with its siblings. It still runs correctly - it costs an extra round trip. Call your steps directly inside the Promise.all to keep them in one batch.

On this page