From 32a4f54f749abace14bd91b442dac1fa71baef0e Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 16 Mar 2026 17:23:41 +0000 Subject: [PATCH 01/13] docs: ai chat.task --- docs/ai-chat/backend.mdx | 856 +++++++++++++++++++++++++++++ docs/ai-chat/features.mdx | 421 ++++++++++++++ docs/ai-chat/frontend.mdx | 234 ++++++++ docs/ai-chat/overview.mdx | 161 ++++++ docs/ai-chat/quick-start.mdx | 108 ++++ docs/ai-chat/reference.mdx | 257 +++++++++ docs/docs.json | 15 + references/ai-chat/ARCHITECTURE.md | 311 +++++++++++ 8 files changed, 2363 insertions(+) create mode 100644 docs/ai-chat/backend.mdx create mode 100644 docs/ai-chat/features.mdx create mode 100644 docs/ai-chat/frontend.mdx create mode 100644 docs/ai-chat/overview.mdx create mode 100644 docs/ai-chat/quick-start.mdx create mode 100644 docs/ai-chat/reference.mdx create mode 100644 references/ai-chat/ARCHITECTURE.md diff --git a/docs/ai-chat/backend.mdx b/docs/ai-chat/backend.mdx new file mode 100644 index 00000000000..5c21e88ee65 --- /dev/null +++ b/docs/ai-chat/backend.mdx @@ -0,0 +1,856 @@ +--- +title: "Backend" +sidebarTitle: "Backend" +description: "Three approaches to building your chat backend — chat.task(), session iterator, or raw task primitives." +--- + +## chat.task() + +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 } from "ai"; +import { openai } from "@ai-sdk/openai"; + +export const simpleChat = chat.task({ + id: "simple-chat", + run: async ({ messages, signal }) => { + return streamText({ + model: openai("gpt-4o"), + system: "You are a helpful assistant.", + messages, + abortSignal: signal, + }); + }, +}); +``` + +### 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 { openai } from "@ai-sdk/openai"; +import type { ModelMessage } from "ai"; + +export const agentChat = chat.task({ + 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: openai("gpt-4o"), + messages, + }); + + // Pipe from anywhere — no need to return it + await chat.pipe(result); +} +``` + +### Lifecycle hooks + +#### onPreload + +Fires when a preloaded run starts — before any messages arrive. Use it to eagerly initialize state (DB records, user context) while the user is still typing. + +Preloaded runs are triggered by calling `transport.preload(chatId)` on the frontend. See [Preload](/ai-chat/features#preload) for details. + +```ts +export const myChat = chat.task({ + id: "my-chat", + clientDataSchema: z.object({ userId: z.string() }), + onPreload: async ({ chatId, clientData, runId, chatAccessToken }) => { + // Initialize early — before the first message arrives + const user = await db.user.findUnique({ where: { id: clientData.userId } }); + userContext.init({ name: user.name, plan: user.plan }); + + await db.chat.create({ data: { id: chatId, userId: clientData.userId } }); + await db.chatSession.upsert({ + where: { id: chatId }, + create: { id: chatId, runId, publicAccessToken: chatAccessToken }, + update: { runId, publicAccessToken: chatAccessToken }, + }); + }, + onChatStart: async ({ preloaded }) => { + if (preloaded) return; // Already initialized in onPreload + // ... non-preloaded initialization + }, + run: async ({ messages, signal }) => { + return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }); + }, +}); +``` + +| Field | Type | Description | +|-------|------|-------------| +| `chatId` | `string` | Chat session ID | +| `runId` | `string` | The Trigger.dev run ID | +| `chatAccessToken` | `string` | Scoped access token for this run | +| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | + +#### onChatStart + +Fires once on the first turn (turn 0) before `run()` executes. Use it to create a chat record in your database. + +The `continuation` field tells you whether this is a brand new chat or a continuation of an existing one (where the previous run timed out or was cancelled). The `preloaded` field tells you whether `onPreload` already ran. + +```ts +export const myChat = chat.task({ + id: "my-chat", + onChatStart: async ({ chatId, clientData, continuation, preloaded }) => { + if (preloaded) return; // Already set up in onPreload + if (continuation) return; // Chat record already exists + + const { userId } = clientData as { userId: string }; + await db.chat.create({ + data: { id: chatId, userId, title: "New chat" }, + }); + }, + run: async ({ messages, signal }) => { + return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }); + }, +}); +``` + + + `clientData` contains custom data from the frontend — either the `clientData` option on the transport constructor (sent with every message) or the `metadata` option on `sendMessage()` (per-message). See [Client data and metadata](/ai-chat/frontend#client-data-and-metadata). + + +#### onTurnStart + +Fires at the start of every turn, after message accumulation and `onChatStart` (turn 0), but **before** `run()` executes. Use it to persist messages before streaming begins — so a mid-stream page refresh still shows the user's message. + +| Field | Type | Description | +|-------|------|-------------| +| `chatId` | `string` | Chat session ID | +| `messages` | `ModelMessage[]` | Full accumulated conversation (model format) | +| `uiMessages` | `UIMessage[]` | Full accumulated conversation (UI format) | +| `turn` | `number` | Turn number (0-indexed) | +| `runId` | `string` | The Trigger.dev run ID | +| `chatAccessToken` | `string` | Scoped access token for this run | +| `continuation` | `boolean` | Whether this run is continuing an existing chat | +| `preloaded` | `boolean` | Whether this run was preloaded | +| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | + +```ts +export const myChat = chat.task({ + id: "my-chat", + onTurnStart: async ({ chatId, uiMessages, runId, chatAccessToken }) => { + await db.chat.update({ + where: { id: chatId }, + data: { messages: uiMessages }, + }); + await db.chatSession.upsert({ + where: { id: chatId }, + create: { id: chatId, runId, publicAccessToken: chatAccessToken }, + update: { runId, publicAccessToken: chatAccessToken }, + }); + }, + run: async ({ messages, signal }) => { + return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }); + }, +}); +``` + + + By persisting in `onTurnStart`, the user's message is saved to your database before the AI starts streaming. If the user refreshes mid-stream, the message is already there. + + +#### onTurnComplete + +Fires after each turn completes — after the response is captured, before waiting for the next message. This is the primary hook for persisting the assistant's response. + +| Field | Type | Description | +|-------|------|-------------| +| `chatId` | `string` | Chat session ID | +| `messages` | `ModelMessage[]` | Full accumulated conversation (model format) | +| `uiMessages` | `UIMessage[]` | Full accumulated conversation (UI format) | +| `newMessages` | `ModelMessage[]` | Only this turn's messages (model format) | +| `newUIMessages` | `UIMessage[]` | Only this turn's messages (UI format) | +| `responseMessage` | `UIMessage \| undefined` | The assistant's response for this turn | +| `turn` | `number` | Turn number (0-indexed) | +| `runId` | `string` | The Trigger.dev run ID | +| `chatAccessToken` | `string` | Scoped access token for this run | +| `lastEventId` | `string \| undefined` | Stream position for resumption. Persist this with the session. | +| `stopped` | `boolean` | Whether the user stopped generation during this turn | +| `continuation` | `boolean` | Whether this run is continuing an existing chat | +| `rawResponseMessage` | `UIMessage \| undefined` | The raw assistant response before abort cleanup (same as `responseMessage` when not stopped) | + +```ts +export const myChat = chat.task({ + id: "my-chat", + onTurnComplete: async ({ chatId, uiMessages, runId, chatAccessToken, lastEventId }) => { + await db.chat.update({ + where: { id: chatId }, + data: { messages: uiMessages }, + }); + await db.chatSession.upsert({ + where: { id: chatId }, + create: { id: chatId, runId, publicAccessToken: chatAccessToken, lastEventId }, + update: { runId, publicAccessToken: chatAccessToken, lastEventId }, + }); + }, + run: async ({ messages, signal }) => { + return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }); + }, +}); +``` + + + Use `uiMessages` to overwrite the full conversation each turn (simplest). Use `newUIMessages` if you prefer to store messages individually — for example, one database row per message. + + + + Persist `lastEventId` alongside the session. When the transport reconnects after a page refresh, it uses this to skip past already-seen events — preventing duplicate messages. + + +### 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.task({ + id: "my-chat", + run: async ({ messages, signal, stopSignal, cancelSignal }) => { + return streamText({ + model: openai("gpt-4o"), + messages, + abortSignal: signal, // Handles both stop and cancel + }); + }, +}); +``` + + + 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. + + +#### 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.task({ + 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: openai("gpt-4o"), 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.task({ + id: "my-chat", + run: async ({ messages, signal }) => { + return streamText({ + model: openai("gpt-4o"), + 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 + } + }, + }); + }, +}); +``` + +#### 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.task` 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`. + + + 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. + + +### Persistence + +#### What needs to be persisted + +To build a chat app that survives page refreshes, you need to persist two things: + +1. **Messages** — The conversation history. Persisted **server-side** in the task via `onTurnStart` and `onTurnComplete`. +2. **Sessions** — The transport's connection state (`runId`, `publicAccessToken`, `lastEventId`). Persisted **server-side** via `onTurnStart` and `onTurnComplete`. + + + 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. + + +#### Full persistence example + + +```ts trigger/chat.ts +import { chat } from "@trigger.dev/sdk/ai"; +import { streamText } from "ai"; +import { openai } from "@ai-sdk/openai"; +import { z } from "zod"; +import { db } from "@/lib/db"; + +export const myChat = chat.task({ + id: "my-chat", + clientDataSchema: z.object({ + userId: z.string(), + }), + onChatStart: async ({ chatId, clientData }) => { + await db.chat.create({ + data: { id: chatId, userId: clientData.userId, title: "New chat", messages: [] }, + }); + }, + onTurnStart: async ({ chatId, uiMessages, runId, chatAccessToken }) => { + // Persist messages + session before streaming + await db.chat.update({ + where: { id: chatId }, + data: { messages: uiMessages }, + }); + await db.chatSession.upsert({ + where: { id: chatId }, + create: { id: chatId, runId, publicAccessToken: chatAccessToken }, + update: { runId, publicAccessToken: chatAccessToken }, + }); + }, + onTurnComplete: async ({ chatId, uiMessages, runId, chatAccessToken, lastEventId }) => { + // Persist assistant response + stream position + await db.chat.update({ + where: { id: chatId }, + data: { messages: uiMessages }, + }); + await db.chatSession.upsert({ + where: { id: chatId }, + create: { id: chatId, runId, publicAccessToken: chatAccessToken, lastEventId }, + update: { runId, publicAccessToken: chatAccessToken, lastEventId }, + }); + }, + run: async ({ messages, signal }) => { + return streamText({ + model: openai("gpt-4o"), + messages, + abortSignal: signal, + }); + }, +}); +``` + +```ts app/actions.ts +"use server"; + +import { chat } from "@trigger.dev/sdk/ai"; +import type { myChat } from "@/trigger/chat"; +import { db } from "@/lib/db"; + +export const getChatToken = () => + chat.createAccessToken("my-chat"); + +export async function getChatMessages(chatId: string) { + const found = await db.chat.findUnique({ where: { id: chatId } }); + return found?.messages ?? []; +} + +export async function getAllSessions() { + const sessions = await db.chatSession.findMany(); + const result: Record = {}; + for (const s of sessions) { + result[s.id] = { + runId: s.runId, + publicAccessToken: s.publicAccessToken, + lastEventId: s.lastEventId ?? undefined, + }; + } + return result; +} + +export async function deleteSession(chatId: string) { + await db.chatSession.delete({ where: { id: chatId } }).catch(() => {}); +} +``` + +```tsx app/components/chat.tsx +"use client"; + +import { useChat } from "@ai-sdk/react"; +import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; +import type { myChat } from "@/trigger/chat"; +import { getChatToken, deleteSession } from "@/app/actions"; + +export function Chat({ chatId, initialMessages, initialSessions }) { + const transport = useTriggerChatTransport({ + task: "my-chat", + accessToken: getChatToken, + clientData: { userId: currentUser.id }, // Type-checked against clientDataSchema + sessions: initialSessions, + onSessionChange: (id, session) => { + if (!session) deleteSession(id); + }, + }); + + const { messages, sendMessage, stop, status } = useChat({ + id: chatId, + messages: initialMessages, + transport, + resume: initialMessages.length > 0, + }); + + return ( +
+ {messages.map((m) => ( +
+ {m.role}: + {m.parts.map((part, i) => + part.type === "text" ? {part.text} : null + )} +
+ ))} + +
{ + e.preventDefault(); + const input = e.currentTarget.querySelector("input"); + if (input?.value) { + sendMessage({ text: input.value }); + input.value = ""; + } + }} + > + + + {status === "streaming" && ( + + )} +
+
+ ); +} +``` +
+ +### 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: openai("gpt-4o"), messages, abortSignal: signal }); +}, +``` + +#### chat.setWarmTimeoutInSeconds() + +Override how long the run stays warm (active, using compute) after each turn: + +```ts +run: async ({ messages, signal }) => { + chat.setWarmTimeoutInSeconds(60); // Stay warm for 1 minute + return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }); +}, +``` + + + Longer warm 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). + + +#### 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.task({ + 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: openai("gpt-4o"), 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.task({ + 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: openai("gpt-4o"), messages, abortSignal: signal }); + }, +}); +``` + +##### 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.task()`, `chat.createSession()` / `turn.complete()`, and `chat.pipeAndCapture()`. + +See [ChatUIMessageStreamOptions](/ai-chat/reference#chatuimessagestreamoptions) for the full reference. + + + `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](#raw-task-with-primitives) for full control over `toUIMessageStream()`. + + +### 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 { openai } from "@ai-sdk/openai"; + +export const manualChat = task({ + id: "manual-chat", + retry: { maxAttempts: 3 }, + queue: { concurrencyLimit: 10 }, + run: async (payload: ChatTaskPayload) => { + const result = streamText({ + model: openai("gpt-4o"), + messages: payload.messages, + }); + + await chat.pipe(result); + }, +}); +``` + + + 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.task()` for the full multi-turn experience. + + +--- + +## chat.createSession() + +A middle ground between `chat.task()` and raw primitives. You get an async iterator that yields `ChatTurn` objects — each turn handles stop signals, message accumulation, and turn-complete signaling automatically. You control initialization, model/tool selection, persistence, and any custom per-turn logic. + +Use `chat.createSession()` inside a standard `task()`: + +```ts +import { task } from "@trigger.dev/sdk"; +import { chat, type ChatTaskWirePayload } from "@trigger.dev/sdk/ai"; +import { streamText } from "ai"; +import { openai } from "@ai-sdk/openai"; + +export const myChat = task({ + id: "my-chat", + run: async (payload: ChatTaskWirePayload, { signal }) => { + // One-time initialization — just code, no hooks + const clientData = payload.metadata as { userId: string }; + await db.chat.create({ data: { id: payload.chatId, userId: clientData.userId } }); + + const session = chat.createSession(payload, { + signal, + warmTimeoutInSeconds: 60, + timeout: "1h", + }); + + for await (const turn of session) { + const result = streamText({ + model: openai("gpt-4o"), + messages: turn.messages, + abortSignal: turn.signal, + }); + + // Pipe, capture, accumulate, and signal turn-complete — all in one call + await turn.complete(result); + + // Persist after each turn + await db.chat.update({ + where: { id: turn.chatId }, + data: { messages: turn.uiMessages }, + }); + } + }, +}); +``` + +### ChatSessionOptions + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `signal` | `AbortSignal` | required | Run-level cancel signal (from task context) | +| `warmTimeoutInSeconds` | `number` | `30` | Seconds to stay warm between turns | +| `timeout` | `string` | `"1h"` | Duration string for suspend timeout | +| `maxTurns` | `number` | `100` | Max turns before ending | + +### ChatTurn + +Each turn yielded by the iterator provides: + +| Field | Type | Description | +|-------|------|-------------| +| `number` | `number` | Turn number (0-indexed) | +| `chatId` | `string` | Chat session ID | +| `trigger` | `string` | What triggered this turn | +| `clientData` | `unknown` | Client data from the transport | +| `messages` | `ModelMessage[]` | Full accumulated model messages — pass to `streamText` | +| `uiMessages` | `UIMessage[]` | Full accumulated UI messages — use for persistence | +| `signal` | `AbortSignal` | Combined stop+cancel signal (fresh each turn) | +| `stopped` | `boolean` | Whether the user stopped generation this turn | +| `continuation` | `boolean` | Whether this is a continuation run | + +| Method | Description | +|--------|-------------| +| `turn.complete(source)` | Pipe stream, capture response, accumulate, and signal turn-complete | +| `turn.done()` | Just signal turn-complete (when you've piped manually) | +| `turn.addResponse(response)` | Add a response to the accumulator manually | + +### turn.complete() vs manual control + +`turn.complete(result)` is the easy path — it handles piping, capturing the response, accumulating messages, cleaning up aborted parts, and writing the turn-complete chunk. + +For more control, you can do each step manually: + +```ts +for await (const turn of session) { + const result = streamText({ + model: openai("gpt-4o"), + messages: turn.messages, + abortSignal: turn.signal, + }); + + // Manual: pipe and capture separately + const response = await chat.pipeAndCapture(result, { signal: turn.signal }); + + if (response) { + // Custom processing before accumulating + await turn.addResponse(response); + } + + // Custom persistence, analytics, etc. + await db.chat.update({ ... }); + + // Must call done() when not using complete() + await turn.done(); +} +``` + +--- + +## Raw task with primitives + +For full control, use a standard `task()` with the composable primitives from the `chat` namespace. You manage everything: the turn loop, stop signals, message accumulation, and turn-complete signaling. + +Raw task mode also lets you call `.toUIMessageStream()` yourself with any options — including `onFinish` and `originalMessages`. This is the right choice when you need complete control over the stream conversion beyond what `chat.setUIMessageStreamOptions()` provides. + +### Primitives + +| Primitive | Description | +|-----------|-------------| +| `chat.messages` | Input stream for incoming messages — use `.waitWithWarmup()` to wait for the next turn | +| `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream | +| `chat.pipeAndCapture(result)` | Pipe a `StreamTextResult` to the chat stream and capture the response | +| `chat.writeTurnComplete()` | Signal the frontend that the current turn is complete | +| `chat.MessageAccumulator` | Accumulates conversation messages across turns | +| `chat.pipe(stream)` | Pipe a stream to the frontend (no response capture) | +| `chat.cleanupAbortedParts(msg)` | Clean up incomplete parts from a stopped response | + +### Example + +```ts +import { task } from "@trigger.dev/sdk"; +import { chat, type ChatTaskWirePayload } from "@trigger.dev/sdk/ai"; +import { streamText } from "ai"; +import { openai } from "@ai-sdk/openai"; + +export const myChat = task({ + id: "my-chat-raw", + run: async (payload: ChatTaskWirePayload, { signal: runSignal }) => { + let currentPayload = payload; + + // Handle preload — wait for the first real message + if (currentPayload.trigger === "preload") { + const result = await chat.messages.waitWithWarmup({ + warmTimeoutInSeconds: 60, + timeout: "1h", + spanName: "waiting for first message", + }); + if (!result.ok) return; + currentPayload = result.output; + } + + const stop = chat.createStopSignal(); + const conversation = new chat.MessageAccumulator(); + + for (let turn = 0; turn < 100; turn++) { + stop.reset(); + + const messages = await conversation.addIncoming( + currentPayload.messages, + currentPayload.trigger, + turn + ); + + const combinedSignal = AbortSignal.any([runSignal, stop.signal]); + + const result = streamText({ + model: openai("gpt-4o"), + messages, + abortSignal: combinedSignal, + }); + + let response; + try { + response = await chat.pipeAndCapture(result, { signal: combinedSignal }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + if (runSignal.aborted) break; + // Stop — fall through to accumulate partial + } else { + throw error; + } + } + + if (response) { + const cleaned = stop.signal.aborted && !runSignal.aborted + ? chat.cleanupAbortedParts(response) + : response; + await conversation.addResponse(cleaned); + } + + if (runSignal.aborted) break; + + // Persist, analytics, etc. + await db.chat.update({ + where: { id: currentPayload.chatId }, + data: { messages: conversation.uiMessages }, + }); + + await chat.writeTurnComplete(); + + // Wait for the next message + const next = await chat.messages.waitWithWarmup({ + warmTimeoutInSeconds: 60, + timeout: "1h", + spanName: "waiting for next message", + }); + if (!next.ok) break; + currentPayload = next.output; + } + + stop.cleanup(); + }, +}); +``` + +### MessageAccumulator + +The `MessageAccumulator` handles the transport protocol automatically: + +- Turn 0: replaces messages (full history from frontend) +- Subsequent turns: appends new messages (frontend only sends the new user message) +- Regenerate: replaces messages (full history minus last assistant message) + +```ts +const conversation = new chat.MessageAccumulator(); + +// Returns full accumulated ModelMessage[] for streamText +const messages = await conversation.addIncoming(payload.messages, payload.trigger, turn); + +// After piping, add the response +const response = await chat.pipeAndCapture(result); +if (response) await conversation.addResponse(response); + +// Access accumulated messages for persistence +conversation.uiMessages; // UIMessage[] +conversation.modelMessages; // ModelMessage[] +``` diff --git a/docs/ai-chat/features.mdx b/docs/ai-chat/features.mdx new file mode 100644 index 00000000000..fd4b63789a1 --- /dev/null +++ b/docs/ai-chat/features.mdx @@ -0,0 +1,421 @@ +--- +title: "Features" +sidebarTitle: "Features" +description: "Per-run data, deferred work, custom streaming, subtask integration, and preload." +--- + +## Per-run data with chat.local + +Use `chat.local` to create typed, run-scoped data that persists across turns and is accessible from anywhere — the run function, tools, nested helpers. Each run gets its own isolated copy, and locals are automatically cleared between runs. + +When a subtask is invoked via `ai.tool()`, initialized locals are automatically serialized into the subtask's metadata and hydrated on first access — no extra code needed. Subtask changes to hydrated locals are local to the subtask and don't propagate back to the parent. + +### Declaring and initializing + +Declare locals at module level with a unique `id`, then initialize them inside a lifecycle hook where you have context (chatId, clientData, etc.): + +```ts +import { chat } from "@trigger.dev/sdk/ai"; +import { streamText, tool } from "ai"; +import { openai } from "@ai-sdk/openai"; +import { z } from "zod"; +import { db } from "@/lib/db"; + +// Declare at module level — each local needs a unique id +const userContext = chat.local<{ + name: string; + plan: "free" | "pro"; + messageCount: number; +}>({ id: "userContext" }); + +export const myChat = chat.task({ + id: "my-chat", + clientDataSchema: z.object({ userId: z.string() }), + onChatStart: async ({ clientData }) => { + // Initialize with real data from your database + const user = await db.user.findUnique({ + where: { id: clientData.userId }, + }); + userContext.init({ + name: user.name, + plan: user.plan, + messageCount: user.messageCount, + }); + }, + run: async ({ messages, signal }) => { + userContext.messageCount++; + + return streamText({ + model: openai("gpt-4o"), + system: `Helping ${userContext.name} (${userContext.plan} plan).`, + messages, + abortSignal: signal, + }); + }, +}); +``` + +### Accessing from tools + +Locals are accessible from anywhere during task execution — including AI SDK tools: + +```ts +const userContext = chat.local<{ plan: "free" | "pro" }>({ id: "userContext" }); + +const premiumTool = tool({ + description: "Access premium features", + inputSchema: z.object({ feature: z.string() }), + execute: async ({ feature }) => { + if (userContext.plan !== "pro") { + return { error: "This feature requires a Pro plan." }; + } + // ... premium logic + }, +}); +``` + +### Accessing from subtasks + +When you use `ai.tool()` to expose a subtask, chat locals are automatically available read-only: + +```ts +import { chat, ai } from "@trigger.dev/sdk/ai"; +import { schemaTask } from "@trigger.dev/sdk"; +import { streamText } from "ai"; +import { openai } from "@ai-sdk/openai"; +import { z } from "zod"; + +const userContext = chat.local<{ name: string; plan: "free" | "pro" }>({ id: "userContext" }); + +export const analyzeData = schemaTask({ + id: "analyze-data", + schema: z.object({ query: z.string() }), + run: async ({ query }) => { + // userContext.name just works — auto-hydrated from parent metadata + console.log(`Analyzing for ${userContext.name}`); + // Changes here are local to this subtask and don't propagate back + }, +}); + +export const myChat = chat.task({ + id: "my-chat", + onChatStart: async ({ clientData }) => { + userContext.init({ name: "Alice", plan: "pro" }); + }, + run: async ({ messages, signal }) => { + return streamText({ + model: openai("gpt-4o"), + messages, + tools: { analyzeData: ai.tool(analyzeData) }, + abortSignal: signal, + }); + }, +}); +``` + + + Values must be JSON-serializable for subtask access. Non-serializable values (functions, class instances, etc.) will be lost during transfer. + + +### Dirty tracking and persistence + +The `hasChanged()` method returns `true` if any property was set since the last check, then resets the flag. Use it in lifecycle hooks to only persist when data actually changed: + +```ts +onTurnComplete: async ({ chatId }) => { + if (userContext.hasChanged()) { + await db.user.update({ + where: { id: userContext.get().userId }, + data: { + messageCount: userContext.messageCount, + }, + }); + } +}, +``` + +### chat.local API + +| Method | Description | +|--------|-------------| +| `chat.local({ id })` | Create a typed local with a unique id (declare at module level) | +| `local.init(value)` | Initialize with a value (call in hooks or `run`) | +| `local.hasChanged()` | Returns `true` if modified since last check, resets flag | +| `local.get()` | Returns a plain object copy (for serialization) | +| `local.property` | Direct property access (read/write via Proxy) | + + + Locals use shallow proxying. Nested object mutations like `local.prefs.theme = "dark"` won't trigger the dirty flag. Instead, replace the whole property: `local.prefs = { ...local.prefs, theme: "dark" }`. + + +--- + +## chat.defer() + +Use `chat.defer()` to run background work in parallel with streaming. The deferred promise runs alongside the LLM response and is awaited (with a 5s timeout) before `onTurnComplete` fires. + +This moves non-blocking work (DB writes, analytics, etc.) out of the critical path: + +```ts +export const myChat = chat.task({ + id: "my-chat", + onTurnStart: async ({ chatId, uiMessages }) => { + // Persist messages without blocking the LLM call + chat.defer(db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } })); + }, + run: async ({ messages, signal }) => { + return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }); + }, +}); +``` + +`chat.defer()` can be called from anywhere during a turn — hooks, `run()`, or nested helpers. All deferred promises are collected and awaited together before `onTurnComplete`. + +--- + +## Custom streaming with chat.stream + +`chat.stream` is a typed stream bound to the chat output. Use it to write custom `UIMessageChunk` data alongside the AI-generated response — for example, status updates or progress indicators. + +```ts +import { chat } from "@trigger.dev/sdk/ai"; + +export const myChat = chat.task({ + id: "my-chat", + run: async ({ messages, signal }) => { + // Write a custom data part to the chat stream. + // The AI SDK's data-* chunk protocol adds this to message.parts + // on the frontend, where you can render it however you like. + const { waitUntilComplete } = chat.stream.writer({ + execute: ({ write }) => { + write({ + type: "data-status", + id: "search-progress", + data: { message: "Searching the web...", progress: 0.5 }, + }); + }, + }); + await waitUntilComplete(); + + // Then stream the AI response + return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }); + }, +}); +``` + + + 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. + + +`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 | + +### Streaming from subtasks + +When a tool invokes a subtask via `triggerAndWait`, the subtask can stream directly to the parent chat using `target: "root"`: + +```ts +import { chat, ai } from "@trigger.dev/sdk/ai"; +import { schemaTask } from "@trigger.dev/sdk"; +import { streamText, generateId } from "ai"; +import { z } from "zod"; + +// A subtask that streams progress back to the parent chat +export const researchTask = schemaTask({ + id: "research", + schema: z.object({ query: z.string() }), + run: async ({ query }) => { + const partId = generateId(); + + // Write a data-* chunk to the root run's chat stream. + // The frontend receives this as a DataUIPart in message.parts. + const { waitUntilComplete } = chat.stream.writer({ + target: "root", + execute: ({ write }) => { + write({ + type: "data-research-status", + id: partId, + data: { query, status: "in-progress" }, + }); + }, + }); + await waitUntilComplete(); + + // Do the work... + const result = await doResearch(query); + + // Update the same part with the final status + const { waitUntilComplete: waitDone } = chat.stream.writer({ + target: "root", + execute: ({ write }) => { + write({ + type: "data-research-status", + id: partId, + data: { query, status: "done", resultCount: result.length }, + }); + }, + }); + await waitDone(); + + return result; + }, +}); + +// The chat task uses it as a tool via ai.tool() +export const myChat = chat.task({ + id: "my-chat", + run: async ({ messages, signal }) => { + return streamText({ + model: openai("gpt-4o"), + messages, + abortSignal: signal, + tools: { + research: ai.tool(researchTask), + }, + }); + }, +}); +``` + +On the frontend, render the custom data part: + +```tsx +{message.parts.map((part, i) => { + if (part.type === "data-research-status") { + const { query, status, resultCount } = part.data; + return ( +
+ {status === "done" ? `Found ${resultCount} results` : `Researching "${query}"...`} +
+ ); + } + // ...other part types +})} +``` + +The `target` option accepts: +- `"self"` — current run (default) +- `"parent"` — parent task's run +- `"root"` — root task's run (the chat task) +- A specific run ID string + +--- + +## ai.tool() — subtask integration + +When a subtask runs via `ai.tool()`, it can access the tool call context and chat context from the parent: + +```ts +import { ai, chat } from "@trigger.dev/sdk/ai"; +import type { myChat } from "./chat"; + +export const mySubtask = schemaTask({ + id: "my-subtask", + schema: z.object({ query: z.string() }), + run: async ({ query }) => { + // Get the AI SDK's tool call ID (useful for data-* chunk IDs) + const toolCallId = ai.toolCallId(); + + // Get typed chat context — pass typeof yourChatTask for typed clientData + const { chatId, clientData } = ai.chatContextOrThrow(); + // clientData is typed based on myChat's clientDataSchema + + // Write a data chunk using the tool call ID + const { waitUntilComplete } = chat.stream.writer({ + target: "root", + execute: ({ write }) => { + write({ + type: "data-progress", + id: toolCallId, + data: { status: "working", query, userId: clientData?.userId }, + }); + }, + }); + await waitUntilComplete(); + + return { result: "done" }; + }, +}); +``` + +| Helper | Returns | Description | +|--------|---------|-------------| +| `ai.toolCallId()` | `string \| undefined` | The AI SDK tool call ID | +| `ai.chatContext()` | `{ chatId, turn, continuation, clientData } \| undefined` | Chat context with typed `clientData`. Returns `undefined` if not in a chat context. | +| `ai.chatContextOrThrow()` | `{ chatId, turn, continuation, clientData }` | Same as above but throws if not in a chat context | +| `ai.currentToolOptions()` | `ToolCallExecutionOptions \| undefined` | Full tool execution options | + +--- + +## Preload + +Preload eagerly triggers a run for a chat before the first message is sent. This allows initialization (DB setup, context loading) to happen while the user is still typing, reducing first-response latency. + +### Frontend + +Call `transport.preload(chatId)` to start a run early: + +```tsx +import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; +import { useChat } from "@ai-sdk/react"; + +export function Chat({ chatId }) { + const transport = useTriggerChatTransport({ + task: "my-chat", + accessToken: getChatToken, + clientData: { userId: currentUser.id }, + }); + + // Preload on mount — run starts before the user types anything + useEffect(() => { + transport.preload(chatId, { warmTimeoutInSeconds: 60 }); + }, [chatId]); + + const { messages, sendMessage } = useChat({ id: chatId, transport }); + // ... +} +``` + +Preload is a no-op if a session already exists for this chatId. + +### Backend + +On the backend, the `onPreload` hook fires immediately. The run then waits for the first message. When the user sends a message, `onChatStart` fires with `preloaded: true` — you can skip initialization that was already done in `onPreload`: + +```ts +export const myChat = chat.task({ + id: "my-chat", + onPreload: async ({ chatId, clientData }) => { + // Eagerly initialize — runs before the first message + userContext.init(await loadUser(clientData.userId)); + await db.chat.create({ data: { id: chatId } }); + }, + onChatStart: async ({ preloaded }) => { + if (preloaded) return; // Already initialized in onPreload + // ... fallback initialization for non-preloaded runs + }, + run: async ({ messages, signal }) => { + return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }); + }, +}); +``` + +With `chat.createSession()` or raw tasks, check `payload.trigger === "preload"` and wait for the first message: + +```ts +if (payload.trigger === "preload") { + // Initialize early... + const result = await chat.messages.waitWithWarmup({ + warmTimeoutInSeconds: 60, + timeout: "1h", + }); + if (!result.ok) return; + currentPayload = result.output; +} +``` diff --git a/docs/ai-chat/frontend.mdx b/docs/ai-chat/frontend.mdx new file mode 100644 index 00000000000..0e7854e4d5d --- /dev/null +++ b/docs/ai-chat/frontend.mdx @@ -0,0 +1,234 @@ +--- +title: "Frontend" +sidebarTitle: "Frontend" +description: "Transport setup, session management, client data, and frontend patterns for AI Chat." +--- + +## Transport setup + +Use the `useTriggerChatTransport` hook from `@trigger.dev/sdk/chat/react` to create a memoized transport instance, then pass it to `useChat`: + +```tsx +import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; +import { useChat } from "@ai-sdk/react"; +import type { myChat } from "@/trigger/chat"; +import { getChatToken } from "@/app/actions"; + +export function Chat() { + const transport = useTriggerChatTransport({ + task: "my-chat", + accessToken: getChatToken, + }); + + const { messages, sendMessage, stop, status } = useChat({ transport }); + // ... render UI +} +``` + +The transport is created once on first render and reused across re-renders. Pass a type parameter for compile-time validation of the task ID. + + + The hook keeps `onSessionChange` up to date via a ref internally, so you don't need to memoize the callback or worry about stale closures. + + +### Dynamic access tokens + +For token refresh, pass a function instead of a string. It's called on each `sendMessage`: + +```ts +const transport = useTriggerChatTransport({ + task: "my-chat", + accessToken: async () => { + const res = await fetch("/api/chat-token"); + return res.text(); + }, +}); +``` + +## Session management + +### Session cleanup (frontend) + +Since session creation and updates are handled server-side, the frontend only needs to handle session deletion when a run ends: + +```tsx +const transport = useTriggerChatTransport({ + task: "my-chat", + accessToken: getChatToken, + sessions: loadedSessions, // Restored from DB on page load + onSessionChange: (chatId, session) => { + if (!session) { + deleteSession(chatId); // Server action — run ended + } + }, +}); +``` + +### Restoring on page load + +On page load, fetch both the messages and the session from your database, then pass them to `useChat` and the transport. Pass `resume: true` to `useChat` when there's an existing conversation — this tells the AI SDK to reconnect to the stream via the transport. + +```tsx app/page.tsx +"use client"; + +import { useEffect, useState } from "react"; +import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; +import { useChat } from "@ai-sdk/react"; +import { getChatToken, getChatMessages, getSession, deleteSession } from "@/app/actions"; + +export default function ChatPage({ chatId }: { chatId: string }) { + const [initialMessages, setInitialMessages] = useState([]); + const [initialSession, setInitialSession] = useState(undefined); + const [loaded, setLoaded] = useState(false); + + useEffect(() => { + async function load() { + const [messages, session] = await Promise.all([ + getChatMessages(chatId), + getSession(chatId), + ]); + setInitialMessages(messages); + setInitialSession(session ? { [chatId]: session } : undefined); + setLoaded(true); + } + load(); + }, [chatId]); + + if (!loaded) return null; + + return ( + + ); +} + +function ChatClient({ chatId, initialMessages, initialSessions }) { + const transport = useTriggerChatTransport({ + task: "my-chat", + accessToken: getChatToken, + sessions: initialSessions, + onSessionChange: (id, session) => { + if (!session) deleteSession(id); + }, + }); + + const { messages, sendMessage, stop, status } = useChat({ + id: chatId, + messages: initialMessages, + transport, + resume: initialMessages.length > 0, // Resume if there's an existing conversation + }); + + // ... render UI +} +``` + + + `resume: true` causes `useChat` to call `reconnectToStream` on the transport when the component mounts. The transport uses the session's `lastEventId` to skip past already-seen stream events, so the frontend only receives new data. Only enable `resume` when there are existing messages — for brand new chats, there's nothing to reconnect to. + + + + In React strict mode (enabled by default in Next.js dev), you may see a `TypeError: Cannot read properties of undefined (reading 'state')` in the console when using `resume`. This is a [known bug in the AI SDK](https://github.com/vercel/ai/issues/8477) caused by React strict mode double-firing the resume effect. The error is caught internally and **does not affect functionality** — streaming and message display work correctly. It only appears in development and will not occur in production builds. + + +## Client data and metadata + +### Transport-level client data + +Set default client data on the transport that's included in every request. When the task uses `clientDataSchema`, this is type-checked to match: + +```ts +const transport = useTriggerChatTransport({ + task: "my-chat", + accessToken: getChatToken, + clientData: { userId: currentUser.id }, +}); +``` + +### Per-message metadata + +Pass metadata with individual messages via `sendMessage`. Per-message values are merged with transport-level client data (per-message wins on conflicts): + +```ts +sendMessage( + { text: "Hello" }, + { metadata: { model: "gpt-4o", priority: "high" } } +); +``` + +### Typed client data with clientDataSchema + +Instead of manually parsing `clientData` with Zod in every hook, pass a `clientDataSchema` to `chat.task`. The schema validates the data once per turn, and `clientData` is typed in all hooks and `run`: + +```ts +import { chat } from "@trigger.dev/sdk/ai"; +import { streamText } from "ai"; +import { openai } from "@ai-sdk/openai"; +import { z } from "zod"; + +export const myChat = chat.task({ + id: "my-chat", + clientDataSchema: z.object({ + model: z.string().optional(), + userId: z.string(), + }), + onChatStart: async ({ chatId, clientData }) => { + // clientData is typed as { model?: string; userId: string } + await db.chat.create({ + data: { id: chatId, userId: clientData.userId }, + }); + }, + run: async ({ messages, clientData, signal }) => { + // Same typed clientData — no manual parsing needed + return streamText({ + model: openai(clientData?.model ?? "gpt-4o"), + messages, + abortSignal: signal, + }); + }, +}); +``` + +The schema also types the `clientData` option on the frontend transport: + +```ts +// TypeScript enforces that clientData matches the schema +const transport = useTriggerChatTransport({ + task: "my-chat", + accessToken: getChatToken, + clientData: { userId: currentUser.id }, +}); +``` + +Supports Zod, ArkType, Valibot, and other schema libraries supported by the SDK. + +## Stop generation + +Calling `stop()` from `useChat` sends a stop signal to the running task via input streams. The task aborts the current `streamText` call, but the run stays alive for the next message: + +```tsx +const { messages, sendMessage, stop, status } = useChat({ transport }); + +{status === "streaming" && ( + +)} +``` + +See [Stop generation](/ai-chat/backend#stop-generation) in the backend docs for how to handle stop signals in your task. + +## Self-hosting + +If you're self-hosting Trigger.dev, pass the `baseURL` option: + +```ts +const transport = useTriggerChatTransport({ + task: "my-chat", + accessToken, + baseURL: "https://your-trigger-instance.com", +}); +``` diff --git a/docs/ai-chat/overview.mdx b/docs/ai-chat/overview.mdx new file mode 100644 index 00000000000..eb3d1ab23df --- /dev/null +++ b/docs/ai-chat/overview.mdx @@ -0,0 +1,161 @@ +--- +title: "AI Chat" +sidebarTitle: "Overview" +description: "Run AI SDK chat completions as durable Trigger.dev tasks with built-in realtime streaming, multi-turn conversations, and message persistence." +--- + +## Overview + +The `@trigger.dev/sdk` provides a custom [ChatTransport](https://sdk.vercel.ai/docs/ai-sdk-ui/transport) for the Vercel AI SDK's `useChat` hook. This lets you run chat completions as **durable Trigger.dev tasks** instead of fragile API routes — with automatic retries, observability, and realtime streaming built in. + +**How it works:** +1. The frontend sends messages via `useChat` through `TriggerChatTransport` +2. The first message triggers a Trigger.dev task; subsequent messages resume the **same run** via input streams +3. The task streams `UIMessageChunk` events back via Trigger.dev's realtime streams +4. The AI SDK's `useChat` processes the stream natively — text, tool calls, reasoning, etc. +5. Between turns, the run stays warm briefly then suspends (freeing compute) until the next message + +No custom API routes needed. Your chat backend is a Trigger.dev task. + + + +### First message flow + +```mermaid +sequenceDiagram + participant User + participant useChat as useChat + Transport + participant API as Trigger.dev API + participant Task as chat.task Worker + participant LLM as LLM Provider + + User->>useChat: sendMessage("Hello") + useChat->>useChat: No session for chatId → trigger new run + useChat->>API: triggerTask(payload, tags: [chat:id]) + API-->>useChat: { runId, publicAccessToken } + useChat->>useChat: Store session, subscribe to SSE + + API->>Task: Start run with ChatTaskWirePayload + Task->>Task: onChatStart({ chatId, messages, clientData }) + Task->>Task: onTurnStart({ chatId, messages }) + Task->>LLM: streamText({ model, messages, abortSignal }) + LLM-->>Task: Stream response chunks + Task->>API: streams.pipe("chat", uiStream) + API-->>useChat: SSE: UIMessageChunks + useChat-->>User: Render streaming text + Task->>API: Write __trigger_turn_complete + API-->>useChat: SSE: turn complete + refreshed token + useChat->>useChat: Close stream, update session + Task->>Task: onTurnComplete({ messages, stopped: false }) + Task->>Task: Wait for next message (warm → suspend) +``` + +### Multi-turn flow + +```mermaid +sequenceDiagram + participant User + participant useChat as useChat + Transport + participant API as Trigger.dev API + participant Task as chat.task Worker + participant LLM as LLM Provider + + Note over Task: Suspended, waiting for message + + User->>useChat: sendMessage("Tell me more") + useChat->>useChat: Session exists → send via input stream + useChat->>API: sendInputStream(runId, "chat-messages", payload) + Note right of useChat: Only sends new message (not full history) + + API->>Task: Deliver to messagesInput + Task->>Task: Wake from suspend + Task->>Task: Append to accumulated messages + Task->>Task: onTurnStart({ turn: 1 }) + Task->>LLM: streamText({ messages: [all accumulated] }) + LLM-->>Task: Stream response + Task->>API: streams.pipe("chat", uiStream) + API-->>useChat: SSE: UIMessageChunks + useChat-->>User: Render streaming text + Task->>API: Write __trigger_turn_complete + Task->>Task: onTurnComplete({ turn: 1 }) + Task->>Task: Wait for next message (warm → suspend) +``` + +### Stop signal flow + +```mermaid +sequenceDiagram + participant User + participant useChat as useChat + Transport + participant API as Trigger.dev API + participant Task as chat.task Worker + participant LLM as LLM Provider + + Note over Task: Streaming response... + + User->>useChat: Click "Stop" + useChat->>API: sendInputStream(runId, "chat-stop", { stop: true }) + API->>Task: Deliver to stopInput + Task->>Task: stopController.abort() + LLM-->>Task: Stream ends (AbortError) + Task->>Task: cleanupAbortedParts(responseMessage) + Note right of Task: Remove partial tool calls,
mark streaming parts as done + Task->>API: Write __trigger_turn_complete + API-->>useChat: SSE: turn complete + Task->>Task: onTurnComplete({ stopped: true }) + Task->>Task: Wait for next message +``` + +
+ + + Requires `@trigger.dev/sdk` version **4.4.0 or later** and the `ai` package **v5.0.0 or later**. + + +## How multi-turn works + +### One run, many turns + +The entire conversation lives in a **single Trigger.dev run**. After each AI response, the run waits for the next message via input streams. The frontend transport handles this automatically — it triggers a new run for the first message, and sends subsequent messages to the existing run. + +This means your conversation has full observability in the Trigger.dev dashboard: every turn is a span inside the same run. + +### Warm and suspended states + +After each turn, the run goes through two phases of waiting: + +1. **Warm phase** (default 30s) — The run stays active and responds instantly to the next message. Uses compute. +2. **Suspended phase** (default up to 1h) — The run suspends, freeing compute. It wakes when the next message arrives. There's a brief delay as the run resumes. + +If no message arrives within the turn timeout, the run ends gracefully. The next message from the frontend will automatically start a fresh run. + + + You are not charged for compute during the suspended phase. Only the warm phase uses compute resources. + + +### What the backend accumulates + +The backend automatically accumulates the full conversation history across turns. After the first turn, the frontend transport only sends the new user message — not the entire history. This is handled transparently by the transport and task. + +The accumulated messages are available in: +- `run()` as `messages` (`ModelMessage[]`) — for passing to `streamText` +- `onTurnStart()` as `uiMessages` (`UIMessage[]`) — for persisting before streaming +- `onTurnComplete()` as `uiMessages` (`UIMessage[]`) — for persisting after the response + +## Three approaches + +There are three ways to build the backend, from most opinionated to most flexible: + +| Approach | Use when | What you get | +|----------|----------|--------------| +| [chat.task()](/ai-chat/backend#chattask) | Most apps | Auto-piping, lifecycle hooks, message accumulation, stop handling | +| [chat.createSession()](/ai-chat/backend#chatcreatesession) | Need a loop but not hooks | Async iterator with per-turn helpers, message accumulation, stop handling | +| [Raw task + primitives](/ai-chat/backend#raw-task-with-primitives) | Full control | Manual control of every step — use `chat.messages`, `chat.createStopSignal()`, etc. | + +## Related + +- [Quick Start](/ai-chat/quick-start) — Get a working chat in 3 steps +- [Backend](/ai-chat/backend) — Backend approaches in detail +- [Frontend](/ai-chat/frontend) — Transport setup, sessions, client data +- [Features](/ai-chat/features) — Per-run data, deferred work, streaming, subtasks +- [API Reference](/ai-chat/reference) — Complete reference tables diff --git a/docs/ai-chat/quick-start.mdx b/docs/ai-chat/quick-start.mdx new file mode 100644 index 00000000000..b8245d92372 --- /dev/null +++ b/docs/ai-chat/quick-start.mdx @@ -0,0 +1,108 @@ +--- +title: "Quick Start" +sidebarTitle: "Quick Start" +description: "Get a working AI chat in 3 steps — define a task, generate a token, and wire up the frontend." +--- + + + + Use `chat.task` from `@trigger.dev/sdk/ai` to define a task that handles chat messages. The `run` function receives `ModelMessage[]` (already converted from the frontend's `UIMessage[]`) — pass them directly to `streamText`. + + If you return a `StreamTextResult`, it's **automatically piped** to the frontend. + + ```ts trigger/chat.ts + import { chat } from "@trigger.dev/sdk/ai"; + import { streamText } from "ai"; + import { openai } from "@ai-sdk/openai"; + + export const myChat = chat.task({ + id: "my-chat", + run: async ({ messages, signal }) => { + // messages is ModelMessage[] — pass directly to streamText + // signal fires on stop or run cancel + return streamText({ + model: openai("gpt-4o"), + messages, + abortSignal: signal, + }); + }, + }); + ``` + + + + On your server (e.g. a Next.js server action), create a trigger public token scoped to your chat task: + + ```ts app/actions.ts + "use server"; + + import { chat } from "@trigger.dev/sdk/ai"; + import type { myChat } from "@/trigger/chat"; + + export const getChatToken = () => + chat.createAccessToken("my-chat"); + ``` + + + + Use the `useTriggerChatTransport` hook from `@trigger.dev/sdk/chat/react` to create a memoized transport instance, then pass it to `useChat`: + + ```tsx app/components/chat.tsx + "use client"; + + import { useChat } from "@ai-sdk/react"; + import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; + import type { myChat } from "@/trigger/chat"; + import { getChatToken } from "@/app/actions"; + + export function Chat() { + const transport = useTriggerChatTransport({ + task: "my-chat", + accessToken: getChatToken, + }); + + const { messages, sendMessage, stop, status } = useChat({ transport }); + + return ( +
+ {messages.map((m) => ( +
+ {m.role}: + {m.parts.map((part, i) => + part.type === "text" ? {part.text} : null + )} +
+ ))} + +
{ + e.preventDefault(); + const input = e.currentTarget.querySelector("input"); + if (input?.value) { + sendMessage({ text: input.value }); + input.value = ""; + } + }} + > + + + {status === "streaming" && ( + + )} +
+
+ ); + } + ``` +
+
+ +## Next steps + +- [Backend](/ai-chat/backend) — Lifecycle hooks, persistence, session iterator, raw task primitives +- [Frontend](/ai-chat/frontend) — Session management, client data, reconnection +- [Features](/ai-chat/features) — Per-run data, deferred work, streaming, subtasks diff --git a/docs/ai-chat/reference.mdx b/docs/ai-chat/reference.mdx new file mode 100644 index 00000000000..420decee98b --- /dev/null +++ b/docs/ai-chat/reference.mdx @@ -0,0 +1,257 @@ +--- +title: "API Reference" +sidebarTitle: "API Reference" +description: "Complete API reference for the AI Chat SDK — backend options, events, frontend transport, and hooks." +--- + +## ChatTaskOptions + +Options for `chat.task()`. + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `id` | `string` | required | Task identifier | +| `run` | `(payload: ChatTaskRunPayload) => Promise` | required | Handler for each turn | +| `clientDataSchema` | `TaskSchema` | — | Schema for validating and typing `clientData` | +| `onPreload` | `(event: PreloadEvent) => Promise \| void` | — | Fires on preloaded runs before the first message | +| `onChatStart` | `(event: ChatStartEvent) => Promise \| void` | — | Fires on turn 0 before `run()` | +| `onTurnStart` | `(event: TurnStartEvent) => Promise \| void` | — | Fires every turn before `run()` | +| `onTurnComplete` | `(event: TurnCompleteEvent) => Promise \| void` | — | Fires after each turn completes | +| `maxTurns` | `number` | `100` | Max conversational turns per run | +| `turnTimeout` | `string` | `"1h"` | How long to wait for next message | +| `warmTimeoutInSeconds` | `number` | `30` | Seconds to stay warm before suspending | +| `chatAccessTokenTTL` | `string` | `"1h"` | How long the scoped access token remains valid | +| `preloadWarmTimeoutInSeconds` | `number` | Same as `warmTimeoutInSeconds` | Warm timeout after `onPreload` fires | +| `preloadTimeout` | `string` | Same as `turnTimeout` | Suspend timeout for preloaded runs | +| `uiMessageStreamOptions` | `ChatUIMessageStreamOptions` | — | Default options for `toUIMessageStream()`. Per-turn override via `chat.setUIMessageStreamOptions()` | + +Plus all standard [TaskOptions](/tasks/overview) — `retry`, `queue`, `machine`, `maxDuration`, etc. + +## ChatTaskRunPayload + +The payload passed to the `run` function. + +| Field | Type | Description | +|-------|------|-------------| +| `messages` | `ModelMessage[]` | Model-ready messages — pass directly to `streamText` | +| `chatId` | `string` | Unique chat session ID | +| `trigger` | `"submit-message" \| "regenerate-message"` | What triggered the request | +| `messageId` | `string \| undefined` | Message ID (for regenerate) | +| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend (typed when schema is provided) | +| `continuation` | `boolean` | Whether this run is continuing an existing chat (previous run ended) | +| `signal` | `AbortSignal` | Combined stop + cancel signal | +| `cancelSignal` | `AbortSignal` | Cancel-only signal | +| `stopSignal` | `AbortSignal` | Stop-only signal (per-turn) | + +## PreloadEvent + +Passed to the `onPreload` callback. + +| Field | Type | Description | +|-------|------|-------------| +| `chatId` | `string` | Chat session ID | +| `runId` | `string` | The Trigger.dev run ID | +| `chatAccessToken` | `string` | Scoped access token for this run | +| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | + +## ChatStartEvent + +Passed to the `onChatStart` callback. + +| Field | Type | Description | +|-------|------|-------------| +| `chatId` | `string` | Chat session ID | +| `messages` | `ModelMessage[]` | Initial model-ready messages | +| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | +| `runId` | `string` | The Trigger.dev run ID | +| `chatAccessToken` | `string` | Scoped access token for this run | +| `continuation` | `boolean` | Whether this run is continuing an existing chat | +| `previousRunId` | `string \| undefined` | Previous run ID (only when `continuation` is true) | +| `preloaded` | `boolean` | Whether this run was preloaded before the first message | + +## TurnStartEvent + +Passed to the `onTurnStart` callback. + +| Field | Type | Description | +|-------|------|-------------| +| `chatId` | `string` | Chat session ID | +| `messages` | `ModelMessage[]` | Full accumulated conversation (model format) | +| `uiMessages` | `UIMessage[]` | Full accumulated conversation (UI format) | +| `turn` | `number` | Turn number (0-indexed) | +| `runId` | `string` | The Trigger.dev run ID | +| `chatAccessToken` | `string` | Scoped access token for this run | +| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | +| `continuation` | `boolean` | Whether this run is continuing an existing chat | +| `previousRunId` | `string \| undefined` | Previous run ID (only when `continuation` is true) | +| `preloaded` | `boolean` | Whether this run was preloaded | + +## TurnCompleteEvent + +Passed to the `onTurnComplete` callback. + +| Field | Type | Description | +|-------|------|-------------| +| `chatId` | `string` | Chat session ID | +| `messages` | `ModelMessage[]` | Full accumulated conversation (model format) | +| `uiMessages` | `UIMessage[]` | Full accumulated conversation (UI format) | +| `newMessages` | `ModelMessage[]` | Only this turn's messages (model format) | +| `newUIMessages` | `UIMessage[]` | Only this turn's messages (UI format) | +| `responseMessage` | `UIMessage \| undefined` | The assistant's response for this turn | +| `rawResponseMessage` | `UIMessage \| undefined` | Raw response before abort cleanup | +| `turn` | `number` | Turn number (0-indexed) | +| `runId` | `string` | The Trigger.dev run ID | +| `chatAccessToken` | `string` | Scoped access token for this run | +| `lastEventId` | `string \| undefined` | Stream position for resumption | +| `stopped` | `boolean` | Whether the user stopped generation during this turn | +| `continuation` | `boolean` | Whether this run is continuing an existing chat | + +## ChatSessionOptions + +Options for `chat.createSession()`. + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `signal` | `AbortSignal` | required | Run-level cancel signal | +| `warmTimeoutInSeconds` | `number` | `30` | Seconds to stay warm between turns | +| `timeout` | `string` | `"1h"` | Duration string for suspend timeout | +| `maxTurns` | `number` | `100` | Max turns before ending | + +## ChatTurn + +Each turn yielded by `chat.createSession()`. + +| Field | Type | Description | +|-------|------|-------------| +| `number` | `number` | Turn number (0-indexed) | +| `chatId` | `string` | Chat session ID | +| `trigger` | `string` | What triggered this turn | +| `clientData` | `unknown` | Client data from the transport | +| `messages` | `ModelMessage[]` | Full accumulated model messages | +| `uiMessages` | `UIMessage[]` | Full accumulated UI messages | +| `signal` | `AbortSignal` | Combined stop+cancel signal (fresh each turn) | +| `stopped` | `boolean` | Whether the user stopped generation this turn | +| `continuation` | `boolean` | Whether this is a continuation run | + +| Method | Returns | Description | +|--------|---------|-------------| +| `complete(source)` | `Promise` | Pipe, capture, accumulate, cleanup, and signal turn-complete | +| `done()` | `Promise` | Signal turn-complete (when you've piped manually) | +| `addResponse(response)` | `Promise` | Add response to accumulator manually | + +## chat namespace + +All methods available on the `chat` object from `@trigger.dev/sdk/ai`. + +| Method | Description | +|--------|-------------| +| `chat.task(options)` | Create a chat task | +| `chat.createSession(payload, options)` | Create an async iterator for chat turns | +| `chat.pipe(source, options?)` | Pipe a stream to the frontend (from anywhere inside a task) | +| `chat.pipeAndCapture(source, options?)` | Pipe and capture the response `UIMessage` | +| `chat.writeTurnComplete(options?)` | Signal the frontend that the current turn is complete | +| `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream | +| `chat.messages` | Input stream for incoming messages — use `.waitWithWarmup()` | +| `chat.local({ id })` | Create a per-run typed local (see [Per-run data](/ai-chat/features#per-run-data-with-chatlocal)) | +| `chat.createAccessToken(taskId)` | Create a public access token for a chat task | +| `chat.setTurnTimeout(duration)` | Override turn timeout at runtime (e.g. `"2h"`) | +| `chat.setTurnTimeoutInSeconds(seconds)` | Override turn timeout at runtime (in seconds) | +| `chat.setWarmTimeoutInSeconds(seconds)` | Override warm timeout at runtime | +| `chat.setUIMessageStreamOptions(options)` | Override `toUIMessageStream()` options for the current turn | +| `chat.defer(promise)` | Run background work in parallel with streaming, awaited before `onTurnComplete` | +| `chat.isStopped()` | Check if the current turn was stopped by the user | +| `chat.cleanupAbortedParts(message)` | Remove incomplete parts from a stopped response message | +| `chat.stream` | Typed chat output stream — use `.writer()`, `.pipe()`, `.append()`, `.read()` | +| `chat.MessageAccumulator` | Class that accumulates conversation messages across turns | + +## ChatUIMessageStreamOptions + +Options for customizing `toUIMessageStream()`. Set as static defaults via `uiMessageStreamOptions` on `chat.task()`, or override per-turn via `chat.setUIMessageStreamOptions()`. See [Stream options](/ai-chat/backend#stream-options) for usage examples. + +Derived from the AI SDK's `UIMessageStreamOptions` with `onFinish`, `originalMessages`, and `generateMessageId` omitted (managed internally). + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `onError` | `(error: unknown) => string` | Raw error message | Called on LLM errors and tool execution errors. Return a sanitized string — sent as `{ type: "error", errorText }` to the frontend. | +| `sendReasoning` | `boolean` | `true` | Send reasoning parts to the client | +| `sendSources` | `boolean` | `false` | Send source parts to the client | +| `sendFinish` | `boolean` | `true` | Send the finish event. Set to `false` when chaining multiple `streamText` calls. | +| `sendStart` | `boolean` | `true` | Send the message start event. Set to `false` when chaining. | +| `messageMetadata` | `(options: { part }) => metadata` | — | Extract message metadata to send to the client. Called on `start` and `finish` events. | + +## TriggerChatTransport options + +Options for the frontend transport constructor and `useTriggerChatTransport` hook. + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `task` | `string` | required | Task ID to trigger | +| `accessToken` | `string \| () => string \| Promise` | required | Auth token or function that returns one | +| `baseURL` | `string` | `"https://api.trigger.dev"` | API base URL (for self-hosted) | +| `streamKey` | `string` | `"chat"` | Stream key (only change if using custom key) | +| `headers` | `Record` | — | Extra headers for API requests | +| `streamTimeoutSeconds` | `number` | `120` | How long to wait for stream data | +| `clientData` | Typed by `clientDataSchema` | — | Default client data for every request | +| `sessions` | `Record` | — | Restore sessions from storage | +| `onSessionChange` | `(chatId, session \| null) => void` | — | Fires when session state changes | +| `triggerOptions` | `{...}` | — | Options for the initial task trigger (see below) | + +### triggerOptions + +Options forwarded to the Trigger.dev API when starting a new run. Only applies to the first message — subsequent messages reuse the same run. + +A `chat:{chatId}` tag is automatically added to every run. + +| Option | Type | Description | +|--------|------|-------------| +| `tags` | `string[]` | Additional tags for the run (merged with auto-tags, max 5 total) | +| `queue` | `string` | Queue name for the run | +| `maxAttempts` | `number` | Maximum retry attempts | +| `machine` | `"micro" \| "small-1x" \| ...` | Machine preset for the run | +| `priority` | `number` | Priority (lower = higher priority) | + +```ts +const transport = useTriggerChatTransport({ + task: "my-chat", + accessToken: getChatToken, + triggerOptions: { + tags: ["user:123"], + queue: "chat-queue", + }, +}); +``` + +### transport.preload() + +Eagerly trigger a run before the first message. + +```ts +transport.preload(chatId, { warmTimeoutInSeconds?: number }): Promise +``` + +No-op if a session already exists for this chatId. See [Preload](/ai-chat/features#preload) for full details. + +## useTriggerChatTransport + +React hook that creates and memoizes a `TriggerChatTransport` instance. Import from `@trigger.dev/sdk/chat/react`. + +```tsx +import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; +import type { myChat } from "@/trigger/chat"; + +const transport = useTriggerChatTransport({ + task: "my-chat", + accessToken: () => getChatToken(), + sessions: savedSessions, + onSessionChange: handleSessionChange, +}); +``` + +The transport is created once on first render and reused across re-renders. Pass a type parameter for compile-time validation of the task ID. + +## Related + +- [Realtime Streams](/tasks/streams) — How streams work under the hood +- [Using the Vercel AI SDK](/guides/examples/vercel-ai-sdk) — Basic AI SDK usage with Trigger.dev +- [Realtime React Hooks](/realtime/react-hooks/overview) — Lower-level realtime hooks +- [Authentication](/realtime/auth) — Public access tokens and trigger tokens diff --git a/docs/docs.json b/docs/docs.json index 779b5d53fb5..8eb60e28f1e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -80,6 +80,17 @@ "hidden-tasks" ] }, + { + "group": "AI Chat", + "pages": [ + "ai-chat/overview", + "ai-chat/quick-start", + "ai-chat/backend", + "ai-chat/frontend", + "ai-chat/features", + "ai-chat/reference" + ] + }, { "group": "Configuration", "pages": [ @@ -733,6 +744,10 @@ { "source": "/insights/metrics", "destination": "/observability/dashboards" + }, + { + "source": "/guides/ai-chat", + "destination": "/ai-chat/overview" } ] } diff --git a/references/ai-chat/ARCHITECTURE.md b/references/ai-chat/ARCHITECTURE.md new file mode 100644 index 00000000000..8adbc0c4a1a --- /dev/null +++ b/references/ai-chat/ARCHITECTURE.md @@ -0,0 +1,311 @@ +# AI Chat Architecture + +## System Overview + +```mermaid +graph TB + subgraph Frontend["Frontend (Browser)"] + UC[useChat Hook] + TCT[TriggerChatTransport] + UI[Chat UI Components] + end + + subgraph Platform["Trigger.dev Platform"] + API[REST API] + RS[Realtime Streams] + RE[Run Engine] + end + + subgraph Worker["Task Worker"] + CT[chat.task Turn Loop] + ST[streamText / AI SDK] + LLM[LLM Provider] + SUB[Subtasks via ai.tool] + end + + UI -->|user types| UC + UC -->|sendMessages| TCT + TCT -->|triggerTask / sendInputStream| API + API -->|queue run / deliver input| RE + RE -->|execute| CT + CT -->|call| ST + ST -->|API call| LLM + LLM -->|stream chunks| ST + ST -->|UIMessageChunks| RS + RS -->|SSE| TCT + TCT -->|ReadableStream| UC + UC -->|update| UI + CT -->|triggerAndWait| SUB + SUB -->|chat.stream target:root| RS +``` + +## Detailed Flow: New Chat (First Message) + +```mermaid +sequenceDiagram + participant User + participant useChat as useChat + Transport + participant API as Trigger.dev API + participant Task as chat.task Worker + participant LLM as LLM Provider + + User->>useChat: sendMessage("Hello") + useChat->>useChat: No session for chatId → trigger new run + + useChat->>API: triggerTask(payload, tags: [chat:id]) + API-->>useChat: { runId, publicAccessToken } + useChat->>useChat: Store session, subscribe to SSE + + API->>Task: Start run with ChatTaskWirePayload + + Note over Task: Preload phase skipped (trigger ≠ "preload") + + rect rgb(240, 248, 255) + Note over Task: Turn 0 + Task->>Task: convertToModelMessages(uiMessages) + Task->>Task: Mint access token + Task->>Task: onChatStart({ chatId, messages, clientData }) + Task->>Task: onTurnStart({ chatId, messages, uiMessages }) + Task->>LLM: streamText({ model, messages, abortSignal }) + LLM-->>Task: Stream response chunks + Task->>API: streams.pipe("chat", uiStream) + API-->>useChat: SSE: UIMessageChunks + useChat-->>User: Render streaming text + Task->>Task: onFinish → capturedResponseMessage + Task->>Task: Accumulate response in messages + Task->>API: Write __trigger_turn_complete chunk + API-->>useChat: SSE: { type: __trigger_turn_complete, publicAccessToken } + useChat->>useChat: Close stream, update session + Task->>Task: onTurnComplete({ messages, uiMessages, stopped }) + end + + rect rgb(255, 248, 240) + Note over Task: Wait for next message + Task->>Task: messagesInput.once() [warm, 30s] + Note over Task: No message → suspend + Task->>Task: messagesInput.wait() [suspended, 1h] + end +``` + +## Detailed Flow: Multi-Turn (Subsequent Messages) + +```mermaid +sequenceDiagram + participant User + participant useChat as useChat + Transport + participant API as Trigger.dev API + participant Task as chat.task Worker + participant LLM as LLM Provider + + Note over Task: Suspended, waiting for message + + User->>useChat: sendMessage("Tell me more") + useChat->>useChat: Session exists → send via input stream + useChat->>API: sendInputStream(runId, "chat-messages", payload) + Note right of useChat: Only sends new message
(not full history) + + API->>Task: Deliver to messagesInput + Task->>Task: Wake from suspend + + rect rgb(240, 248, 255) + Note over Task: Turn 1 + Task->>Task: Append new message to accumulators + Task->>Task: Mint fresh access token + Task->>Task: onTurnStart({ turn: 1, messages }) + Task->>LLM: streamText({ messages: [all accumulated] }) + LLM-->>Task: Stream response + Task->>API: streams.pipe("chat", uiStream) + API-->>useChat: SSE: UIMessageChunks + useChat-->>User: Render streaming text + Task->>API: Write __trigger_turn_complete + Task->>Task: onTurnComplete({ turn: 1 }) + end + + Task->>Task: Wait for next message (warm → suspend) +``` + +## Stop Signal Flow + +```mermaid +sequenceDiagram + participant User + participant useChat as useChat + Transport + participant API as Trigger.dev API + participant Task as chat.task Worker + participant LLM as LLM Provider + + Note over Task: Streaming response... + + User->>useChat: Click "Stop" + useChat->>API: sendInputStream(runId, "chat-stop", { stop: true }) + useChat->>useChat: Set skipToTurnComplete = true + + API->>Task: Deliver to stopInput + Task->>Task: stopController.abort() + Task->>LLM: AbortSignal fires + LLM-->>Task: Stream ends (AbortError) + Task->>Task: Catch AbortError, fall through + Task->>Task: await onFinishPromise (race condition fix) + Task->>Task: cleanupAbortedParts(responseMessage) + Note right of Task: Remove partial tool calls
Mark streaming parts as done + + Task->>API: Write __trigger_turn_complete + API-->>useChat: SSE: __trigger_turn_complete + useChat->>useChat: skipToTurnComplete = false, close stream + + Task->>Task: onTurnComplete({ stopped: true, responseMessage: cleaned }) + Task->>Task: Wait for next message +``` + +## Preload Flow + +```mermaid +sequenceDiagram + participant User + participant useChat as useChat + Transport + participant API as Trigger.dev API + participant Task as chat.task Worker + + User->>useChat: Click "New Chat" + useChat->>API: transport.preload(chatId) + Note right of useChat: payload: { messages: [], trigger: "preload" }
tags: [chat:id, preload:true] + API-->>useChat: { runId, publicAccessToken } + useChat->>useChat: Store session + + API->>Task: Start run (trigger = "preload") + + rect rgb(240, 255, 240) + Note over Task: Preload Phase + Task->>Task: Mint access token + Task->>Task: onPreload({ chatId, clientData }) + Note right of Task: DB setup, load user context,
load dynamic tools + Task->>Task: messagesInput.once() [warm] + Note over Task: Waiting for first message... + end + + Note over User: User is typing... + + User->>useChat: sendMessage("Hello") + useChat->>useChat: Session exists → send via input stream + useChat->>API: sendInputStream(runId, "chat-messages", payload) + API->>Task: Deliver message + + rect rgb(240, 248, 255) + Note over Task: Turn 0 (preloaded = true) + Task->>Task: onChatStart({ preloaded: true }) + Task->>Task: onTurnStart({ preloaded: true }) + Task->>Task: run() with preloaded dynamic tools ready + end +``` + +## Subtask Streaming (Tool as Task) + +```mermaid +sequenceDiagram + participant useChat as useChat + Transport + participant API as Trigger.dev API + participant Chat as chat.task + participant LLM as LLM Provider + participant Sub as Subtask (ai.tool) + + Chat->>LLM: streamText({ tools: { research: ai.tool(task) } }) + LLM-->>Chat: Tool call: research({ query, urls }) + + Chat->>API: triggerAndWait(subtask, input) + Note right of Chat: Passes toolCallId, chatId,
clientData via metadata + + API->>Sub: Start subtask + + Sub->>Sub: ai.chatContextOrThrow() → { chatId, clientData } + Sub->>API: chat.stream.writer({ target: "root" }) + Note right of Sub: Write data-research-progress
chunks to parent's stream + API-->>useChat: SSE: data-* chunks + useChat-->>useChat: Render progress UI + + Sub-->>Chat: Return result + Chat->>LLM: Tool result + LLM-->>Chat: Continue response +``` + +## Continuation Flow (Run Timeout / Cancel) + +```mermaid +sequenceDiagram + participant User + participant useChat as useChat + Transport + participant API as Trigger.dev API + participant Task as chat.task Worker + + Note over Task: Previous run timed out / was cancelled + + User->>useChat: sendMessage("Continue") + useChat->>API: sendInputStream(runId, payload) + API-->>useChat: Error (run dead) + + useChat->>useChat: Delete session, set isContinuation = true + useChat->>API: triggerTask(payload, continuation: true, previousRunId) + API-->>useChat: New { runId, publicAccessToken } + + API->>Task: Start new run + + rect rgb(255, 245, 238) + Note over Task: Turn 0 (continuation = true) + Task->>Task: cleanupAbortedParts(incoming messages) + Note right of Task: Strip incomplete tool calls
from previous run's response + Task->>Task: onChatStart({ continuation: true, previousRunId }) + Task->>Task: Normal turn flow... + end +``` + +## Hook Lifecycle + +```mermaid +graph TD + START([Run Starts]) --> IS_PRELOAD{trigger = preload?} + + IS_PRELOAD -->|Yes| PRELOAD[onPreload] + PRELOAD --> WAIT_MSG[Wait for first message
warm → suspend] + WAIT_MSG --> TURN0 + + IS_PRELOAD -->|No| TURN0 + + TURN0[Turn 0] --> CHAT_START[onChatStart
continuation, preloaded] + CHAT_START --> TURN_START_0[onTurnStart] + TURN_START_0 --> RUN_0[run → streamText] + RUN_0 --> TURN_COMPLETE_0[onTurnComplete
stopped, responseMessage] + + TURN_COMPLETE_0 --> WAIT{Wait for
next message} + WAIT -->|Message arrives| TURN_N[Turn N] + WAIT -->|Timeout| END_RUN([Run Ends]) + + TURN_N --> TURN_START_N[onTurnStart] + TURN_START_N --> RUN_N[run → streamText] + RUN_N --> TURN_COMPLETE_N[onTurnComplete] + TURN_COMPLETE_N --> WAIT +``` + +## Stream Architecture + +```mermaid +graph LR + subgraph Output["Output Stream (chat)"] + direction TB + O1[UIMessageChunks
text, reasoning, tools] + O2[data-* custom chunks] + O3[__trigger_turn_complete
control chunk] + end + + subgraph Input["Input Streams"] + direction TB + I1[chat-messages
User messages] + I2[chat-stop
Stop signal] + end + + Frontend -->|sendInputStream| I1 + Frontend -->|sendInputStream| I2 + I1 -->|messagesInput.once/wait| Worker + I2 -->|stopInput.on| Worker + Worker -->|streams.pipe / chat.stream| Output + Subtask -->|chat.stream target:root| Output + Output -->|SSE /realtime/v1/streams| Frontend +``` From 5356e88a6b621e31b79ba68525ea0104413f99c6 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 23 Mar 2026 10:27:17 +0000 Subject: [PATCH 02/13] docs: rename warmTimeout to idleTimeout in ai-chat docs --- docs/ai-chat/backend.mdx | 22 +++++++++++----------- docs/ai-chat/features.mdx | 6 +++--- docs/ai-chat/overview.mdx | 8 ++++---- docs/ai-chat/reference.mdx | 12 ++++++------ 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/ai-chat/backend.mdx b/docs/ai-chat/backend.mdx index 5c21e88ee65..01b97f41b85 100644 --- a/docs/ai-chat/backend.mdx +++ b/docs/ai-chat/backend.mdx @@ -489,19 +489,19 @@ run: async ({ messages, signal }) => { }, ``` -#### chat.setWarmTimeoutInSeconds() +#### chat.setIdleTimeoutInSeconds() -Override how long the run stays warm (active, using compute) after each turn: +Override how long the run stays idle (active, using compute) after each turn: ```ts run: async ({ messages, signal }) => { - chat.setWarmTimeoutInSeconds(60); // Stay warm for 1 minute + chat.setIdleTimeoutInSeconds(60); // Stay idle for 1 minute return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }); }, ``` - Longer warm 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). + 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). #### Stream options @@ -639,7 +639,7 @@ export const myChat = task({ const session = chat.createSession(payload, { signal, - warmTimeoutInSeconds: 60, + idleTimeoutInSeconds: 60, timeout: "1h", }); @@ -668,7 +668,7 @@ export const myChat = task({ | Option | Type | Default | Description | |--------|------|---------|-------------| | `signal` | `AbortSignal` | required | Run-level cancel signal (from task context) | -| `warmTimeoutInSeconds` | `number` | `30` | Seconds to stay warm between turns | +| `idleTimeoutInSeconds` | `number` | `30` | Seconds to stay idle between turns | | `timeout` | `string` | `"1h"` | Duration string for suspend timeout | | `maxTurns` | `number` | `100` | Max turns before ending | @@ -736,7 +736,7 @@ Raw task mode also lets you call `.toUIMessageStream()` yourself with any option | Primitive | Description | |-----------|-------------| -| `chat.messages` | Input stream for incoming messages — use `.waitWithWarmup()` to wait for the next turn | +| `chat.messages` | Input stream for incoming messages — use `.waitWithIdleTimeout()` to wait for the next turn | | `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream | | `chat.pipeAndCapture(result)` | Pipe a `StreamTextResult` to the chat stream and capture the response | | `chat.writeTurnComplete()` | Signal the frontend that the current turn is complete | @@ -759,8 +759,8 @@ export const myChat = task({ // Handle preload — wait for the first real message if (currentPayload.trigger === "preload") { - const result = await chat.messages.waitWithWarmup({ - warmTimeoutInSeconds: 60, + const result = await chat.messages.waitWithIdleTimeout({ + idleTimeoutInSeconds: 60, timeout: "1h", spanName: "waiting for first message", }); @@ -818,8 +818,8 @@ export const myChat = task({ await chat.writeTurnComplete(); // Wait for the next message - const next = await chat.messages.waitWithWarmup({ - warmTimeoutInSeconds: 60, + const next = await chat.messages.waitWithIdleTimeout({ + idleTimeoutInSeconds: 60, timeout: "1h", spanName: "waiting for next message", }); diff --git a/docs/ai-chat/features.mdx b/docs/ai-chat/features.mdx index fd4b63789a1..9de3d13bf7e 100644 --- a/docs/ai-chat/features.mdx +++ b/docs/ai-chat/features.mdx @@ -374,7 +374,7 @@ export function Chat({ chatId }) { // Preload on mount — run starts before the user types anything useEffect(() => { - transport.preload(chatId, { warmTimeoutInSeconds: 60 }); + transport.preload(chatId, { idleTimeoutInSeconds: 60 }); }, [chatId]); const { messages, sendMessage } = useChat({ id: chatId, transport }); @@ -411,8 +411,8 @@ With `chat.createSession()` or raw tasks, check `payload.trigger === "preload"` ```ts if (payload.trigger === "preload") { // Initialize early... - const result = await chat.messages.waitWithWarmup({ - warmTimeoutInSeconds: 60, + const result = await chat.messages.waitWithIdleTimeout({ + idleTimeoutInSeconds: 60, timeout: "1h", }); if (!result.ok) return; diff --git a/docs/ai-chat/overview.mdx b/docs/ai-chat/overview.mdx index eb3d1ab23df..a1d207c7993 100644 --- a/docs/ai-chat/overview.mdx +++ b/docs/ai-chat/overview.mdx @@ -13,7 +13,7 @@ The `@trigger.dev/sdk` provides a custom [ChatTransport](https://sdk.vercel.ai/d 2. The first message triggers a Trigger.dev task; subsequent messages resume the **same run** via input streams 3. The task streams `UIMessageChunk` events back via Trigger.dev's realtime streams 4. The AI SDK's `useChat` processes the stream natively — text, tool calls, reasoning, etc. -5. Between turns, the run stays warm briefly then suspends (freeing compute) until the next message +5. Between turns, the run stays idle briefly then suspends (freeing compute) until the next message No custom API routes needed. Your chat backend is a Trigger.dev task. @@ -47,7 +47,7 @@ sequenceDiagram API-->>useChat: SSE: turn complete + refreshed token useChat->>useChat: Close stream, update session Task->>Task: onTurnComplete({ messages, stopped: false }) - Task->>Task: Wait for next message (warm → suspend) + Task->>Task: Wait for next message (idle → suspend) ``` ### Multi-turn flow @@ -78,7 +78,7 @@ sequenceDiagram useChat-->>User: Render streaming text Task->>API: Write __trigger_turn_complete Task->>Task: onTurnComplete({ turn: 1 }) - Task->>Task: Wait for next message (warm → suspend) + Task->>Task: Wait for next message (idle → suspend) ``` ### Stop signal flow @@ -130,7 +130,7 @@ After each turn, the run goes through two phases of waiting: If no message arrives within the turn timeout, the run ends gracefully. The next message from the frontend will automatically start a fresh run. - You are not charged for compute during the suspended phase. Only the warm phase uses compute resources. + You are not charged for compute during the suspended phase. Only the idle phase uses compute resources. ### What the backend accumulates diff --git a/docs/ai-chat/reference.mdx b/docs/ai-chat/reference.mdx index 420decee98b..f2a5fb00d07 100644 --- a/docs/ai-chat/reference.mdx +++ b/docs/ai-chat/reference.mdx @@ -19,9 +19,9 @@ Options for `chat.task()`. | `onTurnComplete` | `(event: TurnCompleteEvent) => Promise \| void` | — | Fires after each turn completes | | `maxTurns` | `number` | `100` | Max conversational turns per run | | `turnTimeout` | `string` | `"1h"` | How long to wait for next message | -| `warmTimeoutInSeconds` | `number` | `30` | Seconds to stay warm before suspending | +| `idleTimeoutInSeconds` | `number` | `30` | Seconds to stay idle before suspending | | `chatAccessTokenTTL` | `string` | `"1h"` | How long the scoped access token remains valid | -| `preloadWarmTimeoutInSeconds` | `number` | Same as `warmTimeoutInSeconds` | Warm timeout after `onPreload` fires | +| `preloadIdleTimeoutInSeconds` | `number` | Same as `idleTimeoutInSeconds` | Idle timeout after `onPreload` fires | | `preloadTimeout` | `string` | Same as `turnTimeout` | Suspend timeout for preloaded runs | | `uiMessageStreamOptions` | `ChatUIMessageStreamOptions` | — | Default options for `toUIMessageStream()`. Per-turn override via `chat.setUIMessageStreamOptions()` | @@ -113,7 +113,7 @@ Options for `chat.createSession()`. | Option | Type | Default | Description | |--------|------|---------|-------------| | `signal` | `AbortSignal` | required | Run-level cancel signal | -| `warmTimeoutInSeconds` | `number` | `30` | Seconds to stay warm between turns | +| `idleTimeoutInSeconds` | `number` | `30` | Seconds to stay idle between turns | | `timeout` | `string` | `"1h"` | Duration string for suspend timeout | | `maxTurns` | `number` | `100` | Max turns before ending | @@ -151,12 +151,12 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`. | `chat.pipeAndCapture(source, options?)` | Pipe and capture the response `UIMessage` | | `chat.writeTurnComplete(options?)` | Signal the frontend that the current turn is complete | | `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream | -| `chat.messages` | Input stream for incoming messages — use `.waitWithWarmup()` | +| `chat.messages` | Input stream for incoming messages — use `.waitWithIdleTimeout()` | | `chat.local({ id })` | Create a per-run typed local (see [Per-run data](/ai-chat/features#per-run-data-with-chatlocal)) | | `chat.createAccessToken(taskId)` | Create a public access token for a chat task | | `chat.setTurnTimeout(duration)` | Override turn timeout at runtime (e.g. `"2h"`) | | `chat.setTurnTimeoutInSeconds(seconds)` | Override turn timeout at runtime (in seconds) | -| `chat.setWarmTimeoutInSeconds(seconds)` | Override warm timeout at runtime | +| `chat.setIdleTimeoutInSeconds(seconds)` | Override idle timeout at runtime | | `chat.setUIMessageStreamOptions(options)` | Override `toUIMessageStream()` options for the current turn | | `chat.defer(promise)` | Run background work in parallel with streaming, awaited before `onTurnComplete` | | `chat.isStopped()` | Check if the current turn was stopped by the user | @@ -226,7 +226,7 @@ const transport = useTriggerChatTransport({ Eagerly trigger a run before the first message. ```ts -transport.preload(chatId, { warmTimeoutInSeconds?: number }): Promise +transport.preload(chatId, { idleTimeoutInSeconds?: number }): Promise ``` No-op if a session already exists for this chatId. See [Preload](/ai-chat/features#preload) for full details. From b217ff5a14c8299e5cea00f5495444f2c47983bc Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 24 Mar 2026 14:28:45 +0000 Subject: [PATCH 03/13] add docs for prompts --- docs/ai-chat/backend.mdx | 83 +++++++ docs/ai-chat/compaction.mdx | 228 +++++++++++++++++++ docs/ai-chat/reference.mdx | 47 ++++ docs/ai/prompts.mdx | 424 ++++++++++++++++++++++++++++++++++++ docs/docs.json | 21 +- 5 files changed, 796 insertions(+), 7 deletions(-) create mode 100644 docs/ai-chat/compaction.mdx create mode 100644 docs/ai/prompts.mdx diff --git a/docs/ai-chat/backend.mdx b/docs/ai-chat/backend.mdx index 01b97f41b85..6d80f0fec78 100644 --- a/docs/ai-chat/backend.mdx +++ b/docs/ai-chat/backend.mdx @@ -218,6 +218,51 @@ export const myChat = chat.task({ Persist `lastEventId` alongside the session. When the transport reconnects after a page refresh, it uses this to skip past already-seen events — preventing duplicate messages. +### 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 { openai } from "@ai-sdk/openai"; +import { z } from "zod"; + +const registry = createProviderRegistry({ openai }); + +const systemPrompt = prompts.define({ + id: "my-chat-system", + model: "openai:gpt-4o", + config: { temperature: 0.7 }, + variables: z.object({ name: z.string() }), + content: `You are a helpful assistant for {{name}}.`, +}); + +export const myChat = chat.task({ + 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, + }); + }, +}); +``` + +`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. + + + See [Prompts](/ai/prompts) for the full guide — defining templates, variable schemas, dashboard overrides, and the management SDK. + + ### Stop generation #### How stop works @@ -476,6 +521,44 @@ export function Chat({ chatId, initialMessages, initialSessions }) { ``` +### 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.task({ + 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: openai("gpt-4o"), 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 | + ### Runtime configuration #### chat.setTurnTimeout() diff --git a/docs/ai-chat/compaction.mdx b/docs/ai-chat/compaction.mdx new file mode 100644 index 00000000000..fa78ffc483d --- /dev/null +++ b/docs/ai-chat/compaction.mdx @@ -0,0 +1,228 @@ +--- +title: "Compaction" +sidebarTitle: "Compaction" +description: "Automatic context compaction to keep long conversations within token limits." +--- + +## Overview + +Long conversations accumulate tokens across turns. Eventually the context window fills up, causing errors or degraded responses. Compaction solves this by automatically summarizing the conversation when token usage exceeds a threshold, then using that summary as the context for future turns. + +The `compaction` option on `chat.task()` handles this in both paths: + +- **Between tool-call steps** (inner loop) — via the AI SDK's `prepareStep`, compaction runs between tool calls within a single turn +- **Between turns** (outer loop) — for single-step responses with no tool calls, where `prepareStep` never fires + +## Basic usage + +Provide `shouldCompact` to decide when to compact and `summarize` to generate the summary: + +```ts +import { chat } from "@trigger.dev/sdk/ai"; +import { streamText, generateText } from "ai"; +import { openai } from "@ai-sdk/openai"; + +export const myChat = chat.task({ + id: "my-chat", + compaction: { + shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > 80_000, + summarize: async ({ messages }) => { + const result = await generateText({ + model: openai("gpt-4o-mini"), + messages: [...messages, { role: "user", content: "Summarize this conversation concisely." }], + }); + return result.text; + }, + }, + run: async ({ messages, signal }) => { + return streamText({ + ...chat.toStreamTextOptions({ registry }), + messages, + abortSignal: signal, + }); + }, +}); +``` + + + The `prepareStep` for inner-loop compaction is automatically injected when you spread `chat.toStreamTextOptions()` into your `streamText` call. If you provide your own `prepareStep` after the spread, it overrides the auto-injected one. + + +## How it works + +After each turn completes: + +1. `shouldCompact` is called with the current token usage +2. If it returns `true`, `summarize` generates a summary from the model messages +3. The **model messages** (sent to the LLM) are replaced with the summary +4. The **UI messages** (persisted and displayed) are preserved by default +5. The `onCompacted` hook fires if configured + +On the next turn, the LLM receives the compact summary instead of the full history — dramatically reducing token usage while preserving context. + +## Customizing what gets persisted + +By default, compaction only affects model messages — UI messages stay intact so users see the full conversation after a page refresh. You can customize this with `compactUIMessages`: + +### Summary + recent messages + +Replace older messages with a summary but keep the last few exchanges visible: + +```ts +import { generateId } from "ai"; + +export const myChat = chat.task({ + id: "my-chat", + compaction: { + shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > 80_000, + summarize: async ({ messages }) => { + return generateText({ + model: openai("gpt-4o-mini"), + messages: [...messages, { role: "user", content: "Summarize." }], + }).then((r) => r.text); + }, + compactUIMessages: ({ uiMessages, summary }) => [ + { + id: generateId(), + role: "assistant", + parts: [{ type: "text", text: `[Conversation summary]\n\n${summary}` }], + }, + ...uiMessages.slice(-4), // Keep the last 4 messages + ], + }, + run: async ({ messages, signal }) => { + return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }); + }, +}); +``` + +### Flatten to summary only + +Replace all messages with just the summary (like the LLM sees): + +```ts +compactUIMessages: ({ summary }) => [ + { + id: generateId(), + role: "assistant", + parts: [{ type: "text", text: `[Conversation summary]\n\n${summary}` }], + }, +], +``` + +## Customizing model messages + +By default, model messages are replaced with a single summary message. Use `compactModelMessages` to customize what the LLM sees after compaction: + +### Summary + recent context + +Keep the last few model messages so the LLM has recent detail alongside the summary: + +```ts +compactModelMessages: ({ modelMessages, summary }) => [ + { role: "user", content: summary }, + ...modelMessages.slice(-2), // Keep last exchange for detail +], +``` + +### Keep tool results + +Preserve tool-call results so the LLM remembers what tools returned: + +```ts +compactModelMessages: ({ modelMessages, summary }) => [ + { role: "user", content: summary }, + ...modelMessages.filter((m) => m.role === "tool"), +], +``` + +## shouldCompact event + +The `shouldCompact` callback receives context about the current state: + +| Field | Type | Description | +|-------|------|-------------| +| `messages` | `ModelMessage[]` | Current model messages | +| `totalTokens` | `number \| undefined` | Total tokens from the triggering step/turn | +| `inputTokens` | `number \| undefined` | Input tokens | +| `outputTokens` | `number \| undefined` | Output tokens | +| `usage` | `LanguageModelUsage` | Full usage object | +| `totalUsage` | `LanguageModelUsage` | Cumulative usage across all turns | +| `chatId` | `string` | Chat session ID | +| `turn` | `number` | Current turn (0-indexed) | +| `clientData` | `unknown` | Custom data from the frontend | +| `source` | `"inner" \| "outer"` | Whether this is between steps or between turns | +| `steps` | `CompactionStep[]` | Steps array (inner loop only) | +| `stepNumber` | `number` | Step index (inner loop only) | + +## summarize event + +The `summarize` callback receives similar context: + +| Field | Type | Description | +|-------|------|-------------| +| `messages` | `ModelMessage[]` | Messages to summarize | +| `usage` | `LanguageModelUsage` | Usage from the triggering step/turn | +| `totalUsage` | `LanguageModelUsage` | Cumulative usage | +| `chatId` | `string` | Chat session ID | +| `turn` | `number` | Current turn | +| `clientData` | `unknown` | Custom data from the frontend | +| `source` | `"inner" \| "outer"` | Where compaction is running | +| `stepNumber` | `number` | Step index (inner loop only) | + +## onCompacted hook + +Track compaction events for logging, billing, or analytics: + +```ts +export const myChat = chat.task({ + id: "my-chat", + compaction: { ... }, + onCompacted: async ({ summary, totalTokens, messageCount, chatId, turn }) => { + logger.info("Compacted", { chatId, turn, totalTokens, messageCount }); + await db.compactionLog.create({ + data: { chatId, summary, totalTokens, messageCount }, + }); + }, + run: async ({ messages, signal }) => { + return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }); + }, +}); +``` + +## Low-level compaction + +For `chat.createSession()` or raw task mode, use `chat.compact()` and `chat.compactionStep()` directly inside a custom `prepareStep`: + +```ts +const result = streamText({ + model: openai("gpt-4o"), + messages, + prepareStep: async ({ messages: stepMessages, steps }) => { + const result = await chat.compact(stepMessages, steps, { + threshold: 80_000, + summarize: async (msgs) => + generateText({ model: openai("gpt-4o-mini"), messages: msgs }).then((r) => r.text), + }); + return result.type === "skipped" ? undefined : result; + }, +}); +``` + +Or use the higher-level `chat.compactionStep()` factory: + +```ts +const result = streamText({ + model: openai("gpt-4o"), + messages, + prepareStep: chat.compactionStep({ + threshold: 80_000, + summarize: async (msgs) => + generateText({ model: openai("gpt-4o-mini"), messages: msgs }).then((r) => r.text), + }), +}); +``` + + + The low-level APIs only handle inner-loop compaction (between tool-call steps). For full coverage including single-step turns, use the `compaction` option on `chat.task()`. + diff --git a/docs/ai-chat/reference.mdx b/docs/ai-chat/reference.mdx index f2a5fb00d07..ad738d305a4 100644 --- a/docs/ai-chat/reference.mdx +++ b/docs/ai-chat/reference.mdx @@ -17,6 +17,9 @@ Options for `chat.task()`. | `onChatStart` | `(event: ChatStartEvent) => Promise \| void` | — | Fires on turn 0 before `run()` | | `onTurnStart` | `(event: TurnStartEvent) => Promise \| void` | — | Fires every turn before `run()` | | `onTurnComplete` | `(event: TurnCompleteEvent) => Promise \| void` | — | Fires after each turn completes | +| `onCompacted` | `(event: CompactedEvent) => Promise \| void` | — | Fires when compaction occurs. See [Compaction](/ai-chat/compaction) | +| `compaction` | `ChatTaskCompactionOptions` | — | Automatic context compaction. See [Compaction](/ai-chat/compaction) | +| `prepareMessages` | `(event: PrepareMessagesEvent) => ModelMessage[]` | — | Transform model messages before use (cache breaks, context injection, etc.) | | `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 | @@ -105,6 +108,50 @@ Passed to the `onTurnComplete` callback. | `lastEventId` | `string \| undefined` | Stream position for resumption | | `stopped` | `boolean` | Whether the user stopped generation during this turn | | `continuation` | `boolean` | Whether this run is continuing an existing chat | +| `usage` | `LanguageModelUsage \| undefined` | Token usage for this turn | +| `totalUsage` | `LanguageModelUsage` | Cumulative token usage across all turns | + +## ChatTaskCompactionOptions + +Options for the `compaction` field on `chat.task()`. See [Compaction](/ai-chat/compaction) for usage guide. + +| Option | Type | Required | Description | +|--------|------|----------|-------------| +| `shouldCompact` | `(event: ShouldCompactEvent) => boolean \| Promise` | Yes | Decide whether to compact. Return `true` to trigger | +| `summarize` | `(event: SummarizeEvent) => Promise` | Yes | Generate a summary from the current messages | +| `compactUIMessages` | `(event: CompactMessagesEvent) => UIMessage[] \| Promise` | No | Transform UI messages after compaction. Default: preserve all | +| `compactModelMessages` | `(event: CompactMessagesEvent) => ModelMessage[] \| Promise` | No | Transform model messages after compaction. Default: replace all with summary | + +## CompactMessagesEvent + +Passed to `compactUIMessages` and `compactModelMessages` callbacks. + +| Field | Type | Description | +|-------|------|-------------| +| `summary` | `string` | The generated summary text | +| `uiMessages` | `UIMessage[]` | Current UI messages (full conversation) | +| `modelMessages` | `ModelMessage[]` | Current model messages (full conversation) | +| `chatId` | `string` | Chat session ID | +| `turn` | `number` | Current turn (0-indexed) | +| `clientData` | `unknown` | Custom data from the frontend | +| `source` | `"inner" \| "outer"` | Whether compaction is between steps or between turns | + +## CompactedEvent + +Passed to the `onCompacted` callback. + +| Field | Type | Description | +|-------|------|-------------| +| `summary` | `string` | The generated summary text | +| `messages` | `ModelMessage[]` | Messages that were compacted (pre-compaction) | +| `messageCount` | `number` | Number of messages before compaction | +| `usage` | `LanguageModelUsage` | Token usage from the triggering step/turn | +| `totalTokens` | `number \| undefined` | Total token count that triggered compaction | +| `inputTokens` | `number \| undefined` | Input token count | +| `outputTokens` | `number \| undefined` | Output token count | +| `stepNumber` | `number` | Step number (-1 for outer loop) | +| `chatId` | `string \| undefined` | Chat session ID | +| `turn` | `number \| undefined` | Current turn | ## ChatSessionOptions diff --git a/docs/ai/prompts.mdx b/docs/ai/prompts.mdx new file mode 100644 index 00000000000..4ac324ffff9 --- /dev/null +++ b/docs/ai/prompts.mdx @@ -0,0 +1,424 @@ +--- +title: "Prompts" +sidebarTitle: "Prompts" +description: "Define prompt templates as code, version them on deploy, and override from the dashboard without redeploying." +--- + +## Overview + +AI Prompts let you define prompt templates in your codebase alongside your tasks. When you deploy, Trigger.dev automatically versions your prompts. You can then: + +- View all prompt versions in the dashboard +- Create **overrides** to change the prompt text or model without redeploying +- Track every generation that used each prompt version +- See token usage, cost, and latency metrics per prompt +- Manage prompts programmatically via SDK methods + +## Defining a prompt + +Use `prompts.define()` to create a prompt with typed variables: + +```ts +import { prompts } from "@trigger.dev/sdk"; +import { z } from "zod"; + +export const supportPrompt = prompts.define({ + id: "customer-support", + description: "System prompt for customer support interactions", + model: "gpt-4o", + config: { temperature: 0.7 }, + variables: z.object({ + customerName: z.string(), + plan: z.string(), + issue: z.string(), + }), + content: `You are a support agent for Acme SaaS. + +## Customer context + +- **Name:** {{customerName}} +- **Plan:** {{plan}} +- **Issue:** {{issue}} + +Respond to the customer's issue. Be concise and helpful.`, +}); +``` + +### Options + +| Option | Type | Required | Description | +|--------|------|----------|-------------| +| `id` | `string` | Yes | Unique identifier (becomes the prompt slug) | +| `description` | `string` | No | Shown in the dashboard | +| `model` | `string` | No | Default model (e.g. `"gpt-4o"`, `"claude-sonnet-4-6"`) | +| `config` | `object` | No | Default config (temperature, maxTokens, etc.) | +| `variables` | Zod/ArkType schema | No | Schema for template variables (enables validation and dashboard UI) | +| `content` | `string` | Yes | The prompt template with `{{variable}}` placeholders | + +### Template syntax + +Templates use Mustache-style placeholders: + +- `{{variableName}}` — replaced with the variable value +- `{{#conditionalVar}}...{{/conditionalVar}}` — content only included if the variable is truthy + +```ts +export const prompt = prompts.define({ + id: "summarizer", + model: "gpt-4o-mini", + variables: z.object({ + text: z.string(), + maxSentences: z.string().optional(), + }), + content: `Summarize the following text{{#maxSentences}} in {{maxSentences}} sentences or fewer{{/maxSentences}}: + +{{text}}`, +}); +``` + +## Resolving a prompt + +### Via prompt handle + +Call `.resolve()` on the handle returned by `define()`: + +```ts +const resolved = await supportPrompt.resolve({ + customerName: "Alice", + plan: "Pro", + issue: "Cannot access billing dashboard", +}); + +console.log(resolved.text); // The compiled prompt with variables filled in +console.log(resolved.version); // e.g. 3 +console.log(resolved.model); // "gpt-4o" +console.log(resolved.labels); // ["current"] or ["override"] +``` + +### Via standalone prompts.resolve() + +Resolve any prompt by slug without needing a handle. Pass the prompt handle as a type parameter for full type safety: + +```ts +import { prompts } from "@trigger.dev/sdk"; +import type { supportPrompt } from "./prompts"; + +// Fully typesafe — ID and variables are checked at compile time +const resolved = await prompts.resolve("customer-support", { + customerName: "Alice", + plan: "Pro", + issue: "Cannot access billing dashboard", +}); +``` + +Without the generic, the function still works but accepts any string slug and `Record` variables. + +### Resolve options + +You can resolve a specific version or label: + +```ts +// Resolve a specific version +const v2 = await supportPrompt.resolve(variables, { version: 2 }); + +// Resolve by label +const current = await supportPrompt.resolve(variables, { label: "current" }); +``` + +By default, `resolve()` returns the **override** version if one is active, otherwise the **current** (latest deployed) version. + + + Both `promptHandle.resolve()` and `prompts.resolve()` call the Trigger.dev API when a client is configured. During local dev with `trigger dev`, this means you'll always get the server version (including overrides). + + +## Using with the AI SDK + +The resolved prompt integrates with the [Vercel AI SDK](https://ai-sdk.dev) via `toAISDKTelemetry()`. This links AI generation spans to the prompt in the dashboard. + +### generateText + +```ts +import { task } from "@trigger.dev/sdk"; +import { generateText } from "ai"; +import { openai } from "@ai-sdk/openai"; + +export const supportTask = task({ + id: "handle-support", + run: async (payload) => { + const resolved = await supportPrompt.resolve({ + customerName: payload.name, + plan: payload.plan, + issue: payload.issue, + }); + + const result = await generateText({ + model: openai(resolved.model ?? "gpt-4o"), + system: resolved.text, + prompt: payload.issue, + ...resolved.toAISDKTelemetry(), + }); + + return { response: result.text }; + }, +}); +``` + +### streamText + +```ts +import { streamText } from "ai"; + +export const streamTask = task({ + id: "stream-support", + run: async (payload) => { + const resolved = await supportPrompt.resolve({ + customerName: payload.name, + plan: payload.plan, + issue: payload.issue, + }); + + const result = streamText({ + model: openai(resolved.model ?? "gpt-4o"), + system: resolved.text, + prompt: payload.issue, + ...resolved.toAISDKTelemetry(), + }); + + let fullText = ""; + for await (const chunk of result.textStream) { + fullText += chunk; + } + + return { response: fullText }; + }, +}); +``` + +### Custom telemetry metadata + +Pass additional metadata to `toAISDKTelemetry()` that will appear on the generation span: + +```ts +const result = await generateText({ + model: openai("gpt-4o"), + prompt: resolved.text, + ...resolved.toAISDKTelemetry({ + "task.type": "summarization", + "customer.tier": "enterprise", + }), +}); +``` + +## Using with chat.task() + +Prompts integrate with `chat.task()` via `chat.prompt` — a run-scoped store for the resolved prompt. Store a prompt once in a lifecycle hook, then access it anywhere during the run. + +### chat.prompt.set() and chat.prompt() + +```ts +import { chat } from "@trigger.dev/sdk/ai"; +import { prompts } from "@trigger.dev/sdk"; +import { streamText, createProviderRegistry } from "ai"; +import { openai } from "@ai-sdk/openai"; +import { anthropic } from "@ai-sdk/anthropic"; + +const registry = createProviderRegistry({ openai, anthropic }); + +const systemPrompt = prompts.define({ + id: "my-chat-system", + model: "openai:gpt-4o", + config: { temperature: 0.7 }, + variables: z.object({ name: z.string() }), + content: `You are a helpful assistant for {{name}}.`, +}); + +export const myChat = chat.task({ + id: "my-chat", + onChatStart: async ({ clientData }) => { + const resolved = await systemPrompt.resolve({ name: clientData.name }); + chat.prompt.set(resolved); + }, + run: async ({ messages, signal }) => { + return streamText({ + ...chat.toStreamTextOptions({ registry }), + messages, + abortSignal: signal, + }); + }, +}); +``` + +### chat.toStreamTextOptions() + +Returns an options object ready to spread into `streamText()`. When a prompt is stored via `chat.prompt.set()`, it includes: + +- `system` — the compiled prompt text +- `model` — resolved via the `registry` when provided +- `temperature`, `maxTokens`, etc. — from the prompt's `config` +- `experimental_telemetry` — links generations to the prompt in the dashboard + +```ts +// With registry — model is resolved automatically +const options = chat.toStreamTextOptions({ registry }); +// { system: "...", model: LanguageModel, temperature: 0.7, experimental_telemetry: { ... } } + +// Without registry — model is not included +const options = chat.toStreamTextOptions(); +// { system: "...", temperature: 0.7, experimental_telemetry: { ... } } +``` + + + When the user provides a `registry` and the prompt has a `model` string (e.g. `"openai:gpt-4o"`), the model is resolved via `registry.languageModel()` and included in the returned options. This means `streamText` uses the prompt's model by default — no manual model selection needed. + + +### Reading the prompt + +Access the stored prompt from anywhere in the run: + +```ts +run: async ({ messages, signal }) => { + const prompt = chat.prompt(); // Throws if not set + console.log(prompt.text); // The compiled prompt + console.log(prompt.model); // "openai:gpt-4o" + console.log(prompt.version); // 3 + + return streamText({ + ...chat.toStreamTextOptions({ registry }), + messages, + abortSignal: signal, + }); +}, +``` + +You can also set a plain string if you don't need the full prompt system: + +```ts +chat.prompt.set("You are a helpful assistant."); +``` + +## Prompt management SDK + +The `prompts` namespace includes methods for managing prompts programmatically. These work both inside tasks and outside (e.g. scripts, API handlers) as long as an API client is configured. + +### List prompts + +```ts +const allPrompts = await prompts.list(); +``` + +### List versions + +```ts +const versions = await prompts.versions("customer-support"); +``` + +### Create an override + +Create a new override that takes priority over the deployed version: + +```ts +const result = await prompts.createOverride("customer-support", { + textContent: "New prompt template: Hello {{customerName}}!", + model: "gpt-4o-mini", + commitMessage: "Shorter prompt", +}); +``` + +### Update an override + +```ts +await prompts.updateOverride("customer-support", { + textContent: "Updated template: Hi {{customerName}}!", + model: "gpt-4o", +}); +``` + +### Remove an override + +Remove the active override, reverting to the deployed version: + +```ts +await prompts.removeOverride("customer-support"); +``` + +### Promote a version + +```ts +await prompts.promote("customer-support", 2); +``` + +### All management methods + +| Method | Description | +|--------|-------------| +| `prompts.list()` | List all prompts in the current environment | +| `prompts.versions(slug)` | List all versions for a prompt | +| `prompts.resolve(slug, variables?, options?)` | Resolve a prompt by slug | +| `prompts.promote(slug, version)` | Promote a version to current | +| `prompts.createOverride(slug, body)` | Create an override | +| `prompts.updateOverride(slug, body)` | Update the active override | +| `prompts.removeOverride(slug)` | Remove the active override | +| `prompts.reactivateOverride(slug, version)` | Reactivate a removed override | + +## Overrides + +Overrides let you change a prompt's template or model from the dashboard or SDK without redeploying your code. When an override is active, `resolve()` returns the override version instead of the deployed version. + +### How overrides work + +- Overrides take priority over the deployed ("current") version +- Only one override can be active at a time +- Creating a new override replaces the previous one +- Removing an override reverts to the deployed version +- Overrides are environment-scoped (dev, staging, production are independent) + +### Creating an override (dashboard) + +1. Go to the prompt detail page +2. Click **Create Override** +3. Edit the template text and/or model +4. Add an optional commit message +5. Click **Create override** + +### Version resolution order + +When `resolve()` is called, versions are resolved in this order: + +1. **Specific version** — if `{ version: N }` is passed +2. **Override** — if an override is active in this environment +3. **Label** — if `{ label: "..." }` is passed (defaults to `"current"`) +4. **Current** — the latest deployed version with the "current" label + +## Dashboard + +### Prompts list + +The prompts list page shows all prompts in the current environment with the current or override version, default model, and a usage sparkline. + +### Prompt detail + +Click a prompt to see: + +- **Template panel** — the prompt template for the selected version +- **Details tab** — slug, description, model, config, source file, and variable schema +- **Versions tab** — all versions with labels, source, and commit messages +- **Generations tab** — every AI generation that used this prompt, with live polling +- **Metrics tab** — token usage, cost, and latency charts + +### AI span inspectors + +When you use `toAISDKTelemetry()`, AI generation spans in the run trace get a custom inspector showing: + +- **Overview** — model, provider, token usage, cost, input/output preview +- **Messages** — the full message thread +- **Tools** — tool definitions and tool call details +- **Prompt** — the linked prompt's metadata, input variables, and template content + +## Type utilities + +```ts +import type { PromptHandle, PromptIdentifier, PromptVariables } from "@trigger.dev/sdk"; + +type Id = PromptIdentifier; // "customer-support" +type Vars = PromptVariables; // { customerName: string; plan: string; issue: string } +``` diff --git a/docs/docs.json b/docs/docs.json index 8eb60e28f1e..ab034079f52 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -81,14 +81,21 @@ ] }, { - "group": "AI Chat", + "group": "AI", "pages": [ - "ai-chat/overview", - "ai-chat/quick-start", - "ai-chat/backend", - "ai-chat/frontend", - "ai-chat/features", - "ai-chat/reference" + "ai/prompts", + { + "group": "Chat", + "pages": [ + "ai-chat/overview", + "ai-chat/quick-start", + "ai-chat/backend", + "ai-chat/frontend", + "ai-chat/features", + "ai-chat/compaction", + "ai-chat/reference" + ] + } ] }, { From 3f4af866a479dbbd5ff3dcc728415d6406883bff Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 24 Mar 2026 14:47:40 +0000 Subject: [PATCH 04/13] better compaction support in createSession and manual tasks --- docs/ai-chat/compaction.mdx | 108 +++++++++++++++++++++++++++++------- 1 file changed, 88 insertions(+), 20 deletions(-) diff --git a/docs/ai-chat/compaction.mdx b/docs/ai-chat/compaction.mdx index fa78ffc483d..5f2c61245e9 100644 --- a/docs/ai-chat/compaction.mdx +++ b/docs/ai-chat/compaction.mdx @@ -190,39 +190,107 @@ export const myChat = chat.task({ }); ``` -## Low-level compaction +## Using with chat.createSession() -For `chat.createSession()` or raw task mode, use `chat.compact()` and `chat.compactionStep()` directly inside a custom `prepareStep`: +Pass the same `compaction` config to `chat.createSession()`. The session handles outer-loop compaction automatically inside `turn.complete()`: ```ts -const result = streamText({ - model: openai("gpt-4o"), - messages, - prepareStep: async ({ messages: stepMessages, steps }) => { - const result = await chat.compact(stepMessages, steps, { - threshold: 80_000, - summarize: async (msgs) => - generateText({ model: openai("gpt-4o-mini"), messages: msgs }).then((r) => r.text), - }); - return result.type === "skipped" ? undefined : result; +const session = chat.createSession(payload, { + signal, + idleTimeoutInSeconds: 60, + timeout: "1h", + compaction: { + shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > 80_000, + summarize: async ({ messages }) => + generateText({ model: openai("gpt-4o-mini"), messages }).then((r) => r.text), + compactUIMessages: ({ uiMessages, summary }) => [ + { id: generateId(), role: "assistant", + parts: [{ type: "text", text: `[Summary]\n\n${summary}` }] }, + ...uiMessages.slice(-4), + ], }, }); + +for await (const turn of session) { + const result = streamText({ + model: openai("gpt-4o"), + messages: turn.messages, + abortSignal: turn.signal, + }); + + await turn.complete(result); + // Outer-loop compaction runs automatically after complete() + + await db.chat.update({ + where: { id: turn.chatId }, + data: { messages: turn.uiMessages }, + }); +} ``` -Or use the higher-level `chat.compactionStep()` factory: +## Using with raw tasks (MessageAccumulator) + +Pass `compaction` to the `MessageAccumulator` constructor. Use `prepareStep()` for inner-loop compaction and `compactIfNeeded()` for the outer loop: ```ts -const result = streamText({ - model: openai("gpt-4o"), - messages, - prepareStep: chat.compactionStep({ +const conversation = new chat.MessageAccumulator({ + compaction: { + shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > 80_000, + summarize: async ({ messages }) => + generateText({ model: openai("gpt-4o-mini"), messages }).then((r) => r.text), + compactUIMessages: ({ summary }) => [ + { id: generateId(), role: "assistant", + parts: [{ type: "text", text: `[Summary]\n\n${summary}` }] }, + ], + }, +}); + +for (let turn = 0; turn < 100; turn++) { + const messages = await conversation.addIncoming(payload.messages, payload.trigger, turn); + + const result = streamText({ + model: openai("gpt-4o"), + messages, + prepareStep: conversation.prepareStep(), // Inner-loop compaction + }); + + const response = await chat.pipeAndCapture(result); + if (response) await conversation.addResponse(response); + + // Outer-loop compaction + const usage = await result.totalUsage; + await conversation.compactIfNeeded(usage, { chatId: payload.chatId, turn }); + + await db.chat.update({ data: { messages: conversation.uiMessages } }); + await chat.writeTurnComplete(); +} +``` + +## Fully manual compaction + +For maximum control, use `chat.compact()` directly inside a custom `prepareStep`: + +```ts +prepareStep: async ({ messages: stepMessages, steps }) => { + const result = await chat.compact(stepMessages, steps, { threshold: 80_000, summarize: async (msgs) => generateText({ model: openai("gpt-4o-mini"), messages: msgs }).then((r) => r.text), - }), -}); + }); + return result.type === "skipped" ? undefined : result; +}, +``` + +Or use the `chat.compactionStep()` factory: + +```ts +prepareStep: chat.compactionStep({ + threshold: 80_000, + summarize: async (msgs) => + generateText({ model: openai("gpt-4o-mini"), messages: msgs }).then((r) => r.text), +}), ``` - The low-level APIs only handle inner-loop compaction (between tool-call steps). For full coverage including single-step turns, use the `compaction` option on `chat.task()`. + The fully manual APIs only handle inner-loop compaction (between tool-call steps). For outer-loop coverage, use the `compaction` option on `chat.task()`, `chat.createSession()`, or `MessageAccumulator`. From 6d8d015472b942df1013dc14befa521f8ee6454a Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 25 Mar 2026 14:25:39 +0000 Subject: [PATCH 05/13] docs: add prompts, compaction, and pending messages docs --- docs/ai-chat/backend.mdx | 27 +++ docs/ai-chat/pending-messages.mdx | 327 ++++++++++++++++++++++++++++++ docs/ai-chat/reference.mdx | 52 +++++ docs/docs.json | 1 + 4 files changed, 407 insertions(+) create mode 100644 docs/ai-chat/pending-messages.mdx diff --git a/docs/ai-chat/backend.mdx b/docs/ai-chat/backend.mdx index 6d80f0fec78..8a52e48e45d 100644 --- a/docs/ai-chat/backend.mdx +++ b/docs/ai-chat/backend.mdx @@ -521,6 +521,33 @@ export function Chat({ chatId, initialMessages, initialSessions }) { ``` +### 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.task({ + id: "my-chat", + pendingMessages: { + shouldInject: ({ steps }) => steps.length > 0, + }, + run: async ({ messages, signal }) => { + return streamText({ + ...chat.toStreamTextOptions({ registry }), + messages, + tools: { /* ... */ }, + abortSignal: signal, + }); + }, +}); +``` + +On the frontend, the `usePendingMessages` hook handles sending, tracking, and rendering injection points. + + + 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. + + ### prepareMessages Transform model messages before they're used anywhere — in `run()`, in compaction rebuilds, and in compaction results. Define once, applied everywhere. diff --git a/docs/ai-chat/pending-messages.mdx b/docs/ai-chat/pending-messages.mdx new file mode 100644 index 00000000000..3f0e9ecefda --- /dev/null +++ b/docs/ai-chat/pending-messages.mdx @@ -0,0 +1,327 @@ +--- +title: "Pending Messages" +sidebarTitle: "Pending Messages" +description: "Inject user messages mid-execution to steer agents between tool-call steps." +--- + +## Overview + +When an AI agent is executing tool calls, users may want to send a message that **steers the agent mid-execution** — adding context, correcting course, or refining the request without waiting for the response to finish. + +The `pendingMessages` option enables this by injecting user messages between tool-call steps via the AI SDK's `prepareStep`. Messages that arrive during streaming are queued and injected at the next step boundary. If there are no more step boundaries (single-step response or final text generation), the message becomes the next turn automatically. + +## How it works + +1. User sends a message while the agent is streaming +2. The message is sent to the backend via input stream (`transport.sendPendingMessage`) +3. The backend queues it in the steering queue +4. At the next `prepareStep` boundary (between tool-call steps), `shouldInject` is called +5. If it returns `true`, the message is injected into the LLM's context +6. A `data-pending-message-injected` stream chunk confirms injection to the frontend +7. If `prepareStep` never fires (no tool calls), the message becomes the next turn + +## Backend: chat.task + +Add `pendingMessages` to your `chat.task` configuration: + +```ts +import { chat } from "@trigger.dev/sdk/ai"; +import { streamText } from "ai"; +import { openai } from "@ai-sdk/openai"; + +export const myChat = chat.task({ + id: "my-chat", + pendingMessages: { + // Only inject when there are completed steps (tool calls happened) + shouldInject: ({ steps }) => steps.length > 0, + }, + run: async ({ messages, signal }) => { + return streamText({ + ...chat.toStreamTextOptions({ registry }), + messages, + tools: { /* ... */ }, + abortSignal: signal, + }); + }, +}); +``` + +The `prepareStep` for injection is automatically included when you spread `chat.toStreamTextOptions()`. If you provide your own `prepareStep` after the spread, it overrides the auto-injected one. + +### Options + +| Option | Type | Description | +|--------|------|-------------| +| `shouldInject` | `(event: PendingMessagesBatchEvent) => boolean` | Decide whether to inject the batch. Called once per step boundary. If absent, no injection happens. | +| `prepare` | `(event: PendingMessagesBatchEvent) => ModelMessage[]` | Transform the batch before injection. Default: convert each message via `convertToModelMessages`. | +| `onReceived` | `(event) => void` | Called when a message arrives during streaming (per-message). | +| `onInjected` | `(event) => void` | Called after a batch is injected. | + +### shouldInject + +Called once per step boundary with the full batch of pending messages. Return `true` to inject all of them, `false` to skip (they'll be available at the next boundary or become the next turn). + +```ts +pendingMessages: { + // Always inject + shouldInject: () => true, + + // Only inject after tool calls + shouldInject: ({ steps }) => steps.length > 0, + + // Only inject if there's one message + shouldInject: ({ messages }) => messages.length === 1, +}, +``` + +The event includes: + +| Field | Type | Description | +|-------|------|-------------| +| `messages` | `UIMessage[]` | All pending messages (batch) | +| `modelMessages` | `ModelMessage[]` | Current conversation | +| `steps` | `CompactionStep[]` | Completed steps | +| `stepNumber` | `number` | Current step (0-indexed) | +| `chatId` | `string` | Chat session ID | +| `turn` | `number` | Current turn | +| `clientData` | `unknown` | Frontend metadata | + +### prepare + +Transform the batch of pending messages before they're injected into the LLM's context. By default, each UIMessage is converted to ModelMessages individually. Use `prepare` to combine multiple messages or add context: + +```ts +pendingMessages: { + shouldInject: ({ steps }) => steps.length > 0, + prepare: ({ messages }) => [{ + role: "user", + content: messages.length === 1 + ? messages[0].parts[0]?.text ?? "" + : `The user sent ${messages.length} messages:\n${ + messages.map((m, i) => `${i + 1}. ${m.parts[0]?.text}`).join("\n") + }`, + }], +}, +``` + +### Stream chunk + +When messages are injected, the SDK automatically writes a `data-pending-message-injected` stream chunk containing the message IDs and text. The frontend uses this to: +- Confirm which messages were injected +- Remove them from the pending overlay +- Render them inline at the injection point in the assistant response + +A "pending message injected" span also appears in the run trace. + +## Backend: chat.createSession + +Pass `pendingMessages` to the session options: + +```ts +const session = chat.createSession(payload, { + signal, + idleTimeoutInSeconds: 60, + pendingMessages: { + shouldInject: () => true, + }, +}); + +for await (const turn of session) { + const result = streamText({ + model: openai("gpt-4o"), + messages: turn.messages, + abortSignal: turn.signal, + prepareStep: turn.prepareStep(), // Handles injection + compaction + }); + + await turn.complete(result); +} +``` + +Use `turn.prepareStep()` to get a prepareStep function that handles both injection and compaction. Users who spread `chat.toStreamTextOptions()` get it automatically. + +## Backend: MessageAccumulator (raw task) + +Pass `pendingMessages` to the constructor and wire up the message listener manually: + +```ts +const conversation = new chat.MessageAccumulator({ + pendingMessages: { + shouldInject: () => true, + prepare: ({ messages }) => [{ + role: "user", + content: `[Steering]: ${messages.map(m => m.parts[0]?.text).join(", ")}`, + }], + }, +}); + +for (let turn = 0; turn < 100; turn++) { + const messages = await conversation.addIncoming(payload.messages, payload.trigger, turn); + + // Listen for steering messages during streaming + const sub = chat.messages.on(async (msg) => { + const lastMsg = msg.messages?.[msg.messages.length - 1]; + if (lastMsg) await conversation.steerAsync(lastMsg); + }); + + const result = streamText({ + model: openai("gpt-4o"), + messages, + prepareStep: conversation.prepareStep(), // Handles injection + compaction + }); + + const response = await chat.pipeAndCapture(result); + sub.off(); + + if (response) await conversation.addResponse(response); + await chat.writeTurnComplete(); +} +``` + +### MessageAccumulator methods + +| Method | Description | +|--------|-------------| +| `steer(message, modelMessages?)` | Queue a UIMessage for injection (sync) | +| `steerAsync(message)` | Queue a UIMessage, converting to model messages automatically | +| `drainSteering()` | Get and clear unconsumed steering messages | +| `prepareStep()` | Returns a prepareStep function handling injection + compaction | + +## Frontend: usePendingMessages hook + +The `usePendingMessages` hook manages all the frontend complexity — tracking pending messages, detecting injections, and handling the turn lifecycle. + +```tsx +import { useChat } from "@ai-sdk/react"; +import { useTriggerChatTransport, usePendingMessages } from "@trigger.dev/sdk/chat/react"; + +function Chat({ chatId }) { + const transport = useTriggerChatTransport({ task: "my-chat", accessToken }); + + const { messages, setMessages, sendMessage, stop, status } = useChat({ + id: chatId, + transport, + }); + + const pending = usePendingMessages({ + transport, + chatId, + status, + messages, + setMessages, + sendMessage, + metadata: { model: "gpt-4o" }, + }); + + return ( +
+ {/* Render messages */} + {messages.map((msg) => ( +
+ {msg.role === "assistant" ? ( + msg.parts.map((part, i) => + pending.isInjectionPoint(part) ? ( + // Render injected messages inline at the injection point +
+ {pending.getInjectedMessages(part).map((m) => ( +
{m.text}
+ ))} +
+ ) : ( + + ) + ) + ) : ( + + )} +
+ ))} + + {/* Render pending messages */} + {pending.pending.map((msg) => ( +
+ {msg.text} + {msg.mode === "steering" ? "Steering" : "Queued"} + {msg.mode === "queued" && status === "streaming" && ( + + )} +
+ ))} + + {/* Send form */} +
{ + e.preventDefault(); + pending.steer(input); // Steers during streaming, sends normally when ready + setInput(""); + }}> + setInput(e.target.value)} /> + + {status === "streaming" && ( + + )} +
+
+ ); +} +``` + +### Hook API + +| Property/Method | Type | Description | +|----------------|------|-------------| +| `pending` | `PendingMessage[]` | Current pending messages with `id`, `text`, `mode`, and `injected` status | +| `steer(text)` | `(text: string) => void` | Send a steering message during streaming, or normal message when ready | +| `queue(text)` | `(text: string) => void` | Queue for next turn during streaming, or send normally when ready | +| `promoteToSteering(id)` | `(id: string) => void` | Convert a queued message to steering (sends via input stream immediately) | +| `isInjectionPoint(part)` | `(part: unknown) => boolean` | Check if an assistant message part is an injection confirmation | +| `getInjectedMessageIds(part)` | `(part: unknown) => string[]` | Get message IDs from an injection point | +| `getInjectedMessages(part)` | `(part: unknown) => InjectedMessage[]` | Get messages (id + text) from an injection point | + +### PendingMessage + +| Field | Type | Description | +|-------|------|-------------| +| `id` | `string` | Unique message ID | +| `text` | `string` | Message text | +| `mode` | `"steering" \| "queued"` | How the message is being handled | +| `injected` | `boolean` | Whether the backend confirmed injection | + +### Message lifecycle + +- **Steering messages** are sent via `transport.sendPendingMessage()` immediately. They appear as purple pending bubbles. If injected, they disappear from the overlay and render inline at the injection point. If not injected (no more step boundaries), they auto-send as the next turn when the response finishes. + +- **Queued messages** stay client-side until the turn completes, then auto-send as the next turn via `sendMessage()`. They can be promoted to steering mid-stream by clicking "Steer instead". + +- **Promoted messages** are queued messages that were converted to steering. They get sent via input stream immediately and follow the steering lifecycle from that point. + +## Transport: sendPendingMessage + +The `TriggerChatTransport` exposes a `sendPendingMessage` method for sending messages via input stream without disrupting the active stream subscription: + +```ts +const sent = await transport.sendPendingMessage(chatId, { + id: crypto.randomUUID(), + role: "user", + parts: [{ type: "text", text: "and compare to vercel" }], +}, { model: "gpt-4o" }); +``` + +Unlike `sendMessage()` from useChat, this does NOT: +- Add the message to useChat's local state +- Cancel the active stream subscription +- Start a new response stream + +The `usePendingMessages` hook calls this internally — you typically don't need to use it directly. + +## Coexistence with compaction + +Pending message injection and compaction both use `prepareStep`. When both are configured, the auto-injected `prepareStep` handles them in order: + +1. **Compaction** runs first — checks threshold, generates summary if needed +2. **Injection** runs second — pending messages are appended to either the compacted or original messages + +This means injected messages are always included after compaction, ensuring the LLM sees both the compressed history and the new steering input. diff --git a/docs/ai-chat/reference.mdx b/docs/ai-chat/reference.mdx index ad738d305a4..c3bc8811614 100644 --- a/docs/ai-chat/reference.mdx +++ b/docs/ai-chat/reference.mdx @@ -19,6 +19,7 @@ Options for `chat.task()`. | `onTurnComplete` | `(event: TurnCompleteEvent) => Promise \| void` | — | Fires after each turn completes | | `onCompacted` | `(event: CompactedEvent) => Promise \| void` | — | Fires when compaction occurs. See [Compaction](/ai-chat/compaction) | | `compaction` | `ChatTaskCompactionOptions` | — | 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.) | | `maxTurns` | `number` | `100` | Max conversational turns per run | | `turnTimeout` | `string` | `"1h"` | How long to wait for next message | @@ -153,6 +154,57 @@ Passed to the `onCompacted` callback. | `chatId` | `string \| undefined` | Chat session ID | | `turn` | `number \| undefined` | Current turn | +## PendingMessagesOptions + +Options for the `pendingMessages` field. See [Pending Messages](/ai-chat/pending-messages) for usage guide. + +| Option | Type | Required | Description | +|--------|------|----------|-------------| +| `shouldInject` | `(event: PendingMessagesBatchEvent) => boolean \| Promise` | No | Decide whether to inject the batch between tool-call steps. If absent, no injection. | +| `prepare` | `(event: PendingMessagesBatchEvent) => ModelMessage[] \| Promise` | No | Transform the batch before injection. Default: convert each via `convertToModelMessages`. | +| `onReceived` | `(event: PendingMessageReceivedEvent) => void \| Promise` | No | Called when a message arrives during streaming (per-message). | +| `onInjected` | `(event: PendingMessagesInjectedEvent) => void \| Promise` | No | Called after a batch is injected via prepareStep. | + +## PendingMessagesBatchEvent + +Passed to `shouldInject` and `prepare` callbacks. + +| Field | Type | Description | +|-------|------|-------------| +| `messages` | `UIMessage[]` | All pending messages (batch) | +| `modelMessages` | `ModelMessage[]` | Current conversation | +| `steps` | `CompactionStep[]` | Completed steps so far | +| `stepNumber` | `number` | Current step (0-indexed) | +| `chatId` | `string` | Chat session ID | +| `turn` | `number` | Current turn (0-indexed) | +| `clientData` | `unknown` | Custom data from the frontend | + +## PendingMessagesInjectedEvent + +Passed to `onInjected` callback. + +| Field | Type | Description | +|-------|------|-------------| +| `messages` | `UIMessage[]` | All injected UI messages | +| `injectedModelMessages` | `ModelMessage[]` | The model messages that were injected | +| `chatId` | `string` | Chat session ID | +| `turn` | `number` | Current turn | +| `stepNumber` | `number` | Step where injection occurred | + +## UsePendingMessagesReturn + +Return value of `usePendingMessages` hook. See [Pending Messages — Frontend](/ai-chat/pending-messages#frontend-usependingmessages-hook). + +| Property/Method | Type | Description | +|-----------------|------|-------------| +| `pending` | `PendingMessage[]` | Current pending messages with mode and injection status | +| `steer` | `(text: string) => void` | Send a steering message (or normal message when not streaming) | +| `queue` | `(text: string) => void` | Queue for next turn (or send normally when not streaming) | +| `promoteToSteering` | `(id: string) => void` | Convert a queued message to steering | +| `isInjectionPoint` | `(part: unknown) => boolean` | Check if an assistant message part is an injection confirmation | +| `getInjectedMessageIds` | `(part: unknown) => string[]` | Get message IDs from an injection point | +| `getInjectedMessages` | `(part: unknown) => InjectedMessage[]` | Get messages (id + text) from an injection point | + ## ChatSessionOptions Options for `chat.createSession()`. diff --git a/docs/docs.json b/docs/docs.json index ab034079f52..991b7ca7b89 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -93,6 +93,7 @@ "ai-chat/frontend", "ai-chat/features", "ai-chat/compaction", + "ai-chat/pending-messages", "ai-chat/reference" ] } From 0f917d6b8ada2c26b89b73a19e46b55bb4754e98 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 26 Mar 2026 08:20:57 +0000 Subject: [PATCH 06/13] document the writer stuff --- docs/ai-chat/backend.mdx | 38 +++++++++++++++++++++++++++++++++++- docs/ai-chat/features.mdx | 4 ++++ docs/ai-chat/reference.mdx | 40 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/docs/ai-chat/backend.mdx b/docs/ai-chat/backend.mdx index 8a52e48e45d..81f42c1023d 100644 --- a/docs/ai-chat/backend.mdx +++ b/docs/ai-chat/backend.mdx @@ -101,6 +101,9 @@ export const myChat = chat.task({ | `runId` | `string` | The Trigger.dev run ID | | `chatAccessToken` | `string` | Scoped access token for this run | | `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | +| `writer` | [`ChatWriter`](/ai-chat/reference#chatwriter) | Stream writer for custom chunks | + +Every lifecycle callback receives a `writer` — a lazy stream writer that lets you send custom `UIMessageChunk` parts (like `data-*` parts) to the frontend without the ceremony of `chat.stream.writer()`. See [ChatWriter](/ai-chat/reference#chatwriter). #### onChatStart @@ -145,6 +148,7 @@ Fires at the start of every turn, after message accumulation and `onChatStart` ( | `continuation` | `boolean` | Whether this run is continuing an existing chat | | `preloaded` | `boolean` | Whether this run was preloaded | | `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | +| `writer` | [`ChatWriter`](/ai-chat/reference#chatwriter) | Stream writer for custom chunks | ```ts export const myChat = chat.task({ @@ -170,9 +174,41 @@ export const myChat = chat.task({ By persisting in `onTurnStart`, the user's message is saved to your database before the AI starts streaming. If the user refreshes mid-stream, the message is already there. +#### onBeforeTurnComplete + +Fires after the response is captured but **before** the stream closes. The `writer` can send custom chunks that appear in the current turn — use this for post-processing indicators, compaction progress, or any data the user should see before the turn ends. + +```ts +export const myChat = chat.task({ + id: "my-chat", + onBeforeTurnComplete: async ({ writer, usage, uiMessages }) => { + // Write a custom data part while the stream is still open + writer.write({ + type: "data-usage-summary", + data: { + tokens: usage?.totalTokens, + messageCount: uiMessages.length, + }, + }); + + // You can also compact messages here and write progress + if (usage?.totalTokens && usage.totalTokens > 50_000) { + writer.write({ type: "data-compaction", data: { status: "compacting" } }); + chat.setMessages(compactedMessages); + writer.write({ type: "data-compaction", data: { status: "complete" } }); + } + }, + run: async ({ messages, signal }) => { + return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }); + }, +}); +``` + +Receives the same fields as [`TurnCompleteEvent`](/ai-chat/reference#turncompleteevent), plus a [`writer`](/ai-chat/reference#chatwriter). + #### onTurnComplete -Fires after each turn completes — after the response is captured, before waiting for the next message. This is the primary hook for persisting the assistant's response. +Fires after each turn completes — after the response is captured and the stream is closed. This is the primary hook for persisting the assistant's response. Does not include a `writer` since the stream is already closed. | Field | Type | Description | |-------|------|-------------| diff --git a/docs/ai-chat/features.mdx b/docs/ai-chat/features.mdx index 9de3d13bf7e..789c17531ec 100644 --- a/docs/ai-chat/features.mdx +++ b/docs/ai-chat/features.mdx @@ -207,6 +207,10 @@ export const myChat = chat.task({ 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. + + Inside lifecycle callbacks (`onPreload`, `onChatStart`, `onTurnStart`, `onBeforeTurnComplete`, `onCompacted`), you can use the `writer` parameter instead of `chat.stream.writer()` — it's simpler and avoids the `execute` + `waitUntilComplete` boilerplate. See [ChatWriter](/ai-chat/reference#chatwriter). + + `chat.stream` exposes the full stream API: | Method | Description | diff --git a/docs/ai-chat/reference.mdx b/docs/ai-chat/reference.mdx index c3bc8811614..e7b59187233 100644 --- a/docs/ai-chat/reference.mdx +++ b/docs/ai-chat/reference.mdx @@ -16,8 +16,9 @@ Options for `chat.task()`. | `onPreload` | `(event: PreloadEvent) => Promise \| void` | — | Fires on preloaded runs before the first message | | `onChatStart` | `(event: ChatStartEvent) => Promise \| void` | — | Fires on turn 0 before `run()` | | `onTurnStart` | `(event: TurnStartEvent) => Promise \| void` | — | Fires every turn before `run()` | -| `onTurnComplete` | `(event: TurnCompleteEvent) => Promise \| void` | — | Fires after each turn completes | -| `onCompacted` | `(event: CompactedEvent) => Promise \| void` | — | Fires when compaction occurs. See [Compaction](/ai-chat/compaction) | +| `onBeforeTurnComplete` | `(event: BeforeTurnCompleteEvent) => Promise \| void` | — | Fires after response but before stream closes. Includes `writer`. | +| `onTurnComplete` | `(event: TurnCompleteEvent) => Promise \| void` | — | Fires after each turn completes (stream closed) | +| `onCompacted` | `(event: CompactedEvent) => Promise \| void` | — | Fires when compaction occurs. Includes `writer`. See [Compaction](/ai-chat/compaction) | | `compaction` | `ChatTaskCompactionOptions` | — | 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.) | @@ -57,6 +58,7 @@ Passed to the `onPreload` callback. | `runId` | `string` | The Trigger.dev run ID | | `chatAccessToken` | `string` | Scoped access token for this run | | `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | +| `writer` | [`ChatWriter`](#chatwriter) | Stream writer for custom chunks. Lazy — no overhead if unused. | ## ChatStartEvent @@ -72,6 +74,7 @@ Passed to the `onChatStart` callback. | `continuation` | `boolean` | Whether this run is continuing an existing chat | | `previousRunId` | `string \| undefined` | Previous run ID (only when `continuation` is true) | | `preloaded` | `boolean` | Whether this run was preloaded before the first message | +| `writer` | [`ChatWriter`](#chatwriter) | Stream writer for custom chunks. Lazy — no overhead if unused. | ## TurnStartEvent @@ -89,6 +92,7 @@ Passed to the `onTurnStart` callback. | `continuation` | `boolean` | Whether this run is continuing an existing chat | | `previousRunId` | `string \| undefined` | Previous run ID (only when `continuation` is true) | | `preloaded` | `boolean` | Whether this run was preloaded | +| `writer` | [`ChatWriter`](#chatwriter) | Stream writer for custom chunks. Lazy — no overhead if unused. | ## TurnCompleteEvent @@ -112,6 +116,37 @@ Passed to the `onTurnComplete` callback. | `usage` | `LanguageModelUsage \| undefined` | Token usage for this turn | | `totalUsage` | `LanguageModelUsage` | Cumulative token usage across all turns | +## BeforeTurnCompleteEvent + +Passed to the `onBeforeTurnComplete` callback. Same fields as `TurnCompleteEvent` plus a `writer`. + +| Field | Type | Description | +|-------|------|-------------| +| _(all TurnCompleteEvent fields)_ | | See [TurnCompleteEvent](#turncompleteevent) | +| `writer` | [`ChatWriter`](#chatwriter) | Stream writer — the stream is still open so chunks appear in the current turn | + +## ChatWriter + +A stream writer passed to lifecycle callbacks. Write custom `UIMessageChunk` parts (e.g. `data-*` parts) to the chat stream. + +The writer is lazy — no stream is opened unless you call `write()` or `merge()`, so there's zero overhead for callbacks that don't use it. + +| Method | Type | Description | +|--------|------|-------------| +| `write(part)` | `(part: UIMessageChunk) => void` | Write a single chunk to the chat stream | +| `merge(stream)` | `(stream: ReadableStream) => void` | Merge another stream's chunks into the chat stream | + +```ts +onTurnStart: async ({ writer }) => { + // Write a custom data part — render it on the frontend + writer.write({ type: "data-status", data: { loading: true } }); +}, +onBeforeTurnComplete: async ({ writer, usage }) => { + // Stream is still open — these chunks arrive before the turn ends + writer.write({ type: "data-usage", data: { tokens: usage?.totalTokens } }); +}, +``` + ## ChatTaskCompactionOptions Options for the `compaction` field on `chat.task()`. See [Compaction](/ai-chat/compaction) for usage guide. @@ -153,6 +188,7 @@ Passed to the `onCompacted` callback. | `stepNumber` | `number` | Step number (-1 for outer loop) | | `chatId` | `string \| undefined` | Chat session ID | | `turn` | `number \| undefined` | Current turn | +| `writer` | [`ChatWriter`](#chatwriter) | Stream writer for custom chunks during compaction | ## PendingMessagesOptions From 5483af7486f46628c40b889dc6b3ca251399baa9 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 26 Mar 2026 15:32:22 +0000 Subject: [PATCH 07/13] Add background injection docs --- docs/ai-chat/backend.mdx | 28 ++++ docs/ai-chat/background-injection.mdx | 192 ++++++++++++++++++++++++++ docs/docs.json | 1 + 3 files changed, 221 insertions(+) create mode 100644 docs/ai-chat/background-injection.mdx diff --git a/docs/ai-chat/backend.mdx b/docs/ai-chat/backend.mdx index 81f42c1023d..f2bc0560c6a 100644 --- a/docs/ai-chat/backend.mdx +++ b/docs/ai-chat/backend.mdx @@ -584,6 +584,34 @@ On the frontend, the `usePendingMessages` hook handles sending, tracking, and re 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. +### 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.task({ + 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 }); + }, +}); +``` + + + See [Background Injection](/ai-chat/background-injection) for the full guide — timing, self-review example, and how it differs from pending messages. + + ### prepareMessages Transform model messages before they're used anywhere — in `run()`, in compaction rebuilds, and in compaction results. Define once, applied everywhere. diff --git a/docs/ai-chat/background-injection.mdx b/docs/ai-chat/background-injection.mdx new file mode 100644 index 00000000000..b50c86329f6 --- /dev/null +++ b/docs/ai-chat/background-injection.mdx @@ -0,0 +1,192 @@ +--- +title: "Background injection" +sidebarTitle: "Background injection" +description: "Inject context from background work into the agent's conversation — self-review, RAG augmentation, or any async analysis." +--- + +## Overview + +`chat.inject()` queues model messages for injection into the conversation. Messages are picked up at the start of the next turn or at the next `prepareStep` boundary (between tool-call steps). + +This is the backend counterpart to [pending messages](/ai-chat/pending-messages) — pending messages come from the user via the frontend, while `chat.inject()` comes from your task code. + +## Basic usage + +```ts +import { chat } from "@trigger.dev/sdk/ai"; + +// Queue a system message for injection +chat.inject([ + { + role: "system", + content: "The user's account was just upgraded to Pro.", + }, +]); +``` + +Messages are appended to the model messages before the next LLM inference call. The LLM sees them as part of the conversation context. + +## Common pattern: defer + inject + +The most powerful pattern combines `chat.defer()` (background work) with `chat.inject()` (inject results). Background work runs in parallel with the idle wait between turns, and results are injected before the next response. + +```ts +export const myChat = chat.task({ + id: "my-chat", + onTurnComplete: async ({ messages }) => { + // Kick off background analysis — doesn't block the turn + chat.defer( + (async () => { + const analysis = await analyzeConversation(messages); + chat.inject([ + { + role: "system", + content: `[Analysis of conversation so far]\n\n${analysis}`, + }, + ]); + })() + ); + }, + run: async ({ messages, signal }) => { + return streamText({ + ...chat.toStreamTextOptions({ registry }), + messages, + abortSignal: signal, + }); + }, +}); +``` + +### Timing + +1. Turn completes, `onTurnComplete` fires +2. `chat.defer()` registers the background work +3. The run immediately starts waiting for the next message (no blocking) +4. Background work completes, `chat.inject()` queues the messages +5. User sends next message, turn starts +6. Injected messages are appended before `run()` executes +7. The LLM sees the injected context alongside the new user message + +If the background work finishes *during* a tool-call loop (not between turns), the messages are picked up at the next `prepareStep` boundary instead. + +## Example: self-review + +A cheap model reviews the agent's response after each turn and injects coaching for the next one. Uses [Prompts](/ai/prompts) for the review prompt and `generateObject` for structured output. + +```ts +import { chat } from "@trigger.dev/sdk/ai"; +import { prompts } from "@trigger.dev/sdk"; +import { streamText, generateObject, createProviderRegistry } from "ai"; +import { openai } from "@ai-sdk/openai"; +import { z } from "zod"; + +const registry = createProviderRegistry({ openai }); + +const selfReviewPrompt = prompts.define({ + id: "self-review", + model: "openai:gpt-4o-mini", + content: `You are a conversation quality reviewer. Analyze the assistant's most recent response. + +Focus on: +- Whether the response answered the user's question +- Missed opportunities to use tools or provide more detail +- Tone mismatches + +Be concise. Only flag issues worth fixing.`, +}); + +export const myChat = chat.task({ + id: "my-chat", + onTurnComplete: async ({ messages }) => { + chat.defer( + (async () => { + const resolved = await selfReviewPrompt.resolve({}); + + const review = await generateObject({ + model: registry.languageModel(resolved.model ?? "openai:gpt-4o-mini"), + ...resolved.toAISDKTelemetry(), + system: resolved.text, + prompt: messages + .filter((m) => m.role === "user" || m.role === "assistant") + .map((m) => { + const text = + typeof m.content === "string" + ? m.content + : Array.isArray(m.content) + ? m.content + .filter((p: any) => p.type === "text") + .map((p: any) => p.text) + .join("") + : ""; + return `${m.role}: ${text}`; + }) + .join("\n\n"), + schema: z.object({ + needsImprovement: z.boolean(), + suggestions: z.array(z.string()), + }), + }); + + if (review.object.needsImprovement) { + chat.inject([ + { + role: "system", + content: `[Self-review]\n\n${review.object.suggestions.map((s) => `- ${s}`).join("\n")}\n\nApply these naturally.`, + }, + ]); + } + })() + ); + }, + run: async ({ messages, signal }) => { + return streamText({ + ...chat.toStreamTextOptions({ registry }), + messages, + abortSignal: signal, + }); + }, +}); +``` + +The self-review runs on `gpt-4o-mini` (fast, cheap) in the background. If the user sends another message before it completes, the coaching is still injected — `chat.inject()` persists across the idle wait. + +## Other use cases + +- **RAG augmentation**: After each turn, fetch relevant documents and inject them as context for the next response +- **Safety checks**: Run a moderation model on the response, inject warnings if issues are detected +- **Fact-checking**: Verify claims in the response using search tools, inject corrections +- **Context enrichment**: Look up user/account data based on what was discussed, inject it as system context + +## How it differs from pending messages + +| | `chat.inject()` | [Pending messages](/ai-chat/pending-messages) | +|---|---|---| +| **Source** | Backend task code | Frontend user input | +| **Triggered by** | Your code (e.g. `onTurnComplete` + `chat.defer()`) | User sending a message during streaming | +| **Injection point** | Start of next turn, or next `prepareStep` boundary | Next `prepareStep` boundary only | +| **Message role** | Any (`system`, `user`, `assistant`) | Typically `user` | +| **Frontend visibility** | Not visible unless you write custom `data-*` chunks | Visible via `usePendingMessages` hook | + +## API reference + +### chat.inject() + +```ts +chat.inject(messages: ModelMessage[]): void +``` + +Queue model messages for injection at the next opportunity. Messages persist across the idle wait between turns — they are not reset when a new turn starts. + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `messages` | `ModelMessage[]` | Model messages to inject (from the `ai` package) | + +Messages are drained (consumed) when: +1. A new turn starts — before `run()` executes +2. A `prepareStep` boundary is reached — between tool-call steps during streaming + + + `chat.inject()` writes to an in-memory queue in the current process. It works from any code running in the same task — lifecycle hooks, deferred work, tool execute functions, etc. It does not work from subtasks or other runs. + diff --git a/docs/docs.json b/docs/docs.json index 991b7ca7b89..b49db4aafab 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -94,6 +94,7 @@ "ai-chat/features", "ai-chat/compaction", "ai-chat/pending-messages", + "ai-chat/background-injection", "ai-chat/reference" ] } From 8549dcfeba0dbadd32758b0476a6a14b15bba3e3 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 27 Mar 2026 14:29:17 +0000 Subject: [PATCH 08/13] docs(ai-chat): add Types page, link toolExecute and withUIMessage, fix MDX headings --- docs/ai-chat/backend.mdx | 4 + docs/ai-chat/features.mdx | 32 ++++-- docs/ai-chat/frontend.mdx | 17 ++++ docs/ai-chat/overview.mdx | 1 + docs/ai-chat/quick-start.mdx | 5 + docs/ai-chat/reference.mdx | 44 +++++++++ docs/ai-chat/types.mdx | 137 ++++++++++++++++++++++++++ docs/docs.json | 1 + docs/migrating-from-v3.mdx | 4 +- docs/snippets/migrate-v4-using-ai.mdx | 17 ++-- docs/tasks/schemaTask.mdx | 112 ++++++++++++--------- 11 files changed, 310 insertions(+), 64 deletions(-) create mode 100644 docs/ai-chat/types.mdx diff --git a/docs/ai-chat/backend.mdx b/docs/ai-chat/backend.mdx index f2bc0560c6a..2e48a6b2b57 100644 --- a/docs/ai-chat/backend.mdx +++ b/docs/ai-chat/backend.mdx @@ -8,6 +8,10 @@ description: "Three approaches to building your chat backend — chat.task(), se The highest-level approach. Handles message accumulation, stop signals, turn lifecycle, and auto-piping automatically. + + To fix a **custom** `UIMessage` subtype (typed custom data parts, tool map, etc.), use [`chat.withUIMessage<...>().task({...})`](/ai-chat/types) instead of `chat.task({...})`. Options are the same; defaults for `toUIMessageStream()` can be set on `withUIMessage`. + + ### Simple: return a StreamTextResult Return the `streamText` result from `run` and it's automatically piped to the frontend: diff --git a/docs/ai-chat/features.mdx b/docs/ai-chat/features.mdx index 789c17531ec..ccceebedeea 100644 --- a/docs/ai-chat/features.mdx +++ b/docs/ai-chat/features.mdx @@ -8,7 +8,7 @@ description: "Per-run data, deferred work, custom streaming, subtask integration Use `chat.local` to create typed, run-scoped data that persists across turns and is accessible from anywhere — the run function, tools, nested helpers. Each run gets its own isolated copy, and locals are automatically cleared between runs. -When a subtask is invoked via `ai.tool()`, initialized locals are automatically serialized into the subtask's metadata and hydrated on first access — no extra code needed. Subtask changes to hydrated locals are local to the subtask and don't propagate back to the parent. +When a subtask is invoked via `ai.toolExecute()` (or the deprecated `ai.tool()`), initialized locals are automatically serialized into the subtask's metadata and hydrated on first access — no extra code needed. Subtask changes to hydrated locals are local to the subtask and don't propagate back to the parent. ### Declaring and initializing @@ -76,18 +76,18 @@ const premiumTool = tool({ ### Accessing from subtasks -When you use `ai.tool()` to expose a subtask, chat locals are automatically available read-only: +When you use `ai.toolExecute()` inside AI SDK `tool()` to expose a subtask, chat locals are automatically available read-only: ```ts import { chat, ai } from "@trigger.dev/sdk/ai"; import { schemaTask } from "@trigger.dev/sdk"; -import { streamText } from "ai"; +import { streamText, tool } from "ai"; import { openai } from "@ai-sdk/openai"; import { z } from "zod"; const userContext = chat.local<{ name: string; plan: "free" | "pro" }>({ id: "userContext" }); -export const analyzeData = schemaTask({ +export const analyzeDataTask = schemaTask({ id: "analyze-data", schema: z.object({ query: z.string() }), run: async ({ query }) => { @@ -97,6 +97,12 @@ export const analyzeData = schemaTask({ }, }); +const analyzeData = tool({ + description: analyzeDataTask.description ?? "", + inputSchema: analyzeDataTask.schema!, + execute: ai.toolExecute(analyzeDataTask), +}); + export const myChat = chat.task({ id: "my-chat", onChatStart: async ({ clientData }) => { @@ -106,7 +112,7 @@ export const myChat = chat.task({ return streamText({ model: openai("gpt-4o"), messages, - tools: { analyzeData: ai.tool(analyzeData) }, + tools: { analyzeData }, abortSignal: signal, }); }, @@ -227,7 +233,8 @@ When a tool invokes a subtask via `triggerAndWait`, the subtask can stream direc ```ts import { chat, ai } from "@trigger.dev/sdk/ai"; import { schemaTask } from "@trigger.dev/sdk"; -import { streamText, generateId } from "ai"; +import { streamText, tool, generateId } from "ai"; +import { openai } from "@ai-sdk/openai"; import { z } from "zod"; // A subtask that streams progress back to the parent chat @@ -271,7 +278,12 @@ export const researchTask = schemaTask({ }, }); -// The chat task uses it as a tool via ai.tool() +const research = tool({ + description: researchTask.description ?? "", + inputSchema: researchTask.schema!, + execute: ai.toolExecute(researchTask), +}); + export const myChat = chat.task({ id: "my-chat", run: async ({ messages, signal }) => { @@ -280,7 +292,7 @@ export const myChat = chat.task({ messages, abortSignal: signal, tools: { - research: ai.tool(researchTask), + research, }, }); }, @@ -311,9 +323,9 @@ The `target` option accepts: --- -## ai.tool() — subtask integration +## Task tool subtasks (`ai.toolExecute`) -When a subtask runs via `ai.tool()`, it can access the tool call context and chat context from the parent: +When a subtask runs through **`execute: ai.toolExecute(task)`** (or the deprecated `ai.tool()`), it can access the tool call context and chat context from the parent: ```ts import { ai, chat } from "@trigger.dev/sdk/ai"; diff --git a/docs/ai-chat/frontend.mdx b/docs/ai-chat/frontend.mdx index 0e7854e4d5d..8c0d8cff9da 100644 --- a/docs/ai-chat/frontend.mdx +++ b/docs/ai-chat/frontend.mdx @@ -31,6 +31,23 @@ The transport is created once on first render and reused across re-renders. Pass The hook keeps `onSessionChange` up to date via a ref internally, so you don't need to memoize the callback or worry about stale closures. +## Typed messages (`chat.withUIMessage`) + +If your chat task is defined with [`chat.withUIMessage()`](/ai-chat/types) (custom `data-*` parts, typed tools, etc.), pass the same message type through `useChat` so `messages` and `message.parts` are narrowed on the client: + +```tsx +import { useChat } from "@ai-sdk/react"; +import { useTriggerChatTransport, type InferChatUIMessage } from "@trigger.dev/sdk/chat/react"; +import type { myChat } from "./myChat"; + +type Msg = InferChatUIMessage; + +const transport = useTriggerChatTransport({ task: "my-chat", accessToken: getChatToken }); +const { messages } = useChat({ transport }); +``` + +See the [Types](/ai-chat/types) guide for defining `YourUIMessage`, default stream options, and backend examples. + ### Dynamic access tokens For token refresh, pass a function instead of a string. It's called on each `sendMessage`: diff --git a/docs/ai-chat/overview.mdx b/docs/ai-chat/overview.mdx index a1d207c7993..3fe6d0f3ec2 100644 --- a/docs/ai-chat/overview.mdx +++ b/docs/ai-chat/overview.mdx @@ -157,5 +157,6 @@ There are three ways to build the backend, from most opinionated to most flexibl - [Quick Start](/ai-chat/quick-start) — Get a working chat in 3 steps - [Backend](/ai-chat/backend) — Backend approaches in detail - [Frontend](/ai-chat/frontend) — Transport setup, sessions, client data +- [Types](/ai-chat/types) — TypeScript patterns, including custom `UIMessage` with `chat.withUIMessage` - [Features](/ai-chat/features) — Per-run data, deferred work, streaming, subtasks - [API Reference](/ai-chat/reference) — Complete reference tables diff --git a/docs/ai-chat/quick-start.mdx b/docs/ai-chat/quick-start.mdx index b8245d92372..cfffcc828b9 100644 --- a/docs/ai-chat/quick-start.mdx +++ b/docs/ai-chat/quick-start.mdx @@ -28,6 +28,10 @@ description: "Get a working AI chat in 3 steps — define a task, generate a tok }, }); ``` + + + For a **custom** [`UIMessage`](https://sdk.vercel.ai/docs/reference/ai-sdk-core/ui-message) subtype (typed `data-*` parts, tool map, etc.), define the task with [`chat.withUIMessage<...>().task({...})`](/ai-chat/types) instead of `chat.task`. + @@ -105,4 +109,5 @@ description: "Get a working AI chat in 3 steps — define a task, generate a tok - [Backend](/ai-chat/backend) — Lifecycle hooks, persistence, session iterator, raw task primitives - [Frontend](/ai-chat/frontend) — Session management, client data, reconnection +- [Types](/ai-chat/types) — `chat.withUIMessage`, `InferChatUIMessage`, and related typing - [Features](/ai-chat/features) — Per-run data, deferred work, streaming, subtasks diff --git a/docs/ai-chat/reference.mdx b/docs/ai-chat/reference.mdx index e7b59187233..ff95019be05 100644 --- a/docs/ai-chat/reference.mdx +++ b/docs/ai-chat/reference.mdx @@ -298,6 +298,50 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`. | `chat.cleanupAbortedParts(message)` | Remove incomplete parts from a stopped response message | | `chat.stream` | Typed chat output stream — use `.writer()`, `.pipe()`, `.append()`, `.read()` | | `chat.MessageAccumulator` | Class that accumulates conversation messages across turns | +| `chat.withUIMessage(config?).task(options)` | Same as `chat.task`, but fixes a custom `UIMessage` subtype and optional default stream options. See [Types](/ai-chat/types) | + +## `chat.withUIMessage` + +Returns `{ task }`, where `task` is like [`chat.task`](#chat-namespace) but parameterized on a UI message type `TUIM`. + +```ts +chat.withUIMessage(config?: ChatWithUIMessageConfig): { + task: (options: ChatTaskOptions<..., ..., TUIM>) => Task<...>; +}; +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `config.streamOptions` | `ChatUIMessageStreamOptions` | Optional defaults for `toUIMessageStream()`. Shallow-merged with `uiMessageStreamOptions` on the inner `.task({ ... })` (task wins on key conflicts). | + +Use this when you need [`InferChatUIMessage`](#inferchatuimessage) / typed `data-*` parts / `InferUITools` to line up across backend hooks and `useChat`. Full guide: [Types](/ai-chat/types). + +## `ChatWithUIMessageConfig` + +| Field | Type | Description | +|-------|------|-------------| +| `streamOptions` | `ChatUIMessageStreamOptions` | Default `toUIMessageStream()` options for tasks created via `.task()` | + +## `InferChatUIMessage` + +Type helper: extracts the `UIMessage` subtype from a chat task’s wire payload. + +```ts +import type { InferChatUIMessage } from "@trigger.dev/sdk/ai"; +// or from "@trigger.dev/sdk/chat/react" + +type Msg = InferChatUIMessage; +``` + +Use with `useChat({ transport })` when using [`chat.withUIMessage`](/ai-chat/types). For tasks defined with plain `chat.task()` (no custom generic), this resolves to the base `UIMessage`. + +## AI helpers (`ai` from `@trigger.dev/sdk/ai`) + +| Export | Status | Description | +|--------|--------|-------------| +| `ai.toolExecute(task)` | **Preferred** | Returns the `execute` function for AI SDK `tool()`. Runs the task via `triggerAndSubscribe` and attaches tool/chat metadata (same behavior the deprecated wrapper used internally). | +| `ai.tool(task, options?)` | **Deprecated** | Wraps `tool()` / `dynamicTool()` and the same execute path. Migrate to `tool({ ..., execute: ai.toolExecute(task) })`. See [Task-backed AI tools](/tasks/schemaTask#task-backed-ai-tools). | +| `ai.toolCallId`, `ai.chatContext`, `ai.chatContextOrThrow`, `ai.currentToolOptions` | Supported | Work for any task-backed tool execute path, including `ai.toolExecute`. | ## ChatUIMessageStreamOptions diff --git a/docs/ai-chat/types.mdx b/docs/ai-chat/types.mdx new file mode 100644 index 00000000000..8ddfff063f0 --- /dev/null +++ b/docs/ai-chat/types.mdx @@ -0,0 +1,137 @@ +--- +title: "Types" +sidebarTitle: "Types" +description: "TypeScript types for AI Chat tasks, UI messages, and the frontend transport." +--- + +TypeScript patterns for [AI Chat](/ai-chat/overview). This page will expand over time; it currently documents how to pin a custom AI SDK [`UIMessage`](https://sdk.vercel.ai/docs/reference/ai-sdk-core/ui-message) subtype with `chat.withUIMessage` and align types on the client. + +## Custom `UIMessage` with `chat.withUIMessage` + +`chat.task()` types the wire payload with the base AI SDK `UIMessage`. That is enough for many apps. + +When you add **custom `data-*` parts** (via `chat.stream` / `writer`) or a **typed tool map** (e.g. `InferUITools`), you want a **narrower** `UIMessage` generic so that: + +- `onTurnStart`, `onTurnComplete`, and similar hooks expose correctly typed `uiMessages` +- Stream options like `sendReasoning` align with your message shape +- The frontend can treat `useChat` messages as the same subtype end-to-end + +`chat.withUIMessage(config?)` returns `{ task }`, where `task(...)` accepts the **same options as** [`chat.task()`](/ai-chat/backend#chat-task) but fixes `YourUIMessage` as the UI message type for that chat task. + +### Defining a `UIMessage` subtype + +Build the type from AI SDK helpers and your tools object: + +```ts +import type { InferUITools, UIDataTypes, UIMessage } from "ai"; +import { tool } from "ai"; +import { z } from "zod"; + +const myTools = { + lookup: tool({ + description: "Look up a record", + inputSchema: z.object({ id: z.string() }), + execute: async ({ id }) => ({ id, label: "example" }), + }), +}; + +type MyChatTools = InferUITools; + +type MyChatDataTypes = UIDataTypes & { + "turn-status": { status: "preparing" | "streaming" | "done" }; +}; + +export type MyChatUIMessage = UIMessage; +``` + +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(...).task(...)` + +Call `withUIMessage` **once**, then chain `.task({ ... })` instead of `chat.task({ ... })`: + +```ts +import { chat } from "@trigger.dev/sdk/ai"; +import { streamText, tool } from "ai"; +import { openai } from "@ai-sdk/openai"; +import { z } from "zod"; +import type { MyChatUIMessage } from "./my-chat-types"; + +const myTools = { + lookup: tool({ + description: "Look up a record", + inputSchema: z.object({ id: z.string() }), + execute: async ({ id }) => ({ id, label: "example" }), + }), +}; + +export const myChat = chat.withUIMessage({ + streamOptions: { + sendReasoning: true, + onError: (error) => + error instanceof Error ? error.message : "Something went wrong.", + }, +}).task({ + id: "my-chat", + clientDataSchema: z.object({ userId: z.string() }), + onTurnStart: async ({ uiMessages, writer }) => { + // uiMessages is MyChatUIMessage[] — custom data parts are typed + writer.write({ + type: "data-turn-status", + data: { status: "preparing" }, + }); + }, + run: async ({ messages, signal }) => { + return streamText({ + model: openai("gpt-4o"), + messages, + tools: myTools, + abortSignal: signal, + }); + }, +}); +``` + +### Default stream options + +The optional `streamOptions` object becomes the **default** [`uiMessageStreamOptions`](/ai-chat/reference#chat-task-options) for `toUIMessageStream()`. + +If you also set `uiMessageStreamOptions` on the inner `.task({ ... })`, the two objects are **shallow-merged** — keys on the **task** win on conflicts. Per-turn overrides via [`chat.setUIMessageStreamOptions()`](/ai-chat/backend#stream-options) still apply on top. + +### Frontend: `InferChatUIMessage` + +Import the helper type and pass it to `useChat` so `messages` and render logic match the backend: + +```tsx +import { useChat } from "@ai-sdk/react"; +import { useTriggerChatTransport, type InferChatUIMessage } from "@trigger.dev/sdk/chat/react"; +import type { myChat } from "./myChat"; + +type Msg = InferChatUIMessage; + +export function Chat() { + const transport = useTriggerChatTransport({ + task: "my-chat", + accessToken: getChatToken, + }); + + const { messages } = useChat({ transport }); + + return messages.map((m) => ( +
{/* m.parts narrowed for your UIMessage subtype */}
+ )); +} +``` + +You can also import `InferChatUIMessage` from `@trigger.dev/sdk/ai` in non-React modules. + +### When plain `chat.task()` is enough + +If you do not rely on custom `UIMessage` generics (only default text, reasoning, and built-in tool UI types), **`chat.task()` alone is fine** — no need for `withUIMessage`. + +## See also + +- [Backend — `chat.task()`](/ai-chat/backend#chat-task) +- [Frontend — transport & `useChat`](/ai-chat/frontend) +- [API reference — `chat.withUIMessage`](/ai-chat/reference#chat-withuimessage) +- [Task-backed AI tools — `ai.toolExecute`](/tasks/schemaTask#task-backed-ai-tools) diff --git a/docs/docs.json b/docs/docs.json index b49db4aafab..4854d6d016e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -91,6 +91,7 @@ "ai-chat/quick-start", "ai-chat/backend", "ai-chat/frontend", + "ai-chat/types", "ai-chat/features", "ai-chat/compaction", "ai-chat/pending-messages", diff --git a/docs/migrating-from-v3.mdx b/docs/migrating-from-v3.mdx index 5530d66b62d..c820b25a1de 100644 --- a/docs/migrating-from-v3.mdx +++ b/docs/migrating-from-v3.mdx @@ -34,7 +34,7 @@ We're retiring Trigger.dev v3. **New v3 deploys will stop working from 1 April 2 | [Hidden tasks](/hidden-tasks) | Create tasks that are not exported from your trigger files but can still be executed. | | [Middleware & locals](#middleware-and-locals) | The middleware system runs at the top level, executing before and after all lifecycle hooks. The locals API allows sharing data between middleware and hooks. | | [useWaitToken](/realtime/react-hooks/use-wait-token) | Use the useWaitToken hook to complete a wait token from a React component. | -| [ai.tool](/tasks/schemaTask#ai-tool) | Create an AI tool from an existing `schemaTask` to use with the Vercel [AI SDK](https://vercel.com/docs/ai-sdk). | +| [Task-backed AI tools](/tasks/schemaTask#task-backed-ai-tools) | Use `schemaTask` with AI SDK `tool()` and `ai.toolExecute()` (legacy `ai.tool` is deprecated). | ## Node.js support @@ -165,7 +165,7 @@ export const myAiTask = schemaTask({ }); ``` -We've replaced the `toolTask` function with the `ai.tool` function, which creates an AI tool from an existing `schemaTask`. See the [ai.tool](/tasks/schemaTask#ai-tool) page for more details. +We've replaced the `toolTask` function with `schemaTask` plus AI SDK `tool()` and `ai.toolExecute()` (the older `ai.tool()` wrapper is deprecated). See [Task-backed AI tools](/tasks/schemaTask#task-backed-ai-tools). ## Breaking changes diff --git a/docs/snippets/migrate-v4-using-ai.mdx b/docs/snippets/migrate-v4-using-ai.mdx index fa749ed7231..aa5393c158d 100644 --- a/docs/snippets/migrate-v4-using-ai.mdx +++ b/docs/snippets/migrate-v4-using-ai.mdx @@ -56,7 +56,7 @@ const myTask = task({ }, }); -We’ve deprecated the `toolTask` function and replaced it with the `ai.tool` function, which creates an AI tool from an existing `schemaTask`. This is the old version: +We’ve deprecated the `toolTask` function. Use `schemaTask` plus AI SDK `tool()` with `execute: ai.toolExecute(task)` (the `ai.tool()` wrapper is deprecated). This is the old version: import { toolTask, schemaTask } from "@trigger.dev/sdk"; import { z } from "zod"; @@ -85,9 +85,11 @@ export const myAiTask = schemaTask({ This is the new version: -import { schemaTask, ai } from "@trigger.dev/sdk"; +import { schemaTask } from "@trigger.dev/sdk"; +import { ai } from "@trigger.dev/sdk/ai"; import { z } from "zod"; -import { generateText } from "ai"; +import { generateText, tool } from "ai"; +import { openai } from "@ai-sdk/openai"; // Convert toolTask to schemaTask with a schema const myToolTask = schemaTask({ @@ -99,8 +101,11 @@ const myToolTask = schemaTask({ run: async (payload, { ctx }) => {}, }); -// Create an AI tool from the schemaTask -const myTool = ai.tool(myToolTask); +const myTool = tool({ + description: myToolTask.description ?? "", + inputSchema: myToolTask.schema!, + execute: ai.toolExecute(myToolTask), +}); export const myAiTask = schemaTask({ id: "my-ai-task", @@ -112,7 +117,7 @@ export const myAiTask = schemaTask({ prompt: payload.text, model: openai("gpt-4o"), tools: { - myTool, // Use the ai.tool created from schemaTask + myTool, }, }); }, diff --git a/docs/tasks/schemaTask.mdx b/docs/tasks/schemaTask.mdx index 3692d1d7035..82ba4aa5679 100644 --- a/docs/tasks/schemaTask.mdx +++ b/docs/tasks/schemaTask.mdx @@ -76,51 +76,63 @@ await myTask.trigger({ age: 30, dob: "2020-01-01" }); // this is valid await myTask.trigger({ name: "Alice", age: 30, dob: "2020-01-01" }); // this is also valid ``` -## `ai.tool` +## Task-backed AI tools -The `ai.tool` function allows you to create an AI tool from an existing `schemaTask` to use with the Vercel [AI SDK](https://vercel.com/docs/ai-sdk): +Use a `schemaTask` as the implementation of a Vercel [AI SDK](https://vercel.com/docs/ai-sdk) tool: the model calls the tool, and Trigger runs your task as a **subtask** with tool-call metadata, optional [chat context](/ai-chat/features#task-tool-subtasks), and the same payload validation as a normal trigger. + +### Recommended: `ai.toolExecute` with `tool()` + +Prefer building the tool with the AI SDK’s [`tool()`](https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling) and passing **`execute: ai.toolExecute(yourTask)`**. You keep full control of `description`, `inputSchema`, and AI-SDK-only options (for example `experimental_toToolResultContent`), and your types follow the `ai` version installed in **your** app. ```ts import { ai } from "@trigger.dev/sdk/ai"; import { schemaTask } from "@trigger.dev/sdk"; +import { tool, generateText } from "ai"; +import { openai } from "@ai-sdk/openai"; import { z } from "zod"; -import { generateText } from "ai"; const myToolTask = schemaTask({ id: "my-tool-task", schema: z.object({ foo: z.string(), }), - run: async (payload: any, { ctx }) => {}, + run: async ({ foo }) => { + return { bar: foo.toUpperCase() }; + }, }); -const myTool = ai.tool(myToolTask); +const myTool = tool({ + description: myToolTask.description ?? "", + inputSchema: myToolTask.schema!, + execute: ai.toolExecute(myToolTask), +}); export const myAiTask = schemaTask({ id: "my-ai-task", schema: z.object({ text: z.string(), }), - run: async (payload, { ctx }) => { - const { text } = await generateText({ - prompt: payload.text, + run: async ({ text }) => { + const { text: reply } = await generateText({ + prompt: text, model: openai("gpt-4o"), tools: { myTool, }, }); + return reply; }, }); ``` -You can also pass the `experimental_toToolResultContent` option to the `ai.tool` function to customize the content of the tool result: +`experimental_toToolResultContent` and other tool-level options belong on **`tool({ ... })`**, not on `ai.toolExecute`: ```ts import { openai } from "@ai-sdk/openai"; import { Sandbox } from "@e2b/code-interpreter"; import { ai } from "@trigger.dev/sdk/ai"; import { schemaTask } from "@trigger.dev/sdk"; -import { generateObject } from "ai"; +import { generateObject, tool } from "ai"; import { z } from "zod"; const chartTask = schemaTask({ @@ -135,56 +147,37 @@ const chartTask = schemaTask({ schema: z.object({ code: z.string().describe("The Python code to execute"), }), - system: ` - You are a helpful assistant that can generate Python code to be executed in a sandbox, using matplotlib.pyplot. - - For example: - - import matplotlib.pyplot as plt - plt.plot([1, 2, 3, 4]) - plt.ylabel('some numbers') - plt.show() - - Make sure the code ends with plt.show() - `, + system: `You are a helpful assistant that generates matplotlib code. End with plt.show().`, prompt: input, }); const sandbox = await Sandbox.create(); - const execution = await sandbox.runCode(code.object.code); - const firstResult = execution.results[0]; if (firstResult.png) { - return { - chart: firstResult.png, - }; - } else { - throw new Error("No chart generated"); + return { chart: firstResult.png }; } + throw new Error("No chart generated"); }, }); -// This is useful if you want to return an image from the tool -export const chartTool = ai.tool(chartTask, { - experimental_toToolResultContent: (result) => { - return [ - { - type: "image", - data: result.chart, - mimeType: "image/png", - }, - ]; - }, +export const chartTool = tool({ + description: chartTask.description ?? "", + inputSchema: chartTask.schema!, + execute: ai.toolExecute(chartTask), + experimental_toToolResultContent: (result) => [ + { type: "image", data: result.chart, mimeType: "image/png" }, + ], }); ``` -You can access the current tool execution options inside the task run function using the `ai.currentToolOptions()` function: +Inside the task run, you can read tool execution context with **`ai.currentToolOptions()`** (and helpers like `ai.toolCallId()`, `ai.chatContext()` when running inside a [`chat.task`](/ai-chat/overview)): ```ts import { ai } from "@trigger.dev/sdk/ai"; import { schemaTask } from "@trigger.dev/sdk"; +import { tool } from "ai"; import { z } from "zod"; const myToolTask = schemaTask({ @@ -192,22 +185,49 @@ const myToolTask = schemaTask({ schema: z.object({ foo: z.string(), }), - run: async (payload, { ctx }) => { + run: async ({ foo }) => { const toolOptions = ai.currentToolOptions(); console.log(toolOptions); + return { foo }; }, }); -export const myAiTask = ai.tool(myToolTask); +export const myTool = tool({ + description: myToolTask.description ?? "", + inputSchema: myToolTask.schema!, + execute: ai.toolExecute(myToolTask), +}); ``` -See the [AI SDK tool execution options docs](https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling#tool-execution-options) for more details on the tool execution options. +See the [AI SDK tool execution options](https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling#tool-execution-options) for fields passed through the runtime. - `ai.tool` is compatible with `schemaTask`'s defined with Zod and ArkType schemas, or any schemas - that implement a `.toJsonSchema()` function. + `ai.toolExecute` works with `schemaTask` definitions that use Zod, ArkType, or any schema that provides a JSON schema via `.toJsonSchema()` (same coverage as the legacy `ai.tool` wrapper). +### Deprecated: `ai.tool` + +The **`ai.tool(task, options?)`** helper is **deprecated**. It constructs an AI SDK `Tool` for you (using `tool()` for Zod-like schemas and `dynamicTool()` otherwise) and may be removed in a future major version. New code should use **`tool({ ..., execute: ai.toolExecute(task) })`** as shown above. + +### Legacy `ai.tool` example (deprecated) + +```ts +import { ai } from "@trigger.dev/sdk/ai"; +import { schemaTask } from "@trigger.dev/sdk"; +import { z } from "zod"; +import { generateText } from "ai"; +import { openai } from "@ai-sdk/openai"; + +const myToolTask = schemaTask({ + id: "my-tool-task", + schema: z.object({ foo: z.string() }), + run: async ({ foo }) => ({ foo }), +}); + +// Deprecated — prefer tool({ execute: ai.toolExecute(myToolTask), ... }) +const myTool = ai.tool(myToolTask); +``` + ## Supported schema types ### Zod From 88ee83beb6515eff411ed7f0fade99aaae4eefff Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 27 Mar 2026 16:07:53 +0000 Subject: [PATCH 09/13] Add run-scoped PAT renewal for chat transport --- docs/ai-chat/backend.mdx | 254 ++++++++++-------- docs/ai-chat/features.mdx | 2 + docs/ai-chat/frontend.mdx | 53 ++-- docs/ai-chat/quick-start.mdx | 11 +- docs/ai-chat/reference.mdx | 484 +++++++++++++++++++---------------- 5 files changed, 444 insertions(+), 360 deletions(-) diff --git a/docs/ai-chat/backend.mdx b/docs/ai-chat/backend.mdx index 2e48a6b2b57..50a338cd2c1 100644 --- a/docs/ai-chat/backend.mdx +++ b/docs/ai-chat/backend.mdx @@ -99,13 +99,13 @@ export const myChat = chat.task({ }); ``` -| Field | Type | Description | -|-------|------|-------------| -| `chatId` | `string` | Chat session ID | -| `runId` | `string` | The Trigger.dev run ID | -| `chatAccessToken` | `string` | Scoped access token for this run | -| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | -| `writer` | [`ChatWriter`](/ai-chat/reference#chatwriter) | Stream writer for custom chunks | +| Field | Type | Description | +| ----------------- | --------------------------------------------- | -------------------------------- | +| `chatId` | `string` | Chat session ID | +| `runId` | `string` | The Trigger.dev run ID | +| `chatAccessToken` | `string` | Scoped access token for this run | +| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | +| `writer` | [`ChatWriter`](/ai-chat/reference#chatwriter) | Stream writer for custom chunks | Every lifecycle callback receives a `writer` — a lazy stream writer that lets you send custom `UIMessageChunk` parts (like `data-*` parts) to the frontend without the ceremony of `chat.stream.writer()`. See [ChatWriter](/ai-chat/reference#chatwriter). @@ -134,25 +134,27 @@ export const myChat = chat.task({ ``` - `clientData` contains custom data from the frontend — either the `clientData` option on the transport constructor (sent with every message) or the `metadata` option on `sendMessage()` (per-message). See [Client data and metadata](/ai-chat/frontend#client-data-and-metadata). + `clientData` contains custom data from the frontend — either the `clientData` option on the + transport constructor (sent with every message) or the `metadata` option on `sendMessage()` + (per-message). See [Client data and metadata](/ai-chat/frontend#client-data-and-metadata). #### onTurnStart Fires at the start of every turn, after message accumulation and `onChatStart` (turn 0), but **before** `run()` executes. Use it to persist messages before streaming begins — so a mid-stream page refresh still shows the user's message. -| Field | Type | Description | -|-------|------|-------------| -| `chatId` | `string` | Chat session ID | -| `messages` | `ModelMessage[]` | Full accumulated conversation (model format) | -| `uiMessages` | `UIMessage[]` | Full accumulated conversation (UI format) | -| `turn` | `number` | Turn number (0-indexed) | -| `runId` | `string` | The Trigger.dev run ID | -| `chatAccessToken` | `string` | Scoped access token for this run | -| `continuation` | `boolean` | Whether this run is continuing an existing chat | -| `preloaded` | `boolean` | Whether this run was preloaded | -| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | -| `writer` | [`ChatWriter`](/ai-chat/reference#chatwriter) | Stream writer for custom chunks | +| Field | Type | Description | +| ----------------- | --------------------------------------------- | ----------------------------------------------- | +| `chatId` | `string` | Chat session ID | +| `messages` | `ModelMessage[]` | Full accumulated conversation (model format) | +| `uiMessages` | `UIMessage[]` | Full accumulated conversation (UI format) | +| `turn` | `number` | Turn number (0-indexed) | +| `runId` | `string` | The Trigger.dev run ID | +| `chatAccessToken` | `string` | Scoped access token for this run | +| `continuation` | `boolean` | Whether this run is continuing an existing chat | +| `preloaded` | `boolean` | Whether this run was preloaded | +| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | +| `writer` | [`ChatWriter`](/ai-chat/reference#chatwriter) | Stream writer for custom chunks | ```ts export const myChat = chat.task({ @@ -175,7 +177,8 @@ export const myChat = chat.task({ ``` - By persisting in `onTurnStart`, the user's message is saved to your database before the AI starts streaming. If the user refreshes mid-stream, the message is already there. + By persisting in `onTurnStart`, the user's message is saved to your database before the AI starts + streaming. If the user refreshes mid-stream, the message is already there. #### onBeforeTurnComplete @@ -214,20 +217,20 @@ Receives the same fields as [`TurnCompleteEvent`](/ai-chat/reference#turncomplet Fires after each turn completes — after the response is captured and the stream is closed. This is the primary hook for persisting the assistant's response. Does not include a `writer` since the stream is already closed. -| Field | Type | Description | -|-------|------|-------------| -| `chatId` | `string` | Chat session ID | -| `messages` | `ModelMessage[]` | Full accumulated conversation (model format) | -| `uiMessages` | `UIMessage[]` | Full accumulated conversation (UI format) | -| `newMessages` | `ModelMessage[]` | Only this turn's messages (model format) | -| `newUIMessages` | `UIMessage[]` | Only this turn's messages (UI format) | -| `responseMessage` | `UIMessage \| undefined` | The assistant's response for this turn | -| `turn` | `number` | Turn number (0-indexed) | -| `runId` | `string` | The Trigger.dev run ID | -| `chatAccessToken` | `string` | Scoped access token for this run | -| `lastEventId` | `string \| undefined` | Stream position for resumption. Persist this with the session. | -| `stopped` | `boolean` | Whether the user stopped generation during this turn | -| `continuation` | `boolean` | Whether this run is continuing an existing chat | +| Field | Type | Description | +| -------------------- | ------------------------ | -------------------------------------------------------------------------------------------- | +| `chatId` | `string` | Chat session ID | +| `messages` | `ModelMessage[]` | Full accumulated conversation (model format) | +| `uiMessages` | `UIMessage[]` | Full accumulated conversation (UI format) | +| `newMessages` | `ModelMessage[]` | Only this turn's messages (model format) | +| `newUIMessages` | `UIMessage[]` | Only this turn's messages (UI format) | +| `responseMessage` | `UIMessage \| undefined` | The assistant's response for this turn | +| `turn` | `number` | Turn number (0-indexed) | +| `runId` | `string` | The Trigger.dev run ID | +| `chatAccessToken` | `string` | Scoped access token for this run | +| `lastEventId` | `string \| undefined` | Stream position for resumption. Persist this with the session. | +| `stopped` | `boolean` | Whether the user stopped generation during this turn | +| `continuation` | `boolean` | Whether this run is continuing an existing chat | | `rawResponseMessage` | `UIMessage \| undefined` | The raw assistant response before abort cleanup (same as `responseMessage` when not stopped) | ```ts @@ -251,11 +254,13 @@ export const myChat = chat.task({ ``` - Use `uiMessages` to overwrite the full conversation each turn (simplest). Use `newUIMessages` if you prefer to store messages individually — for example, one database row per message. + Use `uiMessages` to overwrite the full conversation each turn (simplest). Use `newUIMessages` if + you prefer to store messages individually — for example, one database row per message. - Persist `lastEventId` alongside the session. When the transport reconnects after a page refresh, it uses this to skip past already-seen events — preventing duplicate messages. + Persist `lastEventId` alongside the session. When the transport reconnects after a page refresh, + it uses this to skip past already-seen events — preventing duplicate messages. ### Using prompts @@ -300,7 +305,8 @@ export const myChat = chat.task({ `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. - See [Prompts](/ai/prompts) for the full guide — defining templates, variable schemas, dashboard overrides, and the management SDK. + See [Prompts](/ai/prompts) for the full guide — defining templates, variable schemas, dashboard + overrides, and the management SDK. ### Stop generation @@ -313,11 +319,11 @@ Calling `stop()` from `useChat` sends a stop signal to the running task via inpu 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 | +| 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.task({ @@ -333,7 +339,8 @@ export const myChat = chat.task({ ``` - 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. + 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. #### Detecting stop in callbacks @@ -393,7 +400,9 @@ 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`. - 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. + 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. ### Persistence @@ -406,7 +415,9 @@ To build a chat app that survives page refreshes, you need to persist two things 2. **Sessions** — The transport's connection state (`runId`, `publicAccessToken`, `lastEventId`). Persisted **server-side** via `onTurnStart` and `onTurnComplete`. - 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. + 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. #### Full persistence example @@ -470,8 +481,7 @@ import { chat } from "@trigger.dev/sdk/ai"; import type { myChat } from "@/trigger/chat"; import { db } from "@/lib/db"; -export const getChatToken = () => - chat.createAccessToken("my-chat"); +export const getChatToken = () => chat.createAccessToken("my-chat"); export async function getChatMessages(chatId: string) { const found = await db.chat.findUnique({ where: { id: chatId } }); @@ -480,11 +490,14 @@ export async function getChatMessages(chatId: string) { export async function getAllSessions() { const sessions = await db.chatSession.findMany(); - const result: Record = {}; + const result: Record< + string, + { + runId: string; + publicAccessToken: string; + lastEventId?: string; + } + > = {}; for (const s of sessions) { result[s.id] = { runId: s.runId, @@ -552,13 +565,16 @@ export function Chat({ chatId, initialMessages, initialSessions }) { Send {status === "streaming" && ( - + )} ); } ``` + ### Pending messages (steering) @@ -575,7 +591,9 @@ export const myChat = chat.task({ return streamText({ ...chat.toStreamTextOptions({ registry }), messages, - tools: { /* ... */ }, + tools: { + /* ... */ + }, abortSignal: signal, }); }, @@ -585,7 +603,8 @@ export const myChat = chat.task({ On the frontend, the `usePendingMessages` hook handles sending, tracking, and rendering injection points. - 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. + 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. ### Background injection @@ -596,15 +615,21 @@ Inject context from background work into the conversation using `chat.inject()`. export const myChat = chat.task({ 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")}`, - }]); - } - })()); + 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 }); @@ -613,7 +638,8 @@ export const myChat = chat.task({ ``` - See [Background Injection](/ai-chat/background-injection) for the full guide — timing, self-review example, and how it differs from pending messages. + See [Background Injection](/ai-chat/background-injection) for the full guide — timing, self-review + example, and how it differs from pending messages. ### prepareMessages @@ -648,11 +674,11 @@ export const myChat = chat.task({ 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 | +| 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 | ### Runtime configuration @@ -679,7 +705,8 @@ run: async ({ messages, signal }) => { ``` - 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). + 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). #### Stream options @@ -734,8 +761,8 @@ Control which AI SDK features are forwarded to the frontend: export const myChat = chat.task({ id: "my-chat", uiMessageStreamOptions: { - sendReasoning: true, // Forward model reasoning (default: true) - sendSources: true, // Forward source citations (default: false) + sendReasoning: true, // Forward model reasoning (default: true) + sendSources: true, // Forward source citations (default: false) }, run: async ({ messages, signal }) => { return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }); @@ -762,7 +789,9 @@ run: async ({ messages, clientData, signal }) => { See [ChatUIMessageStreamOptions](/ai-chat/reference#chatuimessagestreamoptions) for the full reference. - `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](#raw-task-with-primitives) for full control over `toUIMessageStream()`. + `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](#raw-task-with-primitives) for full control over `toUIMessageStream()`. ### Manual mode with task() @@ -791,7 +820,9 @@ export const manualChat = task({ ``` - 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.task()` for the full multi-turn experience. + 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.task()` for the full multi-turn experience. --- @@ -843,34 +874,34 @@ export const myChat = task({ ### ChatSessionOptions -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `signal` | `AbortSignal` | required | Run-level cancel signal (from task context) | -| `idleTimeoutInSeconds` | `number` | `30` | Seconds to stay idle between turns | -| `timeout` | `string` | `"1h"` | Duration string for suspend timeout | -| `maxTurns` | `number` | `100` | Max turns before ending | +| Option | Type | Default | Description | +| ---------------------- | ------------- | -------- | ------------------------------------------- | +| `signal` | `AbortSignal` | required | Run-level cancel signal (from task context) | +| `idleTimeoutInSeconds` | `number` | `30` | Seconds to stay idle between turns | +| `timeout` | `string` | `"1h"` | Duration string for suspend timeout | +| `maxTurns` | `number` | `100` | Max turns before ending | ### ChatTurn Each turn yielded by the iterator provides: -| Field | Type | Description | -|-------|------|-------------| -| `number` | `number` | Turn number (0-indexed) | -| `chatId` | `string` | Chat session ID | -| `trigger` | `string` | What triggered this turn | -| `clientData` | `unknown` | Client data from the transport | -| `messages` | `ModelMessage[]` | Full accumulated model messages — pass to `streamText` | -| `uiMessages` | `UIMessage[]` | Full accumulated UI messages — use for persistence | -| `signal` | `AbortSignal` | Combined stop+cancel signal (fresh each turn) | -| `stopped` | `boolean` | Whether the user stopped generation this turn | -| `continuation` | `boolean` | Whether this is a continuation run | - -| Method | Description | -|--------|-------------| -| `turn.complete(source)` | Pipe stream, capture response, accumulate, and signal turn-complete | -| `turn.done()` | Just signal turn-complete (when you've piped manually) | -| `turn.addResponse(response)` | Add a response to the accumulator manually | +| Field | Type | Description | +| -------------- | ---------------- | ------------------------------------------------------ | +| `number` | `number` | Turn number (0-indexed) | +| `chatId` | `string` | Chat session ID | +| `trigger` | `string` | What triggered this turn | +| `clientData` | `unknown` | Client data from the transport | +| `messages` | `ModelMessage[]` | Full accumulated model messages — pass to `streamText` | +| `uiMessages` | `UIMessage[]` | Full accumulated UI messages — use for persistence | +| `signal` | `AbortSignal` | Combined stop+cancel signal (fresh each turn) | +| `stopped` | `boolean` | Whether the user stopped generation this turn | +| `continuation` | `boolean` | Whether this is a continuation run | + +| Method | Description | +| ---------------------------- | ------------------------------------------------------------------- | +| `turn.complete(source)` | Pipe stream, capture response, accumulate, and signal turn-complete | +| `turn.done()` | Just signal turn-complete (when you've piped manually) | +| `turn.addResponse(response)` | Add a response to the accumulator manually | ### turn.complete() vs manual control @@ -912,15 +943,15 @@ Raw task mode also lets you call `.toUIMessageStream()` yourself with any option ### Primitives -| Primitive | Description | -|-----------|-------------| -| `chat.messages` | Input stream for incoming messages — use `.waitWithIdleTimeout()` to wait for the next turn | -| `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream | -| `chat.pipeAndCapture(result)` | Pipe a `StreamTextResult` to the chat stream and capture the response | -| `chat.writeTurnComplete()` | Signal the frontend that the current turn is complete | -| `chat.MessageAccumulator` | Accumulates conversation messages across turns | -| `chat.pipe(stream)` | Pipe a stream to the frontend (no response capture) | -| `chat.cleanupAbortedParts(msg)` | Clean up incomplete parts from a stopped response | +| Primitive | Description | +| ------------------------------- | ------------------------------------------------------------------------------------------- | +| `chat.messages` | Input stream for incoming messages — use `.waitWithIdleTimeout()` to wait for the next turn | +| `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream | +| `chat.pipeAndCapture(result)` | Pipe a `StreamTextResult` to the chat stream and capture the response | +| `chat.writeTurnComplete()` | Signal the frontend that the current turn is complete | +| `chat.MessageAccumulator` | Accumulates conversation messages across turns | +| `chat.pipe(stream)` | Pipe a stream to the frontend (no response capture) | +| `chat.cleanupAbortedParts(msg)` | Clean up incomplete parts from a stopped response | ### Example @@ -979,9 +1010,8 @@ export const myChat = task({ } if (response) { - const cleaned = stop.signal.aborted && !runSignal.aborted - ? chat.cleanupAbortedParts(response) - : response; + const cleaned = + stop.signal.aborted && !runSignal.aborted ? chat.cleanupAbortedParts(response) : response; await conversation.addResponse(cleaned); } @@ -1029,6 +1059,6 @@ const response = await chat.pipeAndCapture(result); if (response) await conversation.addResponse(response); // Access accumulated messages for persistence -conversation.uiMessages; // UIMessage[] +conversation.uiMessages; // UIMessage[] conversation.modelMessages; // ModelMessage[] ``` diff --git a/docs/ai-chat/features.mdx b/docs/ai-chat/features.mdx index ccceebedeea..4b262e3929c 100644 --- a/docs/ai-chat/features.mdx +++ b/docs/ai-chat/features.mdx @@ -400,6 +400,8 @@ export function Chat({ chatId }) { Preload is a no-op if a session already exists for this chatId. +When the transport needs a trigger token for preload, your `accessToken` callback receives `{ chatId, purpose: "preload" }` (same as for a normal trigger, but `purpose` is `"trigger"` when starting a run from `sendMessages`). See [TriggerChatTransport options](/ai-chat/reference#triggerchattransport-options). + ### Backend On the backend, the `onPreload` hook fires immediately. The run then waits for the first message. When the user sends a message, `onChatStart` fires with `preloaded: true` — you can skip initialization that was already done in `onPreload`: diff --git a/docs/ai-chat/frontend.mdx b/docs/ai-chat/frontend.mdx index 8c0d8cff9da..c03eb484565 100644 --- a/docs/ai-chat/frontend.mdx +++ b/docs/ai-chat/frontend.mdx @@ -28,7 +28,8 @@ export function Chat() { The transport is created once on first render and reused across re-renders. Pass a type parameter for compile-time validation of the task ID. - The hook keeps `onSessionChange` up to date via a ref internally, so you don't need to memoize the callback or worry about stale closures. + The hook keeps `onSessionChange` up to date via a ref internally, so you don't need to memoize the + callback or worry about stale closures. ## Typed messages (`chat.withUIMessage`) @@ -42,7 +43,10 @@ import type { myChat } from "./myChat"; type Msg = InferChatUIMessage; -const transport = useTriggerChatTransport({ task: "my-chat", accessToken: getChatToken }); +const transport = useTriggerChatTransport({ + task: "my-chat", + accessToken: getChatToken, +}); const { messages } = useChat({ transport }); ``` @@ -50,13 +54,18 @@ See the [Types](/ai-chat/types) guide for defining `YourUIMessage`, default stre ### Dynamic access tokens -For token refresh, pass a function instead of a string. It's called on each `sendMessage`: +For token refresh, pass a function instead of a string. The transport calls it when it needs a **trigger** token: starting a run from `sendMessages`, or when you call `preload()`. The callback receives `chatId` and `purpose` (`"trigger"` | `"preload"`). Import `ResolveChatAccessTokenParams` from `@trigger.dev/sdk/chat` to type your server action or fetch handler (see [reference](/ai-chat/reference#triggerchattransport-options)). ```ts +import type { ResolveChatAccessTokenParams } from "@trigger.dev/sdk/chat"; + const transport = useTriggerChatTransport({ task: "my-chat", - accessToken: async () => { - const res = await fetch("/api/chat-token"); + accessToken: async (input: ResolveChatAccessTokenParams) => { + const res = await fetch("/api/chat-token", { + method: "POST", + body: JSON.stringify(input), + }); return res.text(); }, }); @@ -100,10 +109,7 @@ export default function ChatPage({ chatId }: { chatId: string }) { useEffect(() => { async function load() { - const [messages, session] = await Promise.all([ - getChatMessages(chatId), - getSession(chatId), - ]); + const [messages, session] = await Promise.all([getChatMessages(chatId), getSession(chatId)]); setInitialMessages(messages); setInitialSession(session ? { [chatId]: session } : undefined); setLoaded(true); @@ -144,11 +150,19 @@ function ChatClient({ chatId, initialMessages, initialSessions }) { ``` - `resume: true` causes `useChat` to call `reconnectToStream` on the transport when the component mounts. The transport uses the session's `lastEventId` to skip past already-seen stream events, so the frontend only receives new data. Only enable `resume` when there are existing messages — for brand new chats, there's nothing to reconnect to. + `resume: true` causes `useChat` to call `reconnectToStream` on the transport when the component + mounts. The transport uses the session's `lastEventId` to skip past already-seen stream events, so + the frontend only receives new data. Only enable `resume` when there are existing messages — for + brand new chats, there's nothing to reconnect to. - In React strict mode (enabled by default in Next.js dev), you may see a `TypeError: Cannot read properties of undefined (reading 'state')` in the console when using `resume`. This is a [known bug in the AI SDK](https://github.com/vercel/ai/issues/8477) caused by React strict mode double-firing the resume effect. The error is caught internally and **does not affect functionality** — streaming and message display work correctly. It only appears in development and will not occur in production builds. + In React strict mode (enabled by default in Next.js dev), you may see a `TypeError: Cannot read + properties of undefined (reading 'state')` in the console when using `resume`. This is a [known + bug in the AI SDK](https://github.com/vercel/ai/issues/8477) caused by React strict mode + double-firing the resume effect. The error is caught internally and **does not affect + functionality** — streaming and message display work correctly. It only appears in development and + will not occur in production builds. ## Client data and metadata @@ -170,10 +184,7 @@ const transport = useTriggerChatTransport({ Pass metadata with individual messages via `sendMessage`. Per-message values are merged with transport-level client data (per-message wins on conflicts): ```ts -sendMessage( - { text: "Hello" }, - { metadata: { model: "gpt-4o", priority: "high" } } -); +sendMessage({ text: "Hello" }, { metadata: { model: "gpt-4o", priority: "high" } }); ``` ### Typed client data with clientDataSchema @@ -229,11 +240,13 @@ Calling `stop()` from `useChat` sends a stop signal to the running task via inpu ```tsx const { messages, sendMessage, stop, status } = useChat({ transport }); -{status === "streaming" && ( - -)} +{ + status === "streaming" && ( + + ); +} ``` See [Stop generation](/ai-chat/backend#stop-generation) in the backend docs for how to handle stop signals in your task. diff --git a/docs/ai-chat/quick-start.mdx b/docs/ai-chat/quick-start.mdx index cfffcc828b9..881cc381548 100644 --- a/docs/ai-chat/quick-start.mdx +++ b/docs/ai-chat/quick-start.mdx @@ -32,20 +32,24 @@ description: "Get a working AI chat in 3 steps — define a task, generate a tok For a **custom** [`UIMessage`](https://sdk.vercel.ai/docs/reference/ai-sdk-core/ui-message) subtype (typed `data-*` parts, tool map, etc.), define the task with [`chat.withUIMessage<...>().task({...})`](/ai-chat/types) instead of `chat.task`. +
- On your server (e.g. a Next.js server action), create a trigger public token scoped to your chat task: + On your server (e.g. a Next.js server action), create a trigger public token scoped to your chat task. The transport calls your function with `chatId` and `purpose` (`"trigger"` or `"preload"`). Import `ResolveChatAccessTokenParams` from `@trigger.dev/sdk/chat` so the signature matches — see [TriggerChatTransport options](/ai-chat/reference#triggerchattransport-options). ```ts app/actions.ts "use server"; import { chat } from "@trigger.dev/sdk/ai"; + import type { ResolveChatAccessTokenParams } from "@trigger.dev/sdk/chat"; import type { myChat } from "@/trigger/chat"; - export const getChatToken = () => - chat.createAccessToken("my-chat"); + export async function getChatToken(_input: ResolveChatAccessTokenParams) { + return chat.createAccessToken("my-chat"); + } ``` + @@ -102,6 +106,7 @@ description: "Get a working AI chat in 3 steps — define a task, generate a tok ); } ``` + diff --git a/docs/ai-chat/reference.mdx b/docs/ai-chat/reference.mdx index ff95019be05..40f6f04daf8 100644 --- a/docs/ai-chat/reference.mdx +++ b/docs/ai-chat/reference.mdx @@ -8,27 +8,27 @@ description: "Complete API reference for the AI Chat SDK — backend options, ev Options for `chat.task()`. -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `id` | `string` | required | Task identifier | -| `run` | `(payload: ChatTaskRunPayload) => Promise` | required | Handler for each turn | -| `clientDataSchema` | `TaskSchema` | — | Schema for validating and typing `clientData` | -| `onPreload` | `(event: PreloadEvent) => Promise \| void` | — | Fires on preloaded runs before the first message | -| `onChatStart` | `(event: ChatStartEvent) => Promise \| void` | — | Fires on turn 0 before `run()` | -| `onTurnStart` | `(event: TurnStartEvent) => Promise \| void` | — | Fires every turn before `run()` | -| `onBeforeTurnComplete` | `(event: BeforeTurnCompleteEvent) => Promise \| void` | — | Fires after response but before stream closes. Includes `writer`. | -| `onTurnComplete` | `(event: TurnCompleteEvent) => Promise \| void` | — | Fires after each turn completes (stream closed) | -| `onCompacted` | `(event: CompactedEvent) => Promise \| void` | — | Fires when compaction occurs. Includes `writer`. See [Compaction](/ai-chat/compaction) | -| `compaction` | `ChatTaskCompactionOptions` | — | 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.) | -| `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 | -| `chatAccessTokenTTL` | `string` | `"1h"` | How long the scoped access token remains valid | -| `preloadIdleTimeoutInSeconds` | `number` | Same as `idleTimeoutInSeconds` | Idle timeout after `onPreload` fires | -| `preloadTimeout` | `string` | Same as `turnTimeout` | Suspend timeout for preloaded runs | -| `uiMessageStreamOptions` | `ChatUIMessageStreamOptions` | — | Default options for `toUIMessageStream()`. Per-turn override via `chat.setUIMessageStreamOptions()` | +| Option | Type | Default | Description | +| ----------------------------- | ----------------------------------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------- | +| `id` | `string` | required | Task identifier | +| `run` | `(payload: ChatTaskRunPayload) => Promise` | required | Handler for each turn | +| `clientDataSchema` | `TaskSchema` | — | Schema for validating and typing `clientData` | +| `onPreload` | `(event: PreloadEvent) => Promise \| void` | — | Fires on preloaded runs before the first message | +| `onChatStart` | `(event: ChatStartEvent) => Promise \| void` | — | Fires on turn 0 before `run()` | +| `onTurnStart` | `(event: TurnStartEvent) => Promise \| void` | — | Fires every turn before `run()` | +| `onBeforeTurnComplete` | `(event: BeforeTurnCompleteEvent) => Promise \| void` | — | Fires after response but before stream closes. Includes `writer`. | +| `onTurnComplete` | `(event: TurnCompleteEvent) => Promise \| void` | — | Fires after each turn completes (stream closed) | +| `onCompacted` | `(event: CompactedEvent) => Promise \| void` | — | Fires when compaction occurs. Includes `writer`. See [Compaction](/ai-chat/compaction) | +| `compaction` | `ChatTaskCompactionOptions` | — | 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.) | +| `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 | +| `chatAccessTokenTTL` | `string` | `"1h"` | How long the scoped access token remains valid | +| `preloadIdleTimeoutInSeconds` | `number` | Same as `idleTimeoutInSeconds` | Idle timeout after `onPreload` fires | +| `preloadTimeout` | `string` | Same as `turnTimeout` | Suspend timeout for preloaded runs | +| `uiMessageStreamOptions` | `ChatUIMessageStreamOptions` | — | Default options for `toUIMessageStream()`. Per-turn override via `chat.setUIMessageStreamOptions()` | Plus all standard [TaskOptions](/tasks/overview) — `retry`, `queue`, `machine`, `maxDuration`, etc. @@ -36,94 +36,94 @@ Plus all standard [TaskOptions](/tasks/overview) — `retry`, `queue`, `machine` The payload passed to the `run` function. -| Field | Type | Description | -|-------|------|-------------| -| `messages` | `ModelMessage[]` | Model-ready messages — pass directly to `streamText` | -| `chatId` | `string` | Unique chat session ID | -| `trigger` | `"submit-message" \| "regenerate-message"` | What triggered the request | -| `messageId` | `string \| undefined` | Message ID (for regenerate) | -| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend (typed when schema is provided) | -| `continuation` | `boolean` | Whether this run is continuing an existing chat (previous run ended) | -| `signal` | `AbortSignal` | Combined stop + cancel signal | -| `cancelSignal` | `AbortSignal` | Cancel-only signal | -| `stopSignal` | `AbortSignal` | Stop-only signal (per-turn) | +| Field | Type | Description | +| -------------- | ------------------------------------------ | -------------------------------------------------------------------- | +| `messages` | `ModelMessage[]` | Model-ready messages — pass directly to `streamText` | +| `chatId` | `string` | Unique chat session ID | +| `trigger` | `"submit-message" \| "regenerate-message"` | What triggered the request | +| `messageId` | `string \| undefined` | Message ID (for regenerate) | +| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend (typed when schema is provided) | +| `continuation` | `boolean` | Whether this run is continuing an existing chat (previous run ended) | +| `signal` | `AbortSignal` | Combined stop + cancel signal | +| `cancelSignal` | `AbortSignal` | Cancel-only signal | +| `stopSignal` | `AbortSignal` | Stop-only signal (per-turn) | ## PreloadEvent Passed to the `onPreload` callback. -| Field | Type | Description | -|-------|------|-------------| -| `chatId` | `string` | Chat session ID | -| `runId` | `string` | The Trigger.dev run ID | -| `chatAccessToken` | `string` | Scoped access token for this run | -| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | -| `writer` | [`ChatWriter`](#chatwriter) | Stream writer for custom chunks. Lazy — no overhead if unused. | +| Field | Type | Description | +| ----------------- | --------------------------- | -------------------------------------------------------------- | +| `chatId` | `string` | Chat session ID | +| `runId` | `string` | The Trigger.dev run ID | +| `chatAccessToken` | `string` | Scoped access token for this run | +| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | +| `writer` | [`ChatWriter`](#chatwriter) | Stream writer for custom chunks. Lazy — no overhead if unused. | ## ChatStartEvent Passed to the `onChatStart` callback. -| Field | Type | Description | -|-------|------|-------------| -| `chatId` | `string` | Chat session ID | -| `messages` | `ModelMessage[]` | Initial model-ready messages | -| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | -| `runId` | `string` | The Trigger.dev run ID | -| `chatAccessToken` | `string` | Scoped access token for this run | -| `continuation` | `boolean` | Whether this run is continuing an existing chat | -| `previousRunId` | `string \| undefined` | Previous run ID (only when `continuation` is true) | -| `preloaded` | `boolean` | Whether this run was preloaded before the first message | -| `writer` | [`ChatWriter`](#chatwriter) | Stream writer for custom chunks. Lazy — no overhead if unused. | +| Field | Type | Description | +| ----------------- | --------------------------- | -------------------------------------------------------------- | +| `chatId` | `string` | Chat session ID | +| `messages` | `ModelMessage[]` | Initial model-ready messages | +| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | +| `runId` | `string` | The Trigger.dev run ID | +| `chatAccessToken` | `string` | Scoped access token for this run | +| `continuation` | `boolean` | Whether this run is continuing an existing chat | +| `previousRunId` | `string \| undefined` | Previous run ID (only when `continuation` is true) | +| `preloaded` | `boolean` | Whether this run was preloaded before the first message | +| `writer` | [`ChatWriter`](#chatwriter) | Stream writer for custom chunks. Lazy — no overhead if unused. | ## TurnStartEvent Passed to the `onTurnStart` callback. -| Field | Type | Description | -|-------|------|-------------| -| `chatId` | `string` | Chat session ID | -| `messages` | `ModelMessage[]` | Full accumulated conversation (model format) | -| `uiMessages` | `UIMessage[]` | Full accumulated conversation (UI format) | -| `turn` | `number` | Turn number (0-indexed) | -| `runId` | `string` | The Trigger.dev run ID | -| `chatAccessToken` | `string` | Scoped access token for this run | -| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | -| `continuation` | `boolean` | Whether this run is continuing an existing chat | -| `previousRunId` | `string \| undefined` | Previous run ID (only when `continuation` is true) | -| `preloaded` | `boolean` | Whether this run was preloaded | -| `writer` | [`ChatWriter`](#chatwriter) | Stream writer for custom chunks. Lazy — no overhead if unused. | +| Field | Type | Description | +| ----------------- | --------------------------- | -------------------------------------------------------------- | +| `chatId` | `string` | Chat session ID | +| `messages` | `ModelMessage[]` | Full accumulated conversation (model format) | +| `uiMessages` | `UIMessage[]` | Full accumulated conversation (UI format) | +| `turn` | `number` | Turn number (0-indexed) | +| `runId` | `string` | The Trigger.dev run ID | +| `chatAccessToken` | `string` | Scoped access token for this run | +| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | +| `continuation` | `boolean` | Whether this run is continuing an existing chat | +| `previousRunId` | `string \| undefined` | Previous run ID (only when `continuation` is true) | +| `preloaded` | `boolean` | Whether this run was preloaded | +| `writer` | [`ChatWriter`](#chatwriter) | Stream writer for custom chunks. Lazy — no overhead if unused. | ## TurnCompleteEvent Passed to the `onTurnComplete` callback. -| Field | Type | Description | -|-------|------|-------------| -| `chatId` | `string` | Chat session ID | -| `messages` | `ModelMessage[]` | Full accumulated conversation (model format) | -| `uiMessages` | `UIMessage[]` | Full accumulated conversation (UI format) | -| `newMessages` | `ModelMessage[]` | Only this turn's messages (model format) | -| `newUIMessages` | `UIMessage[]` | Only this turn's messages (UI format) | -| `responseMessage` | `UIMessage \| undefined` | The assistant's response for this turn | -| `rawResponseMessage` | `UIMessage \| undefined` | Raw response before abort cleanup | -| `turn` | `number` | Turn number (0-indexed) | -| `runId` | `string` | The Trigger.dev run ID | -| `chatAccessToken` | `string` | Scoped access token for this run | -| `lastEventId` | `string \| undefined` | Stream position for resumption | -| `stopped` | `boolean` | Whether the user stopped generation during this turn | -| `continuation` | `boolean` | Whether this run is continuing an existing chat | -| `usage` | `LanguageModelUsage \| undefined` | Token usage for this turn | -| `totalUsage` | `LanguageModelUsage` | Cumulative token usage across all turns | +| Field | Type | Description | +| -------------------- | --------------------------------- | ---------------------------------------------------- | +| `chatId` | `string` | Chat session ID | +| `messages` | `ModelMessage[]` | Full accumulated conversation (model format) | +| `uiMessages` | `UIMessage[]` | Full accumulated conversation (UI format) | +| `newMessages` | `ModelMessage[]` | Only this turn's messages (model format) | +| `newUIMessages` | `UIMessage[]` | Only this turn's messages (UI format) | +| `responseMessage` | `UIMessage \| undefined` | The assistant's response for this turn | +| `rawResponseMessage` | `UIMessage \| undefined` | Raw response before abort cleanup | +| `turn` | `number` | Turn number (0-indexed) | +| `runId` | `string` | The Trigger.dev run ID | +| `chatAccessToken` | `string` | Scoped access token for this run | +| `lastEventId` | `string \| undefined` | Stream position for resumption | +| `stopped` | `boolean` | Whether the user stopped generation during this turn | +| `continuation` | `boolean` | Whether this run is continuing an existing chat | +| `usage` | `LanguageModelUsage \| undefined` | Token usage for this turn | +| `totalUsage` | `LanguageModelUsage` | Cumulative token usage across all turns | ## BeforeTurnCompleteEvent Passed to the `onBeforeTurnComplete` callback. Same fields as `TurnCompleteEvent` plus a `writer`. -| Field | Type | Description | -|-------|------|-------------| -| _(all TurnCompleteEvent fields)_ | | See [TurnCompleteEvent](#turncompleteevent) | -| `writer` | [`ChatWriter`](#chatwriter) | Stream writer — the stream is still open so chunks appear in the current turn | +| Field | Type | Description | +| -------------------------------- | --------------------------- | ----------------------------------------------------------------------------- | +| _(all TurnCompleteEvent fields)_ | | See [TurnCompleteEvent](#turncompleteevent) | +| `writer` | [`ChatWriter`](#chatwriter) | Stream writer — the stream is still open so chunks appear in the current turn | ## ChatWriter @@ -131,9 +131,9 @@ A stream writer passed to lifecycle callbacks. Write custom `UIMessageChunk` par The writer is lazy — no stream is opened unless you call `write()` or `merge()`, so there's zero overhead for callbacks that don't use it. -| Method | Type | Description | -|--------|------|-------------| -| `write(part)` | `(part: UIMessageChunk) => void` | Write a single chunk to the chat stream | +| Method | Type | Description | +| --------------- | -------------------------------------------------- | -------------------------------------------------- | +| `write(part)` | `(part: UIMessageChunk) => void` | Write a single chunk to the chat stream | | `merge(stream)` | `(stream: ReadableStream) => void` | Merge another stream's chunks into the chat stream | ```ts @@ -151,153 +151,153 @@ onBeforeTurnComplete: async ({ writer, usage }) => { Options for the `compaction` field on `chat.task()`. See [Compaction](/ai-chat/compaction) for usage guide. -| Option | Type | Required | Description | -|--------|------|----------|-------------| -| `shouldCompact` | `(event: ShouldCompactEvent) => boolean \| Promise` | Yes | Decide whether to compact. Return `true` to trigger | -| `summarize` | `(event: SummarizeEvent) => Promise` | Yes | Generate a summary from the current messages | -| `compactUIMessages` | `(event: CompactMessagesEvent) => UIMessage[] \| Promise` | No | Transform UI messages after compaction. Default: preserve all | -| `compactModelMessages` | `(event: CompactMessagesEvent) => ModelMessage[] \| Promise` | No | Transform model messages after compaction. Default: replace all with summary | +| Option | Type | Required | Description | +| ---------------------- | ---------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------- | +| `shouldCompact` | `(event: ShouldCompactEvent) => boolean \| Promise` | Yes | Decide whether to compact. Return `true` to trigger | +| `summarize` | `(event: SummarizeEvent) => Promise` | Yes | Generate a summary from the current messages | +| `compactUIMessages` | `(event: CompactMessagesEvent) => UIMessage[] \| Promise` | No | Transform UI messages after compaction. Default: preserve all | +| `compactModelMessages` | `(event: CompactMessagesEvent) => ModelMessage[] \| Promise` | No | Transform model messages after compaction. Default: replace all with summary | ## CompactMessagesEvent Passed to `compactUIMessages` and `compactModelMessages` callbacks. -| Field | Type | Description | -|-------|------|-------------| -| `summary` | `string` | The generated summary text | -| `uiMessages` | `UIMessage[]` | Current UI messages (full conversation) | -| `modelMessages` | `ModelMessage[]` | Current model messages (full conversation) | -| `chatId` | `string` | Chat session ID | -| `turn` | `number` | Current turn (0-indexed) | -| `clientData` | `unknown` | Custom data from the frontend | -| `source` | `"inner" \| "outer"` | Whether compaction is between steps or between turns | +| Field | Type | Description | +| --------------- | -------------------- | ---------------------------------------------------- | +| `summary` | `string` | The generated summary text | +| `uiMessages` | `UIMessage[]` | Current UI messages (full conversation) | +| `modelMessages` | `ModelMessage[]` | Current model messages (full conversation) | +| `chatId` | `string` | Chat session ID | +| `turn` | `number` | Current turn (0-indexed) | +| `clientData` | `unknown` | Custom data from the frontend | +| `source` | `"inner" \| "outer"` | Whether compaction is between steps or between turns | ## CompactedEvent Passed to the `onCompacted` callback. -| Field | Type | Description | -|-------|------|-------------| -| `summary` | `string` | The generated summary text | -| `messages` | `ModelMessage[]` | Messages that were compacted (pre-compaction) | -| `messageCount` | `number` | Number of messages before compaction | -| `usage` | `LanguageModelUsage` | Token usage from the triggering step/turn | -| `totalTokens` | `number \| undefined` | Total token count that triggered compaction | -| `inputTokens` | `number \| undefined` | Input token count | -| `outputTokens` | `number \| undefined` | Output token count | -| `stepNumber` | `number` | Step number (-1 for outer loop) | -| `chatId` | `string \| undefined` | Chat session ID | -| `turn` | `number \| undefined` | Current turn | -| `writer` | [`ChatWriter`](#chatwriter) | Stream writer for custom chunks during compaction | +| Field | Type | Description | +| -------------- | --------------------------- | ------------------------------------------------- | +| `summary` | `string` | The generated summary text | +| `messages` | `ModelMessage[]` | Messages that were compacted (pre-compaction) | +| `messageCount` | `number` | Number of messages before compaction | +| `usage` | `LanguageModelUsage` | Token usage from the triggering step/turn | +| `totalTokens` | `number \| undefined` | Total token count that triggered compaction | +| `inputTokens` | `number \| undefined` | Input token count | +| `outputTokens` | `number \| undefined` | Output token count | +| `stepNumber` | `number` | Step number (-1 for outer loop) | +| `chatId` | `string \| undefined` | Chat session ID | +| `turn` | `number \| undefined` | Current turn | +| `writer` | [`ChatWriter`](#chatwriter) | Stream writer for custom chunks during compaction | ## PendingMessagesOptions Options for the `pendingMessages` field. See [Pending Messages](/ai-chat/pending-messages) for usage guide. -| Option | Type | Required | Description | -|--------|------|----------|-------------| -| `shouldInject` | `(event: PendingMessagesBatchEvent) => boolean \| Promise` | No | Decide whether to inject the batch between tool-call steps. If absent, no injection. | -| `prepare` | `(event: PendingMessagesBatchEvent) => ModelMessage[] \| Promise` | No | Transform the batch before injection. Default: convert each via `convertToModelMessages`. | -| `onReceived` | `(event: PendingMessageReceivedEvent) => void \| Promise` | No | Called when a message arrives during streaming (per-message). | -| `onInjected` | `(event: PendingMessagesInjectedEvent) => void \| Promise` | No | Called after a batch is injected via prepareStep. | +| Option | Type | Required | Description | +| -------------- | --------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------- | +| `shouldInject` | `(event: PendingMessagesBatchEvent) => boolean \| Promise` | No | Decide whether to inject the batch between tool-call steps. If absent, no injection. | +| `prepare` | `(event: PendingMessagesBatchEvent) => ModelMessage[] \| Promise` | No | Transform the batch before injection. Default: convert each via `convertToModelMessages`. | +| `onReceived` | `(event: PendingMessageReceivedEvent) => void \| Promise` | No | Called when a message arrives during streaming (per-message). | +| `onInjected` | `(event: PendingMessagesInjectedEvent) => void \| Promise` | No | Called after a batch is injected via prepareStep. | ## PendingMessagesBatchEvent Passed to `shouldInject` and `prepare` callbacks. -| Field | Type | Description | -|-------|------|-------------| -| `messages` | `UIMessage[]` | All pending messages (batch) | -| `modelMessages` | `ModelMessage[]` | Current conversation | -| `steps` | `CompactionStep[]` | Completed steps so far | -| `stepNumber` | `number` | Current step (0-indexed) | -| `chatId` | `string` | Chat session ID | -| `turn` | `number` | Current turn (0-indexed) | -| `clientData` | `unknown` | Custom data from the frontend | +| Field | Type | Description | +| --------------- | ------------------ | ----------------------------- | +| `messages` | `UIMessage[]` | All pending messages (batch) | +| `modelMessages` | `ModelMessage[]` | Current conversation | +| `steps` | `CompactionStep[]` | Completed steps so far | +| `stepNumber` | `number` | Current step (0-indexed) | +| `chatId` | `string` | Chat session ID | +| `turn` | `number` | Current turn (0-indexed) | +| `clientData` | `unknown` | Custom data from the frontend | ## PendingMessagesInjectedEvent Passed to `onInjected` callback. -| Field | Type | Description | -|-------|------|-------------| -| `messages` | `UIMessage[]` | All injected UI messages | +| Field | Type | Description | +| ----------------------- | ---------------- | ------------------------------------- | +| `messages` | `UIMessage[]` | All injected UI messages | | `injectedModelMessages` | `ModelMessage[]` | The model messages that were injected | -| `chatId` | `string` | Chat session ID | -| `turn` | `number` | Current turn | -| `stepNumber` | `number` | Step where injection occurred | +| `chatId` | `string` | Chat session ID | +| `turn` | `number` | Current turn | +| `stepNumber` | `number` | Step where injection occurred | ## UsePendingMessagesReturn Return value of `usePendingMessages` hook. See [Pending Messages — Frontend](/ai-chat/pending-messages#frontend-usependingmessages-hook). -| Property/Method | Type | Description | -|-----------------|------|-------------| -| `pending` | `PendingMessage[]` | Current pending messages with mode and injection status | -| `steer` | `(text: string) => void` | Send a steering message (or normal message when not streaming) | -| `queue` | `(text: string) => void` | Queue for next turn (or send normally when not streaming) | -| `promoteToSteering` | `(id: string) => void` | Convert a queued message to steering | -| `isInjectionPoint` | `(part: unknown) => boolean` | Check if an assistant message part is an injection confirmation | -| `getInjectedMessageIds` | `(part: unknown) => string[]` | Get message IDs from an injection point | -| `getInjectedMessages` | `(part: unknown) => InjectedMessage[]` | Get messages (id + text) from an injection point | +| Property/Method | Type | Description | +| ----------------------- | -------------------------------------- | --------------------------------------------------------------- | +| `pending` | `PendingMessage[]` | Current pending messages with mode and injection status | +| `steer` | `(text: string) => void` | Send a steering message (or normal message when not streaming) | +| `queue` | `(text: string) => void` | Queue for next turn (or send normally when not streaming) | +| `promoteToSteering` | `(id: string) => void` | Convert a queued message to steering | +| `isInjectionPoint` | `(part: unknown) => boolean` | Check if an assistant message part is an injection confirmation | +| `getInjectedMessageIds` | `(part: unknown) => string[]` | Get message IDs from an injection point | +| `getInjectedMessages` | `(part: unknown) => InjectedMessage[]` | Get messages (id + text) from an injection point | ## ChatSessionOptions Options for `chat.createSession()`. -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `signal` | `AbortSignal` | required | Run-level cancel signal | -| `idleTimeoutInSeconds` | `number` | `30` | Seconds to stay idle between turns | -| `timeout` | `string` | `"1h"` | Duration string for suspend timeout | -| `maxTurns` | `number` | `100` | Max turns before ending | +| Option | Type | Default | Description | +| ---------------------- | ------------- | -------- | ----------------------------------- | +| `signal` | `AbortSignal` | required | Run-level cancel signal | +| `idleTimeoutInSeconds` | `number` | `30` | Seconds to stay idle between turns | +| `timeout` | `string` | `"1h"` | Duration string for suspend timeout | +| `maxTurns` | `number` | `100` | Max turns before ending | ## ChatTurn Each turn yielded by `chat.createSession()`. -| Field | Type | Description | -|-------|------|-------------| -| `number` | `number` | Turn number (0-indexed) | -| `chatId` | `string` | Chat session ID | -| `trigger` | `string` | What triggered this turn | -| `clientData` | `unknown` | Client data from the transport | -| `messages` | `ModelMessage[]` | Full accumulated model messages | -| `uiMessages` | `UIMessage[]` | Full accumulated UI messages | -| `signal` | `AbortSignal` | Combined stop+cancel signal (fresh each turn) | -| `stopped` | `boolean` | Whether the user stopped generation this turn | -| `continuation` | `boolean` | Whether this is a continuation run | - -| Method | Returns | Description | -|--------|---------|-------------| -| `complete(source)` | `Promise` | Pipe, capture, accumulate, cleanup, and signal turn-complete | -| `done()` | `Promise` | Signal turn-complete (when you've piped manually) | -| `addResponse(response)` | `Promise` | Add response to accumulator manually | +| Field | Type | Description | +| -------------- | ---------------- | --------------------------------------------- | +| `number` | `number` | Turn number (0-indexed) | +| `chatId` | `string` | Chat session ID | +| `trigger` | `string` | What triggered this turn | +| `clientData` | `unknown` | Client data from the transport | +| `messages` | `ModelMessage[]` | Full accumulated model messages | +| `uiMessages` | `UIMessage[]` | Full accumulated UI messages | +| `signal` | `AbortSignal` | Combined stop+cancel signal (fresh each turn) | +| `stopped` | `boolean` | Whether the user stopped generation this turn | +| `continuation` | `boolean` | Whether this is a continuation run | + +| Method | Returns | Description | +| ----------------------- | --------------------------------- | ------------------------------------------------------------ | +| `complete(source)` | `Promise` | Pipe, capture, accumulate, cleanup, and signal turn-complete | +| `done()` | `Promise` | Signal turn-complete (when you've piped manually) | +| `addResponse(response)` | `Promise` | Add response to accumulator manually | ## chat namespace All methods available on the `chat` object from `@trigger.dev/sdk/ai`. -| Method | Description | -|--------|-------------| -| `chat.task(options)` | Create a chat task | -| `chat.createSession(payload, options)` | Create an async iterator for chat turns | -| `chat.pipe(source, options?)` | Pipe a stream to the frontend (from anywhere inside a task) | -| `chat.pipeAndCapture(source, options?)` | Pipe and capture the response `UIMessage` | -| `chat.writeTurnComplete(options?)` | Signal the frontend that the current turn is complete | -| `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream | -| `chat.messages` | Input stream for incoming messages — use `.waitWithIdleTimeout()` | -| `chat.local({ id })` | Create a per-run typed local (see [Per-run data](/ai-chat/features#per-run-data-with-chatlocal)) | -| `chat.createAccessToken(taskId)` | Create a public access token for a chat task | -| `chat.setTurnTimeout(duration)` | Override turn timeout at runtime (e.g. `"2h"`) | -| `chat.setTurnTimeoutInSeconds(seconds)` | Override turn timeout at runtime (in seconds) | -| `chat.setIdleTimeoutInSeconds(seconds)` | Override idle timeout at runtime | -| `chat.setUIMessageStreamOptions(options)` | Override `toUIMessageStream()` options for the current turn | -| `chat.defer(promise)` | Run background work in parallel with streaming, awaited before `onTurnComplete` | -| `chat.isStopped()` | Check if the current turn was stopped by the user | -| `chat.cleanupAbortedParts(message)` | Remove incomplete parts from a stopped response message | -| `chat.stream` | Typed chat output stream — use `.writer()`, `.pipe()`, `.append()`, `.read()` | -| `chat.MessageAccumulator` | Class that accumulates conversation messages across turns | +| Method | Description | +| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `chat.task(options)` | Create a chat task | +| `chat.createSession(payload, options)` | Create an async iterator for chat turns | +| `chat.pipe(source, options?)` | Pipe a stream to the frontend (from anywhere inside a task) | +| `chat.pipeAndCapture(source, options?)` | Pipe and capture the response `UIMessage` | +| `chat.writeTurnComplete(options?)` | Signal the frontend that the current turn is complete | +| `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream | +| `chat.messages` | Input stream for incoming messages — use `.waitWithIdleTimeout()` | +| `chat.local({ id })` | Create a per-run typed local (see [Per-run data](/ai-chat/features#per-run-data-with-chatlocal)) | +| `chat.createAccessToken(taskId)` | Create a public access token for a chat task | +| `chat.setTurnTimeout(duration)` | Override turn timeout at runtime (e.g. `"2h"`) | +| `chat.setTurnTimeoutInSeconds(seconds)` | Override turn timeout at runtime (in seconds) | +| `chat.setIdleTimeoutInSeconds(seconds)` | Override idle timeout at runtime | +| `chat.setUIMessageStreamOptions(options)` | Override `toUIMessageStream()` options for the current turn | +| `chat.defer(promise)` | Run background work in parallel with streaming, awaited before `onTurnComplete` | +| `chat.isStopped()` | Check if the current turn was stopped by the user | +| `chat.cleanupAbortedParts(message)` | Remove incomplete parts from a stopped response message | +| `chat.stream` | Typed chat output stream — use `.writer()`, `.pipe()`, `.append()`, `.read()` | +| `chat.MessageAccumulator` | Class that accumulates conversation messages across turns | | `chat.withUIMessage(config?).task(options)` | Same as `chat.task`, but fixes a custom `UIMessage` subtype and optional default stream options. See [Types](/ai-chat/types) | ## `chat.withUIMessage` @@ -310,16 +310,16 @@ chat.withUIMessage(config?: ChatWithUIMessageConfig): { }; ``` -| Parameter | Type | Description | -|-----------|------|-------------| +| Parameter | Type | Description | +| ---------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `config.streamOptions` | `ChatUIMessageStreamOptions` | Optional defaults for `toUIMessageStream()`. Shallow-merged with `uiMessageStreamOptions` on the inner `.task({ ... })` (task wins on key conflicts). | Use this when you need [`InferChatUIMessage`](#inferchatuimessage) / typed `data-*` parts / `InferUITools` to line up across backend hooks and `useChat`. Full guide: [Types](/ai-chat/types). ## `ChatWithUIMessageConfig` -| Field | Type | Description | -|-------|------|-------------| +| Field | Type | Description | +| --------------- | ---------------------------------- | --------------------------------------------------------------------- | | `streamOptions` | `ChatUIMessageStreamOptions` | Default `toUIMessageStream()` options for tasks created via `.task()` | ## `InferChatUIMessage` @@ -337,11 +337,11 @@ Use with `useChat({ transport })` when using [`chat.withUIMessage`](/ai-cha ## AI helpers (`ai` from `@trigger.dev/sdk/ai`) -| Export | Status | Description | -|--------|--------|-------------| -| `ai.toolExecute(task)` | **Preferred** | Returns the `execute` function for AI SDK `tool()`. Runs the task via `triggerAndSubscribe` and attaches tool/chat metadata (same behavior the deprecated wrapper used internally). | -| `ai.tool(task, options?)` | **Deprecated** | Wraps `tool()` / `dynamicTool()` and the same execute path. Migrate to `tool({ ..., execute: ai.toolExecute(task) })`. See [Task-backed AI tools](/tasks/schemaTask#task-backed-ai-tools). | -| `ai.toolCallId`, `ai.chatContext`, `ai.chatContextOrThrow`, `ai.currentToolOptions` | Supported | Work for any task-backed tool execute path, including `ai.toolExecute`. | +| Export | Status | Description | +| ----------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `ai.toolExecute(task)` | **Preferred** | Returns the `execute` function for AI SDK `tool()`. Runs the task via `triggerAndSubscribe` and attaches tool/chat metadata (same behavior the deprecated wrapper used internally). | +| `ai.tool(task, options?)` | **Deprecated** | Wraps `tool()` / `dynamicTool()` and the same execute path. Migrate to `tool({ ..., execute: ai.toolExecute(task) })`. See [Task-backed AI tools](/tasks/schemaTask#task-backed-ai-tools). | +| `ai.toolCallId`, `ai.chatContext`, `ai.chatContextOrThrow`, `ai.currentToolOptions` | Supported | Work for any task-backed tool execute path, including `ai.toolExecute`. | ## ChatUIMessageStreamOptions @@ -349,31 +349,65 @@ Options for customizing `toUIMessageStream()`. Set as static defaults via `uiMes Derived from the AI SDK's `UIMessageStreamOptions` with `onFinish`, `originalMessages`, and `generateMessageId` omitted (managed internally). -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `onError` | `(error: unknown) => string` | Raw error message | Called on LLM errors and tool execution errors. Return a sanitized string — sent as `{ type: "error", errorText }` to the frontend. | -| `sendReasoning` | `boolean` | `true` | Send reasoning parts to the client | -| `sendSources` | `boolean` | `false` | Send source parts to the client | -| `sendFinish` | `boolean` | `true` | Send the finish event. Set to `false` when chaining multiple `streamText` calls. | -| `sendStart` | `boolean` | `true` | Send the message start event. Set to `false` when chaining. | -| `messageMetadata` | `(options: { part }) => metadata` | — | Extract message metadata to send to the client. Called on `start` and `finish` events. | +| Option | Type | Default | Description | +| ----------------- | --------------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `onError` | `(error: unknown) => string` | Raw error message | Called on LLM errors and tool execution errors. Return a sanitized string — sent as `{ type: "error", errorText }` to the frontend. | +| `sendReasoning` | `boolean` | `true` | Send reasoning parts to the client | +| `sendSources` | `boolean` | `false` | Send source parts to the client | +| `sendFinish` | `boolean` | `true` | Send the finish event. Set to `false` when chaining multiple `streamText` calls. | +| `sendStart` | `boolean` | `true` | Send the message start event. Set to `false` when chaining. | +| `messageMetadata` | `(options: { part }) => metadata` | — | Extract message metadata to send to the client. Called on `start` and `finish` events. | ## TriggerChatTransport options Options for the frontend transport constructor and `useTriggerChatTransport` hook. -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `task` | `string` | required | Task ID to trigger | -| `accessToken` | `string \| () => string \| Promise` | required | Auth token or function that returns one | -| `baseURL` | `string` | `"https://api.trigger.dev"` | API base URL (for self-hosted) | -| `streamKey` | `string` | `"chat"` | Stream key (only change if using custom key) | -| `headers` | `Record` | — | Extra headers for API requests | -| `streamTimeoutSeconds` | `number` | `120` | How long to wait for stream data | -| `clientData` | Typed by `clientDataSchema` | — | Default client data for every request | -| `sessions` | `Record` | — | Restore sessions from storage | -| `onSessionChange` | `(chatId, session \| null) => void` | — | Fires when session state changes | -| `triggerOptions` | `{...}` | — | Options for the initial task trigger (see below) | +| Option | Type | Default | Description | +| ---------------------- | -------------------------------------------------------------------- | --------------------------- | --------------------------------------------------------------------------- | +| `task` | `string` | required | Task ID to trigger | +| `accessToken` | `string \| (params: ResolveChatAccessTokenParams) => string \| Promise` | required | Trigger / API auth token, or a function that returns one (see below) | +| `baseURL` | `string` | `"https://api.trigger.dev"` | API base URL (for self-hosted) | +| `streamKey` | `string` | `"chat"` | Stream key (only change if using custom key) | +| `headers` | `Record` | — | Extra headers for API requests | +| `streamTimeoutSeconds` | `number` | `120` | How long to wait for stream data | +| `clientData` | Typed by `clientDataSchema` | — | Default client data for every request | +| `sessions` | `Record` | — | Restore sessions from storage | +| `onSessionChange` | `(chatId, session \| null) => void` | — | Fires when session state changes | +| `renewRunAccessToken` | `(params: RenewRunAccessTokenParams) => string \| ... \| Promise<...>` | — | Mint a new run-scoped PAT when the run PAT returns 401 (realtime / input stream). Retries once. | +| `triggerOptions` | `{...}` | — | Options for the initial task trigger (see below) | + +### `accessToken` callback + +When `accessToken` is a function, the transport calls it with **`ResolveChatAccessTokenParams`** (exported from `@trigger.dev/sdk/chat`): + +- `chatId` — the conversation id (`useChat` id / `sendMessages` chat id). +- `purpose` — `"trigger"` when calling `triggerTask` from `sendMessages` (new run or after the session ended), or `"preload"` when calling `preload()`. + +Use this to mint or log per-chat trigger tokens. A plain **`string`** is still supported and skips the callback. + +### `renewRunAccessToken` callback + +Optional. When the **run** public access token used for realtime SSE or input streams expires, the transport calls this once with **`RenewRunAccessTokenParams`** (`chatId`, `runId`), then retries the failing request. Implement it with your server `auth.createPublicToken` (scopes `read:runs:` and `write:inputStreams:`). See [Authentication](/realtime/auth). + +```ts +import { auth } from "@trigger.dev/sdk"; +import type { ResolveChatAccessTokenParams } from "@trigger.dev/sdk/chat"; + +async function getChatToken(input: ResolveChatAccessTokenParams) { + return auth.createTriggerPublicToken("my-chat", { expirationTime: "1h" }); +} + +const transport = useTriggerChatTransport({ + task: "my-chat", + accessToken: getChatToken, + renewRunAccessToken: async ({ chatId, runId }) => { + return auth.createPublicToken({ + scopes: { read: { runs: runId }, write: { inputStreams: runId } }, + expirationTime: "1h", + }); + }, +}); +``` ### triggerOptions @@ -381,13 +415,13 @@ Options forwarded to the Trigger.dev API when starting a new run. Only applies t A `chat:{chatId}` tag is automatically added to every run. -| Option | Type | Description | -|--------|------|-------------| -| `tags` | `string[]` | Additional tags for the run (merged with auto-tags, max 5 total) | -| `queue` | `string` | Queue name for the run | -| `maxAttempts` | `number` | Maximum retry attempts | -| `machine` | `"micro" \| "small-1x" \| ...` | Machine preset for the run | -| `priority` | `number` | Priority (lower = higher priority) | +| Option | Type | Description | +| ------------- | ------------------------------ | ---------------------------------------------------------------- | +| `tags` | `string[]` | Additional tags for the run (merged with auto-tags, max 5 total) | +| `queue` | `string` | Queue name for the run | +| `maxAttempts` | `number` | Maximum retry attempts | +| `machine` | `"micro" \| "small-1x" \| ...` | Machine preset for the run | +| `priority` | `number` | Priority (lower = higher priority) | ```ts const transport = useTriggerChatTransport({ @@ -420,7 +454,7 @@ import type { myChat } from "@/trigger/chat"; const transport = useTriggerChatTransport({ task: "my-chat", - accessToken: () => getChatToken(), + accessToken: getChatToken, // (params) => … — same shape as ResolveChatAccessTokenParams sessions: savedSessions, onSessionChange: handleSessionChange, }); From 3c1417ef16f23d19043414cfae0b71e727c9bbf2 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 27 Mar 2026 17:09:27 +0000 Subject: [PATCH 10/13] patterns and the ctx thing --- docs/ai-chat/backend.mdx | 15 ++++++++++++++- docs/ai-chat/features.mdx | 4 +++- docs/ai-chat/overview.mdx | 2 ++ docs/ai-chat/reference.mdx | 30 +++++++++++++++++++++++++++--- docs/docs.json | 7 +++++++ 5 files changed, 53 insertions(+), 5 deletions(-) diff --git a/docs/ai-chat/backend.mdx b/docs/ai-chat/backend.mdx index 50a338cd2c1..c7b6e051c0b 100644 --- a/docs/ai-chat/backend.mdx +++ b/docs/ai-chat/backend.mdx @@ -67,6 +67,12 @@ async function runAgentLoop(messages: ModelMessage[]) { ### Lifecycle hooks +#### Task context (`ctx`) + +Every chat lifecycle callback and the **`run`** payload include **`ctx`**: the same run context object as `task({ run: (payload, { ctx }) => ... })`. Import the type with **`import type { TaskRunContext } from "@trigger.dev/sdk"`** (the **`Context`** export is the same type). Use **`ctx`** for tags, metadata, or any API that needs the full run record. The string **`runId`** on chat events is always **`ctx.run.id`** (both are provided for convenience). See [Task context (`ctx`)](/ai-chat/reference#task-context-ctx) in the API reference. + +Standard **[task lifecycle hooks](/tasks/overview)** — **`onWait`**, **`onResume`**, **`onComplete`**, **`onFailure`**, etc. — are also available on **`chat.task()`** with the same shapes as on a normal `task()`. For example, tear down an external sandbox **right before the run suspends** waiting for the next message using **`onWait`** when **`wait.type === "token"`**. See the [Code execution sandbox](/ai-chat/patterns/code-sandbox) pattern. + #### onPreload Fires when a preloaded run starts — before any messages arrive. Use it to eagerly initialize state (DB records, user context) while the user is still typing. @@ -77,7 +83,7 @@ Preloaded runs are triggered by calling `transport.preload(chatId)` on the front export const myChat = chat.task({ id: "my-chat", clientDataSchema: z.object({ userId: z.string() }), - onPreload: async ({ chatId, clientData, runId, chatAccessToken }) => { + onPreload: async ({ ctx, chatId, clientData, runId, chatAccessToken }) => { // Initialize early — before the first message arrives const user = await db.user.findUnique({ where: { id: clientData.userId } }); userContext.init({ name: user.name, plan: user.plan }); @@ -101,6 +107,7 @@ export const myChat = chat.task({ | Field | Type | Description | | ----------------- | --------------------------------------------- | -------------------------------- | +| `ctx` | `TaskRunContext` | Full task run context — [reference](/ai-chat/reference#task-context-ctx) | | `chatId` | `string` | Chat session ID | | `runId` | `string` | The Trigger.dev run ID | | `chatAccessToken` | `string` | Scoped access token for this run | @@ -145,6 +152,7 @@ Fires at the start of every turn, after message accumulation and `onChatStart` ( | Field | Type | Description | | ----------------- | --------------------------------------------- | ----------------------------------------------- | +| `ctx` | `TaskRunContext` | Full task run context — [reference](/ai-chat/reference#task-context-ctx) | | `chatId` | `string` | Chat session ID | | `messages` | `ModelMessage[]` | Full accumulated conversation (model format) | | `uiMessages` | `UIMessage[]` | Full accumulated conversation (UI format) | @@ -219,6 +227,7 @@ Fires after each turn completes — after the response is captured and the strea | Field | Type | Description | | -------------------- | ------------------------ | -------------------------------------------------------------------------------------------- | +| `ctx` | `TaskRunContext` | Full task run context — [reference](/ai-chat/reference#task-context-ctx) | | `chatId` | `string` | Chat session ID | | `messages` | `ModelMessage[]` | Full accumulated conversation (model format) | | `uiMessages` | `UIMessage[]` | Full accumulated conversation (UI format) | @@ -263,6 +272,10 @@ export const myChat = chat.task({ it uses this to skip past already-seen events — preventing duplicate messages. + + For a full **conversation + session** persistence pattern (including preload, continuation, and token renewal), see [Database persistence](/ai-chat/patterns/database-persistence). + + ### 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. diff --git a/docs/ai-chat/features.mdx b/docs/ai-chat/features.mdx index 4b262e3929c..efb0cad3692 100644 --- a/docs/ai-chat/features.mdx +++ b/docs/ai-chat/features.mdx @@ -8,6 +8,8 @@ description: "Per-run data, deferred work, custom streaming, subtask integration Use `chat.local` to create typed, run-scoped data that persists across turns and is accessible from anywhere — the run function, tools, nested helpers. Each run gets its own isolated copy, and locals are automatically cleared between runs. +Lifecycle hooks and **`run`** also receive **`ctx`** ([`TaskRunContext`](/ai-chat/reference#task-context-ctx)) — the same object as on a standard `task()` — for tags, metadata, and cleanup that needs the full run record. + When a subtask is invoked via `ai.toolExecute()` (or the deprecated `ai.tool()`), initialized locals are automatically serialized into the subtask's metadata and hydrated on first access — no extra code needed. Subtask changes to hydrated locals are local to the subtask and don't propagate back to the parent. ### Declaring and initializing @@ -156,7 +158,7 @@ onTurnComplete: async ({ chatId }) => { --- -## chat.defer() +## chat.defer() {#chat-defer} Use `chat.defer()` to run background work in parallel with streaming. The deferred promise runs alongside the LLM response and is awaited (with a 5s timeout) before `onTurnComplete` fires. diff --git a/docs/ai-chat/overview.mdx b/docs/ai-chat/overview.mdx index 3fe6d0f3ec2..8a339d5be0f 100644 --- a/docs/ai-chat/overview.mdx +++ b/docs/ai-chat/overview.mdx @@ -155,6 +155,8 @@ There are three ways to build the backend, from most opinionated to most flexibl ## Related - [Quick Start](/ai-chat/quick-start) — Get a working chat in 3 steps +- [Database persistence](/ai-chat/patterns/database-persistence) — Conversation + session state across hooks (ORM-agnostic) +- [Code execution sandbox](/ai-chat/patterns/code-sandbox) — Warm/teardown pattern for E2B (or similar) with `onWait` / `chat.local` - [Backend](/ai-chat/backend) — Backend approaches in detail - [Frontend](/ai-chat/frontend) — Transport setup, sessions, client data - [Types](/ai-chat/types) — TypeScript patterns, including custom `UIMessage` with `chat.withUIMessage` diff --git a/docs/ai-chat/reference.mdx b/docs/ai-chat/reference.mdx index 40f6f04daf8..6cf329acb8e 100644 --- a/docs/ai-chat/reference.mdx +++ b/docs/ai-chat/reference.mdx @@ -30,7 +30,23 @@ Options for `chat.task()`. | `preloadTimeout` | `string` | Same as `turnTimeout` | Suspend timeout for preloaded runs | | `uiMessageStreamOptions` | `ChatUIMessageStreamOptions` | — | Default options for `toUIMessageStream()`. Per-turn override via `chat.setUIMessageStreamOptions()` | -Plus all standard [TaskOptions](/tasks/overview) — `retry`, `queue`, `machine`, `maxDuration`, etc. +Plus all standard [TaskOptions](/tasks/overview) — `retry`, `queue`, `machine`, `maxDuration`, **`onWait`**, **`onResume`**, **`onComplete`**, and other lifecycle hooks. Those hooks use the same parameter shapes as on a normal `task()` (including `ctx`). + +## Task context (`ctx`) + +All **`chat.task`** lifecycle events (**`onPreload`**, **`onChatStart`**, **`onTurnStart`**, **`onBeforeTurnComplete`**, **`onTurnComplete`**, **`onCompacted`**) and the object passed to **`run`** include **`ctx`**: the same **`TaskRunContext`** shape as the `ctx` in `task({ run: (payload, { ctx }) => ... })`. + +Use **`ctx`** for run metadata, tags, parent links, or any API that needs the full run record. The chat-specific string **`runId`** on events is always **`ctx.run.id`**; both are provided for convenience. + +```ts +import type { TaskRunContext } from "@trigger.dev/sdk"; +// Equivalent alias (same type): +import type { Context } from "@trigger.dev/sdk"; +``` + + + Prefer `import type { TaskRunContext } from "@trigger.dev/sdk"` in application code. Do not depend on `@trigger.dev/core` directly. + ## ChatTaskRunPayload @@ -38,6 +54,7 @@ The payload passed to the `run` function. | Field | Type | Description | | -------------- | ------------------------------------------ | -------------------------------------------------------------------- | +| `ctx` | `TaskRunContext` | Full task run context — same as `task` `run`’s `{ ctx }` | | `messages` | `ModelMessage[]` | Model-ready messages — pass directly to `streamText` | | `chatId` | `string` | Unique chat session ID | | `trigger` | `"submit-message" \| "regenerate-message"` | What triggered the request | @@ -47,6 +64,8 @@ The payload passed to the `run` function. | `signal` | `AbortSignal` | Combined stop + cancel signal | | `cancelSignal` | `AbortSignal` | Cancel-only signal | | `stopSignal` | `AbortSignal` | Stop-only signal (per-turn) | +| `previousTurnUsage` | `LanguageModelUsage \| undefined` | Token usage from the previous turn (undefined on turn 0) | +| `totalUsage` | `LanguageModelUsage` | Cumulative token usage across completed turns so far | ## PreloadEvent @@ -54,6 +73,7 @@ Passed to the `onPreload` callback. | Field | Type | Description | | ----------------- | --------------------------- | -------------------------------------------------------------- | +| `ctx` | `TaskRunContext` | Full task run context — see [Task context](#task-context-ctx) | | `chatId` | `string` | Chat session ID | | `runId` | `string` | The Trigger.dev run ID | | `chatAccessToken` | `string` | Scoped access token for this run | @@ -66,6 +86,7 @@ Passed to the `onChatStart` callback. | Field | Type | Description | | ----------------- | --------------------------- | -------------------------------------------------------------- | +| `ctx` | `TaskRunContext` | Full task run context — see [Task context](#task-context-ctx) | | `chatId` | `string` | Chat session ID | | `messages` | `ModelMessage[]` | Initial model-ready messages | | `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | @@ -82,6 +103,7 @@ Passed to the `onTurnStart` callback. | Field | Type | Description | | ----------------- | --------------------------- | -------------------------------------------------------------- | +| `ctx` | `TaskRunContext` | Full task run context — see [Task context](#task-context-ctx) | | `chatId` | `string` | Chat session ID | | `messages` | `ModelMessage[]` | Full accumulated conversation (model format) | | `uiMessages` | `UIMessage[]` | Full accumulated conversation (UI format) | @@ -100,6 +122,7 @@ Passed to the `onTurnComplete` callback. | Field | Type | Description | | -------------------- | --------------------------------- | ---------------------------------------------------- | +| `ctx` | `TaskRunContext` | Full task run context — see [Task context](#task-context-ctx) | | `chatId` | `string` | Chat session ID | | `messages` | `ModelMessage[]` | Full accumulated conversation (model format) | | `uiMessages` | `UIMessage[]` | Full accumulated conversation (UI format) | @@ -118,11 +141,11 @@ Passed to the `onTurnComplete` callback. ## BeforeTurnCompleteEvent -Passed to the `onBeforeTurnComplete` callback. Same fields as `TurnCompleteEvent` plus a `writer`. +Passed to the `onBeforeTurnComplete` callback. Same fields as `TurnCompleteEvent` (including **`ctx`**) plus a `writer`. | Field | Type | Description | | -------------------------------- | --------------------------- | ----------------------------------------------------------------------------- | -| _(all TurnCompleteEvent fields)_ | | See [TurnCompleteEvent](#turncompleteevent) | +| _(all TurnCompleteEvent fields)_ | | See [TurnCompleteEvent](#turncompleteevent) (includes `ctx`) | | `writer` | [`ChatWriter`](#chatwriter) | Stream writer — the stream is still open so chunks appear in the current turn | ## ChatWriter @@ -178,6 +201,7 @@ Passed to the `onCompacted` callback. | Field | Type | Description | | -------------- | --------------------------- | ------------------------------------------------- | +| `ctx` | `TaskRunContext` | Full task run context — see [Task context](#task-context-ctx) | | `summary` | `string` | The generated summary text | | `messages` | `ModelMessage[]` | Messages that were compacted (pre-compaction) | | `messageCount` | `number` | Number of messages before compaction | diff --git a/docs/docs.json b/docs/docs.json index 4854d6d016e..cb8cb864eda 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -96,6 +96,13 @@ "ai-chat/compaction", "ai-chat/pending-messages", "ai-chat/background-injection", + { + "group": "Patterns", + "pages": [ + "ai-chat/patterns/database-persistence", + "ai-chat/patterns/code-sandbox" + ] + }, "ai-chat/reference" ] } From 48274ea85a9e081a3ad27b300daffd56b1408e77 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 28 Mar 2026 09:10:49 +0000 Subject: [PATCH 11/13] docs: add onChatSuspend/onChatResume, exitAfterPreloadIdle, withClientData, ChatBuilder docs --- docs/ai-chat/backend.mdx | 69 ++++++++++++++++- docs/ai-chat/reference.mdx | 56 ++++++++++++-- docs/ai-chat/types.mdx | 147 ++++++++++++++++++++++++++++++------- 3 files changed, 237 insertions(+), 35 deletions(-) diff --git a/docs/ai-chat/backend.mdx b/docs/ai-chat/backend.mdx index c7b6e051c0b..9842f9cfa5b 100644 --- a/docs/ai-chat/backend.mdx +++ b/docs/ai-chat/backend.mdx @@ -9,7 +9,7 @@ description: "Three approaches to building your chat backend — chat.task(), se The highest-level approach. Handles message accumulation, stop signals, turn lifecycle, and auto-piping automatically. - To fix a **custom** `UIMessage` subtype (typed custom data parts, tool map, etc.), use [`chat.withUIMessage<...>().task({...})`](/ai-chat/types) instead of `chat.task({...})`. Options are the same; defaults for `toUIMessageStream()` can be set on `withUIMessage`. + 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 `.task()`. See [Types](/ai-chat/types). ### Simple: return a StreamTextResult @@ -71,7 +71,9 @@ async function runAgentLoop(messages: ModelMessage[]) { Every chat lifecycle callback and the **`run`** payload include **`ctx`**: the same run context object as `task({ run: (payload, { ctx }) => ... })`. Import the type with **`import type { TaskRunContext } from "@trigger.dev/sdk"`** (the **`Context`** export is the same type). Use **`ctx`** for tags, metadata, or any API that needs the full run record. The string **`runId`** on chat events is always **`ctx.run.id`** (both are provided for convenience). See [Task context (`ctx`)](/ai-chat/reference#task-context-ctx) in the API reference. -Standard **[task lifecycle hooks](/tasks/overview)** — **`onWait`**, **`onResume`**, **`onComplete`**, **`onFailure`**, etc. — are also available on **`chat.task()`** with the same shapes as on a normal `task()`. For example, tear down an external sandbox **right before the run suspends** waiting for the next message using **`onWait`** when **`wait.type === "token"`**. See the [Code execution sandbox](/ai-chat/patterns/code-sandbox) pattern. +Standard **[task lifecycle hooks](/tasks/overview)** — **`onWait`**, **`onResume`**, **`onComplete`**, **`onFailure`**, etc. — are also available on **`chat.task()`** with the same shapes as on a normal `task()`. + +Chat tasks also have two dedicated suspension hooks — **`onChatSuspend`** and **`onChatResume`** — that fire at the idle-to-suspended transition with full chat context. Use them for resource cleanup (e.g. tearing down sandboxes) and re-initialization. See [onChatSuspend / onChatResume](#onchatsuspend--onchatresume) and the [Code execution sandbox](/ai-chat/patterns/code-sandbox) pattern. #### onPreload @@ -276,6 +278,69 @@ export const myChat = chat.task({ For a full **conversation + session** persistence pattern (including preload, continuation, and token renewal), see [Database persistence](/ai-chat/patterns/database-persistence). +#### onChatSuspend / onChatResume + +Chat-specific hooks that fire at the **idle-to-suspended** transition — the moment the run stops using compute and waits for the next message. These replace the need for the generic `onWait` / `onResume` task hooks for chat-specific work. + +The `phase` discriminator tells you **when** the suspend/resume happened: + +- `"preload"` — after `onPreload`, waiting for the first message +- `"turn"` — after `onTurnComplete`, waiting for the next message + +```ts +export const myChat = chat.task({ + id: "my-chat", + onChatSuspend: async (event) => { + // Tear down expensive resources before suspending + await disposeCodeSandbox(event.ctx.run.id); + if (event.phase === "turn") { + logger.info("Suspending after turn", { turn: event.turn }); + } + }, + onChatResume: async (event) => { + // Re-initialize after waking up + logger.info("Resumed", { phase: event.phase }); + }, + run: async ({ messages, signal }) => { + return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }); + }, +}); +``` + +| Field | Type | Description | +| ------------ | ---------------- | ------------------------------------------------------------ | +| `phase` | `"preload" \| "turn"` | Whether this is a preload or post-turn suspension | +| `ctx` | `TaskRunContext` | Full task run context | +| `chatId` | `string` | Chat session ID | +| `runId` | `string` | The Trigger.dev run ID | +| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | +| `turn` | `number` | Turn number (**`"turn"` phase only**) | +| `messages` | `ModelMessage[]` | Accumulated model messages (**`"turn"` phase only**) | +| `uiMessages` | `UIMessage[]` | Accumulated UI messages (**`"turn"` phase only**) | + + + Unlike `onWait` (which fires for all wait types — duration, task, batch, token), `onChatSuspend` fires only at chat suspension points with full chat context. No need to filter on `wait.type`. + + +#### exitAfterPreloadIdle + +When set to `true`, a preloaded run completes successfully after the idle timeout elapses instead of suspending. Use this for "fire and forget" preloads — if the user doesn't send a message during the idle window, the run ends cleanly. + +```ts +export const myChat = chat.task({ + id: "my-chat", + preloadIdleTimeoutInSeconds: 10, + exitAfterPreloadIdle: true, + onPreload: async ({ chatId, clientData }) => { + // Eagerly set up state — if no message comes, the run just ends + await initializeChat(chatId, clientData); + }, + run: async ({ messages, signal }) => { + return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }); + }, +}); +``` + ### 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. diff --git a/docs/ai-chat/reference.mdx b/docs/ai-chat/reference.mdx index 6cf329acb8e..6d959171622 100644 --- a/docs/ai-chat/reference.mdx +++ b/docs/ai-chat/reference.mdx @@ -29,6 +29,9 @@ Options for `chat.task()`. | `preloadIdleTimeoutInSeconds` | `number` | Same as `idleTimeoutInSeconds` | Idle timeout after `onPreload` fires | | `preloadTimeout` | `string` | Same as `turnTimeout` | Suspend timeout for preloaded runs | | `uiMessageStreamOptions` | `ChatUIMessageStreamOptions` | — | Default options for `toUIMessageStream()`. Per-turn override via `chat.setUIMessageStreamOptions()` | +| `onChatSuspend` | `(event: ChatSuspendEvent) => Promise \| void` | — | Fires right before the run suspends. See [onChatSuspend](/ai-chat/backend#onchatsuspend--onchatresume) | +| `onChatResume` | `(event: ChatResumeEvent) => Promise \| void` | — | Fires right after the run resumes from suspension | +| `exitAfterPreloadIdle` | `boolean` | `false` | Exit run after preload idle timeout instead of suspending. See [exitAfterPreloadIdle](/ai-chat/backend#exitafterpreloadidle) | Plus all standard [TaskOptions](/tasks/overview) — `retry`, `queue`, `machine`, `maxDuration`, **`onWait`**, **`onResume`**, **`onComplete`**, and other lifecycle hooks. Those hooks use the same parameter shapes as on a normal `task()` (including `ctx`). @@ -148,6 +151,36 @@ Passed to the `onBeforeTurnComplete` callback. Same fields as `TurnCompleteEvent | _(all TurnCompleteEvent fields)_ | | See [TurnCompleteEvent](#turncompleteevent) (includes `ctx`) | | `writer` | [`ChatWriter`](#chatwriter) | Stream writer — the stream is still open so chunks appear in the current turn | +## ChatSuspendEvent + +Passed to the `onChatSuspend` callback. A discriminated union on `phase`. + +| Field | Type | Description | +| ------------ | --------------------------- | -------------------------------------------------------- | +| `phase` | `"preload" \| "turn"` | Whether this is a preload or post-turn suspension | +| `ctx` | `TaskRunContext` | Full task run context | +| `chatId` | `string` | Chat session ID | +| `runId` | `string` | The Trigger.dev run ID | +| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | +| `turn` | `number` | Turn number (**`"turn"` phase only**) | +| `messages` | `ModelMessage[]` | Accumulated model messages (**`"turn"` phase only**) | +| `uiMessages` | `UIMessage[]` | Accumulated UI messages (**`"turn"` phase only**) | + +## ChatResumeEvent + +Passed to the `onChatResume` callback. Same discriminated union shape as `ChatSuspendEvent`. + +| Field | Type | Description | +| ------------ | --------------------------- | -------------------------------------------------------- | +| `phase` | `"preload" \| "turn"` | Whether this is a preload or post-turn resumption | +| `ctx` | `TaskRunContext` | Full task run context | +| `chatId` | `string` | Chat session ID | +| `runId` | `string` | The Trigger.dev run ID | +| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend | +| `turn` | `number` | Turn number (**`"turn"` phase only**) | +| `messages` | `ModelMessage[]` | Accumulated model messages (**`"turn"` phase only**) | +| `uiMessages` | `UIMessage[]` | Accumulated UI messages (**`"turn"` phase only**) | + ## ChatWriter A stream writer passed to lifecycle callbacks. Write custom `UIMessageChunk` parts (e.g. `data-*` parts) to the chat stream. @@ -322,16 +355,15 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`. | `chat.cleanupAbortedParts(message)` | Remove incomplete parts from a stopped response message | | `chat.stream` | Typed chat output stream — use `.writer()`, `.pipe()`, `.append()`, `.read()` | | `chat.MessageAccumulator` | Class that accumulates conversation messages across turns | -| `chat.withUIMessage(config?).task(options)` | Same as `chat.task`, but fixes a custom `UIMessage` subtype and optional default stream options. See [Types](/ai-chat/types) | +| `chat.withUIMessage(config?)` | Returns a [ChatBuilder](/ai-chat/types#chatbuilder) with a fixed `UIMessage` subtype. See [Types](/ai-chat/types) | +| `chat.withClientData({ schema })` | Returns a [ChatBuilder](/ai-chat/types#chatbuilder) with a fixed client data schema. See [Types](/ai-chat/types#typed-client-data-with-chatwithclientdata) | ## `chat.withUIMessage` -Returns `{ task }`, where `task` is like [`chat.task`](#chat-namespace) but parameterized on a UI message type `TUIM`. +Returns a [`ChatBuilder`](/ai-chat/types#chatbuilder) with a fixed `UIMessage` subtype. Chain `.withClientData()`, hook methods, and `.task()`. ```ts -chat.withUIMessage(config?: ChatWithUIMessageConfig): { - task: (options: ChatTaskOptions<..., ..., TUIM>) => Task<...>; -}; +chat.withUIMessage(config?: ChatWithUIMessageConfig): ChatBuilder; ``` | Parameter | Type | Description | @@ -340,6 +372,20 @@ chat.withUIMessage(config?: ChatWithUIMessageConfig): { Use this when you need [`InferChatUIMessage`](#inferchatuimessage) / typed `data-*` parts / `InferUITools` to line up across backend hooks and `useChat`. Full guide: [Types](/ai-chat/types). +## `chat.withClientData` + +Returns a [`ChatBuilder`](/ai-chat/types#chatbuilder) with a fixed client data schema. All hooks and `run` get typed `clientData` without passing `clientDataSchema` in `.task()` options. + +```ts +chat.withClientData({ schema: TSchema }): ChatBuilder; +``` + +| Parameter | Type | Description | +| --------- | ------------ | -------------------------------------------------- | +| `schema` | `TaskSchema` | Zod, ArkType, Valibot, or any supported schema lib | + +Full guide: [Typed client data](/ai-chat/types#typed-client-data-with-chatwithclientdata). + ## `ChatWithUIMessageConfig` | Field | Type | Description | diff --git a/docs/ai-chat/types.mdx b/docs/ai-chat/types.mdx index 8ddfff063f0..1350a2f259e 100644 --- a/docs/ai-chat/types.mdx +++ b/docs/ai-chat/types.mdx @@ -4,7 +4,7 @@ sidebarTitle: "Types" description: "TypeScript types for AI Chat tasks, UI messages, and the frontend transport." --- -TypeScript patterns for [AI Chat](/ai-chat/overview). This page will expand over time; it currently documents how to pin a custom AI SDK [`UIMessage`](https://sdk.vercel.ai/docs/reference/ai-sdk-core/ui-message) subtype with `chat.withUIMessage` and align types on the client. +TypeScript patterns for [AI Chat](/ai-chat/overview). This page covers how to pin a custom AI SDK [`UIMessage`](https://sdk.vercel.ai/docs/reference/ai-sdk-core/ui-message) subtype with `chat.withUIMessage`, fix a typed `clientData` schema with `chat.withClientData`, chain builder-level hooks, and align types on the client. ## Custom `UIMessage` with `chat.withUIMessage` @@ -16,7 +16,7 @@ When you add **custom `data-*` parts** (via `chat.stream` / `writer`) or a **typ - Stream options like `sendReasoning` align with your message shape - The frontend can treat `useChat` messages as the same subtype end-to-end -`chat.withUIMessage(config?)` returns `{ task }`, where `task(...)` accepts the **same options as** [`chat.task()`](/ai-chat/backend#chat-task) but fixes `YourUIMessage` as the UI message type for that chat task. +`chat.withUIMessage(config?)` returns a [ChatBuilder](#chatbuilder) where `.task(...)` accepts the **same options as** [`chat.task()`](/ai-chat/backend#chat-task) but fixes `YourUIMessage` as the UI message type for that chat task. ### Defining a `UIMessage` subtype @@ -48,7 +48,7 @@ Task-backed tools should use AI SDK [`tool()`](https://sdk.vercel.ai/docs/ai-sdk ### Backend: `chat.withUIMessage(...).task(...)` -Call `withUIMessage` **once**, then chain `.task({ ... })` instead of `chat.task({ ... })`: +Call `withUIMessage` **once**, then chain `.task({ ... })` instead of `chat.task({ ... })`. You can also chain `.withClientData()` and hook methods before `.task()`: ```ts import { chat } from "@trigger.dev/sdk/ai"; @@ -65,31 +65,35 @@ const myTools = { }), }; -export const myChat = chat.withUIMessage({ - streamOptions: { - sendReasoning: true, - onError: (error) => - error instanceof Error ? error.message : "Something went wrong.", - }, -}).task({ - id: "my-chat", - clientDataSchema: z.object({ userId: z.string() }), - onTurnStart: async ({ uiMessages, writer }) => { - // uiMessages is MyChatUIMessage[] — custom data parts are typed - writer.write({ - type: "data-turn-status", - data: { status: "preparing" }, - }); - }, - run: async ({ messages, signal }) => { - return streamText({ - model: openai("gpt-4o"), - messages, - tools: myTools, - abortSignal: signal, - }); - }, -}); +export const myChat = chat + .withUIMessage({ + streamOptions: { + sendReasoning: true, + onError: (error) => + error instanceof Error ? error.message : "Something went wrong.", + }, + }) + .withClientData({ + schema: z.object({ userId: z.string() }), + }) + .task({ + id: "my-chat", + onTurnStart: async ({ uiMessages, writer }) => { + // uiMessages is MyChatUIMessage[] — custom data parts are typed + writer.write({ + type: "data-turn-status", + data: { status: "preparing" }, + }); + }, + run: async ({ messages, signal }) => { + return streamText({ + model: openai("gpt-4o"), + messages, + tools: myTools, + abortSignal: signal, + }); + }, + }); ``` ### Default stream options @@ -125,6 +129,91 @@ export function Chat() { You can also import `InferChatUIMessage` from `@trigger.dev/sdk/ai` in non-React modules. +## Typed client data with `chat.withClientData` + +`chat.withClientData({ schema })` returns a [ChatBuilder](#chatbuilder) that fixes the client data schema. All hooks and `run` receive typed `clientData` without needing `clientDataSchema` in `.task()` options. + +```ts +import { chat } from "@trigger.dev/sdk/ai"; +import { z } from "zod"; + +export const myChat = chat + .withClientData({ + schema: z.object({ userId: z.string(), model: z.string().optional() }), + }) + .task({ + id: "my-chat", + onPreload: async ({ clientData }) => { + // clientData is typed as { userId: string; model?: string } + await initUser(clientData.userId); + }, + run: async ({ messages, clientData, signal }) => { + return streamText({ + model: getModel(clientData.model), + messages, + abortSignal: signal, + }); + }, + }); +``` + +## ChatBuilder + +Both `chat.withUIMessage()` and `chat.withClientData()` return a **ChatBuilder** — a chainable object that accumulates configuration before creating the task with `.task()`. + +Builder methods can be chained in any order: + +```ts +export const myChat = chat + .withUIMessage({ + streamOptions: { sendReasoning: true }, + }) + .withClientData({ + schema: z.object({ userId: z.string() }), + }) + .onChatSuspend(async ({ ctx }) => { + await disposeCodeSandbox(ctx.run.id); + }) + .onChatResume(async ({ ctx }) => { + warmCache(ctx.run.id); + }) + .task({ + id: "my-chat", + run: async ({ messages, signal }) => { + return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }); + }, + }); +``` + +### Builder-level hooks + +All [lifecycle hooks](/ai-chat/backend#lifecycle-hooks) can be set on the builder: `onPreload`, `onChatStart`, `onTurnStart`, `onBeforeTurnComplete`, `onTurnComplete`, `onCompacted`, `onChatSuspend`, `onChatResume`. + +Builder hooks and task-level hooks **coexist**. When both are defined for the same event, the builder hook runs first, then the task hook: + +```ts +chat + .withUIMessage() + .onPreload(async (event) => { + // Runs first — shared setup across tasks using this builder + await initializeSharedState(event.chatId); + }) + .task({ + id: "my-chat", + onPreload: async (event) => { + // Runs second — task-specific logic + await createChatRecord(event.chatId); + }, + run: async ({ messages, signal }) => { + return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }); + }, + }); +``` + + + Set types first (`.withUIMessage()`, `.withClientData()`), then hooks. Hook parameters are typed based on the builder's current generics — so hooks registered after `.withClientData()` get typed `clientData`. + + ### When plain `chat.task()` is enough If you do not rely on custom `UIMessage` generics (only default text, reasoning, and built-in tool UI types), **`chat.task()` alone is fine** — no need for `withUIMessage`. @@ -132,6 +221,8 @@ If you do not rely on custom `UIMessage` generics (only default text, reasoning, ## See also - [Backend — `chat.task()`](/ai-chat/backend#chat-task) +- [Backend — Lifecycle hooks](/ai-chat/backend#lifecycle-hooks) - [Frontend — transport & `useChat`](/ai-chat/frontend) - [API reference — `chat.withUIMessage`](/ai-chat/reference#chat-withuimessage) +- [API reference — `chat.withClientData`](/ai-chat/reference#chat-withclientdata) - [Task-backed AI tools — `ai.toolExecute`](/tasks/schemaTask#task-backed-ai-tools) From 0dd6b11e02b9100e72c3c1c75d66dab631a1847e Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sat, 28 Mar 2026 09:11:35 +0000 Subject: [PATCH 12/13] code sandbox and database patterns --- docs/ai-chat/patterns/code-sandbox.mdx | 125 +++++++++++++++++ .../ai-chat/patterns/database-persistence.mdx | 127 ++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 docs/ai-chat/patterns/code-sandbox.mdx create mode 100644 docs/ai-chat/patterns/database-persistence.mdx diff --git a/docs/ai-chat/patterns/code-sandbox.mdx b/docs/ai-chat/patterns/code-sandbox.mdx new file mode 100644 index 00000000000..bf35da3dea8 --- /dev/null +++ b/docs/ai-chat/patterns/code-sandbox.mdx @@ -0,0 +1,125 @@ +--- +title: "Code execution sandbox" +sidebarTitle: "Code sandbox" +description: "Warm an isolated sandbox on each chat turn, run an AI SDK executeCode tool, and tear down right before the run suspends — using chat.task hooks and chat.local." +--- + +Use a **hosted code sandbox** (for example [E2B](https://e2b.dev)) when the model should run short scripts to analyze tool output (PostHog queries, CSV-like data, math) without executing arbitrary code on the Trigger worker host. + +This page describes a **durable chat** pattern that fits `chat.task()`: + +- **Warm** the sandbox at the start of each turn (**non-blocking**). +- **Reuse** it for every `executeCode` tool call during that turn (and across turns in the same run if you keep the handle). +- **Dispose** it **right before the run suspends** waiting for the next user message — using the **`onChatSuspend`** hook, not `onTurnComplete`. + + + The reference implementation lives in the monorepo at [`references/ai-chat`](https://github.com/triggerdotdev/trigger.dev/tree/main/references/ai-chat) (`code-sandbox.ts`, `chat-tools.ts`, `trigger/chat.ts`). + + +## Why not tear down in `onTurnComplete`? + +After a turn finishes, the chat runtime still goes through an **idle** window and only then suspends. During that window the run is still executing — useful for `chat.defer()` work — and the run hasn't suspended yet. + +The boundary you want for “turn done, about to sleep” is **`onChatSuspend`**, which fires right before the run transitions from idle to suspended. It provides the `phase` (`”preload”` or `”turn”`) and full chat context. See [onChatSuspend / onChatResume](/ai-chat/backend#onchatsuspend--onchatresume). + +```mermaid +sequenceDiagram + participant TurnStart as onTurnStart + participant Run as run / streamText + participant TurnDone as onTurnComplete + participant Idle as Idle window + participant Suspend as onChatSuspend + participant Sleep as suspended + + TurnStart->>Run: warm sandbox (async) + Run->>TurnDone: persist / inject / etc. + TurnDone->>Idle: still running + Idle->>Suspend: dispose sandbox + Suspend->>Sleep: waiting for next message +``` + +## Recommended provider: E2B + +- **API key** auth — works from any Trigger.dev worker; no Vercel-only OIDC. +- **Code Interpreter** SDK (`@e2b/code-interpreter`): long-lived sandbox, `runCode()`, `kill()`. + +Alternatives (Modal, Daytona, raw Docker) are fine but more DIY. Vercel’s sandbox + AI SDK helpers are a better fit when execution stays **on Vercel**, not on the Trigger worker. + +## Implementation sketch + +### 1. Run-scoped sandbox map + +Keep a `Map>` (or similar) in a **task-only module** so your Next.js app never imports it. + +### 2. `onTurnStart` — warm without blocking + +```ts +onTurnStart: async ({ runId, ctx, ...rest }) => { + warmCodeSandbox(runId); // fire-and-forget Sandbox.create() + // ...persist messages, writer, etc. +}, +``` + +### 3. `chat.local` — run id for tools + +Tool `execute` functions do not receive hook payloads. Use [`chat.local()`](/ai-chat/features#per-run-data-with-chatlocal) to store the current run id for the sandbox key, **initialized from `onTurnStart`** (same `runId` as the map): + +```ts +// In the same task module as your tools +import { chat } from "@trigger.dev/sdk/ai"; + +export const codeSandboxRun = chat.local<{ runId: string }>({ id: "codeSandboxRun" }); + +export function warmCodeSandbox(runId: string) { + codeSandboxRun.init({ runId }); + // ...start Sandbox.create(), store promise in Map by runId +} +``` + +The **`executeCode`** tool reads `codeSandboxRun.runId` and awaits the sandbox promise before `runCode`. + +### 4. `onChatSuspend` / `onComplete` — teardown + +Use **`onChatSuspend`** to dispose the sandbox right before the run suspends, and **`onComplete`** as a safety net when the run ends entirely. + +```ts +export const aiChat = chat.task({ + id: "ai-chat", + // ... + onChatSuspend: async ({ phase, ctx }) => { + await disposeCodeSandboxForRun(ctx.run.id); + }, + onComplete: async ({ ctx }) => { + await disposeCodeSandboxForRun(ctx.run.id); + }, +}); +``` + +Unlike `onWait` (which fires for all wait types), `onChatSuspend` only fires at chat suspension points — no need to filter on `wait.type`. The `phase` discriminator tells you if this is a preload or post-turn suspension. + +Optional **`onChatResume`**: log or reset flags; a fresh sandbox can be warmed again on the next **`onTurnStart`**. + +### 5. AI SDK tool + +Wrap the provider in a normal AI SDK `tool({ inputSchema, execute })` (same pattern as `webFetch`). Keep tool definitions in **task code**, not in the Next.js server bundle. + +### 6. Environment + +Set **`E2B_API_KEY`** (or your provider’s secret) on the **Trigger environment** for the worker — not in public client env. + +## Typing `ctx` + +Every `chat.task` lifecycle event and the `run` payload include **`ctx`**: the same **[`TaskRunContext`](/ai-chat/reference#task-context-ctx)** shape as `task({ run: (payload, { ctx }) => ... })`. + +```ts +import type { TaskRunContext } from "@trigger.dev/sdk"; +``` + +The alias **`Context`** is also exported from `@trigger.dev/sdk` and is the same type. + +## See also + +- [Database persistence for chat](/ai-chat/patterns/database-persistence) — conversation + session rows, hooks, token renewal +- [Backend — Lifecycle hooks](/ai-chat/backend#lifecycle-hooks) +- [API Reference — `ctx` on events](/ai-chat/reference#task-context-ctx) +- [Per-run data with `chat.local`](/ai-chat/features#per-run-data-with-chatlocal) diff --git a/docs/ai-chat/patterns/database-persistence.mdx b/docs/ai-chat/patterns/database-persistence.mdx new file mode 100644 index 00000000000..4e1126a8931 --- /dev/null +++ b/docs/ai-chat/patterns/database-persistence.mdx @@ -0,0 +1,127 @@ +--- +title: "Database persistence for chat" +sidebarTitle: "Database persistence" +description: "Split conversation state and live session metadata across hooks — preload, turn start, turn complete — without tying the pattern to a specific ORM or schema." +--- + +Durable chat runs can span **hours** and **many turns**. You usually want: + +1. **Conversation state** — full **`UIMessage[]`** (or equivalent) keyed by **`chatId`**, so reloads and history views work. +2. **Live session state** — the **current Trigger `runId`**, a **scoped access token** for realtime + input streams, and optionally **`lastEventId`** for stream resume. + +This page describes a **hook mapping** that works with any database. The [ai-chat reference app](https://github.com/triggerdotdev/trigger.dev/tree/main/references/ai-chat) implements the same idea with a SQL database and an ORM; adapt table and column names to your stack. + +## Conceptual data model + +You can use one table or two; the important split is **semantic**: + +| Concept | Purpose | Typical fields | +| ------- | ------- | -------------- | +| **Conversation** | Durable transcript + display metadata | Stable id (same as **`chatId`**), serialized **`uiMessages`**, title, model choice, owner/user id, timestamps | +| **Active session** | Reconnect + resume the **same** run | Same **`chatId`** as key (or FK), **current `runId`**, **`publicAccessToken`** (or your stored PAT), optional **`lastEventId`** | + +The **conversation** row is what your UI lists as “chats.” The **session** row is what the **transport** needs after a refresh or token expiry: *which run is live* and *how to authenticate* to it. + + + Store **`UIMessage[]`** in a JSON-compatible column, or normalize to a messages table — the pattern is *when* you read/write, not *how* you encode rows. + + +## Where each hook writes + +### `onPreload` (optional) + +When the user triggers [preload](/ai-chat/features#preload), the run starts **before** the first user message. + +- Ensure the **conversation** row exists (create or no-op). +- **Upsert session**: **`runId`**, **`chatAccessToken`** from the event (this is the turn-scoped token for that run). +- Load any **user / tenant context** you need for prompts (`clientData`). + +If you skip preload, do the equivalent in **`onChatStart`** when **`preloaded`** is false. + +### `onChatStart` (turn 0, non-preloaded path) + +- If **`preloaded`** is true, return early — **`onPreload`** already ran. +- Otherwise mirror preload: user/context, conversation create, session upsert. +- If **`continuation`** is true, the conversation row usually **already exists** (previous run ended or timed out); only update **session** fields so the **new** run id and token are stored. + +### `onTurnStart` + +- Persist **`uiMessages`** (full accumulated history including the new user turn) **before** streaming starts — so a mid-stream refresh still shows the user’s message. +- Optionally use [`chat.defer()`](/ai-chat/features#chat-defer) so the write does not block the model if your driver is slow. + +### `onTurnComplete` + +- Persist **`uiMessages`** again with the **assistant** reply finalized. +- **Upsert session** with **`runId`**, fresh **`chatAccessToken`**, and **`lastEventId`** from the event. + +**`lastEventId`** lets the frontend [resume](/ai-chat/frontend) without replaying SSE events it already applied. Treat it as part of session state, not optional polish, if you care about duplicate chunks after refresh. + +## Token renewal (app server) + +Turn tokens expire (see **`chatAccessTokenTTL`** on **`chat.task`**). When the transport gets **401** on realtime or input streams, mint a **new** public access token with the **same** scopes the task uses — typically **read** for that **`runId`** and **write** for **input streams** on that run — then **persist** it on your **session** row. + +Your **Next.js server action**, **Remix action**, or **API route** should: + +1. Load **session** by **`chatId`** → **`runId`**. +2. Call **`auth.createPublicToken`** (or your platform’s equivalent) with those scopes. +3. Save the new token (and confirm **`runId`** is unchanged unless you started a new run). + +No Trigger task code needs to run for renewal. + +## Minimal pseudocode + +```typescript +// Pseudocode — replace saveConversation / saveSession with your DB layer. + +chat.task({ + id: "my-chat", + clientDataSchema: z.object({ userId: z.string() }), + + onPreload: async ({ chatId, runId, chatAccessToken, clientData }) => { + if (!clientData) return; + await ensureUser(clientData.userId); + await upsertConversation({ id: chatId, userId: clientData.userId /* ... */ }); + await upsertSession({ chatId, runId, publicAccessToken: chatAccessToken }); + }, + + onChatStart: async ({ chatId, runId, chatAccessToken, clientData, continuation, preloaded }) => { + if (preloaded) return; + await ensureUser(clientData.userId); + if (!continuation) { + await upsertConversation({ id: chatId, userId: clientData.userId /* ... */ }); + } + await upsertSession({ chatId, runId, publicAccessToken: chatAccessToken }); + }, + + onTurnStart: async ({ chatId, uiMessages }) => { + chat.defer(saveConversationMessages(chatId, uiMessages)); + }, + + onTurnComplete: async ({ chatId, uiMessages, runId, chatAccessToken, lastEventId }) => { + await saveConversationMessages(chatId, uiMessages); + await upsertSession({ + chatId, + runId, + publicAccessToken: chatAccessToken, + lastEventId, + }); + }, + + run: async ({ messages, signal }) => { + /* streamText, etc. */ + }, +}); +``` + +## Design notes + +- **`chatId`** is stable for the life of a thread; **`runId`** changes when the user starts a **new** run (timeout, cancel, explicit new chat). Session rows must always reflect the **current** run. +- **`continuation: true`** means “same logical chat, new run” — update session, don’t assume an empty conversation. +- Keep **task modules** that perform writes **out of** browser bundles; the pattern assumes persistence runs **in the worker** (or your BFF that the task calls). + +## See also + +- [Backend — Lifecycle hooks](/ai-chat/backend#lifecycle-hooks) +- [Session management](/ai-chat/frontend#session-management) — `resume`, `lastEventId`, transport +- [`chat.defer()`](/ai-chat/features#chat-defer) — non-blocking writes during a turn +- [Code execution sandbox](/ai-chat/patterns/code-sandbox) — combines **`onWait`** / **`onComplete`** with this persistence model From 63d471996c330be87a8862859ad2da12ee5a9921 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 30 Mar 2026 21:55:32 +0100 Subject: [PATCH 13/13] docs: rename chat.task to chat.agent across all AI docs --- docs/ai-chat/backend.mdx | 56 +++++++++---------- docs/ai-chat/background-injection.mdx | 4 +- docs/ai-chat/compaction.mdx | 10 ++-- docs/ai-chat/features.mdx | 14 ++--- docs/ai-chat/frontend.mdx | 6 +- docs/ai-chat/overview.mdx | 26 +++++---- docs/ai-chat/patterns/code-sandbox.mdx | 8 +-- .../ai-chat/patterns/database-persistence.mdx | 4 +- docs/ai-chat/pending-messages.mdx | 6 +- docs/ai-chat/quick-start.mdx | 12 ++-- docs/ai-chat/reference.mdx | 32 +++++------ docs/ai-chat/types.mdx | 32 +++++------ docs/ai/prompts.mdx | 6 +- docs/docs.json | 2 +- docs/tasks/schemaTask.mdx | 2 +- 15 files changed, 111 insertions(+), 109 deletions(-) diff --git a/docs/ai-chat/backend.mdx b/docs/ai-chat/backend.mdx index 9842f9cfa5b..ff1f7686e77 100644 --- a/docs/ai-chat/backend.mdx +++ b/docs/ai-chat/backend.mdx @@ -1,15 +1,15 @@ --- title: "Backend" sidebarTitle: "Backend" -description: "Three approaches to building your chat backend — chat.task(), session iterator, or raw task primitives." +description: "Three approaches to building your chat backend — chat.agent(), session iterator, or raw task primitives." --- -## chat.task() +## chat.agent() The highest-level approach. Handles message accumulation, stop signals, turn lifecycle, and auto-piping automatically. - 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 `.task()`. See [Types](/ai-chat/types). + 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). ### Simple: return a StreamTextResult @@ -21,7 +21,7 @@ import { chat } from "@trigger.dev/sdk/ai"; import { streamText } from "ai"; import { openai } from "@ai-sdk/openai"; -export const simpleChat = chat.task({ +export const simpleChat = chat.agent({ id: "simple-chat", run: async ({ messages, signal }) => { return streamText({ @@ -44,7 +44,7 @@ import { streamText } from "ai"; import { openai } from "@ai-sdk/openai"; import type { ModelMessage } from "ai"; -export const agentChat = chat.task({ +export const agentChat = chat.agent({ id: "agent-chat", run: async ({ messages }) => { // Don't return anything — chat.pipe is called inside @@ -71,9 +71,9 @@ async function runAgentLoop(messages: ModelMessage[]) { Every chat lifecycle callback and the **`run`** payload include **`ctx`**: the same run context object as `task({ run: (payload, { ctx }) => ... })`. Import the type with **`import type { TaskRunContext } from "@trigger.dev/sdk"`** (the **`Context`** export is the same type). Use **`ctx`** for tags, metadata, or any API that needs the full run record. The string **`runId`** on chat events is always **`ctx.run.id`** (both are provided for convenience). See [Task context (`ctx`)](/ai-chat/reference#task-context-ctx) in the API reference. -Standard **[task lifecycle hooks](/tasks/overview)** — **`onWait`**, **`onResume`**, **`onComplete`**, **`onFailure`**, etc. — are also available on **`chat.task()`** with the same shapes as on a normal `task()`. +Standard **[task lifecycle hooks](/tasks/overview)** — **`onWait`**, **`onResume`**, **`onComplete`**, **`onFailure`**, etc. — are also available on **`chat.agent()`** with the same shapes as on a normal `task()`. -Chat tasks also have two dedicated suspension hooks — **`onChatSuspend`** and **`onChatResume`** — that fire at the idle-to-suspended transition with full chat context. Use them for resource cleanup (e.g. tearing down sandboxes) and re-initialization. See [onChatSuspend / onChatResume](#onchatsuspend--onchatresume) and the [Code execution sandbox](/ai-chat/patterns/code-sandbox) pattern. +Chat agents also have two dedicated suspension hooks — **`onChatSuspend`** and **`onChatResume`** — that fire at the idle-to-suspended transition with full chat context. Use them for resource cleanup (e.g. tearing down sandboxes) and re-initialization. See [onChatSuspend / onChatResume](#onchatsuspend--onchatresume) and the [Code execution sandbox](/ai-chat/patterns/code-sandbox) pattern. #### onPreload @@ -82,7 +82,7 @@ Fires when a preloaded run starts — before any messages arrive. Use it to eage Preloaded runs are triggered by calling `transport.preload(chatId)` on the frontend. See [Preload](/ai-chat/features#preload) for details. ```ts -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", clientDataSchema: z.object({ userId: z.string() }), onPreload: async ({ ctx, chatId, clientData, runId, chatAccessToken }) => { @@ -125,7 +125,7 @@ Fires once on the first turn (turn 0) before `run()` executes. Use it to create The `continuation` field tells you whether this is a brand new chat or a continuation of an existing one (where the previous run timed out or was cancelled). The `preloaded` field tells you whether `onPreload` already ran. ```ts -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", onChatStart: async ({ chatId, clientData, continuation, preloaded }) => { if (preloaded) return; // Already set up in onPreload @@ -167,7 +167,7 @@ Fires at the start of every turn, after message accumulation and `onChatStart` ( | `writer` | [`ChatWriter`](/ai-chat/reference#chatwriter) | Stream writer for custom chunks | ```ts -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", onTurnStart: async ({ chatId, uiMessages, runId, chatAccessToken }) => { await db.chat.update({ @@ -196,7 +196,7 @@ export const myChat = chat.task({ Fires after the response is captured but **before** the stream closes. The `writer` can send custom chunks that appear in the current turn — use this for post-processing indicators, compaction progress, or any data the user should see before the turn ends. ```ts -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", onBeforeTurnComplete: async ({ writer, usage, uiMessages }) => { // Write a custom data part while the stream is still open @@ -245,7 +245,7 @@ Fires after each turn completes — after the response is captured and the strea | `rawResponseMessage` | `UIMessage \| undefined` | The raw assistant response before abort cleanup (same as `responseMessage` when not stopped) | ```ts -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", onTurnComplete: async ({ chatId, uiMessages, runId, chatAccessToken, lastEventId }) => { await db.chat.update({ @@ -288,7 +288,7 @@ The `phase` discriminator tells you **when** the suspend/resume happened: - `"turn"` — after `onTurnComplete`, waiting for the next message ```ts -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", onChatSuspend: async (event) => { // Tear down expensive resources before suspending @@ -327,7 +327,7 @@ export const myChat = chat.task({ When set to `true`, a preloaded run completes successfully after the idle timeout elapses instead of suspending. Use this for "fire and forget" preloads — if the user doesn't send a message during the idle window, the run ends cleanly. ```ts -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", preloadIdleTimeoutInSeconds: 10, exitAfterPreloadIdle: true, @@ -362,7 +362,7 @@ const systemPrompt = prompts.define({ content: `You are a helpful assistant for {{name}}.`, }); -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", clientDataSchema: z.object({ userId: z.string() }), onChatStart: async ({ clientData }) => { @@ -404,7 +404,7 @@ The `run` function receives three abort signals: | `cancelSignal` | Run cancel, expire, or maxDuration exceeded | Cleanup that should only happen on full cancellation | ```ts -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", run: async ({ messages, signal, stopSignal, cancelSignal }) => { return streamText({ @@ -426,7 +426,7 @@ export const myChat = chat.task({ The `onTurnComplete` event includes a `stopped` boolean that indicates whether the user stopped generation during that turn: ```ts -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", onTurnComplete: async ({ chatId, uiMessages, stopped }) => { await db.chat.update({ @@ -446,7 +446,7 @@ You can also check stop status from **anywhere** during a turn using `chat.isSto import { chat } from "@trigger.dev/sdk/ai"; import { streamText } from "ai"; -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", run: async ({ messages, signal }) => { return streamText({ @@ -469,7 +469,7 @@ export const myChat = chat.task({ 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.task` 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()`: +`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); @@ -508,7 +508,7 @@ import { openai } from "@ai-sdk/openai"; import { z } from "zod"; import { db } from "@/lib/db"; -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", clientDataSchema: z.object({ userId: z.string(), @@ -660,7 +660,7 @@ export function Chat({ chatId, initialMessages, initialSessions }) { 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.task({ +export const myChat = chat.agent({ id: "my-chat", pendingMessages: { shouldInject: ({ steps }) => steps.length > 0, @@ -690,7 +690,7 @@ On the frontend, the `usePendingMessages` hook handles sending, tracking, and re 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.task({ +export const myChat = chat.agent({ id: "my-chat", onTurnComplete: async ({ messages }) => { chat.defer( @@ -727,7 +727,7 @@ Transform model messages before they're used anywhere — in `run()`, in compact Use this for Anthropic cache breaks, injecting system context, stripping PII, etc. ```ts -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", prepareMessages: ({ messages, reason }) => { // Add Anthropic cache breaks to the last message @@ -798,7 +798,7 @@ When `streamText` encounters an error mid-stream (rate limits, API failures, net 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.task({ +export const myChat = chat.agent({ id: "my-chat", uiMessageStreamOptions: { onError: (error) => { @@ -836,7 +836,7 @@ const { messages, sendMessage } = useChat({ Control which AI SDK features are forwarded to the frontend: ```ts -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", uiMessageStreamOptions: { sendReasoning: true, // Forward model reasoning (default: true) @@ -862,7 +862,7 @@ run: async ({ messages, clientData, signal }) => { }, ``` -`chat.setUIMessageStreamOptions()` works across all abstraction levels — `chat.task()`, `chat.createSession()` / `turn.complete()`, and `chat.pipeAndCapture()`. +`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. @@ -900,14 +900,14 @@ export const manualChat = task({ 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.task()` for the full multi-turn experience. + `chat.pipe()` directly. Use `chat.agent()` for the full multi-turn experience. --- ## chat.createSession() -A middle ground between `chat.task()` and raw primitives. You get an async iterator that yields `ChatTurn` objects — each turn handles stop signals, message accumulation, and turn-complete signaling automatically. You control initialization, model/tool selection, persistence, and any custom per-turn logic. +A middle ground between `chat.agent()` and raw primitives. You get an async iterator that yields `ChatTurn` objects — each turn handles stop signals, message accumulation, and turn-complete signaling automatically. You control initialization, model/tool selection, persistence, and any custom per-turn logic. Use `chat.createSession()` inside a standard `task()`: diff --git a/docs/ai-chat/background-injection.mdx b/docs/ai-chat/background-injection.mdx index b50c86329f6..8f1942398ba 100644 --- a/docs/ai-chat/background-injection.mdx +++ b/docs/ai-chat/background-injection.mdx @@ -31,7 +31,7 @@ Messages are appended to the model messages before the next LLM inference call. The most powerful pattern combines `chat.defer()` (background work) with `chat.inject()` (inject results). Background work runs in parallel with the idle wait between turns, and results are injected before the next response. ```ts -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", onTurnComplete: async ({ messages }) => { // Kick off background analysis — doesn't block the turn @@ -95,7 +95,7 @@ Focus on: Be concise. Only flag issues worth fixing.`, }); -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", onTurnComplete: async ({ messages }) => { chat.defer( diff --git a/docs/ai-chat/compaction.mdx b/docs/ai-chat/compaction.mdx index 5f2c61245e9..9039084173a 100644 --- a/docs/ai-chat/compaction.mdx +++ b/docs/ai-chat/compaction.mdx @@ -8,7 +8,7 @@ description: "Automatic context compaction to keep long conversations within tok Long conversations accumulate tokens across turns. Eventually the context window fills up, causing errors or degraded responses. Compaction solves this by automatically summarizing the conversation when token usage exceeds a threshold, then using that summary as the context for future turns. -The `compaction` option on `chat.task()` handles this in both paths: +The `compaction` option on `chat.agent()` handles this in both paths: - **Between tool-call steps** (inner loop) — via the AI SDK's `prepareStep`, compaction runs between tool calls within a single turn - **Between turns** (outer loop) — for single-step responses with no tool calls, where `prepareStep` never fires @@ -22,7 +22,7 @@ import { chat } from "@trigger.dev/sdk/ai"; import { streamText, generateText } from "ai"; import { openai } from "@ai-sdk/openai"; -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", compaction: { shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > 80_000, @@ -71,7 +71,7 @@ Replace older messages with a summary but keep the last few exchanges visible: ```ts import { generateId } from "ai"; -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", compaction: { shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > 80_000, @@ -175,7 +175,7 @@ The `summarize` callback receives similar context: Track compaction events for logging, billing, or analytics: ```ts -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", compaction: { ... }, onCompacted: async ({ summary, totalTokens, messageCount, chatId, turn }) => { @@ -292,5 +292,5 @@ prepareStep: chat.compactionStep({ ``` - The fully manual APIs only handle inner-loop compaction (between tool-call steps). For outer-loop coverage, use the `compaction` option on `chat.task()`, `chat.createSession()`, or `MessageAccumulator`. + The fully manual APIs only handle inner-loop compaction (between tool-call steps). For outer-loop coverage, use the `compaction` option on `chat.agent()`, `chat.createSession()`, or `MessageAccumulator`. diff --git a/docs/ai-chat/features.mdx b/docs/ai-chat/features.mdx index efb0cad3692..a91b61ff03d 100644 --- a/docs/ai-chat/features.mdx +++ b/docs/ai-chat/features.mdx @@ -30,7 +30,7 @@ const userContext = chat.local<{ messageCount: number; }>({ id: "userContext" }); -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", clientDataSchema: z.object({ userId: z.string() }), onChatStart: async ({ clientData }) => { @@ -105,7 +105,7 @@ const analyzeData = tool({ execute: ai.toolExecute(analyzeDataTask), }); -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", onChatStart: async ({ clientData }) => { userContext.init({ name: "Alice", plan: "pro" }); @@ -165,7 +165,7 @@ Use `chat.defer()` to run background work in parallel with streaming. The deferr This moves non-blocking work (DB writes, analytics, etc.) out of the critical path: ```ts -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", onTurnStart: async ({ chatId, uiMessages }) => { // Persist messages without blocking the LLM call @@ -188,7 +188,7 @@ export const myChat = chat.task({ ```ts import { chat } from "@trigger.dev/sdk/ai"; -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", run: async ({ messages, signal }) => { // Write a custom data part to the chat stream. @@ -286,7 +286,7 @@ const research = tool({ execute: ai.toolExecute(researchTask), }); -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", run: async ({ messages, signal }) => { return streamText({ @@ -320,7 +320,7 @@ On the frontend, render the custom data part: The `target` option accepts: - `"self"` — current run (default) - `"parent"` — parent task's run -- `"root"` — root task's run (the chat task) +- `"root"` — root task's run (the chat agent) - A specific run ID string --- @@ -409,7 +409,7 @@ When the transport needs a trigger token for preload, your `accessToken` callbac On the backend, the `onPreload` hook fires immediately. The run then waits for the first message. When the user sends a message, `onChatStart` fires with `preloaded: true` — you can skip initialization that was already done in `onPreload`: ```ts -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", onPreload: async ({ chatId, clientData }) => { // Eagerly initialize — runs before the first message diff --git a/docs/ai-chat/frontend.mdx b/docs/ai-chat/frontend.mdx index c03eb484565..197e738baf4 100644 --- a/docs/ai-chat/frontend.mdx +++ b/docs/ai-chat/frontend.mdx @@ -34,7 +34,7 @@ The transport is created once on first render and reused across re-renders. Pass ## Typed messages (`chat.withUIMessage`) -If your chat task is defined with [`chat.withUIMessage()`](/ai-chat/types) (custom `data-*` parts, typed tools, etc.), pass the same message type through `useChat` so `messages` and `message.parts` are narrowed on the client: +If your chat agent is defined with [`chat.withUIMessage()`](/ai-chat/types) (custom `data-*` parts, typed tools, etc.), pass the same message type through `useChat` so `messages` and `message.parts` are narrowed on the client: ```tsx import { useChat } from "@ai-sdk/react"; @@ -189,7 +189,7 @@ sendMessage({ text: "Hello" }, { metadata: { model: "gpt-4o", priority: "high" } ### Typed client data with clientDataSchema -Instead of manually parsing `clientData` with Zod in every hook, pass a `clientDataSchema` to `chat.task`. The schema validates the data once per turn, and `clientData` is typed in all hooks and `run`: +Instead of manually parsing `clientData` with Zod in every hook, pass a `clientDataSchema` to `chat.agent`. The schema validates the data once per turn, and `clientData` is typed in all hooks and `run`: ```ts import { chat } from "@trigger.dev/sdk/ai"; @@ -197,7 +197,7 @@ import { streamText } from "ai"; import { openai } from "@ai-sdk/openai"; import { z } from "zod"; -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", clientDataSchema: z.object({ model: z.string().optional(), diff --git a/docs/ai-chat/overview.mdx b/docs/ai-chat/overview.mdx index 8a339d5be0f..eaab0db43cd 100644 --- a/docs/ai-chat/overview.mdx +++ b/docs/ai-chat/overview.mdx @@ -1,21 +1,21 @@ --- -title: "AI Chat" +title: "AI Agents" sidebarTitle: "Overview" -description: "Run AI SDK chat completions as durable Trigger.dev tasks with built-in realtime streaming, multi-turn conversations, and message persistence." +description: "Run AI SDK chat completions as durable Trigger.dev agents with built-in realtime streaming, multi-turn conversations, and message persistence." --- ## Overview -The `@trigger.dev/sdk` provides a custom [ChatTransport](https://sdk.vercel.ai/docs/ai-sdk-ui/transport) for the Vercel AI SDK's `useChat` hook. This lets you run chat completions as **durable Trigger.dev tasks** instead of fragile API routes — with automatic retries, observability, and realtime streaming built in. +The `@trigger.dev/sdk` provides a custom [ChatTransport](https://sdk.vercel.ai/docs/ai-sdk-ui/transport) for the Vercel AI SDK's `useChat` hook. This lets you run chat completions as **durable Trigger.dev agents** instead of fragile API routes — with automatic retries, observability, and realtime streaming built in. **How it works:** 1. The frontend sends messages via `useChat` through `TriggerChatTransport` -2. The first message triggers a Trigger.dev task; subsequent messages resume the **same run** via input streams -3. The task streams `UIMessageChunk` events back via Trigger.dev's realtime streams +2. The first message triggers a Trigger.dev agent; subsequent messages resume the **same run** via input streams +3. The agent streams `UIMessageChunk` events back via Trigger.dev's realtime streams 4. The AI SDK's `useChat` processes the stream natively — text, tool calls, reasoning, etc. 5. Between turns, the run stays idle briefly then suspends (freeing compute) until the next message -No custom API routes needed. Your chat backend is a Trigger.dev task. +No custom API routes needed. Your chat backend is a Trigger.dev agent. @@ -26,7 +26,7 @@ sequenceDiagram participant User participant useChat as useChat + Transport participant API as Trigger.dev API - participant Task as chat.task Worker + participant Task as chat.agent Worker participant LLM as LLM Provider User->>useChat: sendMessage("Hello") @@ -57,7 +57,7 @@ sequenceDiagram participant User participant useChat as useChat + Transport participant API as Trigger.dev API - participant Task as chat.task Worker + participant Task as chat.agent Worker participant LLM as LLM Provider Note over Task: Suspended, waiting for message @@ -88,7 +88,7 @@ sequenceDiagram participant User participant useChat as useChat + Transport participant API as Trigger.dev API - participant Task as chat.task Worker + participant Task as chat.agent Worker participant LLM as LLM Provider Note over Task: Streaming response... @@ -116,7 +116,7 @@ sequenceDiagram ### One run, many turns -The entire conversation lives in a **single Trigger.dev run**. After each AI response, the run waits for the next message via input streams. The frontend transport handles this automatically — it triggers a new run for the first message, and sends subsequent messages to the existing run. +The entire conversation lives in a **single Trigger.dev run**. After each AI response, the run waits for the next message via input streams. The frontend transport handles this automatically — it triggers a new run for the first message and sends subsequent messages to the existing run. This means your conversation has full observability in the Trigger.dev dashboard: every turn is a span inside the same run. @@ -135,20 +135,22 @@ If no message arrives within the turn timeout, the run ends gracefully. The next ### What the backend accumulates -The backend automatically accumulates the full conversation history across turns. After the first turn, the frontend transport only sends the new user message — not the entire history. This is handled transparently by the transport and task. +The backend automatically accumulates the full conversation history across turns. After the first turn, the frontend transport only sends the new user message — not the entire history. This is handled transparently by the transport and agent. The accumulated messages are available in: - `run()` as `messages` (`ModelMessage[]`) — for passing to `streamText` - `onTurnStart()` as `uiMessages` (`UIMessage[]`) — for persisting before streaming - `onTurnComplete()` as `uiMessages` (`UIMessage[]`) — for persisting after the response +Agents appear in the **Agents** section of the dashboard (not Tasks) and can be tested via the **Playground**. + ## Three approaches There are three ways to build the backend, from most opinionated to most flexible: | Approach | Use when | What you get | |----------|----------|--------------| -| [chat.task()](/ai-chat/backend#chattask) | Most apps | Auto-piping, lifecycle hooks, message accumulation, stop handling | +| [chat.agent()](/ai-chat/backend#chatagent) | Most apps | Auto-piping, lifecycle hooks, message accumulation, stop handling | | [chat.createSession()](/ai-chat/backend#chatcreatesession) | Need a loop but not hooks | Async iterator with per-turn helpers, message accumulation, stop handling | | [Raw task + primitives](/ai-chat/backend#raw-task-with-primitives) | Full control | Manual control of every step — use `chat.messages`, `chat.createStopSignal()`, etc. | diff --git a/docs/ai-chat/patterns/code-sandbox.mdx b/docs/ai-chat/patterns/code-sandbox.mdx index bf35da3dea8..eb76d8fcbf4 100644 --- a/docs/ai-chat/patterns/code-sandbox.mdx +++ b/docs/ai-chat/patterns/code-sandbox.mdx @@ -1,12 +1,12 @@ --- title: "Code execution sandbox" sidebarTitle: "Code sandbox" -description: "Warm an isolated sandbox on each chat turn, run an AI SDK executeCode tool, and tear down right before the run suspends — using chat.task hooks and chat.local." +description: "Warm an isolated sandbox on each chat turn, run an AI SDK executeCode tool, and tear down right before the run suspends — using chat.agent hooks and chat.local." --- Use a **hosted code sandbox** (for example [E2B](https://e2b.dev)) when the model should run short scripts to analyze tool output (PostHog queries, CSV-like data, math) without executing arbitrary code on the Trigger worker host. -This page describes a **durable chat** pattern that fits `chat.task()`: +This page describes a **durable chat** pattern that fits `chat.agent()`: - **Warm** the sandbox at the start of each turn (**non-blocking**). - **Reuse** it for every `executeCode` tool call during that turn (and across turns in the same run if you keep the handle). @@ -83,7 +83,7 @@ The **`executeCode`** tool reads `codeSandboxRun.runId` and awaits the sandbox p Use **`onChatSuspend`** to dispose the sandbox right before the run suspends, and **`onComplete`** as a safety net when the run ends entirely. ```ts -export const aiChat = chat.task({ +export const aiChat = chat.agent({ id: "ai-chat", // ... onChatSuspend: async ({ phase, ctx }) => { @@ -109,7 +109,7 @@ Set **`E2B_API_KEY`** (or your provider’s secret) on the **Trigger environment ## Typing `ctx` -Every `chat.task` lifecycle event and the `run` payload include **`ctx`**: the same **[`TaskRunContext`](/ai-chat/reference#task-context-ctx)** shape as `task({ run: (payload, { ctx }) => ... })`. +Every `chat.agent` lifecycle event and the `run` payload include **`ctx`**: the same **[`TaskRunContext`](/ai-chat/reference#task-context-ctx)** shape as `task({ run: (payload, { ctx }) => ... })`. ```ts import type { TaskRunContext } from "@trigger.dev/sdk"; diff --git a/docs/ai-chat/patterns/database-persistence.mdx b/docs/ai-chat/patterns/database-persistence.mdx index 4e1126a8931..77bd45ee3e9 100644 --- a/docs/ai-chat/patterns/database-persistence.mdx +++ b/docs/ai-chat/patterns/database-persistence.mdx @@ -58,7 +58,7 @@ If you skip preload, do the equivalent in **`onChatStart`** when **`preloaded`** ## Token renewal (app server) -Turn tokens expire (see **`chatAccessTokenTTL`** on **`chat.task`**). When the transport gets **401** on realtime or input streams, mint a **new** public access token with the **same** scopes the task uses — typically **read** for that **`runId`** and **write** for **input streams** on that run — then **persist** it on your **session** row. +Turn tokens expire (see **`chatAccessTokenTTL`** on **`chat.agent`**). When the transport gets **401** on realtime or input streams, mint a **new** public access token with the **same** scopes the task uses — typically **read** for that **`runId`** and **write** for **input streams** on that run — then **persist** it on your **session** row. Your **Next.js server action**, **Remix action**, or **API route** should: @@ -73,7 +73,7 @@ No Trigger task code needs to run for renewal. ```typescript // Pseudocode — replace saveConversation / saveSession with your DB layer. -chat.task({ +chat.agent({ id: "my-chat", clientDataSchema: z.object({ userId: z.string() }), diff --git a/docs/ai-chat/pending-messages.mdx b/docs/ai-chat/pending-messages.mdx index 3f0e9ecefda..b367b1c3a12 100644 --- a/docs/ai-chat/pending-messages.mdx +++ b/docs/ai-chat/pending-messages.mdx @@ -20,16 +20,16 @@ The `pendingMessages` option enables this by injecting user messages between too 6. A `data-pending-message-injected` stream chunk confirms injection to the frontend 7. If `prepareStep` never fires (no tool calls), the message becomes the next turn -## Backend: chat.task +## Backend: chat.agent -Add `pendingMessages` to your `chat.task` configuration: +Add `pendingMessages` to your `chat.agent` configuration: ```ts import { chat } from "@trigger.dev/sdk/ai"; import { streamText } from "ai"; import { openai } from "@ai-sdk/openai"; -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", pendingMessages: { // Only inject when there are completed steps (tool calls happened) diff --git a/docs/ai-chat/quick-start.mdx b/docs/ai-chat/quick-start.mdx index 881cc381548..cf066f090a3 100644 --- a/docs/ai-chat/quick-start.mdx +++ b/docs/ai-chat/quick-start.mdx @@ -1,12 +1,12 @@ --- title: "Quick Start" sidebarTitle: "Quick Start" -description: "Get a working AI chat in 3 steps — define a task, generate a token, and wire up the frontend." +description: "Get a working AI agent in 3 steps — define an agent, generate a token, and wire up the frontend." --- - - Use `chat.task` from `@trigger.dev/sdk/ai` to define a task that handles chat messages. The `run` function receives `ModelMessage[]` (already converted from the frontend's `UIMessage[]`) — pass them directly to `streamText`. + + Use `chat.agent` from `@trigger.dev/sdk/ai` to define an agent that handles chat messages. The `run` function receives `ModelMessage[]` (already converted from the frontend's `UIMessage[]`) — pass them directly to `streamText`. If you return a `StreamTextResult`, it's **automatically piped** to the frontend. @@ -15,7 +15,7 @@ description: "Get a working AI chat in 3 steps — define a task, generate a tok import { streamText } from "ai"; import { openai } from "@ai-sdk/openai"; - export const myChat = chat.task({ + export const myChat = chat.agent({ id: "my-chat", run: async ({ messages, signal }) => { // messages is ModelMessage[] — pass directly to streamText @@ -30,13 +30,13 @@ description: "Get a working AI chat in 3 steps — define a task, generate a tok ``` - For a **custom** [`UIMessage`](https://sdk.vercel.ai/docs/reference/ai-sdk-core/ui-message) subtype (typed `data-*` parts, tool map, etc.), define the task with [`chat.withUIMessage<...>().task({...})`](/ai-chat/types) instead of `chat.task`. + For a **custom** [`UIMessage`](https://sdk.vercel.ai/docs/reference/ai-sdk-core/ui-message) subtype (typed `data-*` parts, tool map, etc.), define the agent with [`chat.withUIMessage<...>().agent({...})`](/ai-chat/types) instead of `chat.agent`. - On your server (e.g. a Next.js server action), create a trigger public token scoped to your chat task. The transport calls your function with `chatId` and `purpose` (`"trigger"` or `"preload"`). Import `ResolveChatAccessTokenParams` from `@trigger.dev/sdk/chat` so the signature matches — see [TriggerChatTransport options](/ai-chat/reference#triggerchattransport-options). + On your server (e.g. a Next.js server action), create a trigger public token scoped to your chat agent. The transport calls your function with `chatId` and `purpose` (`"trigger"` or `"preload"`). Import `ResolveChatAccessTokenParams` from `@trigger.dev/sdk/chat` so the signature matches — see [TriggerChatTransport options](/ai-chat/reference#triggerchattransport-options). ```ts app/actions.ts "use server"; diff --git a/docs/ai-chat/reference.mdx b/docs/ai-chat/reference.mdx index 6d959171622..6eb024a84bb 100644 --- a/docs/ai-chat/reference.mdx +++ b/docs/ai-chat/reference.mdx @@ -1,12 +1,12 @@ --- title: "API Reference" sidebarTitle: "API Reference" -description: "Complete API reference for the AI Chat SDK — backend options, events, frontend transport, and hooks." +description: "Complete API reference for the AI Agents SDK — backend options, events, frontend transport, and hooks." --- -## ChatTaskOptions +## ChatAgentOptions -Options for `chat.task()`. +Options for `chat.agent()`. | Option | Type | Default | Description | | ----------------------------- | ----------------------------------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------- | @@ -19,7 +19,7 @@ Options for `chat.task()`. | `onBeforeTurnComplete` | `(event: BeforeTurnCompleteEvent) => Promise \| void` | — | Fires after response but before stream closes. Includes `writer`. | | `onTurnComplete` | `(event: TurnCompleteEvent) => Promise \| void` | — | Fires after each turn completes (stream closed) | | `onCompacted` | `(event: CompactedEvent) => Promise \| void` | — | Fires when compaction occurs. Includes `writer`. See [Compaction](/ai-chat/compaction) | -| `compaction` | `ChatTaskCompactionOptions` | — | Automatic context compaction. See [Compaction](/ai-chat/compaction) | +| `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.) | | `maxTurns` | `number` | `100` | Max conversational turns per run | @@ -37,7 +37,7 @@ Plus all standard [TaskOptions](/tasks/overview) — `retry`, `queue`, `machine` ## Task context (`ctx`) -All **`chat.task`** lifecycle events (**`onPreload`**, **`onChatStart`**, **`onTurnStart`**, **`onBeforeTurnComplete`**, **`onTurnComplete`**, **`onCompacted`**) and the object passed to **`run`** include **`ctx`**: the same **`TaskRunContext`** shape as the `ctx` in `task({ run: (payload, { ctx }) => ... })`. +All **`chat.agent`** lifecycle events (**`onPreload`**, **`onChatStart`**, **`onTurnStart`**, **`onBeforeTurnComplete`**, **`onTurnComplete`**, **`onCompacted`**) and the object passed to **`run`** include **`ctx`**: the same **`TaskRunContext`** shape as the `ctx` in `task({ run: (payload, { ctx }) => ... })`. Use **`ctx`** for run metadata, tags, parent links, or any API that needs the full run record. The chat-specific string **`runId`** on events is always **`ctx.run.id`**; both are provided for convenience. @@ -203,9 +203,9 @@ onBeforeTurnComplete: async ({ writer, usage }) => { }, ``` -## ChatTaskCompactionOptions +## ChatAgentCompactionOptions -Options for the `compaction` field on `chat.task()`. See [Compaction](/ai-chat/compaction) for usage guide. +Options for the `compaction` field on `chat.agent()`. See [Compaction](/ai-chat/compaction) for usage guide. | Option | Type | Required | Description | | ---------------------- | ---------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------- | @@ -337,7 +337,7 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`. | Method | Description | | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `chat.task(options)` | Create a chat task | +| `chat.agent(options)` | Create a chat agent | | `chat.createSession(payload, options)` | Create an async iterator for chat turns | | `chat.pipe(source, options?)` | Pipe a stream to the frontend (from anywhere inside a task) | | `chat.pipeAndCapture(source, options?)` | Pipe and capture the response `UIMessage` | @@ -345,7 +345,7 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`. | `chat.createStopSignal()` | Create a managed stop signal wired to the stop input stream | | `chat.messages` | Input stream for incoming messages — use `.waitWithIdleTimeout()` | | `chat.local({ id })` | Create a per-run typed local (see [Per-run data](/ai-chat/features#per-run-data-with-chatlocal)) | -| `chat.createAccessToken(taskId)` | Create a public access token for a chat task | +| `chat.createAccessToken(taskId)` | Create a public access token for a chat agent | | `chat.setTurnTimeout(duration)` | Override turn timeout at runtime (e.g. `"2h"`) | | `chat.setTurnTimeoutInSeconds(seconds)` | Override turn timeout at runtime (in seconds) | | `chat.setIdleTimeoutInSeconds(seconds)` | Override idle timeout at runtime | @@ -360,7 +360,7 @@ All methods available on the `chat` object from `@trigger.dev/sdk/ai`. ## `chat.withUIMessage` -Returns a [`ChatBuilder`](/ai-chat/types#chatbuilder) with a fixed `UIMessage` subtype. Chain `.withClientData()`, hook methods, and `.task()`. +Returns a [`ChatBuilder`](/ai-chat/types#chatbuilder) with a fixed `UIMessage` subtype. Chain `.withClientData()`, hook methods, and `.agent()`. ```ts chat.withUIMessage(config?: ChatWithUIMessageConfig): ChatBuilder; @@ -368,13 +368,13 @@ chat.withUIMessage(config?: ChatWithUIMessageConfig): ChatBuilder` | Optional defaults for `toUIMessageStream()`. Shallow-merged with `uiMessageStreamOptions` on the inner `.task({ ... })` (task wins on key conflicts). | +| `config.streamOptions` | `ChatUIMessageStreamOptions` | Optional defaults for `toUIMessageStream()`. Shallow-merged with `uiMessageStreamOptions` on the inner `.agent({ ... })` (agent wins on key conflicts). | Use this when you need [`InferChatUIMessage`](#inferchatuimessage) / typed `data-*` parts / `InferUITools` to line up across backend hooks and `useChat`. Full guide: [Types](/ai-chat/types). ## `chat.withClientData` -Returns a [`ChatBuilder`](/ai-chat/types#chatbuilder) with a fixed client data schema. All hooks and `run` get typed `clientData` without passing `clientDataSchema` in `.task()` options. +Returns a [`ChatBuilder`](/ai-chat/types#chatbuilder) with a fixed client data schema. All hooks and `run` get typed `clientData` without passing `clientDataSchema` in `.agent()` options. ```ts chat.withClientData({ schema: TSchema }): ChatBuilder; @@ -390,11 +390,11 @@ Full guide: [Typed client data](/ai-chat/types#typed-client-data-with-chatwithcl | Field | Type | Description | | --------------- | ---------------------------------- | --------------------------------------------------------------------- | -| `streamOptions` | `ChatUIMessageStreamOptions` | Default `toUIMessageStream()` options for tasks created via `.task()` | +| `streamOptions` | `ChatUIMessageStreamOptions` | Default `toUIMessageStream()` options for agents created via `.agent()` | ## `InferChatUIMessage` -Type helper: extracts the `UIMessage` subtype from a chat task’s wire payload. +Type helper: extracts the `UIMessage` subtype from a chat agent’s wire payload. ```ts import type { InferChatUIMessage } from "@trigger.dev/sdk/ai"; @@ -403,7 +403,7 @@ import type { InferChatUIMessage } from "@trigger.dev/sdk/ai"; type Msg = InferChatUIMessage; ``` -Use with `useChat({ transport })` when using [`chat.withUIMessage`](/ai-chat/types). For tasks defined with plain `chat.task()` (no custom generic), this resolves to the base `UIMessage`. +Use with `useChat({ transport })` when using [`chat.withUIMessage`](/ai-chat/types). For agents defined with plain `chat.agent()` (no custom generic), this resolves to the base `UIMessage`. ## AI helpers (`ai` from `@trigger.dev/sdk/ai`) @@ -415,7 +415,7 @@ Use with `useChat({ transport })` when using [`chat.withUIMessage`](/ai-cha ## ChatUIMessageStreamOptions -Options for customizing `toUIMessageStream()`. Set as static defaults via `uiMessageStreamOptions` on `chat.task()`, or override per-turn via `chat.setUIMessageStreamOptions()`. See [Stream options](/ai-chat/backend#stream-options) for usage examples. +Options for customizing `toUIMessageStream()`. Set as static defaults via `uiMessageStreamOptions` on `chat.agent()`, or override per-turn via `chat.setUIMessageStreamOptions()`. See [Stream options](/ai-chat/backend#stream-options) for usage examples. Derived from the AI SDK's `UIMessageStreamOptions` with `onFinish`, `originalMessages`, and `generateMessageId` omitted (managed internally). diff --git a/docs/ai-chat/types.mdx b/docs/ai-chat/types.mdx index 1350a2f259e..6f40f4a9a5e 100644 --- a/docs/ai-chat/types.mdx +++ b/docs/ai-chat/types.mdx @@ -1,14 +1,14 @@ --- title: "Types" sidebarTitle: "Types" -description: "TypeScript types for AI Chat tasks, UI messages, and the frontend transport." +description: "TypeScript types for AI Agents, UI messages, and the frontend transport." --- TypeScript patterns for [AI Chat](/ai-chat/overview). This page covers how to pin a custom AI SDK [`UIMessage`](https://sdk.vercel.ai/docs/reference/ai-sdk-core/ui-message) subtype with `chat.withUIMessage`, fix a typed `clientData` schema with `chat.withClientData`, chain builder-level hooks, and align types on the client. ## Custom `UIMessage` with `chat.withUIMessage` -`chat.task()` types the wire payload with the base AI SDK `UIMessage`. That is enough for many apps. +`chat.agent()` types the wire payload with the base AI SDK `UIMessage`. That is enough for many apps. When you add **custom `data-*` parts** (via `chat.stream` / `writer`) or a **typed tool map** (e.g. `InferUITools`), you want a **narrower** `UIMessage` generic so that: @@ -16,7 +16,7 @@ When you add **custom `data-*` parts** (via `chat.stream` / `writer`) or a **typ - Stream options like `sendReasoning` align with your message shape - The frontend can treat `useChat` messages as the same subtype end-to-end -`chat.withUIMessage(config?)` returns a [ChatBuilder](#chatbuilder) where `.task(...)` accepts the **same options as** [`chat.task()`](/ai-chat/backend#chat-task) but fixes `YourUIMessage` as the UI message type for that chat task. +`chat.withUIMessage(config?)` returns a [ChatBuilder](#chatbuilder) where `.agent(...)` accepts the **same options as** [`chat.agent()`](/ai-chat/backend#chat-agent) but fixes `YourUIMessage` as the UI message type for that chat agent. ### Defining a `UIMessage` subtype @@ -46,9 +46,9 @@ export type MyChatUIMessage = UIMessage; 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(...).task(...)` +### Backend: `chat.withUIMessage(...).agent(...)` -Call `withUIMessage` **once**, then chain `.task({ ... })` instead of `chat.task({ ... })`. You can also chain `.withClientData()` and hook methods before `.task()`: +Call `withUIMessage` **once**, then chain `.agent({ ... })` instead of `chat.agent({ ... })`. You can also chain `.withClientData()` and hook methods before `.agent()`: ```ts import { chat } from "@trigger.dev/sdk/ai"; @@ -76,7 +76,7 @@ export const myChat = chat .withClientData({ schema: z.object({ userId: z.string() }), }) - .task({ + .agent({ id: "my-chat", onTurnStart: async ({ uiMessages, writer }) => { // uiMessages is MyChatUIMessage[] — custom data parts are typed @@ -98,9 +98,9 @@ export const myChat = chat ### Default stream options -The optional `streamOptions` object becomes the **default** [`uiMessageStreamOptions`](/ai-chat/reference#chat-task-options) for `toUIMessageStream()`. +The optional `streamOptions` object becomes the **default** [`uiMessageStreamOptions`](/ai-chat/reference#chat-agent-options) for `toUIMessageStream()`. -If you also set `uiMessageStreamOptions` on the inner `.task({ ... })`, the two objects are **shallow-merged** — keys on the **task** win on conflicts. Per-turn overrides via [`chat.setUIMessageStreamOptions()`](/ai-chat/backend#stream-options) still apply on top. +If you also set `uiMessageStreamOptions` on the inner `.agent({ ... })`, the two objects are **shallow-merged** — keys on the **agent** win on conflicts. Per-turn overrides via [`chat.setUIMessageStreamOptions()`](/ai-chat/backend#stream-options) still apply on top. ### Frontend: `InferChatUIMessage` @@ -131,7 +131,7 @@ You can also import `InferChatUIMessage` from `@trigger.dev/sdk/ai` in non-React ## Typed client data with `chat.withClientData` -`chat.withClientData({ schema })` returns a [ChatBuilder](#chatbuilder) that fixes the client data schema. All hooks and `run` receive typed `clientData` without needing `clientDataSchema` in `.task()` options. +`chat.withClientData({ schema })` returns a [ChatBuilder](#chatbuilder) that fixes the client data schema. All hooks and `run` receive typed `clientData` without needing `clientDataSchema` in `.agent()` options. ```ts import { chat } from "@trigger.dev/sdk/ai"; @@ -141,7 +141,7 @@ export const myChat = chat .withClientData({ schema: z.object({ userId: z.string(), model: z.string().optional() }), }) - .task({ + .agent({ id: "my-chat", onPreload: async ({ clientData }) => { // clientData is typed as { userId: string; model?: string } @@ -159,7 +159,7 @@ export const myChat = chat ## ChatBuilder -Both `chat.withUIMessage()` and `chat.withClientData()` return a **ChatBuilder** — a chainable object that accumulates configuration before creating the task with `.task()`. +Both `chat.withUIMessage()` and `chat.withClientData()` return a **ChatBuilder** — a chainable object that accumulates configuration before creating the agent with `.agent()`. Builder methods can be chained in any order: @@ -177,7 +177,7 @@ export const myChat = chat .onChatResume(async ({ ctx }) => { warmCache(ctx.run.id); }) - .task({ + .agent({ id: "my-chat", run: async ({ messages, signal }) => { return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }); @@ -198,7 +198,7 @@ chat // Runs first — shared setup across tasks using this builder await initializeSharedState(event.chatId); }) - .task({ + .agent({ id: "my-chat", onPreload: async (event) => { // Runs second — task-specific logic @@ -214,13 +214,13 @@ chat Set types first (`.withUIMessage()`, `.withClientData()`), then hooks. Hook parameters are typed based on the builder's current generics — so hooks registered after `.withClientData()` get typed `clientData`. -### When plain `chat.task()` is enough +### When plain `chat.agent()` is enough -If you do not rely on custom `UIMessage` generics (only default text, reasoning, and built-in tool UI types), **`chat.task()` alone is fine** — no need for `withUIMessage`. +If you do not rely on custom `UIMessage` generics (only default text, reasoning, and built-in tool UI types), **`chat.agent()` alone is fine** — no need for `withUIMessage`. ## See also -- [Backend — `chat.task()`](/ai-chat/backend#chat-task) +- [Backend — `chat.agent()`](/ai-chat/backend#chat-agent) - [Backend — Lifecycle hooks](/ai-chat/backend#lifecycle-hooks) - [Frontend — transport & `useChat`](/ai-chat/frontend) - [API reference — `chat.withUIMessage`](/ai-chat/reference#chat-withuimessage) diff --git a/docs/ai/prompts.mdx b/docs/ai/prompts.mdx index 4ac324ffff9..e3a7d395a3d 100644 --- a/docs/ai/prompts.mdx +++ b/docs/ai/prompts.mdx @@ -209,9 +209,9 @@ const result = await generateText({ }); ``` -## Using with chat.task() +## Using with chat.agent() -Prompts integrate with `chat.task()` via `chat.prompt` — a run-scoped store for the resolved prompt. Store a prompt once in a lifecycle hook, then access it anywhere during the run. +Prompts integrate with `chat.agent()` via `chat.prompt` — a run-scoped store for the resolved prompt. Store a prompt once in a lifecycle hook, then access it anywhere during the run. ### chat.prompt.set() and chat.prompt() @@ -232,7 +232,7 @@ const systemPrompt = prompts.define({ content: `You are a helpful assistant for {{name}}.`, }); -export const myChat = chat.task({ +export const myChat = chat.agent({ id: "my-chat", onChatStart: async ({ clientData }) => { const resolved = await systemPrompt.resolve({ name: clientData.name }); diff --git a/docs/docs.json b/docs/docs.json index cb8cb864eda..0becfd9e22e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -85,7 +85,7 @@ "pages": [ "ai/prompts", { - "group": "Chat", + "group": "Agents", "pages": [ "ai-chat/overview", "ai-chat/quick-start", diff --git a/docs/tasks/schemaTask.mdx b/docs/tasks/schemaTask.mdx index 82ba4aa5679..71eb6720db9 100644 --- a/docs/tasks/schemaTask.mdx +++ b/docs/tasks/schemaTask.mdx @@ -172,7 +172,7 @@ export const chartTool = tool({ }); ``` -Inside the task run, you can read tool execution context with **`ai.currentToolOptions()`** (and helpers like `ai.toolCallId()`, `ai.chatContext()` when running inside a [`chat.task`](/ai-chat/overview)): +Inside the task run, you can read tool execution context with **`ai.currentToolOptions()`** (and helpers like `ai.toolCallId()`, `ai.chatContext()` when running inside a [`chat.agent`](/ai-chat/overview)): ```ts import { ai } from "@trigger.dev/sdk/ai";