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.
This commit is contained in:
+32
-27
@@ -12,32 +12,6 @@ import RcBanner from "/snippets/ai-chat-rc-banner.mdx";
|
||||
|
||||
The highest-level approach. Handles message accumulation, stop signals, turn lifecycle, and auto-piping automatically.
|
||||
|
||||
<Tip>
|
||||
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).
|
||||
</Tip>
|
||||
|
||||
<Info>
|
||||
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.
|
||||
</Info>
|
||||
|
||||
<Warning>
|
||||
**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.
|
||||
|
||||
Spread it **first** in the options object so any explicit overrides win:
|
||||
|
||||
```ts
|
||||
streamText({
|
||||
...chat.toStreamTextOptions(), // or: chat.toStreamTextOptions({ registry, tools }) — see below
|
||||
messages,
|
||||
abortSignal: signal,
|
||||
// any explicit overrides go here
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
```
|
||||
|
||||
Examples in this doc keep the spread implicit for brevity, but you should include it in real code.
|
||||
</Warning>
|
||||
|
||||
### Simple: return a StreamTextResult
|
||||
|
||||
Return the `streamText` result from `run` and it's automatically piped to the frontend:
|
||||
@@ -51,7 +25,7 @@ export const simpleChat = chat.agent({
|
||||
id: "simple-chat",
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({
|
||||
...chat.toStreamTextOptions(), // prepareStep, system, telemetry — see callout above
|
||||
...chat.toStreamTextOptions(), // prepareStep, system, telemetry (see note below)
|
||||
model: anthropic("claude-sonnet-4-5"),
|
||||
system: "You are a helpful assistant.",
|
||||
messages,
|
||||
@@ -62,6 +36,10 @@ export const simpleChat = chat.agent({
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
**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.
|
||||
</Warning>
|
||||
|
||||
### Using chat.pipe() for complex flows
|
||||
|
||||
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();
|
||||
|
||||
For piping streams from subtasks to the parent chat (via `target: "root"`), see the [Sub-agents pattern](/ai-chat/patterns/sub-agents).
|
||||
|
||||
### Backed by a Session
|
||||
|
||||
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.
|
||||
|
||||
### Tools
|
||||
|
||||
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.
|
||||
|
||||
```ts
|
||||
const tools = { searchDocs };
|
||||
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
tools,
|
||||
run: async ({ messages, tools, signal }) =>
|
||||
streamText({
|
||||
...chat.toStreamTextOptions({ tools }),
|
||||
model: anthropic("claude-sonnet-4-5"),
|
||||
messages,
|
||||
abortSignal: signal,
|
||||
stopWhen: stepCountIs(15),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
See [Tools](/ai-chat/tools) for `toModelOutput` across turns, per-turn dynamic tools, the typed run payload, and how config tools relate to skills.
|
||||
|
||||
### Lifecycle hooks
|
||||
|
||||
`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:
|
||||
|
||||
@@ -4,6 +4,30 @@ sidebarTitle: "Changelog"
|
||||
description: "Pre-release updates for AI chat agents."
|
||||
---
|
||||
|
||||
<Update label="June 1, 2026" description="4.5.0-rc.4" tags={["SDK"]}>
|
||||
|
||||
## `tools` option on `chat.agent`: `toModelOutput` survives across turns
|
||||
|
||||
`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.
|
||||
|
||||
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:
|
||||
|
||||
```ts
|
||||
const tools = { searchDocs, renderChart };
|
||||
|
||||
export const myChat = chat.agent({
|
||||
tools,
|
||||
run: async ({ messages, tools, signal }) =>
|
||||
streamText({ ...chat.toStreamTextOptions({ tools }), messages, abortSignal: signal }),
|
||||
});
|
||||
```
|
||||
|
||||
`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`.
|
||||
|
||||
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.
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="May 23, 2026" description="4.5.0-rc.2" tags={["SDK", "Webapp", "Bug fix"]}>
|
||||
|
||||
## HITL continuations — slim wire by default + field-level merge
|
||||
|
||||
@@ -78,6 +78,9 @@ Three primitives, related but distinct:
|
||||
<Card title="Backend" icon="server" href="/ai-chat/backend">
|
||||
`chat.agent` options, lifecycle hooks, and the raw-task primitives.
|
||||
</Card>
|
||||
<Card title="Tools" icon="wrench" href="/ai-chat/tools">
|
||||
Declare tools so `toModelOutput` survives across turns, typed in `run()`.
|
||||
</Card>
|
||||
<Card title="Patterns" icon="puzzle-piece" href="/ai-chat/patterns/sub-agents">
|
||||
HITL approvals, branching, sub-agents, OOM/crash recovery.
|
||||
</Card>
|
||||
|
||||
@@ -69,11 +69,12 @@ const askUser = tool({
|
||||
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
run: async ({ messages, signal }) => {
|
||||
tools: { askUser },
|
||||
run: async ({ messages, tools, signal }) => {
|
||||
return streamText({
|
||||
model: anthropic("claude-sonnet-4-5"),
|
||||
messages,
|
||||
tools: { askUser },
|
||||
tools,
|
||||
abortSignal: signal,
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
@@ -81,6 +82,8 @@ export const myChat = chat.agent({
|
||||
});
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
## Frontend: render the question and collect the answer
|
||||
|
||||
Two pieces on the client:
|
||||
|
||||
@@ -185,6 +185,8 @@ return streamText({
|
||||
|
||||
Your tools win on name conflicts. (Pick names that don't collide with `loadSkill` / `readFile` / `bash` to keep things predictable.)
|
||||
|
||||
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.
|
||||
|
||||
## Bundling
|
||||
|
||||
Bundling is **built-in to the CLI** — there's no extension to import. When you run `trigger deploy` or `trigger dev`:
|
||||
|
||||
@@ -205,6 +205,10 @@ toModelOutput: ({ output: message }) => {
|
||||
|
||||
This is important for token efficiency: the sub-agent might use 100K tokens exploring and reasoning, but the parent LLM only consumes the summary.
|
||||
|
||||
<Warning>
|
||||
`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).
|
||||
</Warning>
|
||||
|
||||
## ChatStream.messages()
|
||||
|
||||
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,13 +241,16 @@ Sub-agent tools work inside both `chat.agent()` (managed) and `chat.customAgent(
|
||||
|
||||
```ts
|
||||
// Managed agent with sub-agent tool
|
||||
const tools = { research: researchTool };
|
||||
|
||||
export const myAgent = chat.agent({
|
||||
id: "orchestrator",
|
||||
run: async ({ messages, stopSignal }) => {
|
||||
tools, // declare here so toModelOutput survives across turns
|
||||
run: async ({ messages, tools, stopSignal }) => {
|
||||
return streamText({
|
||||
model: anthropic("claude-sonnet-4-6"),
|
||||
messages,
|
||||
tools: { research: researchTool },
|
||||
tools,
|
||||
abortSignal: stopSignal,
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
@@ -251,7 +258,7 @@ export const myAgent = chat.agent({
|
||||
});
|
||||
```
|
||||
|
||||
For `chat.customAgent()`, define the tool and sub-agent Map inside the `run` closure so they survive across turns.
|
||||
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).
|
||||
|
||||
## Streaming progress from a subtask to the parent chat
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import RcBanner from "/snippets/ai-chat-rc-banner.mdx";
|
||||
|
||||
<RcBanner />
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
- **Audit logs** — record every tool result for compliance.
|
||||
- **Billing** — charge per tool invocation.
|
||||
|
||||
@@ -151,6 +151,7 @@ These steps assume you already have a Trigger.dev project with the SDK installed
|
||||
## Next steps
|
||||
|
||||
- [Backend](/ai-chat/backend) — Lifecycle hooks, persistence, session iterator, raw task primitives
|
||||
- [Tools](/ai-chat/tools): Declare tools so `toModelOutput` survives across turns, typed in `run()`
|
||||
- [Frontend](/ai-chat/frontend) — Session management, client data, reconnection
|
||||
- [Types](/ai-chat/types) — `chat.withUIMessage`, `InferChatUIMessage`, and related typing
|
||||
- [`chat.local`](/ai-chat/chat-local) — Per-run typed state across hooks, run, tools, subtasks
|
||||
|
||||
@@ -45,6 +45,7 @@ Options for `chat.agent()`.
|
||||
| `compaction` | `ChatAgentCompactionOptions` | — | Automatic context compaction. See [Compaction](/ai-chat/compaction) |
|
||||
| `pendingMessages` | `PendingMessagesOptions` | — | Mid-execution message injection. See [Pending Messages](/ai-chat/pending-messages) |
|
||||
| `prepareMessages` | `(event: PrepareMessagesEvent) => ModelMessage[]` | — | Transform model messages before use (cache breaks, context injection, etc.) |
|
||||
| `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). |
|
||||
| `maxTurns` | `number` | `100` | Max conversational turns per run |
|
||||
| `turnTimeout` | `string` | `"1h"` | How long to wait for next message |
|
||||
| `idleTimeoutInSeconds` | `number` | `30` | Seconds to stay idle before suspending |
|
||||
@@ -87,6 +88,7 @@ The payload passed to the `run` function.
|
||||
| -------------- | ------------------------------------------ | -------------------------------------------------------------------- |
|
||||
| `ctx` | `TaskRunContext` | Full task run context — same as `task` `run`’s `{ ctx }` |
|
||||
| `messages` | `ModelMessage[]` | Model-ready messages — pass directly to `streamText` |
|
||||
| `tools` | `ToolSet` | Resolved tools declared on the agent config (empty object when none). Pass straight to `streamText`. See [Tools](/ai-chat/tools). |
|
||||
| `chatId` | `string` | Your conversation ID (the session's `externalId`) |
|
||||
| `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. |
|
||||
| `trigger` | `"submit-message" \| "regenerate-message"` | What triggered the request |
|
||||
@@ -191,6 +193,17 @@ Passed to the `onValidateMessages` callback.
|
||||
| `turn` | `number` | Turn number (0-indexed) |
|
||||
| `trigger` | `"submit-message" \| "regenerate-message" \| "preload" \| "close"` | The trigger type for this turn |
|
||||
|
||||
## ResolveToolsEvent
|
||||
|
||||
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).
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------------- | --------------------------- | ------------------------------------------------- |
|
||||
| `chatId` | `string` | Chat session ID |
|
||||
| `turn` | `number` | Turn number (0-indexed) |
|
||||
| `continuation` | `boolean` | Whether this run is continuing an existing chat |
|
||||
| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend |
|
||||
|
||||
## HydrateMessagesEvent
|
||||
|
||||
Passed to the `hydrateMessages` callback. See [hydrateMessages](/ai-chat/lifecycle-hooks#hydratemessages).
|
||||
@@ -532,6 +545,19 @@ type Msg = InferChatUIMessage<typeof myChat>;
|
||||
|
||||
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`.
|
||||
|
||||
## `InferChatUIMessageFromTools`
|
||||
|
||||
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>>`.
|
||||
|
||||
```ts
|
||||
import type { InferChatUIMessageFromTools } from "@trigger.dev/sdk/ai";
|
||||
|
||||
const tools = { search, readFile };
|
||||
type ChatUiMessage = InferChatUIMessageFromTools<typeof tools>;
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
## AI helpers (`ai` from `@trigger.dev/sdk/ai`)
|
||||
|
||||
| Export | Status | Description |
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
title: "Tools"
|
||||
sidebarTitle: "Tools"
|
||||
description: "Declare tools on chat.agent so toModelOutput survives across turns, get them back typed in run(), and type your messages from them."
|
||||
---
|
||||
|
||||
import RcBanner from "/snippets/ai-chat-rc-banner.mdx";
|
||||
|
||||
<RcBanner />
|
||||
|
||||
`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**:
|
||||
|
||||
```ts
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { streamText, stepCountIs, tool } from "ai";
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
import { z } from "zod";
|
||||
|
||||
const tools = {
|
||||
searchDocs: tool({
|
||||
description: "Search the docs.",
|
||||
inputSchema: z.object({ query: z.string() }),
|
||||
execute: async ({ query }) => searchIndex(query),
|
||||
}),
|
||||
};
|
||||
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
tools, // ← declare here
|
||||
run: async ({ messages, tools, signal }) =>
|
||||
streamText({
|
||||
...chat.toStreamTextOptions({ tools }), // ← the same set, handed back on the payload
|
||||
model: anthropic("claude-sonnet-4-5"),
|
||||
messages,
|
||||
abortSignal: signal,
|
||||
stopWhen: stepCountIs(15),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
Declaring `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 [`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)).
|
||||
- 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.
|
||||
|
||||
## Where tools go
|
||||
|
||||
There are three places a tool set shows up. Declare once, reuse:
|
||||
|
||||
| Surface | What it's for |
|
||||
| --- | --- |
|
||||
| `chat.agent({ tools })` | Re-applies `toModelOutput` on prior-turn history; hands the set back typed on the `run()` payload. |
|
||||
| `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. |
|
||||
| `streamText({ tools })` | What the model actually calls. `chat.toStreamTextOptions({ tools })` already sets this, so spread it instead of passing `tools` twice. |
|
||||
|
||||
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.
|
||||
|
||||
<Tip>
|
||||
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`.
|
||||
</Tip>
|
||||
|
||||
## `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.
|
||||
|
||||
```ts
|
||||
const tools = {
|
||||
renderChart: tool({
|
||||
description: "Render a chart and return it as an image.",
|
||||
inputSchema: z.object({ spec: z.string() }),
|
||||
execute: async ({ spec }) => renderToPng(spec), // raw bytes
|
||||
// The model should see an image part, not base64 bytes:
|
||||
toModelOutput: ({ output }) => ({
|
||||
type: "content",
|
||||
value: [{ type: "media", mediaType: "image/png", data: output.base64 }],
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
export const chartChat = chat.agent({
|
||||
id: "chart-chat",
|
||||
tools, // ← without this, the image is "remembered" on turn 1 and gone from turn 2
|
||||
run: async ({ messages, tools, signal }) =>
|
||||
streamText({
|
||||
...chat.toStreamTextOptions({ tools }),
|
||||
model: anthropic("claude-sonnet-4-5"),
|
||||
messages,
|
||||
abortSignal: signal,
|
||||
stopWhen: stepCountIs(15),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## 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:
|
||||
|
||||
```ts
|
||||
export const myChat = chat
|
||||
.withClientData({ schema: z.object({ userId: z.string(), plan: z.string() }) })
|
||||
.agent({
|
||||
id: "my-chat",
|
||||
tools: ({ clientData }) => ({
|
||||
searchDocs,
|
||||
...(clientData?.plan === "pro" ? { deepResearch } : {}),
|
||||
}),
|
||||
run: async ({ messages, tools, signal }) =>
|
||||
streamText({
|
||||
...chat.toStreamTextOptions({ tools }),
|
||||
model: anthropic("claude-sonnet-4-5"),
|
||||
messages,
|
||||
abortSignal: signal,
|
||||
stopWhen: stepCountIs(15),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
The function receives a `ResolveToolsEvent` and runs once per turn (after `clientData` is parsed):
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `chatId` | `string` | The chat session ID. |
|
||||
| `turn` | `number` | The current turn number (0-indexed). |
|
||||
| `continuation` | `boolean` | Whether this run is continuing an existing chat. |
|
||||
| `clientData` | `TClientData` | Parsed client data from the frontend. |
|
||||
|
||||
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:
|
||||
|
||||
```ts
|
||||
run: async ({ messages, tools, signal }) => {
|
||||
// `tools` is typed as your tool set, not a broad `ToolSet`
|
||||
return streamText({
|
||||
...chat.toStreamTextOptions({ tools }),
|
||||
model: anthropic("claude-sonnet-4-5"),
|
||||
messages,
|
||||
abortSignal: signal,
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
When no `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`:
|
||||
|
||||
```ts
|
||||
import type { InferChatUIMessageFromTools } from "@trigger.dev/sdk/ai";
|
||||
|
||||
const tools = { searchDocs, renderChart };
|
||||
|
||||
export type ChatUiMessage = InferChatUIMessageFromTools<typeof tools>;
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
## Skills
|
||||
|
||||
[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.
|
||||
|
||||
## Manual turn loops (`chat.customAgent`)
|
||||
|
||||
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:
|
||||
|
||||
```ts
|
||||
import { convertToModelMessages, streamText } from "ai";
|
||||
|
||||
// Inside your loop, with `tools` in scope:
|
||||
const uiMessages = chat.history.all();
|
||||
const messages = await convertToModelMessages(uiMessages, {
|
||||
tools,
|
||||
ignoreIncompleteToolCalls: true,
|
||||
});
|
||||
|
||||
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, tools });
|
||||
```
|
||||
|
||||
## Learn more
|
||||
|
||||
- [Human-in-the-loop](/ai-chat/patterns/human-in-the-loop): tools that pause for approval.
|
||||
- [Sub-agents](/ai-chat/patterns/sub-agents): tools that delegate to other agents and compress their output with `toModelOutput`.
|
||||
- [Tool result auditing](/ai-chat/patterns/tool-result-auditing): logging tool results as they resolve.
|
||||
- [AI SDK: Tools and tool calling](https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling).
|
||||
@@ -48,6 +48,10 @@ type MyChatDataTypes = UIDataTypes & {
|
||||
export type MyChatUIMessage = UIMessage<unknown, MyChatDataTypes, MyChatTools>;
|
||||
```
|
||||
|
||||
<Tip>
|
||||
If you don't need custom `data-*` parts, [`InferChatUIMessageFromTools<typeof myTools>`](/ai-chat/tools#typing-messages-from-your-tools) from `@trigger.dev/sdk/ai` collapses the tools half into one line (it's shorthand for `UIMessage<unknown, UIDataTypes, InferUITools<typeof myTools>>`).
|
||||
</Tip>
|
||||
|
||||
Task-backed tools should use AI SDK [`tool()`](https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling) with `execute: ai.toolExecute(schemaTask)` where needed — see [Task-backed AI tools](/tasks/schemaTask#task-backed-ai-tools).
|
||||
|
||||
### Backend: `chat.withUIMessage(...).agent(...)`
|
||||
@@ -82,6 +86,7 @@ export const myChat = chat
|
||||
})
|
||||
.agent({
|
||||
id: "my-chat",
|
||||
tools: myTools,
|
||||
onTurnStart: async ({ uiMessages, writer }) => {
|
||||
// uiMessages is MyChatUIMessage[] — custom data parts are typed
|
||||
writer.write({
|
||||
@@ -89,11 +94,12 @@ export const myChat = chat
|
||||
data: { status: "preparing" },
|
||||
});
|
||||
},
|
||||
run: async ({ messages, signal }) => {
|
||||
run: async ({ messages, tools, signal }) => {
|
||||
// `tools` is myTools, typed, handed back on the payload
|
||||
return streamText({
|
||||
model: anthropic("claude-sonnet-4-5"),
|
||||
messages,
|
||||
tools: myTools,
|
||||
tools,
|
||||
abortSignal: signal,
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
"ai-chat/how-it-works",
|
||||
"ai-chat/backend",
|
||||
"ai-chat/lifecycle-hooks",
|
||||
"ai-chat/tools",
|
||||
"ai-chat/frontend",
|
||||
"ai-chat/server-chat",
|
||||
"ai-chat/sessions",
|
||||
|
||||
Reference in New Issue
Block a user