chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,813 @@
|
||||
---
|
||||
title: "Backend"
|
||||
sidebarTitle: "Backend"
|
||||
description: "Three approaches to building your chat backend — chat.agent(), session iterator, or raw task primitives."
|
||||
---
|
||||
|
||||
There are three abstraction levels for a chat backend. All three speak the same wire protocol, so the [frontend transport](/ai-chat/frontend) works unchanged whichever you pick.
|
||||
|
||||
| Capability | `chat.agent()` | `chat.createSession()` | Raw primitives |
|
||||
| ------------------------------------- | -------------- | ------------------------------------------------------------- | -------------- |
|
||||
| Turn loop, stop signals, accumulation | Managed | Managed | You write it |
|
||||
| Lifecycle hooks | Yes | No — inline code per turn | No |
|
||||
| Continuation recovery on new runs | Automatic | [Manual seeding](/ai-chat/custom-agents#continuation-runs-and-history-seeding) | Manual seeding |
|
||||
| Compaction / steering | Built-in | Built-in | Manual |
|
||||
| Head Start, actions, tool approvals | Yes | No | No |
|
||||
| Custom stream conversion | No | Limited | Full control |
|
||||
| Agent dashboard visibility | Yes | Yes (via `customAgent`) | Yes |
|
||||
|
||||
The raw-primitives column assumes [`chat.customAgent()`](/ai-chat/custom-agents) as the wrapper, which is what makes the task visible to the agent dashboard.
|
||||
|
||||
Start with `chat.agent()`. Drop to `chat.createSession()` when you want to own the per-turn code (model routing, persistence, custom telemetry) without rebuilding the turn loop. Drop to raw primitives only when you need full control over stream conversion or a custom protocol.
|
||||
|
||||
## chat.agent()
|
||||
|
||||
The highest-level approach. Handles message accumulation, stop signals, turn lifecycle, and auto-piping automatically.
|
||||
|
||||
### Simple: return a StreamTextResult
|
||||
|
||||
Return the `streamText` result from `run` and it's automatically piped to the frontend:
|
||||
|
||||
```ts
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { streamText, stepCountIs } from "ai";
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
|
||||
export const simpleChat = chat.agent({
|
||||
id: "simple-chat",
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({
|
||||
...chat.toStreamTextOptions(), // prepareStep, system, telemetry (see note below)
|
||||
model: anthropic("claude-sonnet-4-5"),
|
||||
system: "You are a helpful assistant.",
|
||||
messages,
|
||||
abortSignal: signal,
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
<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.
|
||||
|
||||
```ts trigger/agent-chat.ts
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { streamText } from "ai";
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
import type { ModelMessage } from "ai";
|
||||
|
||||
export const agentChat = chat.agent({
|
||||
id: "agent-chat",
|
||||
run: async ({ messages }) => {
|
||||
// Don't return anything — chat.pipe is called inside
|
||||
await runAgentLoop(messages);
|
||||
},
|
||||
});
|
||||
|
||||
async function runAgentLoop(messages: ModelMessage[]) {
|
||||
// ... agent logic, tool calls, etc.
|
||||
|
||||
const result = streamText({
|
||||
model: anthropic("claude-sonnet-4-5"),
|
||||
messages,
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
|
||||
// Pipe from anywhere — no need to return it
|
||||
await chat.pipe(result);
|
||||
}
|
||||
```
|
||||
|
||||
### Custom data parts
|
||||
|
||||
Add custom `data-*` parts to the assistant's response message via `chat.response.write()` (from `run()`) or the `writer` parameter in lifecycle hooks. Non-transient `data-*` chunks are automatically added to `responseMessage.parts` and surface in `onTurnComplete` for persistence:
|
||||
|
||||
```ts
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
onBeforeTurnComplete: async ({ writer, turn }) => {
|
||||
// This data part will be in responseMessage.parts in onTurnComplete
|
||||
writer.write({
|
||||
type: "data-metadata",
|
||||
data: { turn, model: "gpt-4o", timestamp: Date.now() },
|
||||
});
|
||||
},
|
||||
onTurnComplete: async ({ responseMessage }) => {
|
||||
// responseMessage.parts includes the data-metadata part
|
||||
await db.messages.save(responseMessage);
|
||||
},
|
||||
run: async ({ messages, signal }) => {
|
||||
// Also works from run() via chat.response
|
||||
chat.response.write({
|
||||
type: "data-context",
|
||||
data: { searchResults: results },
|
||||
});
|
||||
|
||||
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Add `transient: true` to data chunks that should stream to the frontend but NOT persist in the response message. Use this for progress indicators, loading states, and other temporary UI:
|
||||
|
||||
```ts
|
||||
// Transient — frontend sees it, but NOT in onTurnComplete's responseMessage
|
||||
writer.write({
|
||||
type: "data-progress",
|
||||
id: "search",
|
||||
data: { percent: 50 },
|
||||
transient: true,
|
||||
});
|
||||
```
|
||||
|
||||
<Info>
|
||||
This matches the AI SDK's semantics: `data-*` chunks persist to `message.parts` by default. Only `transient: true` chunks are ephemeral. Non-data chunks (`text-delta`, `tool-*`, etc.) are handled by `streamText` and captured via `onFinish` — they don't need `chat.response`.
|
||||
</Info>
|
||||
|
||||
<Note>
|
||||
`chat.response` and the `writer` accumulation behavior work with `chat.agent` and `chat.createSession`. If you're using [`chat.customAgent`](/ai-chat/custom-agents), you own the accumulator — see the raw-task example for the manual pattern.
|
||||
</Note>
|
||||
|
||||
### Raw streaming with `chat.stream`
|
||||
|
||||
For low-level stream access (piping from subtasks, reading streams by run ID), use `chat.stream`. Chunks written via `chat.stream` go directly to the realtime output — they are **NOT** accumulated into the response message regardless of the `transient` flag.
|
||||
|
||||
```ts
|
||||
// Raw stream — always ephemeral, never in responseMessage
|
||||
const { waitUntilComplete } = chat.stream.writer({
|
||||
execute: ({ write }) => {
|
||||
write({ type: "data-status", data: { message: "Processing..." } });
|
||||
},
|
||||
});
|
||||
await waitUntilComplete();
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Use `data-*` chunk types (e.g. `data-status`, `data-progress`) for custom data. The AI SDK processes these into `DataUIPart` objects in `message.parts` on the frontend. Writing the same `type` + `id` again updates the existing part instead of creating a new one — useful for live progress.
|
||||
</Tip>
|
||||
|
||||
`chat.stream` exposes the full stream API:
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `chat.stream.writer(options)` | Write individual chunks via a callback |
|
||||
| `chat.stream.pipe(stream, options?)` | Pipe a `ReadableStream` or `AsyncIterable` |
|
||||
| `chat.stream.append(value, options?)` | Append raw data |
|
||||
| `chat.stream.read(runId, options?)` | Read the stream by run ID |
|
||||
|
||||
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:
|
||||
|
||||
- [Lifecycle hooks](/ai-chat/lifecycle-hooks) — `onPreload`, `onChatStart`, `onValidateMessages`, `hydrateMessages`, `onTurnStart`, `onBeforeTurnComplete`, `onTurnComplete`, `onChatSuspend` / `onChatResume`, `exitAfterPreloadIdle`, plus how `ctx` plumbs through every callback.
|
||||
|
||||
**Per-turn order:** `onValidateMessages` → `hydrateMessages` → `onChatStart` (chat's first message only) → `onTurnStart` → `run()` → `onBeforeTurnComplete` → `onTurnComplete`.
|
||||
|
||||
### Using prompts
|
||||
|
||||
Use [AI Prompts](/ai/prompts) to manage your system prompt as versioned, overridable config. Store the resolved prompt in a lifecycle hook with `chat.prompt.set()`, then spread `chat.toStreamTextOptions()` into `streamText` — it includes the system prompt, model, config, and telemetry automatically.
|
||||
|
||||
```ts
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { prompts } from "@trigger.dev/sdk";
|
||||
import { streamText, createProviderRegistry } from "ai";
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
import { z } from "zod";
|
||||
|
||||
const registry = createProviderRegistry({ anthropic });
|
||||
|
||||
const systemPrompt = prompts.define({
|
||||
id: "my-chat-system",
|
||||
model: "anthropic:claude-sonnet-4-5",
|
||||
config: { temperature: 0.7 },
|
||||
variables: z.object({ name: z.string() }),
|
||||
content: `You are a helpful assistant for {{name}}.`,
|
||||
});
|
||||
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
clientDataSchema: z.object({ userId: z.string() }),
|
||||
onChatStart: async ({ clientData }) => {
|
||||
const user = await db.user.findUnique({ where: { id: clientData.userId } });
|
||||
const resolved = await systemPrompt.resolve({ name: user.name });
|
||||
chat.prompt.set(resolved);
|
||||
},
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({
|
||||
...chat.toStreamTextOptions({ registry }), // system, model, config, telemetry
|
||||
messages,
|
||||
abortSignal: signal,
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`chat.toStreamTextOptions()` returns an object with `system`, `model` (resolved via the registry), `temperature`, and `experimental_telemetry` — all from the stored prompt. Properties you set after the spread (like a client-selected model) take precedence.
|
||||
|
||||
**Which form to call:**
|
||||
|
||||
| Form | Use when |
|
||||
|---|---|
|
||||
| `chat.toStreamTextOptions()` | Default. Wires up `prepareStep` (compaction, steering, background injection), the stored prompt's `system` / `model` / `config`, and telemetry metadata. |
|
||||
| `chat.toStreamTextOptions({ registry })` | You're using [Prompts](/ai/prompts) with a provider-prefixed model string (e.g. `"anthropic:claude-sonnet-4-5"`). The registry resolves the prefix to a real model instance via `createProviderRegistry({ anthropic, openai, ... })`. |
|
||||
| `chat.toStreamTextOptions({ tools })` | You want HITL tool approvals — pass the same `tools` object you give to `streamText`. The SDK then knows which tool calls need to pause on `needsApproval: true`. |
|
||||
| `chat.toStreamTextOptions({ registry, tools })` | Both of the above. |
|
||||
|
||||
<Tip>
|
||||
See [Prompts](/ai/prompts) for the full guide — defining templates, variable schemas, dashboard
|
||||
overrides, and the management SDK.
|
||||
</Tip>
|
||||
|
||||
### Stop generation
|
||||
|
||||
#### How stop works
|
||||
|
||||
Calling `stop()` from `useChat` sends a stop signal to the running task via input streams. The task's `streamText` call aborts (if you passed `signal` or `stopSignal`), but the **run stays alive** and waits for the next message. The partial response is captured and accumulated normally.
|
||||
|
||||
#### Abort signals
|
||||
|
||||
The `run` function receives three abort signals:
|
||||
|
||||
| Signal | Fires when | Use for |
|
||||
| -------------- | ------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `signal` | Stop **or** cancel | Pass to `streamText` — handles both cases. **Use this in most cases.** |
|
||||
| `stopSignal` | Stop only (per-turn, reset each turn) | Custom logic that should only run on user stop, not cancellation |
|
||||
| `cancelSignal` | Run cancel, expire, or maxDuration exceeded | Cleanup that should only happen on full cancellation |
|
||||
|
||||
```ts
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
run: async ({ messages, signal, stopSignal, cancelSignal }) => {
|
||||
return streamText({
|
||||
model: anthropic("claude-sonnet-4-5"),
|
||||
messages,
|
||||
abortSignal: signal, // Handles both stop and cancel
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Use `signal` (the combined signal) in most cases. The separate `stopSignal` and `cancelSignal` are
|
||||
only needed if you want different behavior for stop vs cancel.
|
||||
</Tip>
|
||||
|
||||
#### Detecting stop in callbacks
|
||||
|
||||
The `onTurnComplete` event includes a `stopped` boolean that indicates whether the user stopped generation during that turn:
|
||||
|
||||
```ts
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
onTurnComplete: async ({ chatId, uiMessages, stopped }) => {
|
||||
await db.chat.update({
|
||||
where: { id: chatId },
|
||||
data: { messages: uiMessages, lastStoppedAt: stopped ? new Date() : undefined },
|
||||
});
|
||||
},
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
You can also check stop status from **anywhere** during a turn using `chat.isStopped()`. This is useful inside `streamText`'s `onFinish` callback where the AI SDK's `isAborted` flag can be unreliable (e.g. when using `createUIMessageStream` + `writer.merge()`):
|
||||
|
||||
```ts
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { streamText } from "ai";
|
||||
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({
|
||||
model: anthropic("claude-sonnet-4-5"),
|
||||
messages,
|
||||
abortSignal: signal,
|
||||
onFinish: ({ isAborted }) => {
|
||||
// isAborted may be false even after stop when using createUIMessageStream
|
||||
const wasStopped = isAborted || chat.isStopped();
|
||||
if (wasStopped) {
|
||||
// handle stop — e.g. log analytics
|
||||
}
|
||||
},
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### Cleaning up aborted messages
|
||||
|
||||
When stop happens mid-stream, the captured response message can contain parts in an incomplete state — tool calls stuck in `partial-call`, reasoning blocks still marked as `streaming`, etc. These can cause UI issues like permanent spinners.
|
||||
|
||||
`chat.agent` automatically cleans up the `responseMessage` when stop is detected before passing it to `onTurnComplete`. If you use `chat.pipe()` manually and capture response messages yourself, use `chat.cleanupAbortedParts()`:
|
||||
|
||||
```ts
|
||||
const cleaned = chat.cleanupAbortedParts(rawResponseMessage);
|
||||
```
|
||||
|
||||
This removes tool invocation parts stuck in `partial-call` state and marks any `streaming` text or reasoning parts as `done`.
|
||||
|
||||
<Note>
|
||||
Stop signal delivery is best-effort. There is a small race window where the model may finish
|
||||
before the stop signal arrives, in which case the turn completes normally with `stopped: false`.
|
||||
This is expected and does not require special handling.
|
||||
</Note>
|
||||
|
||||
### Tool approvals
|
||||
|
||||
Tools with `needsApproval: true` pause execution until the user approves or denies via the frontend. Define the tool as normal and pass it to `streamText` — `chat.agent` handles the rest:
|
||||
|
||||
```ts
|
||||
const sendEmail = tool({
|
||||
description: "Send an email. Requires human approval.",
|
||||
inputSchema: z.object({ to: z.string(), subject: z.string(), body: z.string() }),
|
||||
needsApproval: true,
|
||||
execute: async ({ to, subject, body }) => {
|
||||
await emailService.send({ to, subject, body });
|
||||
return { sent: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({
|
||||
model: anthropic("claude-sonnet-4-5"),
|
||||
messages,
|
||||
tools: { sendEmail },
|
||||
abortSignal: signal,
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
When the model calls an approval-required tool, the turn completes with the tool in `approval-requested` state. After the user approves on the frontend, the updated message is sent back and `chat.agent` replaces it in the conversation accumulator by matching the message ID. `streamText` then executes the approved tool and continues.
|
||||
|
||||
See [Tool approvals](/ai-chat/frontend#tool-approvals) in the frontend docs for the UI setup.
|
||||
|
||||
### Persistence
|
||||
|
||||
To build a chat app that survives page refreshes you persist two things, both server-side from inside the agent:
|
||||
|
||||
1. **Conversation state.** Full `UIMessage[]` keyed by `chatId`. Written from `onTurnStart` (so the user message is durable before streaming begins) and `onTurnComplete` (so the assistant reply lands).
|
||||
2. **Session state.** The transport's reconnect metadata: `publicAccessToken` and `lastEventId`. Written alongside the messages from the same hooks.
|
||||
|
||||
<Note>
|
||||
Sessions let the transport reconnect to an existing run after a page refresh. Without them, every page load would start a new run, losing the conversation context that was accumulated in the previous run.
|
||||
</Note>
|
||||
|
||||
For the full per-hook breakdown, race-condition warnings (atomic `lastEventId` writes, why not to use `chat.defer` in `onTurnStart`), token renewal via the `accessToken` callback, and an end-to-end three-file example, see [Database persistence](/ai-chat/patterns/database-persistence).
|
||||
|
||||
### Pending messages (steering)
|
||||
|
||||
Users can send messages while the agent is executing tool calls. With `pendingMessages`, these messages are injected between tool-call steps, steering the agent mid-execution:
|
||||
|
||||
```ts
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
pendingMessages: {
|
||||
shouldInject: ({ steps }) => steps.length > 0,
|
||||
},
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({
|
||||
...chat.toStreamTextOptions({ registry }),
|
||||
messages,
|
||||
tools: {
|
||||
/* ... */
|
||||
},
|
||||
abortSignal: signal,
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
On the frontend, the `usePendingMessages` hook handles sending, tracking, and rendering injection points.
|
||||
|
||||
<Tip>
|
||||
See [Pending Messages](/ai-chat/pending-messages) for the full guide — backend configuration,
|
||||
frontend hook, queuing vs steering, and how injection works with all three chat variants.
|
||||
</Tip>
|
||||
|
||||
### Background injection
|
||||
|
||||
Inject context from background work into the conversation using `chat.inject()`. Combine with `chat.defer()` to run analysis between turns and inject results before the next response — self-review, RAG augmentation, safety checks, etc.
|
||||
|
||||
```ts
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
onTurnComplete: async ({ messages }) => {
|
||||
chat.defer(
|
||||
(async () => {
|
||||
const review = await generateObject({
|
||||
/* ... */
|
||||
});
|
||||
if (review.object.needsImprovement) {
|
||||
chat.inject([
|
||||
{
|
||||
role: "system",
|
||||
content: `[Self-review]\n${review.object.suggestions.join("\n")}`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
})()
|
||||
);
|
||||
},
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({ ...chat.toStreamTextOptions({ registry }), messages, abortSignal: signal });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
See [Background Injection](/ai-chat/background-injection) for the full guide — timing, self-review
|
||||
example, and how it differs from pending messages.
|
||||
</Tip>
|
||||
|
||||
### Actions
|
||||
|
||||
Custom actions let the frontend send structured commands (undo, rollback, edit, regenerate) that modify the conversation state. **Actions are not turns**: they fire `hydrateMessages` (if set) and `onAction` only. The full surface (defining `actionSchema`, returning a model response from `onAction`, gating against pending HITL tool calls, and sending actions from the frontend) lives on its own page.
|
||||
|
||||
See [Actions](/ai-chat/actions).
|
||||
|
||||
### Chat history
|
||||
|
||||
Imperative API for reading and modifying the accumulated message history. Works from any hook (`onAction`, `onTurnStart`, `onBeforeTurnComplete`, `onTurnComplete`, `hydrateMessages`) or from `run()` and AI SDK tools.
|
||||
|
||||
<Note>
|
||||
The agent's accumulator — not `session.out` — is the source of truth for the full conversation. The `.out` stream is a bounded sliding window (roughly one turn at steady state, see [Records on `session.out`](/ai-chat/client-protocol#records-on-session-out)); the durable history lives in the agent's accumulator and is persisted to S3 between turns for fast next-run boots. `chat.history` reads and mutates that accumulator directly.
|
||||
</Note>
|
||||
|
||||
**Reads.** Synchronous against the current accumulator state.
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `chat.history.all()` | Returns a copy of the current accumulated UI messages. |
|
||||
| `chat.history.getChain()` | Same as `all()`. Use whichever name reads better in context. |
|
||||
| `chat.history.findMessage(messageId)` | Returns the message with that id, or `undefined`. |
|
||||
| `chat.history.getPendingToolCalls()` | Tool calls on the most recent assistant message that are still in `input-available` state (waiting on `addToolOutput`). |
|
||||
| `chat.history.getResolvedToolCalls()` | All tool calls in the chain in `output-available` or `output-error` state. |
|
||||
| `chat.history.extractNewToolResults(message)` | Tool results in `message` whose `toolCallId` is not already resolved in the chain. Most useful in `hydrateMessages` against an incoming wire message, before the runtime merges it. |
|
||||
|
||||
Each pending and resolved entry is shaped `{ toolCallId, toolName, messageId }`. Each new-result entry is `{ toolCallId, toolName, output, errorText? }`, where `errorText` is set only for `output-error` parts.
|
||||
|
||||
**Mutations.** Applied at lifecycle checkpoints (after hooks return). Multiple mutations in the same hook compose correctly.
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `chat.history.set(messages)` | Replace all messages. Same as `chat.setMessages()`. |
|
||||
| `chat.history.remove(messageId)` | Remove a specific message by ID. |
|
||||
| `chat.history.rollbackTo(messageId)` | Keep messages up to and including the given ID (undo). |
|
||||
| `chat.history.replace(messageId, message)` | Replace a specific message by ID (edit). |
|
||||
| `chat.history.slice(start, end?)` | Keep only messages in the given range. |
|
||||
|
||||
```ts
|
||||
// Undo the last exchange in onAction
|
||||
onAction: async ({ action }) => {
|
||||
if (action.type === "undo") {
|
||||
chat.history.slice(0, -2);
|
||||
}
|
||||
},
|
||||
|
||||
// Trim history in onTurnComplete
|
||||
onTurnComplete: async ({ uiMessages }) => {
|
||||
if (uiMessages.length > 50) {
|
||||
chat.history.slice(-20);
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
The HITL reads let an action or hook decide what to do without walking the accumulator manually:
|
||||
|
||||
```ts
|
||||
// Refuse a regenerate while a tool call is still awaiting an answer
|
||||
onAction: async ({ action }) => {
|
||||
if (action.type === "regenerate") {
|
||||
if (chat.history.getPendingToolCalls().length > 0) return;
|
||||
chat.history.slice(0, -1);
|
||||
}
|
||||
},
|
||||
|
||||
// Side-effect once per net-new tool result when wire messages come in
|
||||
hydrateMessages: async ({ incomingMessages }) => {
|
||||
for (const msg of incomingMessages) {
|
||||
for (const r of chat.history.extractNewToolResults(msg)) {
|
||||
await onToolResolved({ id: r.toolCallId, output: r.output, errorText: r.errorText });
|
||||
}
|
||||
}
|
||||
return incomingMessages;
|
||||
},
|
||||
```
|
||||
|
||||
`extractNewToolResults` compares against the *current* chain. Inside `onTurnComplete`, the chain already contains the just-finished `responseMessage`, so it returns `[]`. Use it where the message is from outside the accumulator: `hydrateMessages` (incoming wire), `onAction` if the action carries a message, or any custom pre-merge code path.
|
||||
|
||||
### prepareMessages
|
||||
|
||||
Transform model messages before they're used anywhere — in `run()`, in compaction rebuilds, and in compaction results. Define once, applied everywhere.
|
||||
|
||||
Use this for Anthropic cache breaks, injecting system context, stripping PII, etc.
|
||||
|
||||
```ts
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
prepareMessages: ({ messages, reason }) => {
|
||||
// Add Anthropic cache breaks to the last message
|
||||
if (messages.length === 0) return messages;
|
||||
const last = messages[messages.length - 1];
|
||||
return [
|
||||
...messages.slice(0, -1),
|
||||
{
|
||||
...last,
|
||||
providerOptions: {
|
||||
...last.providerOptions,
|
||||
anthropic: { cacheControl: { type: "ephemeral" } },
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The `reason` field tells you why messages are being prepared:
|
||||
|
||||
| Reason | Description |
|
||||
| ---------------------- | ------------------------------------------------- |
|
||||
| `"run"` | Messages being passed to `run()` for `streamText` |
|
||||
| `"compaction-rebuild"` | Rebuilding from a previous compaction summary |
|
||||
| `"compaction-result"` | Fresh compaction just produced these messages |
|
||||
|
||||
### Version upgrades
|
||||
|
||||
Chat agent runs are pinned to the worker version they started on. When you deploy a new version, suspended runs resume on the old code. Call `chat.requestUpgrade()` in `onTurnStart` to skip `run()` and exit immediately — the transport re-triggers the same message on the latest version. See the [Version Upgrades pattern](/ai-chat/patterns/version-upgrades) for the full guide.
|
||||
|
||||
### Ending a run on your terms
|
||||
|
||||
By default, a chat agent stays idle after each turn waiting for the next user message. Call `chat.endRun()` from `run()`, `chat.defer()`, `onBeforeTurnComplete`, or `onTurnComplete` to exit the loop once the current turn finishes — no upgrade signal, no idle wait.
|
||||
|
||||
```ts
|
||||
chat.agent({
|
||||
id: "one-shot",
|
||||
run: async ({ messages, signal }) => {
|
||||
// Single-response agent — exit after this turn.
|
||||
chat.endRun();
|
||||
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The current turn streams through normally, `onBeforeTurnComplete` / `onTurnComplete` fire, the turn-complete chunk is written, and the run exits instead of suspending. The next user message on the same `chatId` starts a fresh run via the standard continuation flow.
|
||||
|
||||
Use this when the agent knows its work is done (budget exhausted, goal achieved, one-shot response) rather than relying on the idle timeout. Unlike `chat.requestUpgrade()`, no `upgrade-required` signal is sent to the client, so there's no version-migration semantics.
|
||||
|
||||
<Warning>
|
||||
If you persist `lastEventId` to your own storage for cross-page-load resume, **don't clear it on `chat.endRun()`**. The cursor is sessionId-keyed and stays valid across Run boundaries — clearing it forces the next `sendMessages` to subscribe from `seq_num=0`, where it may hit the prior turn's stale `turn-complete` record and close the stream empty before the new Run's chunks arrive.
|
||||
</Warning>
|
||||
|
||||
### Runtime configuration
|
||||
|
||||
#### chat.setTurnTimeout()
|
||||
|
||||
Override how long the run stays suspended waiting for the next message. Call from inside `run()`:
|
||||
|
||||
```ts
|
||||
run: async ({ messages, signal }) => {
|
||||
chat.setTurnTimeout("2h"); // Wait longer for this conversation
|
||||
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
|
||||
},
|
||||
```
|
||||
|
||||
#### chat.setIdleTimeoutInSeconds()
|
||||
|
||||
Override how long the run stays idle (active, using compute) after each turn:
|
||||
|
||||
```ts
|
||||
run: async ({ messages, signal }) => {
|
||||
chat.setIdleTimeoutInSeconds(60); // Stay idle for 1 minute
|
||||
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
|
||||
},
|
||||
```
|
||||
|
||||
<Info>
|
||||
Longer idle timeout means faster responses but more compute usage. Set to `0` to suspend
|
||||
immediately after each turn (minimum latency cost, slight delay on next message).
|
||||
</Info>
|
||||
|
||||
#### Stream options
|
||||
|
||||
Control how `streamText` results are converted to the frontend stream via `toUIMessageStream()`. Set static defaults on the task, or override per-turn.
|
||||
|
||||
##### Error handling with onError
|
||||
|
||||
When `streamText` encounters an error mid-stream (rate limits, API failures, network errors), the `onError` callback converts it to a string that's sent to the frontend as an `{ type: "error", errorText }` chunk. The AI SDK's `useChat` receives this via its `onError` callback.
|
||||
|
||||
By default, the raw error message is sent to the frontend. Use `onError` to sanitize errors and avoid leaking internal details:
|
||||
|
||||
```ts
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
uiMessageStreamOptions: {
|
||||
onError: (error) => {
|
||||
// Log the full error server-side for debugging
|
||||
console.error("Stream error:", error);
|
||||
// Return a sanitized message — this is what the frontend sees
|
||||
if (error instanceof Error && error.message.includes("rate limit")) {
|
||||
return "Rate limited — please wait a moment and try again.";
|
||||
}
|
||||
return "Something went wrong. Please try again.";
|
||||
},
|
||||
},
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`onError` is also called for tool execution errors, so a single handler covers both LLM errors and tool failures.
|
||||
|
||||
On the frontend, handle the error in `useChat`:
|
||||
|
||||
```tsx
|
||||
const { messages, sendMessage } = useChat({
|
||||
transport,
|
||||
onError: (error) => {
|
||||
// error.message contains the string returned by your onError handler
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
##### Reasoning and sources
|
||||
|
||||
Control which AI SDK features are forwarded to the frontend:
|
||||
|
||||
```ts
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
uiMessageStreamOptions: {
|
||||
sendReasoning: true, // Forward model reasoning (default: true)
|
||||
sendSources: true, // Forward source citations (default: false)
|
||||
},
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
##### Custom message IDs
|
||||
|
||||
By default, response message IDs are generated using the AI SDK's built-in `generateId`. Pass a custom `generateMessageId` function to use your own ID format (e.g. UUID-v7):
|
||||
|
||||
```ts
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
uiMessageStreamOptions: {
|
||||
generateMessageId: () => uuidv7(),
|
||||
},
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
With the `.withUIMessage()` builder, set it under `streamOptions`:
|
||||
|
||||
```ts
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
|
||||
export const myChat = chat
|
||||
.withUIMessage<MyChatUIMessage>({
|
||||
streamOptions: {
|
||||
generateMessageId: () => uuidv7(),
|
||||
sendReasoning: true,
|
||||
},
|
||||
})
|
||||
.agent({
|
||||
id: "my-chat",
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
<Info>
|
||||
The generated ID is sent to the frontend in the stream's `start` chunk, so frontend and backend
|
||||
always reference the same ID for each message. This is important for features like tool
|
||||
approvals, where the frontend resends an assistant message and the backend needs to match it
|
||||
by ID in the conversation accumulator.
|
||||
</Info>
|
||||
|
||||
##### Per-turn overrides
|
||||
|
||||
Override per-turn with `chat.setUIMessageStreamOptions()` — per-turn values merge with the static config (per-turn wins on conflicts). The override is cleared automatically after each turn.
|
||||
|
||||
```ts
|
||||
run: async ({ messages, clientData, signal }) => {
|
||||
// Enable reasoning only for certain models
|
||||
if (clientData.model?.includes("claude")) {
|
||||
chat.setUIMessageStreamOptions({ sendReasoning: true });
|
||||
}
|
||||
return streamText({ model: openai(clientData.model ?? "gpt-4o"), messages, abortSignal: signal });
|
||||
},
|
||||
```
|
||||
|
||||
`chat.setUIMessageStreamOptions()` works across all abstraction levels — `chat.agent()`, `chat.createSession()` / `turn.complete()`, and `chat.pipeAndCapture()`.
|
||||
|
||||
See [ChatUIMessageStreamOptions](/ai-chat/reference#chatuimessagestreamoptions) for the full reference.
|
||||
|
||||
<Note>
|
||||
`onFinish` is managed internally for response capture and cannot be overridden here. Use
|
||||
`streamText`'s `onFinish` callback for custom finish handling, or use [raw task
|
||||
mode](/ai-chat/custom-agents) for full control over `toUIMessageStream()`.
|
||||
</Note>
|
||||
|
||||
### Manual mode with task()
|
||||
|
||||
If you need full control over task options, use the standard `task()` with `ChatTaskPayload` and `chat.pipe()`:
|
||||
|
||||
```ts
|
||||
import { task } from "@trigger.dev/sdk";
|
||||
import { chat, type ChatTaskPayload } from "@trigger.dev/sdk/ai";
|
||||
import { streamText } from "ai";
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
|
||||
export const manualChat = task({
|
||||
id: "manual-chat",
|
||||
retry: { maxAttempts: 3 },
|
||||
queue: { concurrencyLimit: 10 },
|
||||
run: async (payload: ChatTaskPayload) => {
|
||||
const result = streamText({
|
||||
model: anthropic("claude-sonnet-4-5"),
|
||||
messages: payload.messages,
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
|
||||
await chat.pipe(result);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Manual mode does not get automatic message accumulation or the `onTurnComplete`/`onChatStart`
|
||||
lifecycle hooks. The `responseMessage` field in `onTurnComplete` will be `undefined` when using
|
||||
`chat.pipe()` directly. Use `chat.agent()` for the full multi-turn experience.
|
||||
</Warning>
|
||||
|
||||
---
|
||||
|
||||
{/* Anchor stubs for inbound deep links to the sections that moved to /ai-chat/custom-agents. */}
|
||||
<a id="chat-createsession" />
|
||||
<a id="chat-customagent" />
|
||||
<a id="raw-task-with-primitives" />
|
||||
|
||||
## Custom agents
|
||||
|
||||
Both lower levels — `chat.createSession()` (managed turn iterator, your turn body) and `chat.customAgent()` with raw primitives (hand-rolled loop, full stream-conversion control) — are covered together on the Custom agents page, including the `ChatTurn` surface, the continuation-seeding pattern, and the hand-rolled-loop checklist:
|
||||
|
||||
<Card title="Custom agents" icon="screwdriver-wrench" href="/ai-chat/custom-agents">
|
||||
Build agents without the managed lifecycle — createSession or raw primitives.
|
||||
</Card>
|
||||
Reference in New Issue
Block a user