Tools
Define tools, attach them to a text request, and let the host run the tool loop while the machine stays in one state.
Alpha:
@statelyai/agent2.0 is in alpha. APIs can change between releases; pin an exact version. Feedback: github.com/statelyai/agent.
Tools belong to a request, not to a machine state. The machine decides when a request runs. The model picks which tools to call. The host executes them. The machine does not see the intermediate tool calls.
The tool contract
AgentTool is a minimal structural contract, so tools from any SDK work without changes:
| Field | Type | Meaning |
|---|---|---|
description? | string | What the model reads to decide whether to call. |
inputSchema? | Standard Schema or any schema object | Arguments contract. |
outputSchema? | Standard Schema or any schema object | Result contract, when the host wants one. |
execute? | (...args) => unknown | The implementation the host runs. |
| anything else | unknown | Passed through untouched (providerOptions, …). |
- A bare function is shorthand for
execute, typed asAgentToolExecute. AgentToolsisRecord<string, AgentTool | undefined>, keyed by tool name.inputSchemais widened toobjectso an SDK-native tool assigns without a cast. Core reads it as a Standard Schema when it can.
A native AI SDK tool owns its input typing, so the argument to execute needs no cast:
import { tool } from "ai";
import { z } from "zod";
const calculate = tool({
description: "Do arithmetic on two numbers.",
inputSchema: z.object({ op: z.enum(["add", "multiply"]), a: z.number(), b: z.number() }),
execute: async ({ op, a, b }) => ({ value: op === "add" ? a + b : a * b }), // typed
});Without an SDK, a plain object is enough. Core reads description and inputSchema, then runs execute:
const calculate = {
description: "Do arithmetic on two numbers.",
inputSchema: z.object({ op: z.enum(["add", "multiply"]), a: z.number(), b: z.number() }),
execute: async (input: unknown) => {
const { op, a, b } = input as { op: "add" | "multiply"; a: number; b: number };
return { value: op === "add" ? a + b : a * b };
},
};Attaching tools to a request
Put tools on any text request, either inline in setupAgent({ requests }) or on a standalone createTextLogic.
maxStepsis a typed field on the request. It bounds the host-side tool loop. Without it, the request is single-step.toolChoiceconstrains selection. It accepts'auto','none','required', or{ type: 'tool', name }.
import { z } from "zod";
import { setupAgent } from "@statelyai/agent";
import { } from "@statelyai/agent/ai-sdk";
import { openai } from "@ai-sdk/openai";
const models = { assistant: openai("gpt-5.4-mini") };
const agentSetup = setupAgent({
models,
context: z.object({ query: z.string(), finalAnswer: z.string().nullable() }),
input: z.object({ query: z.string() }),
output: z.object({ finalAnswer: z.string() }),
requests: {
answer: {
schemas: { input: z.object({ query: z.string() }), output: z.string() },
model: "assistant",
system: "Answer in one sentence. Use calculate for arithmetic.",
prompt: ({ input }) => input.query,
tools: { calculate },
maxSteps: 5,
},
},
});
export const toolCallingMachine = agentSetup.createMachine({
id: "tool-calling",
context: ({ input }) => ({ query: input.query, finalAnswer: null }),
initial: "answering",
states: {
answering: {
invoke: {
src: "answer",
input: ({ context }) => ({ query: context.query }),
onDone: ({ output }) => ({ target: "done", context: { finalAnswer: output.result } }),
},
},
done: { type: "final", output: ({ context }) => ({ finalAnswer: context.finalAnswer ?? "" }) },
},
});Run the machine with any executor set. The machine is the same for scripted and real models.
import { runAgent } from "@statelyai/agent";
import { createAiSdkExecutors } from "@statelyai/agent/ai-sdk";
const result = await runAgent(toolCallingMachine, {
input: { query: "What is 42 times 17?" },
executors: createAiSdkExecutors({ models }),
});For approval and progress around real tools, see review-tool-calls.
Execution flow through the host
- The machine invokes the request. Core lowers it to an
AgentTextRequestcarryingtools,toolChoice, andmetadata. - The executor maps tools to its SDK.
createAiSdkExecutorspasses a nativetool({...})through unchanged and wraps a plain descriptor or bare function intool(). A tool with noinputSchemagets a permissive one. maxStepsbecomesstopWhen: stepCountIs(maxSteps). The loop runs entirely inside the executor.- The final output is validated against the request's output schema and returned to
onDoneasoutput.result, next to the executor's response messages inoutput.messages.
The machine observes the request boundary, not each intermediate tool call. For
example, maxSteps: 5 permits up to five AI SDK-controlled steps before the
machine receives onDone. Model individual calls as states when the machine
must approve, persist, retry, or reject them separately.
Two consequences follow:
- Tool-carrying requests do not retry. The AI SDK adapter retries invalid structured output only when the request has no tools, because a tool loop may already have caused side effects.
metadatais host-owned. A host that does not understand a key ignores it, so requests stay portable.
The raw executor result, including tool calls and results, reaches host code through runAgent's onResult(request, { raw }) and the request.end trace event. See Observability.
Tool results in messages
Tool calls and results stay in the executor framework's native message objects. A tool-carrying request resolves with them in output.messages, and the machine stores them explicitly as context in onDone:
answering: {
invoke: {
src: "answer",
input: ({ context }) => ({ query: context.query }),
onDone: ({ context, output }) => ({
target: "done",
context: {
finalAnswer: output.result,
messages: [...context.messages, ...output.messages],
},
}),
},
},Build these parts by hand only when the machine owns the loop, such as in a ReAct-style machine or when replaying a transcript. With a host-run tool loop, the intermediate calls stay inside the executor. See Messages.
Presets and ejecting
createToolLoopMachine from @statelyai/agent/machines is a prebuilt version of the machine above: one answering state and one tool-carrying request. See Preset machines for its options, including maxSteps.
Eject from the preset when the tool loop needs machine states of its own:
- To model approval as machine states, with approve, edit, and reject steps that persist and resume, eject to examples/review-tool-calls.
- To model each think, act, and observe turn as its own transition, use explicit request, tool, and observation states.
Note: There is no built-in MCP client and no built-in tool-approval gate. MCP discovery, auth, and transport are the host's responsibility. Pass the discovered tool descriptors into
toolslike any other tool. See Scope.
Related
- Read more about Text requests, the request a tool attaches to, including structured and streaming output.
- Read more about Hosts, including executors, model aliases, and writing your own adapter.
- Read more about Messages, the message model that tool parts live in.
- Read more about Preset machines, including
createToolLoopMachine.