> ## Documentation Index
> Fetch the complete documentation index at: https://docs.duraton.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# 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](/start/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.

```ts theme={null}
import { workflow, NonRetriableError, RetryAfterError } from "@duraton/sdk";

export const capture = workflow<{ 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](/core/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.

```ts theme={null}
export const welcome = workflow<{ 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 = workflow<{ 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](/core/workflows) · [Triggers](/core/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.

```ts theme={null}
export const awaitApproval = workflow<{ 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](/core/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.

```ts theme={null}
export const rollup = workflow<{ 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](/core/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.

```ts theme={null}
export const reindex = workflow<{ 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 = workflow<{ 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](/core/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`.

```ts theme={null}
export const placed = workflow<{ 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](/core/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.

```ts theme={null}
export const triage = workflow<{ 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](/ai) · [step.ai reference](/reference/sdk/ai-steps)

## 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.

```ts theme={null}
export const refund = workflow<{ 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](/ai/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.

```ts theme={null}
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](/core/logging) · [Realtime](/core/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.

```ts theme={null}
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](/reference/api/control)

## Receive and send webhooks

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

```ts theme={null}
export const shipped = workflow<{ 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](/integrations/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](/integrations/mcp-server) tools for an agent or editor.

```sh theme={null}
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](/reference/api) · [MCP](/integrations/mcp-server)
