AI steps
Make every model call run once: the step.ai reference for generate, wrap, embed, and the durable agent loop, with providers, cost, and cache options.
step.ai makes a model call a durable step. Like step.run, each call
takes a stable id, records its result under that id, and returns the saved result on replay instead
of calling the model again. So a retry after a crash never re-spends on work that already completed.
New to AI steps? The AI quickstart walks you from a first
generate call to spend landing in the console.
Duraton stores the AI metadata (model, token counts, latency) as an opaque journal block. It never parses it and never stores your prompt, the response text, or your API key - those stay in your runner.
const { output } = await ctx.step.ai.generate<Triage>("classify", {
model: "claude-opus-4-8",
prompt: `Classify this ticket: ${subject}`,
output: triageSchema,
});The step.ai API
Prop
Type
step.ai.generate
Make one model call as a durable step. The built-in provider is Anthropic; the request is validated,
sent, and the result memoized under id.
const result = await ctx.step.ai.generate("draft-reply", {
model: "claude-opus-4-8",
prompt: `Write a one-line apology for ticket ${ticketId}.`,
});
// result.text, result.model, result.usage.inputTokens, result.usage.outputTokensProp
Type
generate returns a StructuredResult<T>:
Prop
Type
Structured output and durable re-ask
Pass output (a JSON Schema) to constrain the model and get a typed, validated value back on
result.output. If the response fails to parse or validate, generate re-prompts with the validation
error - each re-ask is its own memoized step, so the retry survives a crash and never repeats a
committed attempt. Add validate for semantic rules the schema can't express.
const { output } = await ctx.step.ai.generate<{ category: string; priority: string }>("triage", {
model: "claude-opus-4-8",
prompt: `Triage: ${subject}`,
output: {
type: "object",
properties: { category: { type: "string" }, priority: { type: "string" } },
required: ["category", "priority"],
},
reask: 2,
validate: (v) => (["low", "normal", "high"].includes((v as { priority: string }).priority) ? undefined : "priority out of range"),
});The apiKey you pass is used for that one call and never written to the journal or the run store.
Omit it to let the provider SDK read its conventional env var.
Streaming
Pass stream: true to feed the model's tokens to a live viewer as they arrive. Each delta is appended to
the run's durable timeline as an ai_chunk frame, so a viewer sees the text build in real time and a late
or reconnecting viewer replays it from token 0. The return value is unchanged - result.text is still the
complete response, memoized on replay - so streaming affects only what a viewer sees while the step runs.
const result = await ctx.step.ai.generate("summarize-thread", {
model: "claude-opus-4-8",
prompt: `Summarize this thread:\n\n${thread}`,
stream: true,
});
// result.text is the full summary; deltas streamed live on the way there.Live deltas need a live channel to Duraton, which the connect runner transport
provides; over an HTTP serve runner the call falls back to a plain generate (identical result, only the
final text recorded). See the Streaming concept for the timeline frames,
resumability, and the useStream React hook.
Fallback chains
Pass fallback - an ordered list of backup models - to keep a call resilient when a model is rate-limited
or down. The primary model is tried first; if it fails with a retryable error (429, a 5xx, or a
timeout), the call advances to the next candidate, and the first one to return wins. Its result is the
step's durable output, so a caller never sees the failover.
const answer = await ctx.step.ai.generate("answer", {
model: "claude-opus-4-8",
prompt: question,
fallback: [{ model: "claude-sonnet-4-6" }, { model: "claude-haiku-4-5" }],
});Each candidate is { model, provider? }; provider defaults to the call's provider, so a chain can span
providers once you have more than one adapter configured. The step's journal records the outcome:
Prop
Type
The console renders this as a chain pill on the AI step, and it rides the opaque journal so an agent
reading the run over MCP sees the same chain / used / reason.
Only 429, 5xx, and timeout advance the chain. A terminal 4xx (a bad request, an auth failure) fails the
step immediately - another model won't fix a malformed request. An exhausted chain also fails the step,
re-throwing the last error, so the workflow's own durable retry policy still applies. Fallback is
per-call resilience, distinct from the flow-control spend controls
(cap / budget / tokenThrottle).
Inference cache
Set cache to reuse the result of an identical earlier call instead of paying for it again. On a hit
the provider is never called, so the step commits with zero spend - the cache is the one control that
reduces spend rather than capping it, and a cached call counts nothing against cap / budget /
tokenThrottle. Where step memoization already makes a replay free, the cache makes an identical call
in a different run free too.
const answer = await ctx.step.ai.generate("answer", {
model: "claude-opus-4-8",
prompt: question,
temperature: 0, // required: caching engages only for a deterministic call
cache: true, // or { ttlMs, seed }
});The key is an exact match over the runner's app, the model, the prompt, and every output-affecting parameter, so no entry ever crosses a project boundary and a different config never returns a stale answer. The step's journal records the outcome:
Prop
Type
The console renders this as a cache pill on the AI step, and it rides the opaque journal so an agent
reading the run over MCP sees the same hit / key / ageMs.
Caching is exact-match and engages only when temperature is explicitly set to 0.2 or lower -
caching a sampled (high-temperature) answer would freeze one draw, and an unset temperature is treated
as non-deterministic (a provider default is often 1.0). The default TTL is 24h, overridable per call with
{ ttlMs }; { seed } overrides the default project seed (the runner's app) to scope entries further.
step.ai.wrap
Makes a model call you already write yourself - through the OpenAI SDK, the Anthropic SDK, the Vercel
AI SDK, or anything else - a durable step, with no other change to the call site. wrap returns your
function's value unchanged; when it recognizes the response shape it enriches the journal with the
model and token counts and records which library it wrapped.
import OpenAI from "openai";
const openai = new OpenAI();
const completion = await ctx.step.ai.wrap("classify", () =>
openai.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: subject }],
}),
);Recognized shapes: the Anthropic SDK, the OpenAI SDK, and the Vercel AI SDK. An unrecognized value
still becomes a durable wrap step - you just get less metadata on the journal.
step.ai.embed
Turn a list of inputs into vectors, one durable batch at a time. Anthropic has no embeddings API, so
you supply the embedding call (embed); Duraton owns the batching and per-batch checkpointing. If a
batch fails, only that batch re-runs on retry - committed batches are not re-embedded.
const { vectors } = await ctx.step.ai.embed("embed-kb", {
model: "voyage-3",
inputs: ["duplicate charge policy", "annual plan refunds", "refund SLA"],
embed: (batch) => voyage.embed(batch),
batchSize: 2,
});Prop
Type
embed returns { vectors } - one vector per input, in input order.
step.ai.loop
A durable agent loop. Each turn is your own model call (bring-your-own, normalized to tool calls or a
final answer); the loop executes the tools the turn requested and feeds the results into the next turn,
until the model returns a final answer, stop fires, or maxIterations is reached.
Every turn is a durable step (id:iter:N) and every tool call is a durable step
(id:iter:N:tool:<callId>), so an agent that crashes mid-run resumes at the last committed turn.
const agent = await ctx.step.ai.loop<{ resolution: string }>("agent", {
prompt: `Resolve the ticket about: ${subject}`,
maxIterations: 6,
tools: {
"search-kb": { handler: (input) => searchKb(input) },
"lookup-order": { workflow: "orders.lookup", app: "orders" },
},
turn: (ctx, iteration) => callModel(ctx.prompt, ctx.history, iteration),
});
// agent.final, agent.iterations, agent.stopReasonProp
Type
Your turn returns a LoopTurn - either tool calls to run, or a final answer:
Prop
Type
A tool is either a handler (a local function) or a workflow (another Duraton workflow, called as a linked child run):
Prop
Type
maxIterations bounds how many turns a loop may run; a per-run budget
cap bounds how much it may spend. When a run reaches its cap the loop
halts before its next turn's model call and the run fails with a BudgetError - the committed turns
stay, and the halted turn is the loop's last (failed) iteration.
ctx.history gives each turn the prior turns' toolCalls and toolResults, so your model call can
see what it has already tried. loop returns:
Prop
Type
Providers
step.ai.generate resolves its provider name to an AIProvider adapter through a port, so you
can supply your own instead of the built-in registry. Pass resolveProvider to
connect or serve and every generate call in that runner
goes through it - the call sites are unchanged.
import { connect } from "@duraton/sdk";
import { type AIProvider, createAnthropicProvider, getProvider } from "@duraton/sdk/ai";
const recording: AIProvider = {
name: "anthropic",
generate: (req) => fixtures[req.prompt] ?? getProvider("anthropic").generate(req),
};
connect({
app: "support-app",
workflows,
resolveProvider: (name) => (name === "anthropic" ? recording : getProvider(name)),
});| Export | Type | Description |
|---|---|---|
PROVIDERS | readonly ["anthropic"] | The closed set of provider names the SDK ships an adapter for. |
ProviderName | "anthropic" | The type derived from PROVIDERS; what GenerateOptions.provider accepts. |
getProvider(name) | (name: ProviderName) => AIProvider | The built-in registry: one adapter per name. The default resolveProvider. |
createAnthropicProvider(opts?) | (opts?: { fetch?, baseURL? }) => AIProvider | The Anthropic adapter. It loads @anthropic-ai/sdk lazily, so that package is an optional peer dependency you install only if you call Anthropic. |
ProviderResolver | (name: ProviderName) => AIProvider | The resolveProvider option's type. |
An AIProvider implements generate(req) and, optionally, stream(req, onDelta) (an adapter without
it falls back to generate, so stream: true still returns the right text) and classifyError(err)
(which decides whether a failure is retryable, and so whether a fallback chain
advances - an unclassified error is treated as terminal).
The API key rides each GenerateRequest and is never stored by the SDK, never journaled, and never
sent to Duraton. Omit it and the adapter falls back to its provider SDK's conventional env var (for
Anthropic, ANTHROPIC_API_KEY). Your model keys stay in your runner.
Cost
Duraton holds no model price list, so a call's cost is absent unless your runner supplies it. Pass
resolveCost - the CostSource port - and each step.ai call is priced from the axes the journal
already holds. Supplying it is what makes cap: { maxCost } and budget: { maxCost } bite; maxTokens
needs nothing, because tokens are metered from every call.
import type { CostSource } from "@duraton/sdk/ai";
const PRICES: Record<string, { in: number; out: number }> = {
"claude-opus-4-8": { in: 5 / 1_000_000, out: 25 / 1_000_000 },
};
const resolveCost: CostSource = ({ model, tokensIn = 0, tokensOut = 0 }) => {
const p = model ? PRICES[model] : undefined;
return p ? tokensIn * p.in + tokensOut * p.out : undefined; // undefined leaves cost absent
};
connect({ app: "support-app", workflows, resolveCost });Prop
Type
Returning undefined leaves the cost absent - Duraton never fabricates a zero - and a call that
already carries an explicit cost is left untouched.
Cache store
The inference cache is backed by the AICache port, so the store is swappable.
The default is createMemoryCache(): a process-local Map with per-entry TTL and LRU eviction, bounded
at 1000 entries. Pass cache to connect or serve to swap it - for a store shared across runner
processes, say.
import { createMemoryCache } from "@duraton/sdk/ai";
connect({
app: "support-app",
workflows,
cache: createMemoryCache({ maxEntries: 10_000 }),
});Prop
Type
The store is only ever consulted for a call that opted in with cache - its mere presence changes
nothing. The cached completion is held runner-side: Duraton's journal records only the cache
metadata (hit, key, ageMs), never the payload. A store you share across processes must seed its
keys deliberately, since the default seed (the runner's app) assumes the process boundary isolates it.
Workflows as tools
Hand any workflow to a loop as a tool and the model can drive it: the tool call becomes a linked child run, the model's tool input is the child's trigger data, and the child's result is the tool result. The child is a full durable run of its own - it can retry, sleep, and call further workflows - and shows up linked to the parent in the inspector. This is how the orchestrator-workers pattern maps onto Duraton.
Durable steps and replay
Because every step.ai call is a durable step, the same replay rules as regular
steps apply: keep turn, validate, and stop deterministic (a pure
function of their inputs), since they run again on replay while the memoized model results do not. Model
calls happen exactly once per committed step; everything around them must be replay-safe.
For the concepts behind durable AI steps and end-to-end recipes, see AI agents.