|
| 1 | +--- |
| 2 | +name: authoring-chat-agent |
| 3 | +description: > |
| 4 | + Author and run a durable AI chat agent with chat.agent from @trigger.dev/sdk/ai: the per-turn |
| 5 | + run loop, why you MUST spread ...chat.toStreamTextOptions() first, returning a StreamTextResult |
| 6 | + vs calling chat.pipe(), the two server actions (chat.createStartSessionAction + |
| 7 | + auth.createPublicToken), and wiring useChat to useTriggerChatTransport. Load this when building, |
| 8 | + modifying, or debugging a chat backend (the agent task or its lifecycle hooks) or its React |
| 9 | + transport, when declaring typed tools or custom data parts, or when migrating a plain AI SDK |
| 10 | + streamText route to chat.agent. |
| 11 | +type: core |
| 12 | +library: trigger.dev |
| 13 | +library_version: "{{TRIGGER_SDK_VERSION}}" |
| 14 | +sources: |
| 15 | + - docs/ai-chat/overview.mdx |
| 16 | + - docs/ai-chat/quick-start.mdx |
| 17 | + - docs/ai-chat/how-it-works.mdx |
| 18 | + - docs/ai-chat/backend.mdx |
| 19 | + - docs/ai-chat/frontend.mdx |
| 20 | + - docs/ai-chat/reference.mdx |
| 21 | + - docs/ai-chat/types.mdx |
| 22 | + - docs/ai-chat/tools.mdx |
| 23 | + - docs/ai-chat/lifecycle-hooks.mdx |
| 24 | + - docs/ai-chat/error-handling.mdx |
| 25 | +--- |
| 26 | + |
| 27 | +# Authoring a chat agent |
| 28 | + |
| 29 | +A `chat.agent` runs an entire conversation as one long-lived Trigger.dev task. It wakes when a |
| 30 | +message arrives, freezes when none do, and in-memory state survives page refreshes, deploys, idle |
| 31 | +gaps, and crashes. Your code is the loop you would write anyway: messages in, `streamText` out. |
| 32 | +There are no API routes. The frontend talks to the agent through a `TriggerChatTransport`, so |
| 33 | +history accumulates server-side and the client ships only the new message each turn. |
| 34 | + |
| 35 | +Works with Vercel AI SDK v5, v6, or v7. On v7 also install `@ai-sdk/otel` so model calls are traced |
| 36 | +(the SDK registers it for you). |
| 37 | + |
| 38 | +## Setup |
| 39 | + |
| 40 | +Three pieces: the agent task, two server actions, and the frontend transport. |
| 41 | + |
| 42 | +### 1. Define the agent |
| 43 | + |
| 44 | +```ts trigger/chat.ts |
| 45 | +import { chat } from "@trigger.dev/sdk/ai"; |
| 46 | +import { streamText, stepCountIs } from "ai"; |
| 47 | +import { anthropic } from "@ai-sdk/anthropic"; |
| 48 | + |
| 49 | +export const myChat = chat.agent({ |
| 50 | + id: "my-chat", |
| 51 | + run: async ({ messages, signal }) => |
| 52 | + streamText({ |
| 53 | + // Spread this FIRST. See "Common mistakes". |
| 54 | + ...chat.toStreamTextOptions(), |
| 55 | + model: anthropic("claude-sonnet-4-5"), |
| 56 | + messages, |
| 57 | + abortSignal: signal, |
| 58 | + stopWhen: stepCountIs(15), |
| 59 | + }), |
| 60 | +}); |
| 61 | +``` |
| 62 | + |
| 63 | +`run` receives `messages` already converted to `ModelMessage[]` (the SDK converts the frontend's |
| 64 | +`UIMessage[]` for you) plus a `signal` that aborts on stop or cancel. Returning the |
| 65 | +`StreamTextResult` auto-pipes it to the frontend. |
| 66 | + |
| 67 | +### 2. Add two server actions |
| 68 | + |
| 69 | +Both run on your server, so the browser never holds your environment secret key. This is also |
| 70 | +where per-user / per-plan authorization and any paired DB writes live. |
| 71 | + |
| 72 | +```ts app/actions.ts |
| 73 | +"use server"; |
| 74 | +import { auth } from "@trigger.dev/sdk"; |
| 75 | +import { chat } from "@trigger.dev/sdk/ai"; |
| 76 | + |
| 77 | +// Creates the Session + first run, returns a session PAT. Idempotent on (env, chatId). |
| 78 | +export const startChatSession = chat.createStartSessionAction("my-chat"); |
| 79 | + |
| 80 | +// Pure mint. The transport calls this on 401/403 to refresh an expired token. |
| 81 | +export async function mintChatAccessToken(chatId: string) { |
| 82 | + return auth.createPublicToken({ |
| 83 | + scopes: { read: { sessions: chatId }, write: { sessions: chatId } }, |
| 84 | + expirationTime: "1h", |
| 85 | + }); |
| 86 | +} |
| 87 | +``` |
| 88 | + |
| 89 | +### 3. Wire the frontend |
| 90 | + |
| 91 | +```tsx app/components/chat.tsx |
| 92 | +"use client"; |
| 93 | +import { useState } from "react"; |
| 94 | +import { useChat } from "@ai-sdk/react"; |
| 95 | +import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; |
| 96 | +import type { myChat } from "@/trigger/chat"; |
| 97 | +import { mintChatAccessToken, startChatSession } from "@/app/actions"; |
| 98 | + |
| 99 | +export function Chat() { |
| 100 | + const transport = useTriggerChatTransport<typeof myChat>({ |
| 101 | + task: "my-chat", // typeof myChat gives compile-time task-id validation |
| 102 | + accessToken: ({ chatId }) => mintChatAccessToken(chatId), |
| 103 | + startSession: ({ chatId, clientData }) => startChatSession({ chatId, clientData }), |
| 104 | + }); |
| 105 | + |
| 106 | + const { messages, sendMessage, stop, status } = useChat({ transport }); |
| 107 | + const [input, setInput] = useState(""); |
| 108 | + // render messages, a form that calls sendMessage({ text: input }), |
| 109 | + // and a Stop button (onClick={stop}) while status === "streaming". |
| 110 | +} |
| 111 | +``` |
| 112 | + |
| 113 | +The transport is memoized (created once, reused across renders). Passing `typeof myChat` flows the |
| 114 | +agent's message type through `useChat`. |
| 115 | + |
| 116 | +## Core patterns |
| 117 | + |
| 118 | +### 1. Return vs pipe |
| 119 | + |
| 120 | +Return the `streamText` result from `run` for the simple case. When `streamText` is called deep |
| 121 | +inside nested helpers, call `await chat.pipe(result)` from anywhere in the task instead, and let |
| 122 | +`run` resolve `void`. |
| 123 | + |
| 124 | +```ts |
| 125 | +export const agentChat = chat.agent({ |
| 126 | + id: "agent-chat", |
| 127 | + run: async ({ messages }) => { |
| 128 | + await runAgentLoop(messages); // don't return; pipe inside |
| 129 | + }, |
| 130 | +}); |
| 131 | + |
| 132 | +async function runAgentLoop(messages: ModelMessage[]) { |
| 133 | + const result = streamText({ |
| 134 | + ...chat.toStreamTextOptions(), |
| 135 | + model: anthropic("claude-sonnet-4-5"), |
| 136 | + messages, |
| 137 | + }); |
| 138 | + await chat.pipe(result); // works from anywhere in the task |
| 139 | +} |
| 140 | +``` |
| 141 | + |
| 142 | +### 2. Typed tools (declare on config AND spread back) |
| 143 | + |
| 144 | +Declare tools on `chat.agent({ tools })`, read them back typed from the `run()` payload, and pass |
| 145 | +that set to `chat.toStreamTextOptions({ tools })`. One declaration flows everywhere. |
| 146 | + |
| 147 | +```ts |
| 148 | +import { tool, stepCountIs } from "ai"; |
| 149 | +import { z } from "zod"; |
| 150 | + |
| 151 | +const tools = { |
| 152 | + searchDocs: tool({ |
| 153 | + description: "Search the docs.", |
| 154 | + inputSchema: z.object({ query: z.string() }), |
| 155 | + execute: async ({ query }) => searchIndex(query), |
| 156 | + }), |
| 157 | +}; |
| 158 | + |
| 159 | +export const myChat = chat.agent({ |
| 160 | + id: "my-chat", |
| 161 | + tools, // so toModelOutput survives across turns |
| 162 | + run: async ({ messages, tools, signal }) => |
| 163 | + streamText({ |
| 164 | + ...chat.toStreamTextOptions({ tools }), // same set, handed back typed |
| 165 | + model: anthropic("claude-sonnet-4-5"), |
| 166 | + messages, |
| 167 | + abortSignal: signal, |
| 168 | + stopWhen: stepCountIs(15), |
| 169 | + }), |
| 170 | +}); |
| 171 | +``` |
| 172 | + |
| 173 | +`tools` also accepts a function `(event) => ToolSet` resolved per turn, where `event` carries |
| 174 | +`chatId`, `turn`, `continuation`, and `clientData`. |
| 175 | + |
| 176 | +### 3. Custom data parts (persisted vs transient) |
| 177 | + |
| 178 | +`data-*` parts written via `chat.response.write()` in `run()` (or `writer.write()` in hooks) |
| 179 | +persist into `responseMessage.parts` and surface in `onTurnComplete`. Add `transient: true` to |
| 180 | +stream them without persisting. Writes via `chat.stream` are always ephemeral. |
| 181 | + |
| 182 | +```ts |
| 183 | +// In run() - persists, surfaces in onTurnComplete's responseMessage |
| 184 | +chat.response.write({ type: "data-context", data: { searchResults } }); |
| 185 | + |
| 186 | +// In a hook via writer - streams but does NOT persist |
| 187 | +writer.write({ type: "data-progress", id: "search", data: { percent: 50 }, transient: true }); |
| 188 | +``` |
| 189 | + |
| 190 | +### 4. Custom UIMessage type, client data, and builder hooks |
| 191 | + |
| 192 | +For typed `data-*` parts or a tool map, build the agent through `chat.withUIMessage<T>()` and |
| 193 | +`chat.withClientData({ schema })`. Builder methods chain in any order; builder hooks run before the |
| 194 | +matching task hook. `streamOptions` becomes the default `uiMessageStreamOptions` (shallow-merged, |
| 195 | +agent wins). |
| 196 | + |
| 197 | +```ts |
| 198 | +export const myChat = chat |
| 199 | + .withUIMessage<MyChatUIMessage>({ streamOptions: { sendReasoning: true } }) |
| 200 | + .withClientData({ schema: z.object({ userId: z.string() }) }) |
| 201 | + .agent({ |
| 202 | + id: "my-chat", |
| 203 | + tools: myTools, |
| 204 | + onTurnStart: async ({ uiMessages, writer }) => { |
| 205 | + writer.write({ type: "data-turn-status", data: { status: "preparing" } }); |
| 206 | + }, |
| 207 | + run: async ({ messages, tools, signal }) => |
| 208 | + streamText({ ...chat.toStreamTextOptions({ tools }), model, messages, abortSignal: signal }), |
| 209 | + }); |
| 210 | +``` |
| 211 | + |
| 212 | +Build `MyChatUIMessage` as `UIMessage<unknown, MyDataTypes, InferUITools<typeof tools>>` (or, for |
| 213 | +tools only, `InferChatUIMessageFromTools<typeof tools>` from `@trigger.dev/sdk/ai`). On the |
| 214 | +frontend, narrow `useChat` with `InferChatUIMessage<typeof myChat>` from `@trigger.dev/sdk/chat/react`. |
| 215 | + |
| 216 | +### 5. Lifecycle hooks and stop |
| 217 | + |
| 218 | +`chat.agent` accepts hooks that fire in a fixed per-turn order: |
| 219 | + |
| 220 | +``` |
| 221 | +onValidateMessages -> hydrateMessages -> onChatStart (chat's first message only) |
| 222 | + -> onTurnStart -> run() -> onBeforeTurnComplete -> onTurnComplete |
| 223 | +``` |
| 224 | + |
| 225 | +`onBoot` fires once per worker process (every fresh boot, including continuation runs) and is where |
| 226 | +`chat.local`, DB connections, and per-process state belong. `onChatStart` fires only on the chat's |
| 227 | +first message. Suspend/resume use `onChatSuspend` / `onChatResume`. Config options include |
| 228 | +`tools`, `clientDataSchema`, `maxTurns` (100), `turnTimeout` ("1h"), `idleTimeoutInSeconds` (30), |
| 229 | +`uiMessageStreamOptions`, and `exitAfterPreloadIdle`. There is no generic `retry`; `chat.agent` |
| 230 | +runs with `maxAttempts: 1` internally. |
| 231 | + |
| 232 | +Stop is load-bearing: the `signal` passed to `run` aborts on stop or cancel. Forward it as |
| 233 | +`abortSignal` to `streamText`, or the Stop button updates the UI while the model keeps generating |
| 234 | +server-side. |
| 235 | + |
| 236 | +```ts |
| 237 | +run: async ({ messages, signal }) => |
| 238 | + streamText({ ...chat.toStreamTextOptions(), model, messages, abortSignal: signal, stopWhen: stepCountIs(15) }); |
| 239 | +``` |
| 240 | + |
| 241 | +### 6. Migrating from a plain AI SDK `streamText` route |
| 242 | + |
| 243 | +There is no API route in this model. The transport replaces the route round-trip, so: |
| 244 | + |
| 245 | +- Delete the route handler. Move per-request auth into the two server actions from Setup step 2. |
| 246 | +- Move the `streamText` call into `run`. It already receives pre-converted `ModelMessage[]`. |
| 247 | +- Return the `StreamTextResult` (it auto-pipes) and add `...chat.toStreamTextOptions()` first. |
| 248 | +- On the client, swap the `api` URL for `useTriggerChatTransport`; `useChat` stays the same shape. |
| 249 | + |
| 250 | +## Common mistakes |
| 251 | + |
| 252 | +- **CRITICAL: forgetting `...chat.toStreamTextOptions()`.** |
| 253 | + ```ts |
| 254 | + // Wrong - compaction / steering / background injection silently no-op |
| 255 | + return streamText({ model, messages, abortSignal: signal }); |
| 256 | + // Correct - spread FIRST so explicit overrides win |
| 257 | + return streamText({ ...chat.toStreamTextOptions(), model, messages, abortSignal: signal }); |
| 258 | + ``` |
| 259 | + It wires the `prepareStep` callback behind compaction, mid-turn steering, and background |
| 260 | + injection, injects the system prompt from `chat.prompt()`, resolves the registry model, and adds |
| 261 | + telemetry. Omitting it makes all of those silently no-op with no error. |
| 262 | + |
| 263 | +- **Declaring tools only on `streamText`.** Also declare them on `chat.agent({ tools })`, read them |
| 264 | + back from `run`, and pass `chat.toStreamTextOptions({ tools })`. Otherwise each tool's |
| 265 | + `toModelOutput` runs on turn 1 but is dropped when history is re-converted on later turns. |
| 266 | + |
| 267 | +- **Not forwarding `signal` for stop.** Without `abortSignal: signal`, Stop updates the UI but the |
| 268 | + model keeps generating server-side. |
| 269 | + |
| 270 | +- **Initializing `chat.local` in `onChatStart`.** Initialize it in `onBoot`. `onChatStart` fires |
| 271 | + once per chat, so continuation runs skip it and crash with |
| 272 | + `chat.local can only be modified after initialization`. `onBoot` fires on every fresh worker. |
| 273 | + |
| 274 | +- **Minting tokens in the browser.** Never expose the environment secret key client-side. Mint via |
| 275 | + the two server actions; the transport calls them. |
| 276 | + |
| 277 | +- **Clearing `lastEventId` on `chat.endRun()`.** Keep the cursor for the Session lifetime; clear it |
| 278 | + only when the Session itself closes. It is sessionId-keyed, so clearing forces a resubscribe from |
| 279 | + `seq_num=0` that can hit the prior turn's stale `turn-complete` and close the stream empty. |
| 280 | + |
| 281 | +- **Returning the raw error from `uiMessageStreamOptions.onError`.** It leaks internals (keys, |
| 282 | + stack traces). Return a sanitized string instead. |
| 283 | + |
| 284 | +## References |
| 285 | + |
| 286 | +- `chat-agent-advanced` skill - lifecycle hooks in depth, sessions, raw-task primitives |
| 287 | + (`chat.createSession`, `chat.customAgent`, `chat.stream`), compaction, HITL approvals, recovery. |
| 288 | +- `realtime-and-frontend` skill - Realtime hooks and frontend streaming beyond the chat transport. |
| 289 | +- `authoring-tasks` skill - base `task()` semantics, `ctx`, and standard lifecycle hooks. |
| 290 | +- Docs: /ai-chat/quick-start, /ai-chat/backend, /ai-chat/tools, /ai-chat/types, /ai-chat/frontend |
| 291 | + |
| 292 | +## Version |
| 293 | + |
| 294 | +Generated for `@trigger.dev/sdk` `{{TRIGGER_SDK_VERSION}}`. Re-run the trigger.dev skills installer |
| 295 | +after upgrading. |
0 commit comments