Recipes

One complete, paste-and-run workflow per task - making a model call durable, parking a run on a human decision, retrying, waiting, scheduling, replaying, and webhooks.

Each section below is one task, with a complete workflow or client call you can paste into the quickstart runner and run as-is. Nothing is elided. Every link goes to the reference for that capability.

Not sure which you need? Start with your goal.

Retry a flaky call, fail fast on a bad one

A step retries on its policy. NonRetriableError ends the run on the first attempt - retrying a declined card cannot fix it - and RetryAfterError retries on a delay the upstream dictated.

import { defineWorkflow, NonRetriableError, RetryAfterError } from "@duraton/sdk";

export const capture = defineWorkflow<{ ticketId: string; amount: number }>({
  name: "ticket.capture",
  retry: { maxAttempts: 4 },
  handler: async (ctx) => {
    await ctx.step.run("validate", () => {
      if (ctx.event.data.amount <= 0) throw new NonRetriableError("amount must be positive");
    });

    return await ctx.step.run("triage", async () => {
      const res = await fetch("https://api.example.com/triage", { method: "POST" });
      if (res.status === 429) throw new RetryAfterError("rate limited", "30s");
      if (!res.ok) throw new Error(`gateway ${res.status}`);
      return await res.json();
    });
  },
  onFailure: async (ctx) => {
    await ctx.step.run("void-hold", () => voidHold(ctx.event.data.ticketId, ctx.error?.message));
  },
});

onFailure runs durably after the run has exhausted its retries and failed. It receives the original event plus ctx.error, and cannot un-fail the run. Retries

Fan one event out to many workflows

Every workflow subscribed to user.signup gets its own run. A CEL if filter narrows a subscription to the events that match it.

export const welcome = defineWorkflow<{ userId: string; plan: string }>({
  name: "signup.welcome",
  triggers: [{ event: "user.signup" }],
  handler: async (ctx) => ctx.step.run("email", () => sendWelcome(ctx.event.data.userId)),
});

export const welcomePro = defineWorkflow<{ userId: string; plan: string }>({
  name: "signup.welcome-pro",
  triggers: [{ event: "user.signup", if: 'event.data.plan == "pro"' }],
  handler: async (ctx) => ctx.step.run("concierge", () => bookOnboarding(ctx.event.data.userId)),
});

A free signup starts one run; a pro signup starts two. Events · Triggers

Pause for hours, then continue

step.sleep parks the run - it holds no process and no connection. step.waitForEvent parks it until a matching event arrives, and resolves to null if the timeout matures first.

export const awaitApproval = defineWorkflow<{ ticketId: string }>({
  name: "ticket.await-approval",
  handler: async (ctx) => {
    const decision = await ctx.step.waitForEvent<{ approver: string }>("await", {
      event: "approval.granted",
      timeout: "48h",
    });

    if (decision === null) {
      return await ctx.step.run("expire", () => cancelOrder(ctx.event.data.ticketId));
    }

    await ctx.step.sleep("cool-off", "1h");
    return await ctx.step.run("refund", () => issueRefund(ctx.event.data.ticketId));
  },
});

Steps

Run on a schedule

A cron trigger needs no event. singleton: { mode: "skip" } drops a tick that would overlap a run still in flight.

export const rollup = defineWorkflow<{ cron: string; scheduledFor: string }>({
  name: "metrics.rollup",
  triggers: [{ cron: "@every 1m" }],
  singleton: { mode: "skip" },
  handler: async (ctx) => {
    ctx.log.info("rollup tick", { scheduledFor: ctx.event.data.scheduledFor });
    return await ctx.step.run("aggregate", () => rollupHour());
  },
});

Each scheduled run's input is { cron, scheduledFor }. Triggers

Shape a burst of events into runs

Flow control is declared on the workflow and applied before a run starts. debounce coalesces a burst into one run carrying the last event's data; batch accumulates events into one run delivered as ctx.events; rateLimit drops what is over the cap.

export const reindex = defineWorkflow<{ documentId: string }>({
  name: "search.reindex",
  debounce: { periodMs: 5_000, key: "documentId" },
  handler: async (ctx) => ctx.step.run("index", () => reindexDoc(ctx.event.data.documentId)),
});

export const flush = defineWorkflow<{ metric: string; value: number }>({
  name: "metrics.flush",
  batch: { maxSize: 50, timeoutMs: 5_000 },
  handler: async (ctx) => {
    const points = (ctx.events ?? []).map((e) => e.data);
    return await ctx.step.run("write", () => writePoints(points));
  },
});

The eight controls - concurrency, throttle, rateLimit, debounce, batch, priority, singleton, idempotency - and what each does to an event over its cap: Flow control

Call a child workflow, in parallel

step.runWorkflow starts another workflow as a child run and returns its output. Steps that do not depend on each other run concurrently under Promise.all.

