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));
},
});from duraton import NonRetriableError, RetryAfterError, RetryConfig, define_workflow
from duraton.context import StepContext
async def capture(ctx: StepContext) -> object:
def validate() -> None:
if ctx.event.data["amount"] <= 0:
raise NonRetriableError("amount must be positive")
await ctx.step.run("validate", validate)
async def triage() -> object:
res = await http_post("https://api.example.com/triage")
if res.status == 429:
raise RetryAfterError("rate limited", "30s")
if not res.ok:
raise RuntimeError(f"gateway {res.status}")
return await res.json()
return await ctx.step.run("triage", triage)
async def on_capture_failure(ctx: StepContext) -> None:
await ctx.step.run("void-hold", lambda: void_hold(ctx.event.data["ticketId"], ctx.error))
capture_wf = define_workflow(
"ticket.capture",
capture,
retry=RetryConfig(max_attempts=4),
on_failure=on_capture_failure,
)type Capture struct {
TicketID string `json:"ticketId"`
Amount int `json:"amount"`
}
var capture = duraton.DefineWorkflow(duraton.WorkflowDefinition{
Name: "ticket.capture",
Retry: &duraton.RetryConfig{MaxAttempts: 4},
Handler: func(c *duraton.Context) (any, error) {
ticket, err := duraton.EventData[Capture](c)
if err != nil {
return nil, err
}
if _, err := duraton.Run(c, "validate", func() (any, error) {
if ticket.Amount <= 0 {
return nil, duraton.NewNonRetriableError("amount must be positive")
}
return nil, nil
}); err != nil {
return nil, err
}
return duraton.Run(c, "triage", func() (any, error) {
res, err := http.Post("https://api.example.com/triage", "application/json", nil)
if err != nil {
return nil, err
}
if res.StatusCode == 429 {
return nil, duraton.NewRetryAfterError("rate limited", 30*time.Second)
}
if res.StatusCode >= 400 {
return nil, fmt.Errorf("gateway %d", res.StatusCode)
}
return decodeCharge(res)
})
},
OnFailure: func(c *duraton.Context) (any, error) {
ticket, err := duraton.EventData[Capture](c)
if err != nil {
return nil, err
}
return duraton.Run(c, "void-hold", func() (any, error) {
return voidHold(ticket.TicketID, c.Error)
})
},
})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)),
});from duraton import define_workflow
from duraton.context import StepContext
async def welcome(ctx: StepContext) -> object:
return await ctx.step.run("email", lambda: send_welcome(ctx.event.data["userId"]))
async def welcome_pro(ctx: StepContext) -> object:
return await ctx.step.run("concierge", lambda: book_onboarding(ctx.event.data["userId"]))
welcome_wf = define_workflow(
"signup.welcome",
welcome,
triggers=[{"event": "user.signup"}],
)
welcome_pro_wf = define_workflow(
"signup.welcome-pro",
welcome_pro,
triggers=[{"event": "user.signup", "if": 'event.data.plan == "pro"'}],
)type Signup struct {
UserID string `json:"userId"`
Plan string `json:"plan"`
}
var welcome = duraton.DefineWorkflow(duraton.WorkflowDefinition{
Name: "signup.welcome",
Triggers: []duraton.Trigger{{Event: "user.signup"}},
Handler: func(c *duraton.Context) (any, error) {
user, err := duraton.EventData[Signup](c)
if err != nil {
return nil, err
}
return duraton.Run(c, "email", func() (any, error) { return sendWelcome(user.UserID) })
},
})
var welcomePro = duraton.DefineWorkflow(duraton.WorkflowDefinition{
Name: "signup.welcome-pro",
Triggers: []duraton.Trigger{{Event: "user.signup", If: `event.data.plan == "pro"`}},
Handler: func(c *duraton.Context) (any, error) {
user, err := duraton.EventData[Signup](c)
if err != nil {
return nil, err
}
return duraton.Run(c, "concierge", func() (any, error) { return bookOnboarding(user.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));
},
});from duraton import define_workflow
from duraton.context import StepContext
async def await_approval(ctx: StepContext) -> object:
ticket_id = ctx.event.data["ticketId"]
decision = await ctx.step.wait_for_event("await", event="approval.granted", timeout="48h")
if decision is None:
return await ctx.step.run("expire", lambda: cancel_order(ticket_id))
await ctx.step.sleep("cool-off", "1h")
return await ctx.step.run("refund", lambda: issue_refund(ticket_id))
await_approval_wf = define_workflow("ticket.await-approval", await_approval)type Ticket struct {
TicketID string `json:"ticketId"`
}
type Approval struct {
Approver string `json:"approver"`
}
var awaitApproval = duraton.DefineWorkflow(duraton.WorkflowDefinition{
Name: "ticket.await-approval",
Handler: func(c *duraton.Context) (any, error) {
ticket, err := duraton.EventData[Ticket](c)
if err != nil {
return nil, err
}
decision, err := duraton.WaitForEvent[Approval](c, "await", duraton.WaitForEventOptions{
Event: "approval.granted",
Timeout: 48 * time.Hour,
})
if err != nil {
return nil, err
}
if decision == (Approval{}) {
return duraton.Run(c, "expire", func() (any, error) { return cancelOrder(ticket.TicketID) })
}
if err := duraton.Sleep(c, "cool-off", time.Hour); err != nil {
return nil, err
}
return duraton.Run(c, "refund", func() (any, error) { return issueRefund(ticket.TicketID) })
},
})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());
},
});from duraton import define_workflow
from duraton.context import StepContext
async def rollup(ctx: StepContext) -> object:
ctx.log.info("rollup tick", {"scheduledFor": ctx.event.data["scheduledFor"]})
return await ctx.step.run("aggregate", lambda: rollup_hour())
rollup_wf = define_workflow(
"metrics.rollup",
rollup,
triggers=[{"cron": "@every 1m"}],
extra={"singleton": {"mode": "skip"}},
)type Tick struct {
Cron string `json:"cron"`
ScheduledFor string `json:"scheduledFor"`
}
var rollup = duraton.DefineWorkflow(duraton.WorkflowDefinition{
Name: "metrics.rollup",
Triggers: []duraton.Trigger{{Cron: "@every 1m"}},
Singleton: &duraton.SingletonConfig{Mode: "skip"},
Handler: func(c *duraton.Context) (any, error) {
tick, err := duraton.EventData[Tick](c)
if err != nil {
return nil, err
}
c.Log.Info("rollup tick", map[string]any{"scheduledFor": tick.ScheduledFor})
return duraton.Run(c, "aggregate", func() (any, error) { return 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));
},
});from duraton import define_workflow
from duraton.context import StepContext
async def reindex(ctx: StepContext) -> object:
return await ctx.step.run("index", lambda: reindex_doc(ctx.event.data["documentId"]))
async def flush(ctx: StepContext) -> object:
points = [e.data for e in (ctx.events or [])]
return await ctx.step.run("write", lambda: write_points(points))
reindex_wf = define_workflow(
"search.reindex",
reindex,
extra={"debounce": {"periodMs": 5_000, "key": "documentId"}},
)
flush_wf = define_workflow(
"metrics.flush",
flush,
extra={"batch": {"maxSize": 50, "timeoutMs": 5_000}},
)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 };
},
});import asyncio
from duraton import define_workflow
from duraton.context import StepContext
async def placed(ctx: StepContext) -> object:
data = ctx.event.data
refund, notified = await asyncio.gather(
ctx.step.run_workflow("refund", name="support.refund", app="support", data=data),
ctx.step.run("notify", lambda: notify_requester(data["ticketId"])),
)
await ctx.step.emit(
"receipt",
name="receipt.requested",
app="support",
data={"ticketId": data["ticketId"], "refundId": refund["refundId"]},
)
return {"refund": refund, "notified": notified}
placed_wf = define_workflow("ticket.placed", placed)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!));
},
});from duraton import GenerateOptions, define_workflow
from duraton.context import StepContext
async def triage(ctx: StepContext) -> object:
subject = ctx.event.data["subject"]
ticket_id = ctx.event.data["ticketId"]
result = await ctx.step.ai.generate("classify", GenerateOptions(
model="claude-opus-4-8",
prompt=f"Classify this ticket: {subject}",
output={
"type": "object",
"properties": {"category": {"type": "string"}, "priority": {"type": "number"}},
"required": ["category", "priority"],
},
validate=lambda v: None if 1 <= v["priority"] <= 5 else "priority must be 1-5",
))
return await ctx.step.run("route", lambda: route_ticket(ticket_id, result.output))
triage_wf = define_workflow("support.triage", triage)type Ticket struct {
TicketID string `json:"ticketId"`
Subject string `json:"subject"`
}
type Triage struct {
Category string `json:"category"`
Priority int `json:"priority"`
}
var triage = duraton.DefineWorkflow(duraton.WorkflowDefinition{
Name: "support.triage",
Handler: func(c *duraton.Context) (any, error) {
ticket, err := duraton.EventData[Ticket](c)
if err != nil {
return nil, err
}
res, err := duraton.GenerateStruct[Triage](c, "classify", duraton.GenerateOptions{
Model: "claude-opus-4-8",
Prompt: "Classify this ticket: " + ticket.Subject,
Output: json.RawMessage(`{"type":"object","required":["category","priority"]}`),
})
if err != nil {
return nil, err
}
return duraton.Run(c, "route", func() (any, error) {
return routeTicket(ticket.TicketID, res.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));
},
});from duraton import ApprovalRequest, define_workflow
from duraton.context import StepContext
async def refund(ctx: StepContext) -> object:
data = ctx.event.data
decision = await ctx.step.approval("refund-gate", ApprovalRequest(
tool="issue-refund",
args=data,
risk="high",
summary=f"Refund {data['amount']} on {data['ticketId']}",
))
if decision.status != "approved":
return {"refunded": False}
return await ctx.step.run("issue", lambda: issue_refund(decision.args))
refund_wf = define_workflow("support.refund", refund)type RefundInput struct {
TicketID string `json:"ticketId"`
Amount int `json:"amount"`
}
var refund = duraton.DefineWorkflow(duraton.WorkflowDefinition{
Name: "support.refund",
Handler: func(c *duraton.Context) (any, error) {
data, err := duraton.EventData[RefundInput](c)
if err != nil {
return nil, err
}
decision, err := duraton.Approval[RefundInput](c, "refund-gate", duraton.ApprovalRequest{
Tool: "issue-refund",
Args: data,
Risk: duraton.RiskHigh,
Summary: fmt.Sprintf("Refund %d on %s", data.Amount, data.TicketID),
})
if err != nil {
return nil, err
}
if decision.Status != duraton.Approved {
return map[string]any{"refunded": false}, nil
}
return duraton.Run(c, "issue", func() (any, error) {
return 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);
}
}import asyncio
from duraton.client import AsyncDuratonClient, SendEventInput
async def main() -> None:
async with AsyncDuratonClient() as dx:
res = await dx.events.send(
SendEventInput(name="ticket.created", app="support-app", data={"ticketId": "T-421"})
)
if res.run_id:
async for frame in dx.runs.watch(res.run_id):
print(frame)
asyncio.run(main())import (
"context"
"encoding/json"
"fmt"
"log"
"duraton.dev/sdk-go/client"
)
func main() {
dx := client.New(client.Options{})
ctx := context.Background()
res, err := dx.Events.Send(ctx, client.SendEventInput{
Name: "ticket.created",
App: "support-app",
Data: json.RawMessage(`{"ticketId":"T-421"}`),
})
if err != nil {
log.Fatal(err)
}
if res.RunID != "" {
err = dx.Runs.Watch(ctx, res.RunID, client.WatchOptions{}, func(f client.TimelineFrame) bool {
fmt.Println(f)
return true
})
if err != nil {
log.Fatal(err)
}
}
}runId is absent when the event started no run - it was deduped, dropped, debounced, or batched by a
flow-control policy.
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",
});from duraton.client import AsyncDuratonClient, BulkReplayFilter
async def main(run_id: str) -> None:
async with AsyncDuratonClient() as dx:
await dx.runs.replay(run_id) # re-execute every step
await dx.runs.replay(run_id, input={"ticketId": "A2"}) # fork with an edited input
await dx.runs.retry_from_step(run_id, "refund") # carry triage + cool-off, resume at refund
await dx.runs.bulk_replay(BulkReplayFilter(workflow="ticket.created", status="failed"))import (
"context"
"encoding/json"
"duraton.dev/sdk-go/client"
)
func replayExamples(runID string) {
dx := client.New(client.Options{})
ctx := context.Background()
dx.Runs.Replay(ctx, runID, nil) // re-execute every step
dx.Runs.Replay(ctx, runID, json.RawMessage(`{"ticketId":"A2"}`)) // fork with an edited input
dx.Runs.RetryFromStep(ctx, runID, "refund") // carry triage + cool-off, resume at refund
dx.Runs.BulkReplay(ctx, client.BulkReplayFilter{
WorkflowName: "ticket.created",
Status: []client.RunStatus{"failed"},
})
}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,
});
},
});from duraton import define_workflow
from duraton.context import StepContext
async def shipped(ctx: StepContext) -> object:
await ctx.webhook.send(
"notify-partner",
url="https://partner.example.com/hooks/shipped",
data=ctx.event.data,
)
return None
shipped_wf = define_workflow("ticket.shipped", shipped)type Refund struct {
TicketID string `json:"ticketId"`
Tracking string `json:"tracking"`
}
var shipped = duraton.DefineWorkflow(duraton.WorkflowDefinition{
Name: "ticket.shipped",
Handler: func(c *duraton.Context) (any, error) {
ticket, err := duraton.EventData[Refund](c)
if err != nil {
return nil, err
}
if err := duraton.Webhook(c, "notify-partner", duraton.WebhookOptions{
URL: "https://partner.example.com/hooks/shipped",
Data: ticket,
}); err != nil {
return nil, err
}
return nil, nil
},
})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"}}'Workspaces & projects
Keep teams and environments apart: a workspace holds members, roles, and one pooled allowance; a project is the isolation boundary for runs, events, and keys.
Production and operations
Deploy new agent code without stranding runs in flight: how many runners to run, pin vs anycast routing, graceful shutdown, and the step-id footgun.