Skip to content

Commit 4c4ed22

Browse files
authored
docs(ai-chat): document the chat.agent tools option (#3791)
## Summary Documents the new `tools` option on `chat.agent` (companion to #3790). Adds a dedicated [Tools](/ai-chat/tools) guide: the three places tools show up (config, `toStreamTextOptions`, `streamText`), why declaring them on the config matters for `toModelOutput` across turns, static vs per-turn tools, the typed `run()` payload, `InferChatUIMessageFromTools`, the relationship to skills, and the manual `convertToModelMessages` path for `customAgent` loops. Threads the option through the rest of the guide: the reference tables, a happy-path section on the backend page, the types page, and the HITL / skills / tool-result-auditing patterns. Corrects the sub-agents guide, where the `toModelOutput` compression was implied to work across turns but silently degraded from turn 2 without config tools. Also unstacks the three callouts that were piled under the `chat.agent()` header on the backend page, and adds a changelog entry.
1 parent e0681d2 commit 4c4ed22

12 files changed

Lines changed: 304 additions & 35 deletions

docs/ai-chat/backend.mdx

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,32 +12,6 @@ import RcBanner from "/snippets/ai-chat-rc-banner.mdx";
1212

1313
The highest-level approach. Handles message accumulation, stop signals, turn lifecycle, and auto-piping automatically.
1414

15-
<Tip>
16-
To fix a **custom** `UIMessage` subtype or typed client data schema, use the [ChatBuilder](/ai-chat/types#chatbuilder) via `chat.withUIMessage<...>()` and/or `chat.withClientData({ schema })`. Builder-level hooks can also be chained before `.agent()`. See [Types](/ai-chat/types).
17-
</Tip>
18-
19-
<Info>
20-
Every `chat.agent` conversation is backed by a durable Session — `externalId` is your `chatId`, `type` is `"chat.agent"`, `taskIdentifier` is the agent's task ID. The session is the run manager: it owns the chat's runs, persists across run lifecycles, and orchestrates handoffs (idle continuation, `chat.requestUpgrade`). You rarely need to touch the session directly (`chat.stream`, `chat.messages`, `chat.stopSignal` wrap everything), but `payload.sessionId` is available if you want to reach in — e.g. `sessions.open(payload.sessionId)` to write from a sub-agent or from outside the turn loop.
21-
</Info>
22-
23-
<Warning>
24-
**Always spread `chat.toStreamTextOptions()` into every `streamText` call.** It wires up the `prepareStep` callback that drives [compaction](/ai-chat/compaction), [steering](/ai-chat/pending-messages), and [background injection](/ai-chat/background-injection) — features that silently no-op if the spread is missing. It also injects the system prompt set via `chat.prompt()`, the resolved model (when a registry is provided), and telemetry metadata.
25-
26-
Spread it **first** in the options object so any explicit overrides win:
27-
28-
```ts
29-
streamText({
30-
...chat.toStreamTextOptions(), // or: chat.toStreamTextOptions({ registry, tools }) — see below
31-
messages,
32-
abortSignal: signal,
33-
// any explicit overrides go here
34-
stopWhen: stepCountIs(15),
35-
});
36-
```
37-
38-
Examples in this doc keep the spread implicit for brevity, but you should include it in real code.
39-
</Warning>
40-
4115
### Simple: return a StreamTextResult
4216

4317
Return the `streamText` result from `run` and it's automatically piped to the frontend:
@@ -51,7 +25,7 @@ export const simpleChat = chat.agent({
5125
id: "simple-chat",
5226
run: async ({ messages, signal }) => {
5327
return streamText({
54-
...chat.toStreamTextOptions(), // prepareStep, system, telemetry see callout above
28+
...chat.toStreamTextOptions(), // prepareStep, system, telemetry (see note below)
5529
model: anthropic("claude-sonnet-4-5"),
5630
system: "You are a helpful assistant.",
5731
messages,
@@ -62,6 +36,10 @@ export const simpleChat = chat.agent({
6236
});
6337
```
6438

39+
<Warning>
40+
**Always spread `chat.toStreamTextOptions()` first** (as above) so your explicit overrides win. It wires up the `prepareStep` callback behind [compaction](/ai-chat/compaction), [steering](/ai-chat/pending-messages), and [background injection](/ai-chat/background-injection), all of which silently no-op without it, and injects the system prompt from `chat.prompt()`, the resolved model (when you pass a `registry`), and telemetry metadata. Examples below keep the spread implicit for brevity, so include it in real code.
41+
</Warning>
42+
6543
### Using chat.pipe() for complex flows
6644

6745
For complex agent flows where `streamText` is called deep inside your code, use `chat.pipe()`. It works from **anywhere inside a task** — even nested function calls.
@@ -173,6 +151,33 @@ await waitUntilComplete();
173151

174152
For piping streams from subtasks to the parent chat (via `target: "root"`), see the [Sub-agents pattern](/ai-chat/patterns/sub-agents).
175153

154+
### Backed by a Session
155+
156+
Every `chat.agent` conversation is backed by a durable [Session](/ai-chat/sessions): `externalId` is your `chatId`, `type` is `"chat.agent"`, and `taskIdentifier` is the agent's task ID. The session is the run manager. It owns the chat's runs, persists across run lifecycles, and orchestrates handoffs (idle continuation, `chat.requestUpgrade`). You rarely touch it directly, since `chat.stream`, `chat.messages`, and `chat.stopSignal` wrap everything, but `payload.sessionId` is there when you need to reach in, e.g. `sessions.open(payload.sessionId)` to write from a sub-agent or from outside the turn loop.
157+
158+
### Tools
159+
160+
Declare your tools on the agent config, then read them back (typed) from the `run()` payload. Declaring them on the config, not just on `streamText`, is what lets the SDK re-apply each tool's `toModelOutput` when it re-converts history on later turns.
161+
162+
```ts
163+
const tools = { searchDocs };
164+
165+
export const myChat = chat.agent({
166+
id: "my-chat",
167+
tools,
168+
run: async ({ messages, tools, signal }) =>
169+
streamText({
170+
...chat.toStreamTextOptions({ tools }),
171+
model: anthropic("claude-sonnet-4-5"),
172+
messages,
173+
abortSignal: signal,
174+
stopWhen: stepCountIs(15),
175+
}),
176+
});
177+
```
178+
179+
See [Tools](/ai-chat/tools) for `toModelOutput` across turns, per-turn dynamic tools, the typed run payload, and how config tools relate to skills.
180+
176181
### Lifecycle hooks
177182

178183
`chat.agent({ ... })` accepts hooks that fire in a fixed order around each turn, plus dedicated suspend/resume hooks. The full reference lives on its own page:

docs/ai-chat/changelog.mdx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,30 @@ sidebarTitle: "Changelog"
44
description: "Pre-release updates for AI chat agents."
55
---
66

7+
<Update label="June 1, 2026" description="4.5.0-rc.4" tags={["SDK"]}>
8+
9+
## `tools` option on `chat.agent`: `toModelOutput` survives across turns
10+
11+
`chat.agent` now takes a `tools` option. Until now tools only went to `streamText` inside `run()`, which meant the SDK had no tools when it re-converted the persisted `UIMessage` history at the start of each turn. Any tool with a `toModelOutput` (raw image bytes turned into an image content part, or a sub-agent transcript compressed to a summary) had its transform applied on turn 1 and skipped from turn 2 onward, so the raw output got stringified back into the prompt.
12+
13+
Declare your tools on the config and the SDK threads them into that conversion, so `toModelOutput` is re-applied every turn. The resolved set is handed back, typed, on the `run()` payload as `tools`, so you declare them once:
14+
15+
```ts
16+
const tools = { searchDocs, renderChart };
17+
18+
export const myChat = chat.agent({
19+
tools,
20+
run: async ({ messages, tools, signal }) =>
21+
streamText({ ...chat.toStreamTextOptions({ tools }), messages, abortSignal: signal }),
22+
});
23+
```
24+
25+
`tools` also accepts a per-turn function (`(event) => ToolSet`) for tools that depend on the user or a feature flag. Only `inputSchema` and `toModelOutput` are read during conversion, never `execute`. No behavior change for agents that don't declare `tools`.
26+
27+
A new `InferChatUIMessageFromTools<typeof tools>` helper derives the chat `UIMessage` type (with typed tool parts) directly from a tool set. See the new [Tools](/ai-chat/tools) guide.
28+
29+
</Update>
30+
731
<Update label="May 23, 2026" description="4.5.0-rc.2" tags={["SDK", "Webapp", "Bug fix"]}>
832

933
## HITL continuations — slim wire by default + field-level merge

docs/ai-chat/overview.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ Three primitives, related but distinct:
7878
<Card title="Backend" icon="server" href="/ai-chat/backend">
7979
`chat.agent` options, lifecycle hooks, and the raw-task primitives.
8080
</Card>
81+
<Card title="Tools" icon="wrench" href="/ai-chat/tools">
82+
Declare tools so `toModelOutput` survives across turns, typed in `run()`.
83+
</Card>
8184
<Card title="Patterns" icon="puzzle-piece" href="/ai-chat/patterns/sub-agents">
8285
HITL approvals, branching, sub-agents, OOM/crash recovery.
8386
</Card>

docs/ai-chat/patterns/human-in-the-loop.mdx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,18 +69,21 @@ const askUser = tool({
6969

7070
export const myChat = chat.agent({
7171
id: "my-chat",
72-
run: async ({ messages, signal }) => {
72+
tools: { askUser },
73+
run: async ({ messages, tools, signal }) => {
7374
return streamText({
7475
model: anthropic("claude-sonnet-4-5"),
7576
messages,
76-
tools: { askUser },
77+
tools,
7778
abortSignal: signal,
7879
stopWhen: stepCountIs(15),
7980
});
8081
},
8182
});
8283
```
8384

85+
Declaring `tools` on the config (and reading them back from the payload) is the recommended shape for any agent with tools. See [Tools](/ai-chat/tools).
86+
8487
## Frontend: render the question and collect the answer
8588

8689
Two pieces on the client:

docs/ai-chat/patterns/skills.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,8 @@ return streamText({
185185

186186
Your tools win on name conflicts. (Pick names that don't collide with `loadSkill` / `readFile` / `bash` to keep things predictable.)
187187

188+
Also declare those same tools on the agent's [`tools`](/ai-chat/tools) config. `toStreamTextOptions` merges them with the skill tools for the model call, while the config option threads them into history re-conversion so any `toModelOutput` survives across turns. The auto-injected skill tools (`loadSkill` / `readFile` / `bash`) don't define `toModelOutput`, so they don't need to be on the config.
189+
188190
## Bundling
189191

190192
Bundling is **built-in to the CLI** — there's no extension to import. When you run `trigger deploy` or `trigger dev`:

docs/ai-chat/patterns/sub-agents.mdx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,10 @@ toModelOutput: ({ output: message }) => {
205205

206206
This is important for token efficiency: the sub-agent might use 100K tokens exploring and reasoning, but the parent LLM only consumes the summary.
207207

208+
<Warning>
209+
`toModelOutput` only runs when the SDK has your tools at conversion time. On a multi-turn parent, the SDK re-converts the persisted history at the start of each turn, so you must declare the sub-agent tool on the agent config (`chat.agent({ tools })`) for the compression to survive. Without it, the summary holds on turn 1 but turn 2 onward re-ingests the full sub-agent output. In a `chat.customAgent` loop you own the conversion, so pass the tools to `convertToModelMessages(uiMessages, { tools })` yourself. See [Tools: toModelOutput across turns](/ai-chat/tools#tomodeloutput-across-turns).
210+
</Warning>
211+
208212
## ChatStream.messages()
209213

210214
The `messages()` method on `ChatStream` wraps the AI SDK's `readUIMessageStream`. It reads the raw `UIMessageChunk` stream and yields complete `UIMessage` snapshots — each containing all parts received so far.
@@ -237,21 +241,24 @@ Sub-agent tools work inside both `chat.agent()` (managed) and `chat.customAgent(
237241

238242
```ts
239243
// Managed agent with sub-agent tool
244+
const tools = { research: researchTool };
245+
240246
export const myAgent = chat.agent({
241247
id: "orchestrator",
242-
run: async ({ messages, stopSignal }) => {
248+
tools, // declare here so toModelOutput survives across turns
249+
run: async ({ messages, tools, stopSignal }) => {
243250
return streamText({
244251
model: anthropic("claude-sonnet-4-6"),
245252
messages,
246-
tools: { research: researchTool },
253+
tools,
247254
abortSignal: stopSignal,
248255
stopWhen: stepCountIs(15),
249256
});
250257
},
251258
});
252259
```
253260

254-
For `chat.customAgent()`, define the tool and sub-agent Map inside the `run` closure so they survive across turns.
261+
For `chat.customAgent()`, define the tool and sub-agent Map inside the `run` closure so they survive across turns. Since you own the turn loop there, convert history with your tools in scope so `toModelOutput` is re-applied each turn: `convertToModelMessages(uiMessages, { tools })`. See [Tools: manual turn loops](/ai-chat/tools#manual-turn-loops-chatcustomagent).
255262

256263
## Streaming progress from a subtask to the parent chat
257264

docs/ai-chat/patterns/tool-result-auditing.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import RcBanner from "/snippets/ai-chat-rc-banner.mdx";
88

99
<RcBanner />
1010

11-
When a chat agent uses tools (especially [human-in-the-loop](/ai-chat/patterns/human-in-the-loop) tools that wait on `addToolOutput` from the frontend), you often need to fire side effects exactly once per resolved tool call:
11+
When a chat agent uses [tools](/ai-chat/tools) (especially [human-in-the-loop](/ai-chat/patterns/human-in-the-loop) tools that wait on `addToolOutput` from the frontend), you often need to fire side effects exactly once per resolved tool call:
1212

1313
- **Audit logs** — record every tool result for compliance.
1414
- **Billing** — charge per tool invocation.

docs/ai-chat/quick-start.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ These steps assume you already have a Trigger.dev project with the SDK installed
151151
## Next steps
152152

153153
- [Backend](/ai-chat/backend) — Lifecycle hooks, persistence, session iterator, raw task primitives
154+
- [Tools](/ai-chat/tools): Declare tools so `toModelOutput` survives across turns, typed in `run()`
154155
- [Frontend](/ai-chat/frontend) — Session management, client data, reconnection
155156
- [Types](/ai-chat/types)`chat.withUIMessage`, `InferChatUIMessage`, and related typing
156157
- [`chat.local`](/ai-chat/chat-local) — Per-run typed state across hooks, run, tools, subtasks

docs/ai-chat/reference.mdx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Options for `chat.agent()`.
4545
| `compaction` | `ChatAgentCompactionOptions` || Automatic context compaction. See [Compaction](/ai-chat/compaction) |
4646
| `pendingMessages` | `PendingMessagesOptions` || Mid-execution message injection. See [Pending Messages](/ai-chat/pending-messages) |
4747
| `prepareMessages` | `(event: PrepareMessagesEvent) => ModelMessage[]` || Transform model messages before use (cache breaks, context injection, etc.) |
48+
| `tools` | `ToolSet \| ((event: ResolveToolsEvent) => ToolSet \| Promise<ToolSet>)` || Tools for this agent. Threads each tool's `toModelOutput` through cross-turn history re-conversion, and hands the resolved set back on the run payload. Static set or per-turn function. See [Tools](/ai-chat/tools). |
4849
| `maxTurns` | `number` | `100` | Max conversational turns per run |
4950
| `turnTimeout` | `string` | `"1h"` | How long to wait for next message |
5051
| `idleTimeoutInSeconds` | `number` | `30` | Seconds to stay idle before suspending |
@@ -87,6 +88,7 @@ The payload passed to the `run` function.
8788
| -------------- | ------------------------------------------ | -------------------------------------------------------------------- |
8889
| `ctx` | `TaskRunContext` | Full task run context — same as `task` `run`’s `{ ctx }` |
8990
| `messages` | `ModelMessage[]` | Model-ready messages — pass directly to `streamText` |
91+
| `tools` | `ToolSet` | Resolved tools declared on the agent config (empty object when none). Pass straight to `streamText`. See [Tools](/ai-chat/tools). |
9092
| `chatId` | `string` | Your conversation ID (the session's `externalId`) |
9193
| `sessionId` | `string` | Friendly ID of the backing Session (`session_*`). Use with `sessions.open()` for advanced cases. Always set — every chat.agent run is bound to a Session. |
9294
| `trigger` | `"submit-message" \| "regenerate-message"` | What triggered the request |
@@ -191,6 +193,17 @@ Passed to the `onValidateMessages` callback.
191193
| `turn` | `number` | Turn number (0-indexed) |
192194
| `trigger` | `"submit-message" \| "regenerate-message" \| "preload" \| "close"` | The trigger type for this turn |
193195

196+
## ResolveToolsEvent
197+
198+
Passed to the `tools` function form on `chat.agent`, once per turn, to resolve the tool set for that turn. See [Tools](/ai-chat/tools#static-or-per-turn-tools).
199+
200+
| Field | Type | Description |
201+
| -------------- | --------------------------- | ------------------------------------------------- |
202+
| `chatId` | `string` | Chat session ID |
203+
| `turn` | `number` | Turn number (0-indexed) |
204+
| `continuation` | `boolean` | Whether this run is continuing an existing chat |
205+
| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend |
206+
194207
## HydrateMessagesEvent
195208

196209
Passed to the `hydrateMessages` callback. See [hydrateMessages](/ai-chat/lifecycle-hooks#hydratemessages).
@@ -532,6 +545,19 @@ type Msg = InferChatUIMessage<typeof myChat>;
532545

533546
Use with `useChat<Msg>({ transport })` when using [`chat.withUIMessage`](/ai-chat/types). For agents defined with plain `chat.agent()` (no custom generic), this resolves to the base `UIMessage`.
534547

548+
## `InferChatUIMessageFromTools`
549+
550+
Type helper: derives the chat `UIMessage` type (with typed `tool-${name}` parts) directly from a tool set. Shorthand for `UIMessage<unknown, UIDataTypes, InferUITools<typeof tools>>`.
551+
552+
```ts
553+
import type { InferChatUIMessageFromTools } from "@trigger.dev/sdk/ai";
554+
555+
const tools = { search, readFile };
556+
type ChatUiMessage = InferChatUIMessageFromTools<typeof tools>;
557+
```
558+
559+
Pin it on the agent with [`chat.withUIMessage<ChatUiMessage>()`](/ai-chat/types) and reuse it on the client. See [Tools](/ai-chat/tools#typing-messages-from-your-tools).
560+
535561
## AI helpers (`ai` from `@trigger.dev/sdk/ai`)
536562

537563
| Export | Status | Description |

0 commit comments

Comments
 (0)