export const placed = defineWorkflow<{ ticketId: string; amount: number }>({
  name: "ticket.placed",
  handler: async (ctx) => {
    const [refund, notified] = await Promise.all([
      ctx.step.runWorkflow<{ refundId: string }>("refund", {
        name: "support.refund",
        app: "support",
        data: ctx.event.data,
      }),
      ctx.step.run("notify", () => notifyRequester(ctx.event.data.ticketId)),
    ]);

    await ctx.step.emit("receipt", {
      name: "receipt.requested",
      app: "support",
      data: { ticketId: ctx.event.data.ticketId, refundId: refund.refundId },
    });

    return { refund, notified };
  },
});

Passing app addresses the child to that app exactly; omit it to resolve the name in the caller's app first. Workflows

Make a model call durable

step.ai.generate is one model call as a step: it runs once, and a retry after a crash returns the recorded result instead of paying the model again. The call happens in your runner, with your provider key.

export const triage = defineWorkflow<{ ticketId: string; subject: string }>({
  name: "support.triage",
  handler: async (ctx) => {
    const { output } = await ctx.step.ai.generate<{ category: string; priority: number }>("classify", {
      model: "claude-opus-4-8",
      prompt: `Classify this ticket: ${ctx.event.data.subject}`,
      output: {
        type: "object",
        properties: { category: { type: "string" }, priority: { type: "number" } },
        required: ["category", "priority"],
      },
      validate: (v) => {
        const p = (v as { priority: number }).priority;
        return p >= 1 && p <= 5 ? undefined : "priority must be 1-5";
      },
    });

    return await ctx.step.run("route", () => routeTicket(ctx.event.data.ticketId, output!));
  },
});

A failed validate triggers a durable re-ask, itself a memoized step. AI · step.ai reference

Park a run on a human decision

step.approval suspends the run at its checkpoint, holding no worker, until someone approves or denies. The decision resumes the run from that checkpoint and is memoized, so a replay never re-parks.

export const refund = defineWorkflow<{ ticketId: string; amount: number }>({
  name: "support.refund",
  handler: async (ctx) => {
    const decision = await ctx.step.approval<{ ticketId: string; amount: number }>("refund-gate", {
      tool: "issue-refund",
      args: ctx.event.data,
      risk: "high",
      summary: `Refund ${ctx.event.data.amount} on ${ctx.event.data.ticketId}`,
    });

    if (decision.status === "denied") return { refunded: false, decidedBy: decision.decidedBy };

    return await ctx.step.run("issue", () => issueRefund(decision.args));
  },
});

The decider may edit the proposed args; decision.args are the effective ones. Approvals

Log, then watch a run live

ctx.log writes structured, leveled lines onto the run. runs.watch streams the run's timeline - status transitions, step transitions, and log lines - and ends on its own when the run is terminal.

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

const duraton = createClient({
  url: process.env.DURATON_URL!,
  apiKey: process.env.DURATON_API_KEY,
});

const { runId } = await duraton.events.send({
  name: "ticket.created",
  app: "support-app",
  data: { ticketId: "T-421" },
});

if (runId) {
  for await (const frame of duraton.runs.watch(runId)) {
    console.log(frame);
  }
}

runId is absent when the event started no run - it was deduped, dropped, debounced, or batched by a flow-control policy.

Logging · Realtime

Replay a finished run

A replay forks a new run from the original's trigger and links it back through replayOf; it does not mutate the original. retryFromStep carries the steps before the named one as memoized and resumes there, so completed work is not re-executed.

await duraton.runs.replay(runId);                        // re-execute every step
await duraton.runs.replay(runId, { ticketId: "A2" });     // fork with an edited input
await duraton.runs.retryFromStep(runId, "refund");         // carry triage + cool-off, resume at refund
await duraton.runs.bulkReplay({
  app: "support-app",
  workflow: "ticket.created",
  status: "failed",
  since: "2026-07-01T00:00:00Z",
});

Every cancel, pause, resume, replay, and retry is recorded with the API key that performed it. Control API

Receive and send webhooks

ctx.webhook.send is a durable outbound delivery: retried on a backoff, with every attempt recorded in the delivery log.

export const shipped = defineWorkflow<{ ticketId: string; tracking: string }>({
  name: "ticket.shipped",
  handler: async (ctx) => {
    await ctx.webhook.send("notify-partner", {
      url: "https://partner.example.com/hooks/shipped",
      data: ctx.event.data,
    });
  },
});

Inbound is the mirror: register a receiver in the console and a signature-verified POST becomes an event that starts a run. Webhooks

Drive it from anywhere else

No SDK required. Every run, event, and control action is an HTTP endpoint your key can call, and the same surface is exposed as MCP tools for an agent or editor.

curl -X POST "$DURATON_URL/events" \
  -H "Authorization: Bearer $DURATON_API_KEY" \
  -d '{"name":"ticket.created","app":"support-app","data":{"ticketId":"T-421"}}'

REST API · MCP

On this page