chat.agent doesn’t call the model for you. Your tools still go to streamText inside run(). But you should also declare them on the agent config:
tools on the config does two things you can’t get by passing them to streamText alone:
- It threads your tools into the SDK’s internal message conversion, so each tool’s
toModelOutputis re-applied when prior-turn history is re-converted (seetoModelOutputacross turns). - It hands the resolved set back, typed, on the
run()payload astools, so you declare them once and don’t re-import the map.
Where tools go
There are three places a tool set shows up. Declare once, reuse:
The canonical pattern: declare
tools on the config, read them back from the run() payload, and pass that to chat.toStreamTextOptions({ tools }). One declaration flows everywhere.
toModelOutput across turns
toModelOutput transforms a tool’s result before it enters the model’s context, turning raw image bytes into an image content part, or compressing a long sub-agent transcript into a one-line summary. The full result still streams to the frontend; the model only sees the transformed version.
The catch is multi-turn. After each turn, chat.agent persists the conversation as UIMessage[] and re-converts it to model messages at the start of the next turn. That conversion needs your tools to find each toModelOutput. If you only pass tools to streamText and not to the config, the transform runs on turn 1 but is skipped on every later turn. The raw output gets stringified back into the prompt instead, and the model loses the transformed view.
Declaring tools on the config fixes this: the SDK threads them into the conversion, so toModelOutput is re-applied on every turn.
Static or per-turn tools
tools accepts either a static ToolSet or a function that returns one per turn, for tools that depend on the user, a feature flag, or anything in the turn context:
ResolveToolsEvent and runs once per turn (after clientData is parsed):
The resolved set is what lands on the
run() payload’s tools.
Typed tools in run()
The run() payload’s tools is typed to whatever you declared, so you can pass it straight through without re-importing the map:
tools are declared, the payload’s tools is an empty object and behaves exactly as before, so declaring tools is fully opt-in.
Typing messages from your tools
To get typed tool parts (tool-${name} with typed input/output) on your UIMessage, in hooks like onTurnComplete and on the frontend, derive the message type from your tool set with InferChatUIMessageFromTools:
UIMessage<unknown, UIDataTypes, InferUITools<typeof tools>>. Pin it on the agent with chat.withUIMessage<ChatUiMessage>() and reuse it on the client. If you also have custom data-* parts, build the UIMessage generic directly instead. See Types.
Skills
Agent skills are auto-injected as tools (loadSkill, readFile, bash) by chat.toStreamTextOptions(). They’re separate from your config tools: declare your own tools on the config (so their toModelOutput survives across turns), and let toStreamTextOptions merge the skill tools on top at call time. Skill tools don’t define toModelOutput, so they don’t need to be on the config.
Manual turn loops (chat.customAgent)
The tools config option belongs to the managed chat.agent. When you drive the loop yourself with chat.customAgent (or build messages from chat.history), you own the conversion, so pass your tools to convertToModelMessages directly to get the same cross-turn toModelOutput behavior:
Learn more
- Human-in-the-loop: tools that pause for approval.
- Sub-agents: tools that delegate to other agents and compress their output with
toModelOutput. - Tool result auditing: logging tool results as they resolve.
- AI SDK: Tools and tool calling.

