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-kit

A 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.

DecisionWhat runsWhat the model gets
ApprovedThe handler, with the decided inputThe handler's return value
Approved with editsThe handler, with the reviewer's inputThe handler's return value
DeniedNothing{ 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 saysThe agent has a defaultThe gate
requiresApproval: falseeither waynone. An opt-out is an answer, and it beats the default
approval: { ... }either wayraised, under the tool's own rule
requiresApproval: trueyesraised, under the agent's default
requiresApproval: truenoraised, with nothing said about it
nothingyesraised, under the agent's default
nothingnonone

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 substring

The 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:

SituationWhat happens
The provider cannot constrain the modelThe 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 JSONThe 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),
});
HintSays
titleA human-readable name for the tool, for a picker or a form label
readOnlyHintThe tool does not change anything
destructiveHintThe tool can destroy or overwrite something
idempotentHintCalling it twice with the same input is the same as calling it once
openWorldHintThe 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:

AdapterWhat 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 nothingThe 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 },
});
strategyWhat it does to the record
countDrops 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-budgetSame drop, sized by maxTokens instead of a count - an approximation (estimateTokens), not a real tokenizer
summarizeOnce 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 returnsThe gate
an ApprovalRuleraised, under that rule. It sets the rule and leaves requiresApproval unset, so an explicit false stays the only opt-out
trueraised, under the agent's approval default when there is one
falsenone, whatever the default says
nothing, or no predicate at allthe 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/sdk

What it records

StepWritten whenHolds
<id>:mcp:<server>once, before the first turnthe tool list the server offered
<id>:iter:N:tool:<callId>each remote callthe 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 destructiveHint in the server's own annotations never gates on its own - hints inform a default, requiresApproval is 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.

PortDecidesAdapters
AgentStrategyhow a turn is composedfunction-calling
ToolEmitterwhich dialect a tool is emitted infunction-calling, mcp
Guardrailwhat a tool call is checked against before it runsschema
McpTransporthow an attached MCP server is reachedhttp

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.

On this page