Steps
Everything a handler can do durably: the step API - run, sleep, sleepUntil, waitForEvent, runWorkflow, emit, and approval - plus the handler context.
The handler context
Every handler receives a StepContext: the triggering event, the durable step API, and the run's
metadata.
defineWorkflow<{ ticketId: string }>({
name: "ticket.created",
handler: async (ctx) => {
ctx.log.info("refunding", { ticketId: ctx.event.data.ticketId, attempt: ctx.attempt });
return ctx.step.run("refund", () => issueRefund(ctx.event.data.ticketId));
},
});Prop
Type
step
Every step takes a stable id, unique within the workflow. The result is recorded under that id, and
on replay after a crash or retry a completed step returns its saved result instead of running again.
Prop
Type
run
The unit of durable work: fn executes once and its result is memoized under id. The three-argument
overload also records an explicit input, which the run inspector shows on the step's Input tab and
hands to fn.
const triage = await ctx.step.run("triage", () => classify(ctx.event.data.subject));
const reply = await ctx.step.run("reply", { ticketId, tone: "apologetic" }, (input) =>
drafts.create(input.ticketId, input.tone),
);sleep and sleepUntil
The run is suspended, not blocked: it holds no worker while it waits, and it survives a restart.
await ctx.step.sleep("cool-off", "30s");
await ctx.step.sleepUntil("follow-up", new Date("2026-08-01T00:00:00Z"));waitForEvent
Suspends until an event with the given name arrives, returning its data - or null when timeout
elapses first, which is how you branch on the timeout.
const paid = await ctx.step.waitForEvent<{ amount: number }>("await-payment", {
event: "payment.received",
timeout: "24h",
});
if (paid === null) return ctx.step.run("expire", () => expireOrder(ticketId));poll
Re-checks an external resource until it is ready, sleeping durably between checks. The probe reads
the resource and returns its value once ready, or a "not ready yet" signal otherwise; a not-ready check
is a normal successful read, so it never spends the step's retry budget. Returns the ready value, or
throws PollTimeoutError when timeout elapses first.
const record = await ctx.step.poll("provision", () => fetchRecordOrNull(), {
every: "5s",
timeout: "10m",
until: (v) => v != null,
});Prop
Type
A probe that throws is a genuine error, not a not-ready signal: it retries under the step's
retry policy and fails the run if it exhausts its attempts. A
PollTimeoutError fails the run and routes to onFailure; wrap the call in try/catch to treat a
missed deadline as a non-fatal branch instead. See Poll until ready.
Each check costs two durable steps (a step.run probe plus a step.sleep gap), not a free
suspension - every: "5s" over timeout: "10m" is up to 120 checks, 240 durable steps. Pick the
widest every the resource's provisioning time tolerates.
runWorkflow
Invokes another workflow as a linked child run and waits for its result. Omit app to resolve the
name in the caller's app first, then any app in the project; set runner to pin the child to a
specific runner. Pass tags to attach run tags to the child - it also inherits
the parent run's tags, with the child's value winning on a shared key.
const score = await ctx.step.runWorkflow<{ risk: number }>("risk-check", {
name: "fraud.score",
app: "risk",
data: { ticketId },
tags: { stage: "risk-check" },
});emit
Emits an event from inside a run, which fans out to whatever triggers match it. Omit app to
broadcast project-wide; set it to narrow the event to one app's triggers.
await ctx.step.emit("notify", {
name: "ticket.shipped",
app: "notifications",
data: { ticketId, carrier: "ups" },
});approval
Parks the run on a human decision. The run suspends in needs_attention - checkpoint kept, no worker
held - until it is approved or denied, then resolves. args on the result are the effective
arguments: the decider's edits when they changed them, otherwise the proposed ones. See
Approvals.
const decision = await ctx.step.approval<{ amount: number }>("refund-gate", {
tool: "issue-refund",
args: { priority: "high" },
risk: "high",
summary: "Refund ticket A1 in full",
});
if (decision.status === "approved") {
await ctx.step.run("refund", () => stripe.refunds.create({ amount: decision.args.amount }));
}hashStepId
Returns the stable hash Duraton keys a step's memoized result by. It is exposed for tooling that correlates a step id with its recorded entry.
import { hashStepId } from "@duraton/sdk";
const key = hashStepId("triage"); // the hash the "triage" step's result is stored underDefining workflows
Declare a workflow in one object: defineWorkflow takes its name, triggers, retry policy, flow control, and handler, and types ctx.event.data for you.
connect
Run your workflow code from behind NAT, a laptop, or a container with no ingress - connect dials out over a WebSocket, so there is no inbound URL to expose.