Skip to content

Commit 77a2d14

Browse files
committed
docs(ai-chat): add Tools page
Document declaring tools on chat.agent: why it matters for toModelOutput across turns, static vs per-turn tools, the typed tools handed back on the run payload, typing messages via InferChatUIMessageFromTools, how config tools relate to skills and toStreamTextOptions, and the manual convertToModelMessages path for customAgent loops.
1 parent e21b68c commit 77a2d14

2 files changed

Lines changed: 192 additions & 0 deletions

File tree

docs/ai-chat/tools.mdx

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
---
2+
title: "Tools"
3+
sidebarTitle: "Tools"
4+
description: "Declare tools on chat.agent so toModelOutput survives across turns, get them back typed in run(), and type your messages from them."
5+
---
6+
7+
import RcBanner from "/snippets/ai-chat-rc-banner.mdx";
8+
9+
<RcBanner />
10+
11+
`chat.agent` doesn't call the model for you — your tools still go to [`streamText`](https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling) inside `run()`. But you should **also declare them on the agent config**:
12+
13+
```ts
14+
import { chat } from "@trigger.dev/sdk/ai";
15+
import { streamText, stepCountIs, tool } from "ai";
16+
import { anthropic } from "@ai-sdk/anthropic";
17+
import { z } from "zod";
18+
19+
const tools = {
20+
searchDocs: tool({
21+
description: "Search the docs.",
22+
inputSchema: z.object({ query: z.string() }),
23+
execute: async ({ query }) => searchIndex(query),
24+
}),
25+
};
26+
27+
export const myChat = chat.agent({
28+
id: "my-chat",
29+
tools, // ← declare here
30+
run: async ({ messages, tools, signal }) =>
31+
streamText({
32+
...chat.toStreamTextOptions({ tools }), // ← the same set, handed back on the payload
33+
model: anthropic("claude-sonnet-4-5"),
34+
messages,
35+
abortSignal: signal,
36+
stopWhen: stepCountIs(15),
37+
}),
38+
});
39+
```
40+
41+
Declaring `tools` on the config does two things you can't get by passing them to `streamText` alone:
42+
43+
- It threads your tools into the SDK's internal message conversion, so each tool's [`toModelOutput`](https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling#tomodeloutput) is re-applied when prior-turn history is re-converted (see [`toModelOutput` across turns](#tomodeloutput-across-turns)).
44+
- It hands the resolved set back, typed, on the `run()` payload as `tools`, so you declare them once and don't re-import the map.
45+
46+
## Where tools go
47+
48+
There are three places a tool set shows up. Declare once, reuse:
49+
50+
| Surface | What it's for |
51+
| --- | --- |
52+
| `chat.agent({ tools })` | Re-applies `toModelOutput` on prior-turn history; hands the set back typed on the `run()` payload. |
53+
| `chat.toStreamTextOptions({ tools })` | Detects which tool calls need [HITL approval](/ai-chat/patterns/human-in-the-loop) (`needsApproval`) and merges any auto-injected [skill](/ai-chat/patterns/skills) tools. |
54+
| `streamText({ tools })` | What the model actually calls. `chat.toStreamTextOptions({ tools })` already sets this — spread it instead of passing `tools` twice. |
55+
56+
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.
57+
58+
<Tip>
59+
Conversion only reads each tool's `inputSchema` and `toModelOutput` — never `execute`. If you keep heavy `execute` dependencies out of a module (for bundle reasons), you can declare a lightweight schema-only tool map on the config and add the executes where you call `streamText`.
60+
</Tip>
61+
62+
## `toModelOutput` across turns
63+
64+
`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.
65+
66+
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.
67+
68+
Declaring `tools` on the config fixes this: the SDK threads them into the conversion, so `toModelOutput` is re-applied on every turn.
69+
70+
```ts
71+
const tools = {
72+
renderChart: tool({
73+
description: "Render a chart and return it as an image.",
74+
inputSchema: z.object({ spec: z.string() }),
75+
execute: async ({ spec }) => renderToPng(spec), // raw bytes
76+
// The model should see an image part, not base64 bytes:
77+
toModelOutput: ({ output }) => ({
78+
type: "content",
79+
value: [{ type: "media", mediaType: "image/png", data: output.base64 }],
80+
}),
81+
}),
82+
};
83+
84+
export const chartChat = chat.agent({
85+
id: "chart-chat",
86+
tools, // ← without this, the image is "remembered" on turn 1 and gone from turn 2
87+
run: async ({ messages, tools, signal }) =>
88+
streamText({
89+
...chat.toStreamTextOptions({ tools }),
90+
model: anthropic("claude-sonnet-4-5"),
91+
messages,
92+
abortSignal: signal,
93+
stopWhen: stepCountIs(15),
94+
}),
95+
});
96+
```
97+
98+
## Static or per-turn tools
99+
100+
`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:
101+
102+
```ts
103+
export const myChat = chat
104+
.withClientData({ schema: z.object({ userId: z.string(), plan: z.string() }) })
105+
.agent({
106+
id: "my-chat",
107+
tools: ({ clientData }) => ({
108+
searchDocs,
109+
...(clientData?.plan === "pro" ? { deepResearch } : {}),
110+
}),
111+
run: async ({ messages, tools, signal }) =>
112+
streamText({
113+
...chat.toStreamTextOptions({ tools }),
114+
model: anthropic("claude-sonnet-4-5"),
115+
messages,
116+
abortSignal: signal,
117+
stopWhen: stepCountIs(15),
118+
}),
119+
});
120+
```
121+
122+
The function receives a `ResolveToolsEvent` and runs once per turn (after `clientData` is parsed):
123+
124+
| Field | Type | Description |
125+
| --- | --- | --- |
126+
| `chatId` | `string` | The chat session ID. |
127+
| `turn` | `number` | The current turn number (0-indexed). |
128+
| `continuation` | `boolean` | Whether this run is continuing an existing chat. |
129+
| `clientData` | `TClientData` | Parsed client data from the frontend. |
130+
131+
The resolved set is what lands on the `run()` payload's `tools`.
132+
133+
## Typed tools in `run()`
134+
135+
The `run()` payload's `tools` is typed to whatever you declared, so you can pass it straight through without re-importing the map:
136+
137+
```ts
138+
run: async ({ messages, tools, signal }) => {
139+
// `tools` is typed as your tool set, not a broad `ToolSet`
140+
return streamText({
141+
...chat.toStreamTextOptions({ tools }),
142+
model: anthropic("claude-sonnet-4-5"),
143+
messages,
144+
abortSignal: signal,
145+
});
146+
};
147+
```
148+
149+
When no `tools` are declared, the payload's `tools` is an empty object and behaves exactly as before — declaring tools is fully opt-in.
150+
151+
## Typing messages from your tools
152+
153+
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`:
154+
155+
```ts
156+
import type { InferChatUIMessageFromTools } from "@trigger.dev/sdk/ai";
157+
158+
const tools = { searchDocs, renderChart };
159+
160+
export type ChatUiMessage = InferChatUIMessageFromTools<typeof tools>;
161+
```
162+
163+
This is shorthand for `UIMessage<unknown, UIDataTypes, InferUITools<typeof tools>>`. Pin it on the agent with [`chat.withUIMessage<ChatUiMessage>()`](/ai-chat/types#custom-uimessage-with-chat-withuimessage) and reuse it on the client. If you also have custom `data-*` parts, build the `UIMessage` generic directly instead — see [Types](/ai-chat/types).
164+
165+
## Skills
166+
167+
[Agent skills](/ai-chat/patterns/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.
168+
169+
## Manual turn loops (`chat.customAgent`)
170+
171+
The `tools` config option belongs to the managed [`chat.agent`](/ai-chat/backend#chat-agent). When you drive the loop yourself with [`chat.customAgent`](/ai-chat/backend#raw-task-primitives) (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:
172+
173+
```ts
174+
import { convertToModelMessages, streamText } from "ai";
175+
176+
// Inside your loop, with `tools` in scope:
177+
const uiMessages = chat.history.all();
178+
const messages = await convertToModelMessages(uiMessages, {
179+
tools,
180+
ignoreIncompleteToolCalls: true,
181+
});
182+
183+
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, tools });
184+
```
185+
186+
## Learn more
187+
188+
- [Human-in-the-loop](/ai-chat/patterns/human-in-the-loop) — tools that pause for approval.
189+
- [Sub-agents](/ai-chat/patterns/sub-agents) — tools that delegate to other agents and compress their output with `toModelOutput`.
190+
- [Tool result auditing](/ai-chat/patterns/tool-result-auditing) — logging tool results as they resolve.
191+
- [AI SDK: Tools and tool calling](https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling).

docs/docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
"ai-chat/how-it-works",
108108
"ai-chat/backend",
109109
"ai-chat/lifecycle-hooks",
110+
"ai-chat/tools",
110111
"ai-chat/frontend",
111112
"ai-chat/server-chat",
112113
"ai-chat/sessions",

0 commit comments

Comments
 (0)