Introduction
The pieces of a Duraton run - steps, events, and runners - whether it is an AI agent or an ordinary background job.
An agent is an ordinary function - in TypeScript, Python, or Go. So is a nightly job, and Duraton treats them the same way. Wrap each unit of work in a step, and Duraton records that step's result the moment it completes. So a crash, a restart, or a deploy resumes the run at the next step instead of starting over, and a step that waits on a human holds no worker while it waits.
import { defineWorkflow } from "@duraton/sdk";
const ticketCreated = defineWorkflow<{ ticketId: string; subject: string }>({
name: "ticket.created",
retry: { maxAttempts: 3 },
handler: async (ctx) => {
const { text } = await ctx.step.ai.generate("triage", {
model: "claude-opus-4-8",
prompt: `Summarise this refund request: ${ctx.event.data.subject}`,
});
const decision = await ctx.step.approval("refund-gate", {
tool: "issue-refund",
args: { ticketId: ctx.event.data.ticketId },
risk: "high",
summary: text,
});
if (decision.status === "denied") return { outcome: "denied" };
return await ctx.step.run("refund", () => issueRefund(decision.args));
},
});from duraton import ApprovalRequest, GenerateOptions, RetryConfig, define_workflow
from duraton.context import StepContext
async def handle_ticket(ctx: StepContext) -> object:
triage = await ctx.step.ai.generate("triage", GenerateOptions(
model="claude-opus-4-8",
prompt=f"Summarise this refund request: {ctx.event.data['subject']}",
))
decision = await ctx.step.approval("refund-gate", ApprovalRequest(
tool="issue-refund",
args={"ticketId": ctx.event.data["ticketId"]},
risk="high",
summary=triage.text,
))
if decision.status == "denied":
return {"outcome": "denied"}
return await ctx.step.run("refund", lambda: issue_refund(decision.args))
ticket_created = define_workflow(
"ticket.created",
handle_ticket,
retry=RetryConfig(max_attempts=3),
)import "duraton.dev/sdk-go/duraton"
var ticketCreated = duraton.DefineWorkflow(duraton.WorkflowDefinition{
Name: "ticket.created",
Retry: &duraton.RetryConfig{MaxAttempts: 3},
Handler: func(c *duraton.Context) (any, error) {
ticket, err := duraton.EventData[Ticket](c)
if err != nil {
return nil, err
}
triage, err := duraton.Generate(c, "triage", duraton.GenerateOptions{
Model: "claude-opus-4-8",
Prompt: "Summarise this refund request: " + ticket.Subject,
})
if err != nil {
return nil, err
}
decision, err := duraton.Approval[RefundArgs](c, "refund-gate", duraton.ApprovalRequest{
Tool: "issue-refund",
Args: RefundArgs{TicketID: ticket.ID},
Risk: duraton.RiskHigh,
Summary: triage.Text,
})
if err != nil {
return nil, err
}
if decision.Status == duraton.Denied {
return map[string]any{"outcome": "denied"}, nil
}
return duraton.Run(c, "refund", func() (Refund, error) {
return issueRefund(decision.Args)
})
},
})Each step executes once; its result is memoized. triage never pays the model twice. The
approval parks the run holding no worker, for as long as the decision takes. If refund fails, only
refund retries - the model is not called again and nobody is asked to approve a second time.
A step id ("triage", "refund-gate", "refund") is how its saved result is found on the next
pass. Keep ids stable and unique within a handler, or a replay will not match the work it already
did.
Not building an agent? Drop the ai and approval steps and the rest is unchanged. A nightly
job, a cron, a webhook handler, or a fan-out gets the same durability, the same per-step retries,
and the same replay - see Running anything else durably.
The pieces
| Piece | What it is | Reference |
|---|---|---|
| Workflow | A named function, triggered by an event or a cron schedule. | Workflows |
| Step | One durable unit inside a handler. Runs once, result recorded, retried on its own. | Steps |
| Run | One execution of one workflow, with its own status, steps, logs, and output. | Runs API |
| Event | The message that starts a run. Persisted, and can fan out to many workflows. | Events |
| Runner | Your process, holding your workflow code. It dials out with connect() or is served over HTTP. | Runners |
| App | The name a runner registers under; workflows are addressed by name + app. | Workflows |
| Project | The isolated slice - its own runs, events, keys, and runners. | Workspaces |
What you can build with
| Capability | Surface | Reference |
|---|---|---|
| Durable work | step.run, step.sleep, step.sleepUntil, step.waitForEvent, step.runWorkflow, step.emit | Steps |
| Parallel work | Promise.all over steps | Steps |
| Failure handling | Per-step retries, NonRetriableError, RetryAfterError, an onFailure handler | Retries |
| Triggers | Event triggers with CEL filters and wildcards, cron triggers | Triggers |
| Flow control | concurrency, throttle, rateLimit, debounce, batch, priority, singleton, idempotency | Flow control |
| Run control | Cancel, pause, resume, replay, retry-from-step | Control API |
| Live output | ctx.log, and a streaming run timeline | Logging, Realtime |
| AI | step.ai.generate, step.ai.loop, streaming, approvals, spend caps, evals | AI |
However the work arrives, however the result leaves
An agent is only useful if something real can start it and something real happens when it finishes. You do not have to change how your business emits work to get either.
Getting a run started. Pick whichever you already have - a run can be started by more than one:
| The work arrives as | How it starts a run | Reference |
|---|---|---|
| An event you send yourself | POST /events, or duraton.events.send() from any service | Events API |
| A POST from a third party | A webhook source verifies the signature, then turns it into an event | Webhooks |
| A record on your own Kafka topics | The engine consumes the topic and maps records to events | Kafka |
| Nothing at all - it is just time | A cron trigger, with no event behind it | Triggers |
| A person deciding to run it | A manual trigger from the console or the API | Triggers |
Getting the result out. The same three shapes, in reverse:
| You want to | Use | Reference |
|---|---|---|
| Hand off to another workflow | ctx.step.emit | Steps |
| Tell an outside system | ctx.webhook.send - signed, retried, every attempt logged | Webhooks |
| Publish back to your own topics | Produce to Kafka from inside a step | Publishing back |
Both directions keep a durable attempt log you can inspect, redeliver, or replay - so "did the partner ever get it?" is a question with an answer.
And an agent can drive all of it. Duraton ships an MCP server, so an AI agent is a first-class operator: it can list and control runs, send events, score runs, and run comparisons. An agent is not only the thing being run - it can be the thing doing the running.