AI agents
Build AI agents that survive a crash - step.ai turns model calls, tool calls, and agent loops into durable steps, plus the classic agent patterns.
A model call is slow, expensive, and can fail halfway. Wrapping it in a durable step turns it into a
checkpoint: the call happens once, its result is saved, and a crash or retry resumes from after it
rather than paying for it again. That is all step.ai is - the step
API for model calls, embeddings, and agent loops. See
AI steps for the full reference, or the
AI quickstart to run your first one end to end.
The building block
The unit is a single augmented model call - an LLM with tools and structured output. In Duraton that is
step.ai.generate:
const { output } = await ctx.step.ai.generate<Triage>("classify", {
model: "claude-opus-4-8",
prompt: `Classify this ticket: ${subject}`,
output: triageSchema,
});result = await ctx.step.ai.generate("classify", GenerateOptions(
model="claude-opus-4-8",
prompt=f"Classify this ticket: {subject}",
output=triage_schema,
))
result.outputres, err := duraton.GenerateStruct[Triage](c, "classify", duraton.GenerateOptions{
Model: "claude-opus-4-8",
Prompt: "Classify this ticket: " + subject,
Output: triageSchema,
})
res.OutputThe call is memoized under "classify", so on replay the workflow skips it and reuses the saved
answer. Composing calls means composing checkpoints:
export const triageTicket = defineWorkflow<{ subject: string }>({
name: "ticket.created",
handler: async (ctx) => {
const { output } = await ctx.step.ai.generate<{ category: string; priority: string }>("classify", {
model: "claude-opus-4-8",
prompt: `Classify this ticket: ${ctx.event.data.subject}`,
output: {
type: "object",
properties: { category: { type: "string" }, priority: { type: "string" } },
required: ["category", "priority"],
},
});
// A crash here replays the handler from the top: "classify" returns its recorded
// answer without calling the model, and only "draft-reply" is actually charged.
const { text } = await ctx.step.ai.generate("draft-reply", {
model: "claude-opus-4-8",
prompt: `Write a reply for a ${output?.priority} ${output?.category} ticket.`,
});
return { category: output?.category, reply: text };
},
});async def triage(ctx: StepContext) -> object:
classified = await ctx.step.ai.generate("classify", GenerateOptions(
model="claude-opus-4-8",
prompt=f"Classify this ticket: {ctx.event.data['subject']}",
output={
"type": "object",
"properties": {"category": {"type": "string"}, "priority": {"type": "string"}},
"required": ["category", "priority"],
},
))
result = classified.output or {}
# A crash here replays the handler from the top: "classify" returns its recorded
# answer without calling the model, and only "draft-reply" is actually charged.
reply = await ctx.step.ai.generate("draft-reply", GenerateOptions(
model="claude-opus-4-8",
prompt=f"Write a reply for a {result.get('priority')} {result.get('category')} ticket.",
))
return {"category": result.get("category"), "reply": reply.text}
triage_ticket = define_workflow("ticket.created", triage)type Triage struct {
Category string `json:"category"`
Priority string `json:"priority"`
}
var triageTicket = duraton.DefineWorkflow(duraton.WorkflowDefinition{
Name: "ticket.created",
Handler: func(c *duraton.Context) (any, error) {
ticket, err := duraton.EventData[Ticket](c)
if err != nil {
return nil, err
}
classified, 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
}
// A crash here replays the handler from the top: "classify" returns its recorded
// answer without calling the model, and only "draft-reply" is actually charged.
reply, err := duraton.Generate(c, "draft-reply", duraton.GenerateOptions{
Model: "claude-opus-4-8",
Prompt: fmt.Sprintf("Write a reply for a %s %s ticket.", classified.Output.Priority, classified.Output.Category),
})
if err != nil {
return nil, err
}
return map[string]any{"category": classified.Output.Category, "reply": reply.Text}, nil
},
})The agent loop
When the model should decide its own next step - call a tool, look at the result, call another - use
step.ai.loop. Each turn is a durable step and each tool call is a durable step, so a long-running
agent survives a restart and resumes at the last committed turn instead of starting over.
const agent = await ctx.step.ai.loop("agent", {
prompt: `Resolve the ticket about: ${subject}`,
maxIterations: 6,
tools: {
"search-kb": { handler: (q) => searchKb(q) },
"lookup-order": { workflow: "orders.lookup", app: "orders" },
},
turn: (ctx, i) => callModel(ctx.prompt, ctx.history, i),
});from duraton import LoopHandlerTool, LoopOptions, LoopWorkflowTool
agent = await ctx.step.ai.loop("agent", LoopOptions(
prompt=f"Resolve the ticket about: {subject}",
max_iterations=6,
tools={
"search-kb": LoopHandlerTool(handler=lambda q: search_kb(q)),
"lookup-order": LoopWorkflowTool(workflow="orders.lookup", app="orders"),
},
turn=lambda lctx, i: call_model(lctx.prompt, lctx.history, i),
))agent, err := duraton.Loop[Resolution](c, "agent", duraton.LoopOptions{
Prompt: "Resolve the ticket about: " + subject,
MaxIterations: 6,
Tools: map[string]duraton.LoopTool{
"search-kb": {Handler: func(in any) (any, error) { return searchKb(in) }},
"lookup-order": {Workflow: "orders.lookup", App: "orders"},
},
Turn: func(lc duraton.LoopContext, i int) (duraton.LoopTurn, error) {
return callModel(lc.Prompt, lc.History, i)
},
})A tool can be a local handler or another workflow. A workflow tool becomes a linked child run -
a full durable run with its own retries and steps - so the agent can delegate real work, not just call
a function. That linkage is what makes the orchestrator pattern below durable end to end.
Agent patterns
Anthropic's Building effective agents distinguishes composable workflows (the model follows a fixed structure) from agents (the model directs itself). Each maps onto Duraton primitives, and because every model call is a step, each becomes crash-safe for free.
The pattern names and definitions below are Anthropic's; the mapping to step.ai is the durable
implementation.
Prompt chaining
Decompose a task into a fixed sequence of calls, each consuming the previous one's output. Every call is its own step, so a failure in the third link resumes from the saved output of the second.
const outline = await ctx.step.ai.generate("outline", { model, prompt: `Outline: ${topic}` });
const draft = await ctx.step.ai.generate("draft", { model, prompt: `Expand:\n${outline.text}` });
const final = await ctx.step.ai.generate("polish", { model, prompt: `Tighten:\n${draft.text}` });Routing
Classify the input, then send it to a specialized follow-up. Classify with a structured generate,
then branch to the workflow that handles that class:
const { output } = await ctx.step.ai.generate<{ queue: string }>("route", {
model,
prompt: `Which queue handles: ${subject}`,
output: { type: "object", properties: { queue: { type: "string" } }, required: ["queue"] },
});
const result = await ctx.step.runWorkflow("handle", { name: `support.${output?.queue}` });Parallelization
Run independent calls at once and aggregate the results (sectioning), or run the same call several
times and combine the answers (voting). Fan out with Promise.all - each branch is its own durable
step and the run joins when all have committed:
const [safety, topic, sentiment] = await Promise.all([
ctx.step.ai.generate("safety", { model, prompt: `Unsafe? ${text}` }),
ctx.step.ai.generate("topic", { model, prompt: `Topic of: ${text}` }),
ctx.step.ai.generate("sentiment", { model, prompt: `Sentiment of: ${text}` }),
]);Orchestrator-workers
A central model breaks a task down, delegates to workers, and synthesizes the results. This is
step.ai.loop with workflow tools: the loop is the orchestrator, each workflow tool is a worker,
and every delegation is a linked child run that retries and checkpoints on its own.
await ctx.step.ai.loop("orchestrate", {
prompt: `Ship the change described in: ${brief}`,
maxIterations: 12,
tools: {
"search-code": { workflow: "repo.search" },
"open-pr": { workflow: "repo.open-pr", app: "ci" },
},
turn: (ctx, i) => planNextStep(ctx.prompt, ctx.history, i),
});Evaluator-optimizer
One call generates, another evaluates and feeds back, in a loop until the check passes. For a single
output against a schema, generate's built-in durable
re-ask is this pattern - validate is the
evaluator and each re-ask is a durable step:
const { output } = await ctx.step.ai.generate("summary", {
model,
prompt: `Summarize in under 40 words: ${doc}`,
output: summarySchema,
validate: (v) => (wordCount(v) <= 40 ? undefined : "too long, tighten it"),
reask: 3,
});For a free-form generate/critique cycle, run the two calls as steps inside step.ai.loop and stop
when the evaluator is satisfied.
Keeping it replay-safe
The model results are memoized, but the code around them - your turn, validate, and stop
functions, and any branching on a result - runs again on every replay. Keep them deterministic (a pure
function of their inputs) so a resumed run takes the same path it took the first time. The
durable-execution rules for regular steps apply unchanged.
AI quickstart
Make your first model call crash-safe: add a durable AI step, point it at your provider, and watch its spend, tokens, and latency land in the console.
Streaming
Show a viewer tokens as the model produces them and still get one durable result - the stream replays from token 0, the memoized value is the full text.