Agent kit
Write an AI agent as a durable workflow: agent() and tool() from @duraton/agent-kit, where every model turn and tool call is a step that survives a crash.
@duraton/agent-kit is the authoring layer on top of step.ai.loop.
You declare a model, some instructions and some tools; the kit composes the model call for each
turn. Everything durable stays where it already was - one step per turn, one per tool call,
memoized on replay - so an agent written with the kit is an ordinary durable run.
npm install @duraton/agent-kitA first agent
import { agent, tool } from "@duraton/agent-kit";
import { defineWorkflow } from "@duraton/sdk";
const searchPolicy = tool({
name: "search-policy",
description: "Look up the refund policy that applies to a ticket",
inputSchema: {
type: "object",
properties: { topic: { type: "string" } },
required: ["topic"],
},
handler: (input) => policyIndex.find(parseTopic(input)),
});
export const support = defineWorkflow<{ ticket: string }>({
name: "support.agent",
handler: async (ctx) => {
const result = await agent(ctx, "agent", {
model: "claude-opus-4-8",
instructions: "You are a support agent. Check the refund policy before answering.",
prompt: `Resolve this ticket: ${ctx.event.data.ticket}`,
tools: [searchPolicy],
maxIterations: 6,
});
return { answer: result.final, iterations: result.iterations };
},
});That run records agent:iter:0, agent:iter:0:tool:<callId>, agent:iter:1, and so on - the same
step names a hand-written loop writes, which is why an agent needs no special handling to show up
in the console as an agent.
agent
agent(ctx, id, options) takes the workflow context, a stable step id, and:
Prop
Type
It returns the loop's result: final (the model's answer), iterations, and stopReason
("final", "max-iterations", "stopped" or "approval-budget").
tool
A tool is one definition. Give it a name the model calls back with, a description it reads to decide, and a JSON Schema for the input it must produce:
Prop
Type
Exactly one of handler or workflow - a tool is backed by your code or by a workflow:
const lookupOrder = tool({
name: "lookup-order",
description: "Fetch an order by id",
inputSchema: { type: "object", properties: { orderId: { type: "string" } } },
workflow: "orders.lookup",
app: "orders",
});A tool's input is raw model output. The schema tells the model what to produce; it does not
validate what arrives, so parse it in the handler before you trust it.
Tools a human has to approve
Mark the tools an agent should not use unattended. The run parks on a reviewer before the tool runs, holding no worker while it waits:
const issueRefund = tool({
name: "issue-refund",
description: "Issue a refund to the customer",
inputSchema: { type: "object", properties: { amount: { type: "number" } } },
requiresApproval: true,
handler: (input) => refunds.issue(input),
});requiresApproval: true raises the gate and says nothing else about it. Say more with approval,
and the same annotations a hand-written ctx.step.approval takes apply to the
tool's gate:
const issueRefund = tool({
name: "issue-refund",
requiresApproval: true,
approval: {
risk: "high",
summary: "Refund a customer for a duplicate charge",
allow: ["approve", "reject"],
timeout: "30m",
onTimeout: "reject",
},
handler: (input) => refunds.issue(input),
});Declare a risk on anything you would not want cleared by a credential. A gate that states no
risk is stored at the default, medium, and
DURATON_APPROVAL_HUMAN_RISK_FLOOR reads the stored risk - so a
floor of high cannot refuse a gate that never claimed to be high.
The reviewer sees the input the model proposed and can change it before approving - the tool then runs with their input, not the model's. A denial is not an error: it comes back to the model as that tool's result, so the agent can say what it could not do instead of the run failing.
| Decision | What runs | What the model gets |
|---|---|---|
| Approved | The handler, with the decided input | The handler's return value |
| Approved with edits | The handler, with the reviewer's input | The handler's return value |
| Denied | Nothing | { approved: false, tool, decidedBy } |
Each gate is its own durable step, so resuming the run replays the agent without asking anyone twice. Both the approval and the tool call show up under their turn in the console, so a parked agent reads as parked rather than as a tool that never finished.
Gating some calls and not others
requiresApproval is one bit for the whole tool, so a refund tool marked true wakes someone for
a $2 refund and one marked false wakes nobody for a $50,000 one. approval.if and
approval.minRisk are the third answer: a condition on this call, written as data and evaluated
by Duraton.
const issueRefund = tool({
name: "issue-refund",
inputSchema: { type: "object", properties: { amount: { type: "number" } } },
requiresApproval: true,
approval: {
risk: "high",
// Small refunds go through; anything above the limit waits for a person. And after
// three turns the agent is looping, so gate whatever it reaches for next.
if: "args.amount > 1000.0 || iteration > 3",
},
handler: (input) => refunds.issue(input),
});The expression reads four names and no others - tool, args, risk and iteration - and every
JSON number in args reaches it as a double. A rule that cannot be evaluated raises the gate rather
than skipping it, and one that declines completes the step as decidedBy: "system:rule" with no
approval for anyone to answer. The full environment, the failure modes and when this beats a
hand-written if are on Approvals.
Which rule applies to a tool
A rule can be declared in two places: on the tool, and on the agent as the default for every tool that has not answered for itself.
await agent(ctx, "support", {
model: "claude-opus-4-8",
prompt: `Resolve this ticket: ${ctx.event.data.ticket}`,
tools: [searchPolicy, issueRefund],
mcpServers: [{ name: "orders", transport: { kind: "http", url: process.env.ORDERS_MCP_URL } }],
// Anything that did not answer for itself gates, and gates like this.
approval: { risk: "high", summary: "A tool this agent was not told how to treat", timeout: "1h" },
maxIterations: 6,
});The two levels resolve in one order, and requiresApproval is tri-state on purpose - absent,
true and false are three different answers rather than a boolean with a default:
| The tool says | The agent has a default | The gate |
|---|---|---|
requiresApproval: false | either way | none. An opt-out is an answer, and it beats the default |
approval: { ... } | either way | raised, under the tool's own rule |
requiresApproval: true | yes | raised, under the agent's default |
requiresApproval: true | no | raised, with nothing said about it |
| nothing | yes | raised, under the agent's default |
| nothing | no | none |
Two rows are worth reading twice. requiresApproval: true on a tool the agent has a default for
takes that default's annotations rather than erasing them: true asks for a gate, it does not claim
there is nothing to say about one. And a tool's own approval replaces the default rather than
merging with it - a rule is one policy, so a tool that states risk and no timeout has no timeout,
whatever the default said.
A tool with no opinion at all inherits the default. That is the point: requiresApproval: false
is how a tool says it never waits for anyone, and saying nothing is not the same as saying that.
The default is also what covers an MCP server's tools that the server's own predicate says nothing about. Those tools are attached here and discovered at runtime, so there is no declaration above to annotate and no list to enumerate ahead of time.
Answering in a shape
By default an agent's answer is the model's text. Pass output - a JSON Schema, the same raw-schema
form step.ai.generate takes - and two things change: the model is constrained to that shape, and
the answer comes back parsed, so final is the object rather than a string you have to parse
yourself.
interface Invoice {
total: number;
currency: string;
}
const result = await agent<Invoice>(ctx, "billing", {
model: "claude-opus-4-8",
prompt: "Total this invoice and give me the currency.",
maxIterations: 4,
tools: [lineItems],
output: {
type: "object",
properties: { total: { type: "number" }, currency: { type: "string" } },
required: ["total", "currency"],
additionalProperties: false,
},
});
result.final?.total; // a number, not a substringThe type parameter on agent<Invoice> is your assertion about the schema you passed - nothing
checks the parsed value against the schema, exactly as with step.ai.generate. What output
guarantees is that the answer is valid JSON and that the model was constrained while producing it.
Two ways this fails, both loudly rather than by handing you the wrong thing:
| Situation | What happens |
|---|---|
| The provider cannot constrain the model | The agent refuses before calling it, naming the provider. Only adapters declaring the structured-output capability are accepted - the Anthropic adapter does, the AI SDK adapter does not, because generateText takes no schema |
| The model answers with text that is not JSON | The run fails. Returning the raw string would hand back a value typed as your shape that is not one |
Without output, agent() behaves exactly as it always has and final is the model's text. The
option changes nothing for an agent that does not use it.
Capping the decisions an agent asks for
maxApprovals is a ceiling on how many human decisions one agent may ask for. When a turn's tool
calls would take it past the ceiling, the agent stops instead of running them, and returns
stopReason: "approval-budget":
const result = await agent(ctx, "support", {
model: "claude-opus-4-8",
prompt: `Resolve this ticket: ${ctx.event.data.ticket}`,
tools: [searchPolicy, issueRefund],
maxIterations: 12,
maxApprovals: 3,
});
if (result.stopReason === "approval-budget") {
return { outcome: "handed-off", reason: "the agent asked for more sign-off than it was allowed" };
}Omitted, there is no ceiling. Set one where an agent could plausibly keep reaching for a gated tool:
maxIterations bounds how long it runs, and this bounds how much of a person's attention it can
spend doing so. Handle the stop like any other terminal reason - the agent returns rather than
throwing, so the workflow decides what a run that ran out of sign-off does next.
Showing the model less than you record
A tool that returns a thousand rows costs a thousand rows of context on every turn after it. Give
it a toModelOutput and the model reads the summary while the durable step keeps the whole thing:
const listOrders = tool({
name: "list-orders",
description: "Every order for a customer",
outputSchema: { type: "object", properties: { orders: { type: "array" } } },
toModelOutput: (output) => ({
count: output.orders.length,
ids: output.orders.slice(0, 5).map((o) => o.id),
}),
handler: (input) => orders.listFor(input.customerId),
});The projection is not a truncation you can never undo. The tool's step still holds all thousand orders, so the run's history, a replay, and anything reading the timeline all see the full result - only the model's context was spent on the summary.
toModelOutput must be pure. A replay re-projects the result the tool already returned rather
than calling the tool again, so a projection that reads the clock or a counter makes a replayed
turn disagree with the original.
It runs only on a result your tool actually produced. A denial from an approval gate is the loop telling the model about its own gate, so it reaches the model unprojected.
Behaviour hints
annotations describes how a tool behaves, in the same field names MCP uses, so one declaration
means the same thing to Duraton and to any MCP client reading your tools:
const dropIndex = tool({
name: "drop-index",
description: "Drop a database index",
annotations: {
title: "Drop index",
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: false,
},
requiresApproval: true,
handler: (input) => db.dropIndex(input.name),
});| Hint | Says |
|---|---|
title | A human-readable name for the tool, for a picker or a form label |
readOnlyHint | The tool does not change anything |
destructiveHint | The tool can destroy or overwrite something |
idempotentHint | Calling it twice with the same input is the same as calling it once |
openWorldHint | The tool reaches something outside your system |
These are hints, and a hint is not a gate. destructiveHint: true on its own stops nothing -
requiresApproval and its rule are what park the run on a
person, and a hint is not one of the four names a rule can read. The hints exist so a surface can
suggest the right default without every dangerous tool having to be marked by hand, and MCP defines
them the same way: a client is told never to make tool-use decisions on annotations it received from
a server it does not trust - on an attached server the hint is
written by that server's operator, not by you. Set both on a tool that is genuinely destructive.
What the model sees
Every turn sees the task, the exchanges so far, and your tools declared as function-calling schemas. How the exchanges reach the model depends on the adapter:
| Adapter | What it gets |
|---|---|
Declares the transcript capability (the built-in Anthropic one does) | The task as the prompt, and the turns as structured transcript - which the adapter maps to its provider's native tool blocks |
| Declares nothing | The task with the turns rendered into it as text, exactly as before |
The fallback is not a lesser answer, just a lesser shape: a model reading its own tool calls as prose is working in a form it was not trained on. Either way the turns are read from the loop's recorded history and nothing else, so a replayed turn is identical to the original one.
Bounding context
Nothing trims history by default, so an agent that runs long enough eventually hits the model's
context window and fails. Pass context and every turn - both a native transcript and the
rendered-text fallback above - sees a bounded view instead of the full, ever-growing history:
const result = await agent(ctx, "support", {
model: "claude-opus-4-8",
prompt: `Resolve this ticket: ${ticket}`,
tools: [searchPolicy, issueRefund],
maxIterations: 20,
context: { strategy: "count", keepLast: 10 },
});strategy | What it does to the record |
|---|---|
count | Drops the oldest iterations beyond keepLast. A fresh view computed for each turn - never written back, so the run's own history still has everything |
token-budget | Same drop, sized by maxTokens instead of a count - an approximation (estimateTokens), not a real tokenizer |
summarize | Once history exceeds maxTokens, overwrites the earliest surviving iteration's own result with a model-generated summary of everything older than keepLast, and drops the rest. Reuses that iteration's real id rather than inventing one - see Context management for why |
count and token-budget are free: recomputed fresh every turn, they never change what the run
itself remembers. summarize is the one that costs something, so it is the one that persists - a
later turn starts from the already-reduced history instead of paying to re-summarize the same
prefix every time. summarize's model call rides its own durable step, so a replay reads the
recorded summary rather than generating a new one - full detail on that step and its replay
guarantee lives on the Context management page, the SDK-level page for the
loop-level context option this wraps.
Without context, an agent behaves exactly as it did before the option existed - the whole
history reaches every turn. The option changes nothing for an agent that does not use it.
Bring your own provider
provider takes an AIProvider instance as well as a name, which is how an agent runs against an
adapter you wrote - or against a deterministic stand-in in tests, with no API key:
await agent(ctx, "agent", {
model: "demo-1",
prompt: "Resolve the ticket",
tools: [searchPolicy],
maxIterations: 4,
provider: myProvider,
});See Providers for the AIProvider contract, including how a tool
declaration reaches the model and how tool calls come back.
Attaching an external MCP server
mcpServers gives an agent tools you did not write. The kit connects to the server, reads its
tool list, and hands those tools to the model alongside your own - each call running as an ordinary
durable step.
const result = await agent(ctx, "support", {
model: "claude-opus-4-8",
prompt: `Resolve this ticket: ${ctx.event.data.ticket}`,
tools: [searchPolicy],
mcpServers: [
{
name: "orders",
transport: { kind: "http", url: process.env.ORDERS_MCP_URL },
tools: ["lookup-order"],
requiresApproval: (tool) => tool.name === "cancel-order",
},
],
maxIterations: 6,
});Prop
Type
The predicate answers per tool, and what it returns places that tool in the same two levels a declared tool goes through:
| It returns | The gate |
|---|---|
an ApprovalRule | raised, under that rule. It sets the rule and leaves requiresApproval unset, so an explicit false stays the only opt-out |
true | raised, under the agent's approval default when there is one |
false | none, whatever the default says |
| nothing, or no predicate at all | the agent's approval default |
The last row is why the default exists. These are the tools you did not write, attached here and discovered at runtime, so there is no declaration to annotate and no list to enumerate ahead of time. Silence about a tool you did not write is not a decision that it is harmless.
Install the MCP SDK alongside the kit - it is an optional peer, so an agent that attaches no server never pulls it:
bun add @modelcontextprotocol/sdkWhat it records
| Step | Written when | Holds |
|---|---|---|
<id>:mcp:<server> | once, before the first turn | the tool list the server offered |
<id>:iter:N:tool:<callId> | each remote call | the call's result, memoized |
Discovery is a durable step because the tool list decides what the model was offered. A server that adds or drops a tool mid-run cannot change what a replay sees, and a fully replayed run opens no connection at all.
Name the tools you want. An allow-list is what stops a server you do not control from widening your agent's reach by adding a tool - and asking for one the server does not offer fails the run rather than quietly attaching fewer tools than you asked for.
Credentials, approval and bad arguments
A remote tool is still a tool, so everything the kit already does applies to it:
-
Credentials resolve inside the step, so a stored connection works:
headers: async () => { const orders = await client.connections.resolve("orders"); return { Authorization: `Bearer ${orders.token}` }; }, -
Approval parks the run before the call, and a denial comes back to the model as that tool's result. A
destructiveHintin the server's own annotations never gates on its own - hints inform a default,requiresApprovalis the boundary. -
Guardrails validate the model's arguments against the server's
inputSchema, so a schema you did not write still stops a malformed call reaching a third party.
A remote tool that answers with MCP's isError fails its step rather than returning the error
text to the model. The step then retries under your workflow's policy and the run records why;
memoizing a broken call as a successful result would leave no replay able to get past it.
Listing tools before they run
By default the engine has no record of a tool until a run calls it. toolManifest() projects
your tools and mcpServers arrays into defineWorkflow's advisory tools field, so a tool -
its name, description, and parameter schema - is listable before it has ever executed:
import { agent, tool, toolManifest } from "@duraton/agent-kit";
const tools = [searchPolicy, issueRefund];
const mcpServers = [{ name: "orders", transport: { kind: "http", url: process.env.ORDERS_MCP_URL } }];
export default defineWorkflow({
name: "support.ticket",
tools: toolManifest({ tools, mcpServers }),
handler: (ctx) =>
agent(ctx, "support", { model: "claude-opus-4-8", prompt: "Resolve the ticket", tools, mcpServers }),
});One array, two readers - toolManifest() reads the same tools/mcpServers you pass to agent(),
so the manifest can never silently disagree with what the model actually sees. An attached MCP
server appears as an unexpanded group (its name only) rather than a tool list: its tools are
discovered at run time, not registration time, so listing them here would mean contacting the
server before a run even starts - reintroducing the replay drift durable discovery exists to
prevent. Full field reference and the advisory guarantees are in Tool
manifest.
Exposing the same tools over MCP
A tool is one definition, and the kit emits it in whichever dialect a surface needs. agent() uses
the function-calling dialect; createMcpEmitter() produces the same tools as
Model Context Protocol definitions, so the tools your agent uses
are the tools an MCP client sees - one source, not two lists that drift.
import { createMcpEmitter } from "@duraton/agent-kit";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
const tools = [searchPolicy, lookupOrder];
server.setRequestHandler(ListToolsRequestSchema, () => ({
tools: createMcpEmitter().emit(tools),
}));The emitter produces the definitions a server advertises - the name, description, input schema, and
the outputSchema and annotations when a tool declares them. What a call then does is the
server's decision, and stays yours to write.
requiresApproval is never emitted. Approval is Duraton parking your run on your reviewer; it
is not something a client on the other end of an MCP connection can honour, and advertising it
would read as a promise the protocol cannot keep.
Use the low-level tools/list handler as above rather than registerTool: registerTool takes a
Zod schema for its input, while a kit tool declares a JSON Schema, which is what tools/list
carries on the wire.
Extension points
Two seams, each a closed set of adapters. A new one is a new adapter, not a change to the existing ones.
| Port | Decides | Adapters |
|---|---|---|
AgentStrategy | how a turn is composed | function-calling |
ToolEmitter | which dialect a tool is emitted in | function-calling, mcp |
Guardrail | what a tool call is checked against before it runs | schema |
McpTransport | how an attached MCP server is reached | http |
Checking the model's tool arguments
The arguments a tool is called with are written by the model. Pass a guardrail and they are checked
against the tool's own inputSchema before the handler ever sees them; a refusal comes back to the
model as that tool's result, so it can correct itself.
import { createSchemaGuardrail } from "@duraton/sdk";
const result = await agent(ctx, "agent", {
model: "claude-opus-4-8",
prompt: `Resolve this ticket: ${ctx.event.data.ticket}`,
tools: [searchPolicy],
maxIterations: 6,
guardrails: [createSchemaGuardrail()],
});The verdict is its own durable step, so a replay reads what was decided instead of deciding again. Guardrails covers the placements, the actions, and how to write your own.
The kit adds no loop
agent() holds no iteration counter, no history and no retry logic. step.ai.loop already owns
all three, and its turn is the extension point the kit fills. That is what keeps an agent
resumable after a crash, free of repeated model calls on replay, and visible in the console with
nothing extra to configure.
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.
Evals
Measure whether output is getting better: ctx.score records a score inline, graders score finished runs, and you can filter runs by their scores.