chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
---
|
||||
title: "Branching conversations"
|
||||
sidebarTitle: "Branching conversations"
|
||||
description: "Build ChatGPT-style conversation trees with edit, regenerate, undo, and branch switching using hydrateMessages, chat.history, and actions."
|
||||
---
|
||||
|
||||
Most chat UIs treat conversations as linear sequences. But real conversations branch — users edit previous messages, regenerate responses, undo exchanges, and explore alternative paths. This pattern shows how to build a branching conversation system using `hydrateMessages`, `chat.history`, and custom actions.
|
||||
|
||||
## Data model
|
||||
|
||||
The standard approach (used by ChatGPT, Open WebUI, LibreChat, and others) stores messages as a tree with parent pointers:
|
||||
|
||||
```ts
|
||||
// Each message is a node in the tree
|
||||
type ChatNode = {
|
||||
id: string;
|
||||
chatId: string;
|
||||
parentId: string | null; // null for root
|
||||
role: "user" | "assistant";
|
||||
message: UIMessage; // the full AI SDK message
|
||||
createdAt: Date;
|
||||
};
|
||||
```
|
||||
|
||||
A conversation is a tree of nodes. The **active branch** is resolved by walking from a leaf node up through `parentId` pointers to the root, then reversing:
|
||||
|
||||
```
|
||||
root
|
||||
├── user: "Hello"
|
||||
│ └── assistant: "Hi there!"
|
||||
│ ├── user: "What's the weather?" ← branch A
|
||||
│ │ └── assistant: "It's sunny!"
|
||||
│ └── user: "Tell me a joke" ← branch B (active)
|
||||
│ └── assistant: "Why did the..."
|
||||
```
|
||||
|
||||
Switching branches means changing which leaf is "active" — the same tree, different path.
|
||||
|
||||
## Backend setup
|
||||
|
||||
### Store: tree operations
|
||||
|
||||
Define helpers that read and write the node tree. Adapt to your database:
|
||||
|
||||
```ts
|
||||
// Resolve the active path: walk from leaf to root, reverse
|
||||
async function getActiveBranch(chatId: string): Promise<UIMessage[]> {
|
||||
const nodes = await db.chatNode.findMany({ where: { chatId } });
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||
|
||||
// Find active leaf (most recently created leaf node)
|
||||
const childIds = new Set(nodes.map((n) => n.parentId).filter(Boolean));
|
||||
const leaves = nodes.filter((n) => !childIds.has(n.id));
|
||||
const activeLeaf = leaves.sort((a, b) => b.createdAt - a.createdAt)[0];
|
||||
if (!activeLeaf) return [];
|
||||
|
||||
// Walk to root
|
||||
const path: UIMessage[] = [];
|
||||
let current: ChatNode | undefined = activeLeaf;
|
||||
while (current) {
|
||||
path.unshift(current.message);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
// Append a message as a child of the current leaf
|
||||
async function appendMessage(chatId: string, message: UIMessage): Promise<void> {
|
||||
const branch = await getActiveBranch(chatId);
|
||||
const parentId = branch.length > 0 ? branch[branch.length - 1]!.id : null;
|
||||
|
||||
await db.chatNode.create({
|
||||
data: { id: message.id, chatId, parentId, role: message.role, message, createdAt: new Date() },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Agent: hydration + actions
|
||||
|
||||
```ts
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { streamText, stepCountIs } from "ai";
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
import { z } from "zod";
|
||||
|
||||
export const myChat = chat.agent({
|
||||
id: "branching-chat",
|
||||
|
||||
// Load the active branch from the DB on every turn.
|
||||
// The frontend's message array is ignored — the tree is the source of truth.
|
||||
hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
|
||||
if (trigger === "submit-message" && incomingMessages.length > 0) {
|
||||
await appendMessage(chatId, incomingMessages[incomingMessages.length - 1]!);
|
||||
}
|
||||
return getActiveBranch(chatId);
|
||||
},
|
||||
|
||||
actionSchema: z.discriminatedUnion("type", [
|
||||
// Edit a previous user message — creates a sibling node in the tree
|
||||
z.object({ type: z.literal("edit"), messageId: z.string(), text: z.string() }),
|
||||
// Switch to a different branch by selecting a leaf node
|
||||
z.object({ type: z.literal("switch-branch"), leafId: z.string() }),
|
||||
// Undo the last user + assistant exchange
|
||||
z.object({ type: z.literal("undo") }),
|
||||
]),
|
||||
|
||||
onAction: async ({ action, chatId }) => {
|
||||
switch (action.type) {
|
||||
case "edit": {
|
||||
// Find the original message's parent, create a sibling with new content
|
||||
const original = await db.chatNode.findUnique({ where: { id: action.messageId } });
|
||||
if (!original) break;
|
||||
|
||||
const newId = generateId();
|
||||
await db.chatNode.create({
|
||||
data: {
|
||||
id: newId,
|
||||
chatId,
|
||||
parentId: original.parentId, // same parent = sibling
|
||||
role: "user",
|
||||
message: { id: newId, role: "user", parts: [{ type: "text", text: action.text }] },
|
||||
createdAt: new Date(),
|
||||
},
|
||||
});
|
||||
// Active branch now resolves through the new sibling (most recent leaf)
|
||||
break;
|
||||
}
|
||||
|
||||
case "switch-branch": {
|
||||
// Mark this leaf as the most recently accessed so getActiveBranch picks it
|
||||
await db.chatNode.update({
|
||||
where: { id: action.leafId },
|
||||
data: { createdAt: new Date() },
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "undo": {
|
||||
// Remove the last two nodes (user + assistant) from the active branch
|
||||
const branch = await getActiveBranch(chatId);
|
||||
if (branch.length >= 2) {
|
||||
const lastTwo = branch.slice(-2);
|
||||
await db.chatNode.deleteMany({
|
||||
where: { id: { in: lastTwo.map((m) => m.id) } },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Reload the (now modified) active branch into the accumulator
|
||||
const updated = await getActiveBranch(chatId);
|
||||
chat.history.set(updated);
|
||||
},
|
||||
|
||||
onTurnComplete: async ({ chatId, responseMessage }) => {
|
||||
// Persist the assistant's response as a new node
|
||||
if (responseMessage) {
|
||||
await appendMessage(chatId, responseMessage);
|
||||
}
|
||||
},
|
||||
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({
|
||||
model: anthropic("claude-sonnet-4-5"),
|
||||
messages,
|
||||
abortSignal: signal,
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
### Sending actions
|
||||
|
||||
Wire up edit, undo, and branch switching to the transport:
|
||||
|
||||
```tsx
|
||||
function MessageActions({ message, chatId }: { message: UIMessage; chatId: string }) {
|
||||
const transport = useTransport();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editText, setEditText] = useState("");
|
||||
|
||||
if (message.role !== "user") return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{editing ? (
|
||||
<form onSubmit={() => {
|
||||
transport.sendAction(chatId, { type: "edit", messageId: message.id, text: editText });
|
||||
setEditing(false);
|
||||
}}>
|
||||
<input value={editText} onChange={(e) => setEditText(e.target.value)} />
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
) : (
|
||||
<button onClick={() => { setEditText(getMessageText(message)); setEditing(true); }}>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Branch navigation
|
||||
|
||||
To show the `< 2/3 >` sibling switcher, query the tree for siblings at each fork point. This is a frontend concern — the backend exposes the data, the UI navigates it.
|
||||
|
||||
```tsx
|
||||
function BranchSwitcher({ message, chatId, siblings }: {
|
||||
message: UIMessage;
|
||||
chatId: string;
|
||||
siblings: { id: string; createdAt: string }[];
|
||||
}) {
|
||||
const transport = useTransport();
|
||||
if (siblings.length <= 1) return null;
|
||||
|
||||
const currentIndex = siblings.findIndex((s) => s.id === message.id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
disabled={currentIndex === 0}
|
||||
onClick={() => {
|
||||
// Find the leaf of the previous sibling's subtree
|
||||
transport.sendAction(chatId, {
|
||||
type: "switch-branch",
|
||||
leafId: siblings[currentIndex - 1]!.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<
|
||||
</button>
|
||||
<span>{currentIndex + 1}/{siblings.length}</span>
|
||||
<button
|
||||
disabled={currentIndex === siblings.length - 1}
|
||||
onClick={() => {
|
||||
transport.sendAction(chatId, {
|
||||
type: "switch-branch",
|
||||
leafId: siblings[currentIndex + 1]!.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
<Note>
|
||||
The sibling data (which messages share the same parent) needs to come from your database — query it when loading the chat or include it as client data. The agent only returns the active branch via `hydrateMessages`.
|
||||
</Note>
|
||||
|
||||
## How it works
|
||||
|
||||
| Operation | What happens |
|
||||
|-----------|-------------|
|
||||
| **Send message** | `hydrateMessages` appends the new message as a child of the current leaf, returns the active path |
|
||||
| **Edit message** | `onAction` creates a sibling node with the same parent. The new node becomes the latest leaf, so `hydrateMessages` resolves through it. LLM responds to the edited history |
|
||||
| **Regenerate** | Same as edit — create a new assistant sibling. The AI SDK's `regenerate()` handles this via `trigger: "regenerate-message"` |
|
||||
| **Undo** | `onAction` removes the last two nodes. `chat.history.set()` updates the accumulator. LLM responds to the earlier state |
|
||||
| **Switch branch** | `onAction` updates which leaf is "active". `hydrateMessages` loads the new path. LLM responds to the switched context |
|
||||
|
||||
## Design notes
|
||||
|
||||
- **Messages are immutable** — edits create siblings, not mutations. This preserves full history for analytics and auditing.
|
||||
- **The tree lives in your database** — the agent loads a linear path from it via `hydrateMessages`. The agent itself doesn't know about the tree structure.
|
||||
- **`hydrateMessages` + `onAction` + `chat.history`** are the three primitives. Hydration loads the active path, actions modify the tree, and `chat.history.set()` syncs the accumulator after tree modifications.
|
||||
- **Frontend owns navigation** — the `< 2/3 >` UI, sibling queries, and branch switching triggers are client-side concerns. The backend just processes actions and returns responses.
|
||||
|
||||
## See also
|
||||
|
||||
- [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) — backend-controlled message history
|
||||
- [Actions](/ai-chat/actions) — custom actions with `actionSchema` and `onAction`
|
||||
- [`chat.history`](/ai-chat/backend#chat-history) — imperative history mutations
|
||||
- [Database persistence](/ai-chat/patterns/database-persistence) — basic persistence pattern (linear)
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
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.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.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).
|
||||
- **Dispose** it **right before the run suspends** waiting for the next user message — using the **`onChatSuspend`** hook, not `onTurnComplete`.
|
||||
|
||||
|
||||
## 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/lifecycle-hooks#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<runId, Promise<Sandbox>>` (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/chat-local) 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.agent({
|
||||
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.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";
|
||||
```
|
||||
|
||||
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
|
||||
- [Lifecycle hooks](/ai-chat/lifecycle-hooks)
|
||||
- [API Reference — `ctx` on events](/ai-chat/reference#task-context-ctx)
|
||||
- [Per-run data with `chat.local`](/ai-chat/chat-local)
|
||||
@@ -0,0 +1,410 @@
|
||||
---
|
||||
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** — a **scoped access token** for the session and optionally **`lastEventId`** for stream resume.
|
||||
|
||||
This page describes a **hook mapping** that works with any database. 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** | Hydrate the transport on page reload | Same **`chatId`** as key (or FK), **`publicAccessToken`**, optional **`lastEventId`** |
|
||||
|
||||
The **conversation** row is what your UI lists as "chats." The **session** row is what the **transport** needs after a refresh: a session-scoped PAT (so the transport doesn't have to re-mint on first paint) and the SSE resume cursor.
|
||||
|
||||
Storing the current **`runId`** is optional — useful for telemetry / dashboard linking ("View this run") but not required for resume. The Session row owns its current run server-side; the transport reads from `session.out` keyed on `chatId`, so a run swap (continuation, upgrade) is invisible to your DB schema.
|
||||
|
||||
<Note>
|
||||
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.
|
||||
</Note>
|
||||
|
||||
## Where each hook writes
|
||||
|
||||
This pattern covers **durable DB rows** (the conversation and the active session). Per-process in-memory state ([`chat.local`](/ai-chat/chat-local), [DB connection pools](/database-connections), sandboxes, etc.) belongs in [`onBoot`](/ai-chat/lifecycle-hooks#onboot) — it fires on every fresh worker including continuation runs, where `onPreload` and `onChatStart` do not.
|
||||
|
||||
### `onPreload` (optional)
|
||||
|
||||
When the user triggers [preload](/ai-chat/fast-starts#preload), the run starts **before** the first user message.
|
||||
|
||||
- Ensure the **conversation** row exists (create or no-op).
|
||||
- **Upsert session**: **`chatAccessToken`** from the event (a session-scoped PAT covering both `read:sessions:{chatId}` and `write:sessions:{chatId}`).
|
||||
- Load any **user / tenant context** you need for prompts (`clientData`).
|
||||
|
||||
If you skip preload, do the equivalent in **`onChatStart`** when **`preloaded`** is false.
|
||||
|
||||
### `onChatStart` (chat's first message, non-preloaded path)
|
||||
|
||||
- Fires **once per chat**, on the very first user message. Does NOT fire on continuation runs (post-`endRun`, post-waitpoint-timeout, post-`chat.requestUpgrade`) or on OOM-retry attempts.
|
||||
- If **`preloaded`** is true, return early — **`onPreload`** already ran.
|
||||
- Otherwise mirror preload: user/context, conversation create, session upsert.
|
||||
- No need to gate the conversation create on `continuation` — it's always a brand-new chat at this point.
|
||||
- For continuation runs that need to refresh per-run state (new PAT, new `lastEventId`), do it in **`onTurnStart`** / **`onTurnComplete`** — both fire on every turn including the first turn of a continuation run.
|
||||
|
||||
### `onTurnStart`
|
||||
|
||||
- **`await`** persist **`uiMessages`** (full accumulated history including the new user turn) **before** the hook returns — `chat.agent` does not begin streaming until `onTurnStart` resolves, so this is what bounds "user message is durable before the stream".
|
||||
|
||||
<Warning>
|
||||
**Don't use [`chat.defer()`](/ai-chat/background-injection#chat-defer-standalone) for the message write here.** `chat.defer` is fire-and-forget — the hook resolves before the write lands and the stream starts immediately. If the user refreshes mid-stream, the next page load reads `[]` from your DB, the resumed SSE stream pushes the assistant into an empty array, and the user's message disappears from the rendered conversation forever.
|
||||
|
||||
```ts
|
||||
// ❌ Bad — non-blocking write, mid-stream refresh drops the user message.
|
||||
onTurnStart: async ({ chatId, uiMessages }) => {
|
||||
chat.defer(db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } }));
|
||||
},
|
||||
|
||||
// ✅ Good — awaited, durable before the model starts.
|
||||
onTurnStart: async ({ chatId, uiMessages }) => {
|
||||
await db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } });
|
||||
},
|
||||
```
|
||||
|
||||
`chat.defer` is for writes whose timing doesn't matter for resume — analytics, audit logs, search-index updates, etc. Anything the next page load reads needs to land before the stream begins.
|
||||
</Warning>
|
||||
|
||||
### `onTurnComplete`
|
||||
|
||||
- Persist **`uiMessages`** again with the **assistant** reply finalized.
|
||||
- **Upsert session** with the 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.
|
||||
|
||||
<Warning>
|
||||
**Write the messages and `lastEventId` in a single transaction.** Both values are read in parallel on the next page load (one fetches the conversation, the other fetches the session). If a refresh races between the two writes, the page can see the assistant message persisted (full history) but a stale `lastEventId` from the previous turn. The transport then resumes from that stale cursor and replays this turn's chunks on top of the already-persisted assistant message, producing a duplicated render.
|
||||
|
||||
```ts
|
||||
// ✅ Atomic — refresh on the next page load reads both writes consistently.
|
||||
await db.$transaction([
|
||||
db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } }),
|
||||
db.chatSession.upsert({
|
||||
where: { id: chatId },
|
||||
create: { id: chatId, publicAccessToken: chatAccessToken, lastEventId },
|
||||
update: { publicAccessToken: chatAccessToken, lastEventId },
|
||||
}),
|
||||
]);
|
||||
|
||||
// ❌ Two awaits — narrow race window where messages are post-write but
|
||||
// lastEventId is still pre-write. A page refresh that lands here will
|
||||
// duplicate the assistant message on resume.
|
||||
await db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } });
|
||||
await db.chatSession.upsert({ /* ... */ });
|
||||
```
|
||||
</Warning>
|
||||
|
||||
## Token renewal (app server)
|
||||
|
||||
The persisted PAT has a TTL (see **`chatAccessTokenTTL`** on **`chat.agent`**, default 1h). When the transport gets a **401** on a session-PAT-authed request, it calls your **`accessToken`** callback to mint a fresh PAT — no DB lookup required, since the session is keyed on `chatId` (which the transport already has).
|
||||
|
||||
Your `accessToken` callback typically just wraps `auth.createPublicToken`:
|
||||
|
||||
```ts
|
||||
"use server";
|
||||
import { auth } from "@trigger.dev/sdk";
|
||||
|
||||
export async function mintChatAccessToken(chatId: string) {
|
||||
return auth.createPublicToken({
|
||||
scopes: { read: { sessions: chatId }, write: { sessions: chatId } },
|
||||
expirationTime: "1h",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
If you want to keep your DB session row in sync, the transport's **`onSessionChange`** callback fires every time the cached PAT changes — persist the new value there.
|
||||
|
||||
No Trigger task code needs to run for renewal.
|
||||
|
||||
## Minimal pseudocode
|
||||
|
||||
```typescript
|
||||
// Pseudocode — replace saveConversation / saveSession with your DB layer.
|
||||
|
||||
chat.agent({
|
||||
id: "my-chat",
|
||||
clientDataSchema: z.object({ userId: z.string() }),
|
||||
|
||||
onPreload: async ({ chatId, chatAccessToken, clientData }) => {
|
||||
if (!clientData) return;
|
||||
await ensureUser(clientData.userId);
|
||||
await upsertConversation({ id: chatId, userId: clientData.userId /* ... */ });
|
||||
await upsertSession({ chatId, publicAccessToken: chatAccessToken });
|
||||
},
|
||||
|
||||
onChatStart: async ({ chatId, chatAccessToken, clientData, preloaded }) => {
|
||||
if (preloaded) return;
|
||||
// Fires once per chat — no continuation gate needed.
|
||||
await ensureUser(clientData.userId);
|
||||
await upsertConversation({ id: chatId, userId: clientData.userId /* ... */ });
|
||||
await upsertSession({ chatId, publicAccessToken: chatAccessToken });
|
||||
},
|
||||
|
||||
onTurnStart: async ({ chatId, uiMessages }) => {
|
||||
// Awaited, not chat.defer — see the warning in `onTurnStart` above.
|
||||
await saveConversationMessages(chatId, uiMessages);
|
||||
},
|
||||
|
||||
onTurnComplete: async ({ chatId, uiMessages, chatAccessToken, lastEventId }) => {
|
||||
// Atomic: messages + lastEventId must be readable consistently on resume.
|
||||
// See the warning above for why a non-atomic write causes duplicate renders.
|
||||
await db.$transaction([
|
||||
saveConversationMessagesQuery(chatId, uiMessages),
|
||||
upsertSessionQuery({ chatId, publicAccessToken: chatAccessToken, lastEventId }),
|
||||
]);
|
||||
},
|
||||
|
||||
run: async ({ messages, signal }) => {
|
||||
/* streamText, etc. */
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Alternative: `hydrateMessages`
|
||||
|
||||
For apps that need the backend to be the single source of truth for message history — abuse prevention, branching conversations, or rollback support — use [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) instead of relying on the frontend's accumulated state.
|
||||
|
||||
With hydration, the hook loads messages from your database on every turn. The frontend's messages are ignored (except for the new user message, which arrives in `incomingMessages`):
|
||||
|
||||
```ts
|
||||
import { chat, upsertIncomingMessage } from "@trigger.dev/sdk/ai";
|
||||
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
|
||||
const record = await db.chat.findUnique({ where: { id: chatId } });
|
||||
const stored = record?.messages ?? [];
|
||||
|
||||
// `upsertIncomingMessage` pushes a fresh user message and no-ops
|
||||
// on HITL continuations (the runtime overlays the new tool-state
|
||||
// advance onto the existing entry). See lifecycle hooks for the
|
||||
// full pattern: /ai-chat/lifecycle-hooks#hydratemessages
|
||||
if (upsertIncomingMessage(stored, { trigger, incomingMessages })) {
|
||||
// Upsert, not update: on a head-start first turn no preload ran,
|
||||
// so the row may not exist yet when this hook fires.
|
||||
await db.chat.upsert({
|
||||
where: { id: chatId },
|
||||
create: { id: chatId, messages: stored },
|
||||
update: { messages: stored },
|
||||
});
|
||||
}
|
||||
|
||||
return stored;
|
||||
},
|
||||
onTurnComplete: async ({ chatId, uiMessages, chatAccessToken, lastEventId }) => {
|
||||
// Persist the response and refresh session state atomically — see the
|
||||
// warning in the previous section for why these two writes have to be
|
||||
// in the same transaction.
|
||||
await db.$transaction([
|
||||
db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } }),
|
||||
db.chatSession.upsert({
|
||||
where: { id: chatId },
|
||||
create: { id: chatId, publicAccessToken: chatAccessToken, lastEventId },
|
||||
update: { publicAccessToken: chatAccessToken, lastEventId },
|
||||
}),
|
||||
]);
|
||||
},
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
This replaces the `onTurnStart` persistence pattern — the hook handles both loading and persisting the new message in one place.
|
||||
|
||||
Hydration composes with [Head Start](/ai-chat/fast-starts#with-hydratemessages): on a head-start first turn the route handler's history arrives as `incomingMessages`, and the write path must be an upsert because no preload ran to create the row.
|
||||
|
||||
## Design notes
|
||||
|
||||
- **`chatId`** is stable for the life of a thread and is the only identifier the transport persists. Runs come and go (idle continuation, upgrade, cancel/restart) but the chat keeps its identity.
|
||||
- **`continuation: true`** means "same logical chat, new run" — refresh the persisted PAT, don't assume an empty conversation.
|
||||
- The current `runId` is available on every hook event for telemetry / dashboard linking ("View this run"), but you don't need to persist it for resume to work — the transport addresses by `chatId`.
|
||||
- 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).
|
||||
|
||||
## Complete example
|
||||
|
||||
End-to-end implementation across the three files involved: agent task, server actions, and React component.
|
||||
|
||||
<Warning>
|
||||
The example below trusts raw `chatId` and returns rows without filtering by user. In a real multi-user app, **scope every query by the authenticated user** — read the user from your auth/session in each server action and add `where: { userId }` to all `db.chat.*` and `db.chatSession.*` queries. Without that, one client could read or delete another user's chat state, and `getAllSessions()` would leak other users' `publicAccessToken`s. The snippet keeps auth out of the way to focus on the persistence shape.
|
||||
</Warning>
|
||||
|
||||
<CodeGroup>
|
||||
```ts trigger/chat.ts
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { streamText, stepCountIs } from "ai";
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export const myChat = chat.agent({
|
||||
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 atomically — see the
|
||||
// race-condition warning earlier on this page.
|
||||
await db.$transaction([
|
||||
db.chat.update({
|
||||
where: { id: chatId },
|
||||
data: { messages: uiMessages },
|
||||
}),
|
||||
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: anthropic("claude-sonnet-4-5"),
|
||||
messages,
|
||||
abortSignal: signal,
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts app/actions.ts
|
||||
"use server";
|
||||
|
||||
import { auth } from "@trigger.dev/sdk";
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export const startChatSession = chat.createStartSessionAction("my-chat");
|
||||
|
||||
export async function mintChatAccessToken(chatId: string) {
|
||||
return auth.createPublicToken({
|
||||
scopes: { read: { sessions: chatId }, write: { sessions: chatId } },
|
||||
expirationTime: "1h",
|
||||
});
|
||||
}
|
||||
|
||||
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<
|
||||
string,
|
||||
{
|
||||
publicAccessToken: string;
|
||||
lastEventId?: string;
|
||||
}
|
||||
> = {};
|
||||
for (const s of sessions) {
|
||||
result[s.id] = {
|
||||
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 { mintChatAccessToken, startChatSession, deleteSession } from "@/app/actions";
|
||||
|
||||
export function Chat({ chatId, initialMessages, initialSessions }) {
|
||||
const transport = useTriggerChatTransport<typeof myChat>({
|
||||
task: "my-chat",
|
||||
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
|
||||
startSession: ({ chatId, clientData }) =>
|
||||
startChatSession({ chatId, clientData }),
|
||||
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 (
|
||||
<div>
|
||||
{messages.map((m) => (
|
||||
<div key={m.id}>
|
||||
<strong>{m.role}:</strong>
|
||||
{m.parts.map((part, i) =>
|
||||
part.type === "text" ? <span key={i}>{part.text}</span> : null
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget.querySelector("input");
|
||||
if (input?.value) {
|
||||
sendMessage({ text: input.value });
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input placeholder="Type a message..." />
|
||||
<button type="submit" disabled={status === "streaming"}>
|
||||
Send
|
||||
</button>
|
||||
{status === "streaming" && (
|
||||
<button type="button" onClick={stop}>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## See also
|
||||
|
||||
- [Lifecycle hooks](/ai-chat/lifecycle-hooks)
|
||||
- [Session management](/ai-chat/frontend#session-management) — `resume`, `lastEventId`, transport
|
||||
- [`chat.defer()`](/ai-chat/background-injection#chat-defer-standalone) — non-blocking writes during a turn
|
||||
- [Code execution sandbox](/ai-chat/patterns/code-sandbox) — combines **`onWait`** / **`onComplete`** with this persistence model
|
||||
@@ -0,0 +1,279 @@
|
||||
---
|
||||
title: "Human-in-the-loop"
|
||||
sidebarTitle: "Human-in-the-loop"
|
||||
description: "Pause the agent mid-response to ask the user a clarifying question, then resume with their answer."
|
||||
---
|
||||
|
||||
Some turns need to stop and ask the user something before they can finish — picking between options, confirming a destructive action, or clarifying an ambiguous request. The AI SDK calls this **human-in-the-loop** (HITL), and the building block is a tool with no `execute` function.
|
||||
|
||||
When the LLM calls a tool that has no `execute`, `streamText` ends with the tool call still pending. The turn completes cleanly, the frontend renders UI to collect the answer, and when the user responds, a new turn resumes with the answer merged into the same assistant message.
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
Turn N:
|
||||
User message → run()
|
||||
LLM streams text → calls askUser tool (no execute)
|
||||
streamText ends with tool-call in `input-available` state
|
||||
onTurnComplete fires (finishReason = "tool-calls")
|
||||
Agent suspends (compute freed) — maxDuration does not tick while paused
|
||||
|
||||
Frontend:
|
||||
Renders question + option buttons from tool input
|
||||
User clicks → addToolOutput({ tool, toolCallId, output })
|
||||
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls
|
||||
→ sendMessage() fires next turn
|
||||
|
||||
Turn N+1:
|
||||
hydrateMessages / accumulator sees the updated assistant message
|
||||
run() is called, LLM continues from the tool result
|
||||
onTurnComplete fires (finishReason = "stop", responseMessage is the FULL merged message)
|
||||
```
|
||||
|
||||
The AI SDK's `toUIMessageStream` automatically reuses the assistant message ID across the pause (we pass `originalMessages` internally), so `responseMessage` in the post-resume `onTurnComplete` is the **full merged message** — the original text, the completed tool call, and any follow-up content — not just the new parts.
|
||||
|
||||
## Duration and cost while paused
|
||||
|
||||
A pause doesn't hold compute. After the model calls a no-execute tool, the turn finishes and the run stays warm for `idleTimeoutInSeconds` (default 30s), then **suspends** and frees its compute, the same way [`wait.for`](/wait-for) does. The user's `addToolOutput` wakes it back up.
|
||||
|
||||
Because the run is suspended while it waits, the human's thinking time is not billed and does **not** count against [`maxDuration`](/runs/max-duration). `maxDuration` measures active CPU time and excludes suspended waitpoint time, exactly like `wait.for`, so a user can take minutes, hours, or days to answer without the run hitting `maxDuration`. The only time that counts is each turn's actual compute plus the short warm window before each suspend.
|
||||
|
||||
You don't need to raise `maxDuration` or end the run to support long human waits. How long a single suspended pause stays open is governed by the run's suspend timeout, not `maxDuration`; if a wait outlives it the run ends, and the next `addToolOutput` boots a fresh continuation that picks up the resolved tool result.
|
||||
|
||||
## Backend: define the tool
|
||||
|
||||
A HITL tool has an `inputSchema` describing what the model can ask, but **no `execute` function**. When the LLM calls it, `streamText` returns control to your agent.
|
||||
|
||||
```ts trigger/my-chat.ts
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { streamText, tool, stepCountIs } from "ai";
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
import { z } from "zod";
|
||||
|
||||
const askUser = tool({
|
||||
description:
|
||||
"Ask the user a clarifying question when you need their input. " +
|
||||
"Present 2-4 options for them to pick from.",
|
||||
inputSchema: z.object({
|
||||
question: z.string(),
|
||||
options: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.min(2)
|
||||
.max(4),
|
||||
}),
|
||||
// No execute function — streamText ends, the frontend supplies the output
|
||||
// via addToolOutput, and the next turn continues from the result.
|
||||
});
|
||||
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
tools: { askUser },
|
||||
run: async ({ messages, tools, signal }) => {
|
||||
return streamText({
|
||||
model: anthropic("claude-sonnet-4-5"),
|
||||
messages,
|
||||
tools,
|
||||
abortSignal: signal,
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Declaring `tools` on the config (and reading them back from the payload) is the recommended shape for any agent with tools. See [Tools](/ai-chat/tools).
|
||||
|
||||
## Frontend: render the question and collect the answer
|
||||
|
||||
Two pieces on the client:
|
||||
|
||||
1. **UI for the pending tool call** — render when the tool part is in `input-available` state, i.e. the LLM has called the tool but there's no output yet.
|
||||
2. **Auto-send on resolution** — use `sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls` so answering kicks off the next turn without the user having to hit "send."
|
||||
|
||||
```tsx
|
||||
import { useChat, lastAssistantMessageIsCompleteWithToolCalls } from "@ai-sdk/react";
|
||||
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
|
||||
|
||||
function ChatView({ chatId }: { chatId: string }) {
|
||||
const transport = useTriggerChatTransport({
|
||||
task: "my-chat",
|
||||
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
|
||||
startSession: ({ chatId, clientData }) =>
|
||||
startChatSession({ chatId, clientData }),
|
||||
});
|
||||
const { messages, sendMessage, addToolOutput } = useChat({
|
||||
id: chatId,
|
||||
transport,
|
||||
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{messages.map((m) =>
|
||||
m.parts.map((part, i) => {
|
||||
if (part.type === "tool-askUser" && part.state === "input-available") {
|
||||
return (
|
||||
<AskUserCard
|
||||
key={i}
|
||||
question={part.input.question}
|
||||
options={part.input.options}
|
||||
onAnswer={(opt) =>
|
||||
addToolOutput({
|
||||
tool: "askUser",
|
||||
toolCallId: part.toolCallId,
|
||||
output: { optionId: opt.id, label: opt.label },
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (part.type === "text") return <Markdown key={i}>{part.text}</Markdown>;
|
||||
return null;
|
||||
})
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`addToolOutput` patches the assistant message locally with `state: "output-available"` and fills in `output`. `lastAssistantMessageIsCompleteWithToolCalls` detects that every pending tool call now has a result, and `useChat` fires a new `sendMessage` — the backend picks it up as the next turn.
|
||||
|
||||
## Detecting a paused turn in `onTurnComplete`
|
||||
|
||||
Two ways to detect "this turn paused for user input" vs "this turn finished normally":
|
||||
|
||||
### Via `finishReason` (recommended)
|
||||
|
||||
The AI SDK's finish reason is surfaced on every `onTurnComplete` event. If the model stopped on tool calls, it's `"tool-calls"`:
|
||||
|
||||
```ts
|
||||
onTurnComplete: async ({ finishReason, responseMessage }) => {
|
||||
if (finishReason === "tool-calls") {
|
||||
// Turn paused — assistant message has pending tool call(s)
|
||||
const pending = responseMessage?.parts.filter(
|
||||
(p) => p.type.startsWith("tool-") && p.state === "input-available"
|
||||
);
|
||||
// Persist as a checkpoint / partial turn
|
||||
} else {
|
||||
// finishReason === "stop" — normal completion
|
||||
// Persist as a completed turn
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
<Note>
|
||||
`finishReason` is only undefined for manual `chat.pipe()` flows or aborted streams. For the common `run() → return streamText(...)` pattern it's always populated.
|
||||
</Note>
|
||||
|
||||
### Via response parts
|
||||
|
||||
If you need more nuance (e.g. which specific tool is pending), use `chat.history.getPendingToolCalls()`:
|
||||
|
||||
```ts
|
||||
const pending = chat.history.getPendingToolCalls();
|
||||
// [{ toolCallId, toolName, messageId }]
|
||||
```
|
||||
|
||||
The result reflects the most recent assistant message: the one waiting on `addToolOutput`. Use it from `onAction` to gate fresh user turns ("can't send a new message while a HITL is open"), or from `onTurnComplete` to decide what to persist.
|
||||
|
||||
Both `finishReason === "tool-calls"` and `chat.history.getPendingToolCalls().length > 0` are equivalent in practice. Use `finishReason` for dispatch, the helper for detail.
|
||||
|
||||
### Acting once per net-new tool result
|
||||
|
||||
When the user's `addToolOutput` round-trips a tool answer back to the agent, the wire message carries the resolved tool part. If you want to fire side-effects (audit log, billing, notifications) exactly once per resolved tool call, do it in `hydrateMessages` before the runtime merges. `chat.history.extractNewToolResults(message)` returns only the parts whose `toolCallId` isn't already resolved on the chain:
|
||||
|
||||
```ts
|
||||
hydrateMessages: async ({ incomingMessages }) => {
|
||||
for (const msg of incomingMessages) {
|
||||
if (msg.role !== "assistant") continue;
|
||||
for (const r of chat.history.extractNewToolResults(msg)) {
|
||||
await auditLog.record({
|
||||
toolCallId: r.toolCallId,
|
||||
toolName: r.toolName,
|
||||
output: r.output,
|
||||
errorText: r.errorText, // set only for output-error parts
|
||||
});
|
||||
}
|
||||
}
|
||||
return incomingMessages;
|
||||
},
|
||||
```
|
||||
|
||||
`extractNewToolResults` compares against the current `chat.history`. By the time `onTurnComplete` fires, the chain already contains `responseMessage`, so the helper returns `[]` there. Use it where the message is from outside the accumulator: `hydrateMessages`, `onAction` if the action carries a message, or any custom pre-merge code path.
|
||||
|
||||
## Persistence: one message vs one record per pause
|
||||
|
||||
Because the AI SDK reuses the assistant message ID across the pause, the "same turn" from the user's perspective maps to **two `onTurnComplete` firings** on the server — but both receive a `responseMessage` with the **same `id`**, and the second firing's `responseMessage` contains the fully merged content.
|
||||
|
||||
Two common persistence patterns:
|
||||
|
||||
### Overwrite on every turn (simplest)
|
||||
|
||||
Just store the latest `uiMessages` array on every `onTurnComplete`. The paused-turn write is overwritten by the resume-turn write; the final DB state has the full merged message.
|
||||
|
||||
```ts
|
||||
onTurnComplete: async ({ chatId, uiMessages }) => {
|
||||
await db.chat.update({
|
||||
where: { id: chatId },
|
||||
data: { messages: uiMessages },
|
||||
});
|
||||
},
|
||||
```
|
||||
|
||||
Use this unless you specifically need an audit trail.
|
||||
|
||||
### Checkpoint nodes (immutable history)
|
||||
|
||||
For apps that want every pause point recorded as its own immutable snapshot (branching, replay, diff review), save a checkpoint when paused and a sibling when complete:
|
||||
|
||||
```ts
|
||||
onTurnComplete: async ({ chatId, responseMessage, finishReason, uiMessages }) => {
|
||||
if (!responseMessage) return;
|
||||
|
||||
if (finishReason === "tool-calls") {
|
||||
// Paused — save a checkpoint
|
||||
await db.turnCheckpoint.create({
|
||||
data: {
|
||||
chatId,
|
||||
messageId: responseMessage.id,
|
||||
parts: responseMessage.parts,
|
||||
kind: "partial",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Completed — save a sibling with the merged full message
|
||||
await db.turnCheckpoint.create({
|
||||
data: {
|
||||
chatId,
|
||||
messageId: responseMessage.id,
|
||||
parts: responseMessage.parts,
|
||||
kind: "final",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Always update the canonical chat record for `hydrateMessages` to load
|
||||
await db.chat.update({
|
||||
where: { id: chatId },
|
||||
data: { messages: uiMessages },
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
Both writes see `responseMessage.id` as the same value — they're checkpoints of the same logical message. Grouping by `messageId` + ordering by `createdAt` gives you the progression.
|
||||
|
||||
## Multi-pause turns
|
||||
|
||||
A single logical turn can pause more than once — the LLM asks question A, gets the answer, thinks, then asks question B before finishing. Each pause fires its own `onTurnComplete` with `finishReason === "tool-calls"`; only the last firing has `finishReason === "stop"`. The checkpoint pattern above handles this naturally — each pause adds a new checkpoint sharing the same `responseMessage.id`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Don't set an `execute` function on the HITL tool.** If it has one, `streamText` will call it immediately instead of handing control back.
|
||||
- **The frontend must use `sendAutomaticallyWhen`.** Without it, the user has to press Enter after answering — `addToolOutput` updates local state but doesn't fire a new turn by itself.
|
||||
- **Don't mutate `responseMessage` in `onTurnComplete`.** It's the captured snapshot. To add custom parts, use `chat.response.append()` in `onBeforeTurnComplete` (while the stream is open).
|
||||
- **Stop handling.** If the user stops the run while a pause is active (`chat.stop()` on the transport), `onTurnComplete` fires with `stopped: true` and `finishReason` reflecting the last successful step. Treat stopped paused turns the same as stopped normal turns.
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
title: "Large payloads in chat.agent"
|
||||
sidebarTitle: "Large payloads"
|
||||
description: "Why a single chunk on the chat stream is capped at ~1 MiB, what error you'll see, and how to work around it with ID references."
|
||||
---
|
||||
|
||||
The realtime stream that backs `chat.agent` enforces a **per-record cap of ~1 MiB** (`1048576` bytes minus a small envelope reserve). Anything written through the chat output — auto-piped LLM chunks, `chat.response.write`, custom `writer.write` parts — counts as one record per chunk and is rejected if it crosses the cap.
|
||||
|
||||
This is a platform-level limit and cannot be raised per project or per stream.
|
||||
|
||||
## What you'll see
|
||||
|
||||
When a chunk crosses the cap, the run fails with a typed [`ChatChunkTooLargeError`](/ai-chat/error-handling):
|
||||
|
||||
```
|
||||
ChatChunkTooLargeError: chat.agent chunk of type "tool-output-available" is 2000126 bytes,
|
||||
over the realtime stream's per-record cap of 1047552 bytes. For oversized payloads
|
||||
(e.g. large tool outputs), write the value to your own store and emit only an id/url
|
||||
through the chat stream — see https://trigger.dev/docs/ai-chat/patterns/large-payloads.
|
||||
```
|
||||
|
||||
The error includes:
|
||||
|
||||
- `chunkType` — discriminant on the chunk that failed (e.g. `tool-output-available`, `data-handover`, `text-delta`).
|
||||
- `chunkSize` — UTF-8 byte count of the JSON-serialized record.
|
||||
- `maxSize` — the effective cap.
|
||||
|
||||
You can catch and re-throw / log it explicitly:
|
||||
|
||||
```ts
|
||||
import { ChatChunkTooLargeError, isChatChunkTooLargeError } from "@trigger.dev/sdk";
|
||||
|
||||
try {
|
||||
await someWrite();
|
||||
} catch (err) {
|
||||
if (isChatChunkTooLargeError(err)) {
|
||||
logger.error("Oversized chunk", { type: err.chunkType, size: err.chunkSize });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## Most common cause: large tool outputs
|
||||
|
||||
If you return a `streamText` result from `run()`, the AI SDK auto-pipes its `UIMessageStream` into the chat output. A tool whose result object is large (a fetched HTML body, a CSV blob, an image as base64, a deep DB row dump) gets emitted as one `tool-output-available` chunk — and that's the chunk that overruns.
|
||||
|
||||
**Diagnose first**: log tool sizes during development.
|
||||
|
||||
```ts
|
||||
const fetchPage = tool({
|
||||
inputSchema: z.object({ url: z.string().url() }),
|
||||
execute: async ({ url }) => {
|
||||
const html = await (await fetch(url)).text();
|
||||
if (html.length > 500_000) {
|
||||
logger.warn("Large tool output", { tool: "fetchPage", bytes: html.length });
|
||||
}
|
||||
return { html };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
If the size is unbounded by input, fix the tool — not the stream.
|
||||
|
||||
## ID-reference pattern
|
||||
|
||||
Store the large value in your own database (or object store) and emit only an identifier through the chat stream. The frontend fetches the full payload separately on demand.
|
||||
|
||||
This keeps the chat stream small, predictable, and resumable, and lets you reuse the value across turns or sessions without re-streaming it.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```ts task.ts
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
const fetchPage = tool({
|
||||
description: "Fetch a URL and store the HTML for later inspection.",
|
||||
inputSchema: z.object({ url: z.string().url() }),
|
||||
execute: async ({ url }) => {
|
||||
const html = await (await fetch(url)).text();
|
||||
const docId = await db.documents.create({
|
||||
data: { url, html, byteSize: html.length },
|
||||
});
|
||||
|
||||
// Tool result is small — just an id and metadata.
|
||||
// The model and the UI both work with this lightweight handle.
|
||||
return {
|
||||
docId,
|
||||
url,
|
||||
byteSize: html.length,
|
||||
preview: html.slice(0, 500),
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts api/document/[id]/route.ts
|
||||
// Frontend fetches the full document on demand.
|
||||
import { auth, currentUser } from "@/lib/auth";
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: { id: string } }) {
|
||||
const user = await currentUser();
|
||||
const doc = await db.documents.findUniqueOrThrow({
|
||||
where: { id: params.id, userId: user.id },
|
||||
});
|
||||
return new Response(doc.html, { headers: { "content-type": "text/html" } });
|
||||
}
|
||||
```
|
||||
|
||||
```tsx component.tsx
|
||||
function ToolResultCard({ part }: { part: ToolUIPart<"fetchPage"> }) {
|
||||
const { docId, url, byteSize, preview } = part.output;
|
||||
return (
|
||||
<div>
|
||||
<p>{url} — {(byteSize / 1024).toFixed(0)} KB</p>
|
||||
<pre>{preview}…</pre>
|
||||
<a href={`/api/document/${docId}`}>Open full HTML</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
The same pattern works for `chat.response.write` — push the heavy value to your DB, then emit a small data part with the id:
|
||||
|
||||
```ts
|
||||
const id = await db.attachments.create({ data: { content: hugeReport } });
|
||||
chat.response.write({ type: "data-report", data: { id, summary: shortSummary } });
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Persist the large value **before** you emit the id chunk. If the chunk reaches the UI before the row is written, the frontend gets a 404 on the follow-up fetch.
|
||||
</Tip>
|
||||
|
||||
## Transient UI parts
|
||||
|
||||
For progress indicators or status data that should stream to the UI but not persist into the response message, use `chat.response.write` with `transient: true`. The chunk still travels on the chat stream (so the 1 MiB per-record cap still applies), but it never lands in `responseMessage` or `uiMessages`:
|
||||
|
||||
```ts
|
||||
chat.response.write({
|
||||
type: "data-progress",
|
||||
data: { percent: 50 },
|
||||
transient: true,
|
||||
});
|
||||
```
|
||||
|
||||
For genuinely high-volume diagnostic data (per-token traces, large debug dumps), don't try to ship it through the realtime stream at all. Log to your own store (DB, object storage, OTel logger) and surface it through a separate UI route that isn't tied to the chat session.
|
||||
|
||||
## What does **not** trigger the cap
|
||||
|
||||
These calls don't go through the realtime stream and have no per-record cap:
|
||||
|
||||
- [`chat.history.set` / `slice` / `replace` / `remove`](/ai-chat/backend#chat-history) — locals-only mutations on the in-memory message list.
|
||||
- [`chat.inject`](/ai-chat/background-injection#chat-inject) — appends to the run's pending message queue, not the stream.
|
||||
- [`chat.defer`](/ai-chat/background-injection#chat-defer-standalone) — promise registry; awaited at turn boundaries, never serialized to the stream.
|
||||
|
||||
The control markers `chat.agent` emits internally (`trigger:turn-complete`, `trigger:upgrade-required`) are tiny by construction.
|
||||
|
||||
## See also
|
||||
|
||||
- [Error handling](/ai-chat/error-handling) — how `ChatChunkTooLargeError` flows through the layers.
|
||||
- [Database persistence](/ai-chat/patterns/database-persistence) — your own store as the durable backing for ID references.
|
||||
- [Client protocol](/ai-chat/client-protocol) — chunk shapes that travel on the chat stream.
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
title: "OOM resilience"
|
||||
sidebarTitle: "OOM resilience"
|
||||
description: "Recover from out-of-memory errors mid-turn by automatically retrying the failed turn on a larger machine — without losing the in-flight user message or re-processing completed turns."
|
||||
---
|
||||
|
||||
When a `chat.agent` turn runs out of memory, the worker process dies and everything in it is gone: the in-flight LLM call, the accumulator, any tool execution mid-flight. By default, Trigger.dev surfaces the OOM as a run failure.
|
||||
|
||||
Setting `oomMachine` opts the agent into automatic recovery: the failed turn re-runs on a larger machine, picks up the user message that triggered the OOM (without re-processing earlier completed turns), and produces a normal response.
|
||||
|
||||
## Setup
|
||||
|
||||
```ts
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
machine: "small-1x", // default machine
|
||||
oomMachine: "medium-2x", // fallback on OOM
|
||||
run: async ({ messages, signal }) =>
|
||||
streamText({ model, messages, abortSignal: signal }),
|
||||
});
|
||||
```
|
||||
|
||||
That's the entire opt-in. With `oomMachine` set, the agent gets:
|
||||
|
||||
- **`retry.maxAttempts: 2`** internally — one retry for OOM only; non-OOM errors don't retry.
|
||||
- **`retry.outOfMemory.machine: oomMachine`** — the fresh attempt boots on the larger machine.
|
||||
- **`session.in` cursor recovery** — the new attempt skips records belonging to turns that already completed on the prior attempt and only re-runs the OOM'd turn.
|
||||
|
||||
`chat.agent` does not expose generic `retry` options. OOM recovery is the only retry path because retrying an LLM-driven loop on non-OOM errors tends to be expensive and side-effecting. Drop down to a [raw `task()` with chat primitives](/ai-chat/custom-agents) if you need richer retry semantics.
|
||||
|
||||
## How recovery works
|
||||
|
||||
The recovery doesn't need any customer-side persistence to avoid duplicate processing. It uses two pieces of durable state Trigger already maintains for every chat:
|
||||
|
||||
- **`session.out`** — the durable response stream. Every successful turn writes a `trigger:turn-complete` chunk here.
|
||||
- **`session.in`** — the durable input stream. Every user message after the first turn lands here as a record with a server-assigned timestamp.
|
||||
|
||||
On retry boot, the SDK:
|
||||
|
||||
1. Scans `session.out` for the latest `trigger:turn-complete` chunk and reads its timestamp. Call this `T_last_complete`.
|
||||
2. Sets a per-stream filter on `session.in` so any record with `timestamp <= T_last_complete` is dropped before it reaches the turn loop.
|
||||
3. Begins normal processing. The first record that passes the filter is the message that triggered the OOM (or any newer message that arrived during the retry window).
|
||||
|
||||
Result: turns 1..N-1 are not re-processed, turn N runs on the larger machine, and the conversation continues.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Run as chat.agent run
|
||||
participant SessionIn as session.in
|
||||
participant SessionOut as session.out
|
||||
|
||||
User->>SessionIn: u2 (turn 2)
|
||||
Run->>SessionIn: read u2
|
||||
Run->>SessionOut: turn-complete (T1)
|
||||
User->>SessionIn: u3 (turn 3)
|
||||
Run->>SessionIn: read u3
|
||||
Run->>SessionOut: turn-complete (T2)
|
||||
User->>SessionIn: u4 (turn 4)
|
||||
Run->>SessionIn: read u4
|
||||
Note over Run: OOM mid-turn
|
||||
Run->>Run: ⚠️ killed
|
||||
Note over Run: Attempt 2 boots on oomMachine
|
||||
Run->>SessionOut: scan → T_last_complete = T2
|
||||
Run->>SessionIn: read with filter (ts > T2)
|
||||
SessionIn-->>Run: u2 (filtered, ts < T2)
|
||||
SessionIn-->>Run: u3 (filtered, ts < T2)
|
||||
SessionIn-->>Run: u4 (passes — the OOM'd turn)
|
||||
Run->>SessionOut: turn 4 complete
|
||||
```
|
||||
|
||||
The scan on `session.out` is streaming and bounded in memory: each chunk is inspected and discarded one at a time, so a long-running chat doesn't bloat the retry-boot worker. Bandwidth scales linearly with `session.out` size, but only on the OOM-retry path — a rare event.
|
||||
|
||||
## With `hydrateMessages`
|
||||
|
||||
If your agent uses [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) to load the durable conversation history per turn, the OOM'd turn re-runs against the full prior accumulator: the model sees `[u1, a1, u2, a2, ..., u_N]` and responds in context. This is the recommended pattern for production chats.
|
||||
|
||||
## Without `hydrateMessages`
|
||||
|
||||
Recovery boot reconstructs context automatically. The boot reads both the durable `session.out` snapshot (settled turns) and the `session.out` tail past the snapshot cursor (the partial assistant chunks the OOM'd turn streamed before dying). When the new attempt processes the OOM'd user message, the model sees the full prior conversation **plus** the partial assistant that was cut off — so a "keep going" follow-up continues naturally, and any other follow-up has the same context the original turn had.
|
||||
|
||||
`hydrateMessages` is still the right choice if you want a single source of truth in your own database (branching conversations, message-level access control, etc.). It's no longer required for OOM continuity.
|
||||
|
||||
For full control over recovery — drop the partial, synthesize tool results for an interrupted tool call, emit a recovery banner to the UI — register [`onRecoveryBoot`](/ai-chat/patterns/recovery-boot).
|
||||
|
||||
## Tool execute idempotency
|
||||
|
||||
If an OOM hits mid-tool-execution, the new attempt re-runs the entire turn — including the tool call. Make tool `execute` functions idempotent or checkpoint their progress externally. Trigger doesn't roll back side effects automatically.
|
||||
|
||||
```ts
|
||||
import { tool } from "ai";
|
||||
|
||||
export const sendEmail = tool({
|
||||
description: "Send an email",
|
||||
inputSchema: z.object({ to: z.string(), idempotencyKey: z.string() }),
|
||||
execute: async ({ to, idempotencyKey }) => {
|
||||
// Stripe-style: dedupe at the side-effect layer with a customer-supplied key.
|
||||
return await mailer.send({ to, idempotencyKey });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- **One OOM retry per run.** `chat.agent` sets `maxAttempts: 2`. If attempt 2 also OOMs, the run fails. Use a sufficiently large `oomMachine` to avoid this.
|
||||
- **Single fallback tier.** Only one `oomMachine`. There's no "tiered retry" (small → medium → large). If you need that, drop down to a [raw `task()` with chat primitives](/ai-chat/custom-agents) and configure `retry` directly.
|
||||
- **Non-OOM errors don't retry.** Schema errors, model-call rejections, tool throws, etc. fail the run as before. Out-of-memory is the only retry trigger.
|
||||
- **Tools mid-execution are not checkpointed.** A partially-run tool re-runs from scratch on the new attempt. Make them idempotent.
|
||||
|
||||
## See also
|
||||
|
||||
- [Recovery boot](/ai-chat/patterns/recovery-boot) — the underlying hook + smart default that gives OOM recovery its full-context behavior
|
||||
- [Lifecycle hooks](/ai-chat/lifecycle-hooks) — `onChatResume` fires on every retry attempt with `phase: "preload"` or `"turn"`
|
||||
- [Database persistence](/ai-chat/patterns/database-persistence) — the `hydrateMessages` pattern for branching, ACL, and DB-as-source-of-truth scenarios
|
||||
@@ -0,0 +1,207 @@
|
||||
---
|
||||
title: "Persistence and replay"
|
||||
sidebarTitle: "Persistence and replay"
|
||||
description: "How chat.agent rebuilds conversation history at run boot — durable JSON snapshot in object storage plus session.out replay, with a hydrateMessages short-circuit for backend-owned history."
|
||||
---
|
||||
|
||||
`chat.agent` runs are processes — they boot, stream a turn, and either suspend (waiting for the next message) or exit. When the next message arrives at a session whose previous run already exited, a **fresh** run boots with no in-memory state. Something has to rebuild the conversation history before that turn can produce a coherent response.
|
||||
|
||||
This page walks through the **snapshot + replay** model the runtime uses by default, and the [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) short-circuit that turns the whole thing off when the customer owns history.
|
||||
|
||||
## Why a snapshot at all
|
||||
|
||||
The wire is delta-only: each `.in/append` carries at most one new `UIMessage` (see [Client Protocol](/ai-chat/client-protocol#chattaskwirepayload)). A long conversation might be 50 turns deep with megabytes of tool results — the wire never carries that. So when run #2 boots to handle turn 51, the wire alone tells it almost nothing about turns 1–50.
|
||||
|
||||
Two existing pieces of durable state already capture everything that happened:
|
||||
|
||||
- **`session.in`** — every user message and tool-approval response ever sent.
|
||||
- **`session.out`** — every assistant token, tool call, and tool result the agent emitted, ordered.
|
||||
|
||||
Replaying `session.out` from the beginning is correct but expensive — bandwidth scales with chat length, and parsing N megabytes of streamed chunks at every boot adds latency. So the runtime writes a **snapshot** after every turn and reads it on the next boot. Replay only covers the gap between the snapshot's cursor and now.
|
||||
|
||||
## The model end-to-end
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Run1 as Run 1 (turn 1)
|
||||
participant Snapshot as Object storage
|
||||
participant SessionOut as session.out
|
||||
participant Run2 as Run 2 (turn 2+)
|
||||
|
||||
User->>Run1: u1
|
||||
Run1->>SessionOut: assistant chunks for a1
|
||||
Run1->>Run1: onTurnComplete
|
||||
Run1->>Snapshot: write { messages: [u1, a1], lastOutEventId, lastOutTimestamp }
|
||||
Note over Run1: idle suspend (or exit)
|
||||
|
||||
User->>Run2: u2 (delta only)
|
||||
Run2->>Snapshot: read snapshot
|
||||
Run2->>SessionOut: subscribe(lastEventId, wait=0)
|
||||
SessionOut-->>Run2: (empty — nothing since snapshot)
|
||||
Note over Run2: accumulator = [u1, a1]
|
||||
Run2->>Run2: append u2 from wire
|
||||
Run2->>SessionOut: assistant chunks for a2
|
||||
Run2->>Run2: onTurnComplete
|
||||
Run2->>Snapshot: write { messages: [u1, a1, u2, a2], ... }
|
||||
```
|
||||
|
||||
### Run 1 — first turn
|
||||
|
||||
The accumulator starts empty. The wire delivers `u1`. After the model finishes, `onTurnComplete` fires, then the runtime serializes the full accumulator and writes:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"savedAt": 1715180400000,
|
||||
"messages": [u1, a1],
|
||||
"lastOutEventId": "42",
|
||||
"lastOutTimestamp": 1715180399000
|
||||
}
|
||||
```
|
||||
|
||||
The key is `packets/{projectRef}/{envSlug}/sessions/{sessionId}/snapshot.json` — overwritten every turn, never appended. The write is **awaited**, not fire-and-forget — if the run idle-suspends immediately after, in-flight promises don't reliably complete and the snapshot would be lost.
|
||||
|
||||
### Run 2 — boot
|
||||
|
||||
A new run boots when the user sends `u2`. Run 1 has long since exited. Run 2 has no in-memory state. The boot sequence:
|
||||
|
||||
<Steps>
|
||||
<Step title="Read the snapshot">
|
||||
GET the JSON blob. On 404 (no snapshot yet — first-ever turn) or read error or version mismatch, treat as empty and continue. Snapshot misses are non-fatal — replay alone may still be sufficient.
|
||||
</Step>
|
||||
<Step title="Replay session.out tail">
|
||||
Subscribe to `session.out` with `wait=0` starting from `snapshot.lastOutEventId`. Drain whatever's there and close. Returns:
|
||||
- **Settled messages** — closed assistant turns past the snapshot cursor (the chunks of a turn that completed after the snapshot was written but before the run exited cleanly).
|
||||
- **A partial assistant** — the trailing message if its stream never received a `finish` chunk. The dead run was mid-response when it died. `cleanupAbortedParts` has already stripped streaming-in-progress fragments.
|
||||
|
||||
In the steady state this returns empty. In recovery, it returns whatever the dead run was in the middle of.
|
||||
</Step>
|
||||
<Step title="Replay session.in tail">
|
||||
GET `session.in` records past the last `turn-complete`'s `session-in-event-id` cursor. Returns the user messages the dead run hadn't acknowledged — typically the message that triggered the cancelled / crashed turn, plus anything the customer typed after.
|
||||
</Step>
|
||||
<Step title="Reconstruct the chain (smart default)">
|
||||
Snapshot messages merge with the settled replay (replay wins on `id` collision). Then:
|
||||
|
||||
- If there's a partial assistant **and** at least one in-flight user message, splice `[firstInFlightUser, partialAssistant]` onto the end of the chain. The model sees the prior turn's incomplete attempt and can continue, abandon, or pivot based on the next user message.
|
||||
- Remaining in-flight users dispatch as fresh turns after the recovered first one.
|
||||
- If there's no partial OR no in-flight users, the chain is just the settled chain and any in-flight users dispatch normally.
|
||||
|
||||
Customers can override this entirely via [`onRecoveryBoot`](/ai-chat/patterns/recovery-boot).
|
||||
</Step>
|
||||
<Step title="Append the new wire message">
|
||||
Append `u2` from the wire payload, exactly as on turn 1.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
The model now sees `[u1, a1, u2]` and produces `a2`. After `onTurnComplete`, the runtime overwrites the snapshot with `[u1, a1, u2, a2]` and the cycle repeats.
|
||||
|
||||
### Crash mid-turn — replay carries the load
|
||||
|
||||
Suppose Run 1's turn 1 streams partial assistant chunks to `session.out` and then crashes (OOM, exception, server-side cancel) before `onTurnComplete` fires. No snapshot was written. The next run boots and:
|
||||
|
||||
1. Snapshot read returns 404 → empty.
|
||||
2. `session.out` tail replay picks up the partial assistant chunks emitted before the crash. `cleanupAbortedParts` strips streaming-in-progress fragments but keeps the cleaned trailing message as the `partialAssistant`.
|
||||
3. `session.in` tail replay finds the user message the dead run was answering (no `turn-complete` was written, so the cursor never advanced past it).
|
||||
4. Smart default splices `[firstInFlightUser, partialAssistant]` onto the chain. Any later user messages (including the customer's follow-up) dispatch as fresh turns.
|
||||
5. The model sees full prior context and responds in kind — continuing a cut-off essay on "keep going", answering a fresh question on "actually, what's 7+8?", abandoning the prior work on "scrap that, do X instead".
|
||||
|
||||
Replay carries the conversation across the crash boundary with zero customer code. For policies different from "preserve context" — drop the partial entirely, synthesize tool results for an interrupted tool call, write a recovery banner to the UI — register [`onRecoveryBoot`](/ai-chat/patterns/recovery-boot).
|
||||
|
||||
## OOM-retry interaction
|
||||
|
||||
The runtime already had an OOM-retry path that scans `session.out` for the latest `trigger:turn-complete` timestamp to use as a cutoff for `session.in` (so the retry doesn't re-process completed turns — see [OOM resilience](/ai-chat/patterns/oom-resilience)). The snapshot includes a `lastOutTimestamp` field that is exactly that high-water mark.
|
||||
|
||||
When a snapshot exists, the OOM-retry path reads `lastOutTimestamp` directly instead of scanning `session.out`. One fewer stream subscription per retry. Free win.
|
||||
|
||||
If no snapshot exists (first turn, or `hydrateMessages` registered), the path falls back to the scan.
|
||||
|
||||
## Action turns — no snapshot write
|
||||
|
||||
[Action turns](/ai-chat/actions) (`trigger: "action"`) don't fire `onTurnComplete` — they fire `onAction` only. The snapshot write site is gated on `onTurnComplete`, so action turns don't snapshot.
|
||||
|
||||
If `onAction` mutates `chat.history.*` and then the run crashes before the next regular turn, the mutation is lost. The user re-fires the action. This matches `chat.history` semantics in general — mutations are persisted at turn boundaries, not action boundaries.
|
||||
|
||||
## The `hydrateMessages` short-circuit
|
||||
|
||||
When the customer registers a [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) hook, the runtime trusts the hook to be the source of truth for history. Snapshot read and replay are **skipped entirely** at boot. The hook fires per turn, returns the canonical chain from the customer's database, and the accumulator is set to whatever the hook returned.
|
||||
|
||||
```ts
|
||||
import { chat, upsertIncomingMessage } from "@trigger.dev/sdk/ai";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
|
||||
const stored = (await db.chat.findUnique({ where: { id: chatId } }))?.messages ?? [];
|
||||
|
||||
// See lifecycle-hooks for the full upsert pattern + rationale:
|
||||
// /ai-chat/lifecycle-hooks#hydratemessages
|
||||
if (upsertIncomingMessage(stored, { trigger, incomingMessages })) {
|
||||
// Upsert, not update: head-start first turns run without a preload
|
||||
// to create the row.
|
||||
await db.chat.upsert({
|
||||
where: { id: chatId },
|
||||
create: { id: chatId, messages: stored },
|
||||
update: { messages: stored },
|
||||
});
|
||||
}
|
||||
|
||||
return stored;
|
||||
},
|
||||
onTurnComplete: async ({ chatId, uiMessages }) => {
|
||||
await db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } });
|
||||
},
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
What you gain:
|
||||
|
||||
- **Zero object-store traffic per turn.** No snapshot read, no snapshot write, no replay subscription. `OBJECT_STORE_*` env vars don't have to be set.
|
||||
- **Branching, undo, edit, abuse prevention** — patterns that need a backend-side single source of truth work naturally because the customer mediates every read.
|
||||
|
||||
What you give up:
|
||||
|
||||
- **You own persistence end-to-end.** A bug in `hydrateMessages` that returns the wrong chain corrupts the conversation visible to the model.
|
||||
- **OOM-retry needs a `session.out` scan again** because there's no snapshot to short-circuit it. (Same as the pre-snapshot baseline — not a regression, just a missed optimization.)
|
||||
|
||||
The runtime's snapshot+replay is the safer default. `hydrateMessages` is the right choice when you already have authoritative storage for messages and want one consistent persistence path.
|
||||
|
||||
## When neither is configured
|
||||
|
||||
If `hydrateMessages` is not registered **and** no object store is configured, conversations don't survive run boundaries. A continuation boots empty. The runtime logs a warning at agent registration time so you see this at deploy time, not at user-traffic time.
|
||||
|
||||
For local development this is sometimes fine — you're not testing continuations. For production it isn't. Configure one of:
|
||||
|
||||
- **Object store** (`OBJECT_STORE_*` env vars on your webapp) — easiest, default behavior.
|
||||
- **`hydrateMessages` + your own database** — stronger control, suits multi-tenant apps with audit needs.
|
||||
|
||||
## Snapshot key & lifecycle
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Bucket | Whatever `OBJECT_STORE_BASE_URL` points to |
|
||||
| Key prefix | `packets/{projectRef}/{envSlug}/` (server-prefixed) |
|
||||
| Key suffix | `sessions/{sessionId}/snapshot.json` |
|
||||
| Final key | `packets/{projectRef}/{envSlug}/sessions/{sessionId}/snapshot.json` |
|
||||
| Size | Tens of KB typical, capped only by object-store limits |
|
||||
| Cadence | Overwritten after every successful `onTurnComplete` |
|
||||
|
||||
Snapshots accumulate per-session forever unless you set a lifecycle policy on the bucket. A 90-day expiry on `packets/*/sessions/*/snapshot.json` is a reasonable default if your chats don't typically resume after that window. Closed sessions are not auto-cleaned today.
|
||||
|
||||
### MinIO and S3-compatible stores
|
||||
|
||||
Snapshot read/write reuses the same object-store layer as Trigger.dev's existing large-payload routes. Anything that already works for large payloads — AWS S3, MinIO (self-host or local development), Cloudflare R2, Tigris, Backblaze B2 — works for snapshots too. `OBJECT_STORE_DEFAULT_PROTOCOL` controls the routing (`s3`, `minio`, etc.) and the SDK picks the right driver automatically. No snapshot-specific config.
|
||||
|
||||
For local development against `pnpm run docker`, the bundled MinIO container is enough — set `OBJECT_STORE_DEFAULT_PROTOCOL=minio` and the standard MinIO env vars on the webapp, and continuations work end-to-end against a local stack.
|
||||
|
||||
## See also
|
||||
|
||||
- [Client Protocol](/ai-chat/client-protocol#how-history-is-rebuilt) — the wire-level view of the same model
|
||||
- [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) — the short-circuit hook
|
||||
- [OOM resilience](/ai-chat/patterns/oom-resilience) — how `session.in` cutoffs interact with snapshots
|
||||
- [Database persistence](/ai-chat/patterns/database-persistence) — the canonical persistence pattern using `onTurnComplete`
|
||||
- [v4.5 upgrade guide](/ai-chat/upgrade-guide#v45-wire-format-change) — when this model landed and what changed
|
||||
@@ -0,0 +1,226 @@
|
||||
---
|
||||
title: "Recovery boot"
|
||||
sidebarTitle: "Recovery boot"
|
||||
description: "Recover from cancel-mid-stream, crashes, and OOM kills with full conversational context. The smart default Just Works; the onRecoveryBoot hook is the override path for advanced policies."
|
||||
---
|
||||
|
||||
When a `chat.agent` run dies in the middle of streaming a response — the user cancels, the worker OOMs, or an unhandled exception kills the process — the durable streams hold what was in flight. The next run boots as a continuation, reads both stream tails, and reconstructs a chain that preserves the partial response so any follow-up (`keep going`, `actually do X instead`, a new question) has full context.
|
||||
|
||||
The behavior is automatic. The `onRecoveryBoot` hook is opt-in for policies that need something different.
|
||||
|
||||
## The scenario
|
||||
|
||||
```ts
|
||||
// Turn 1 is mid-essay when the user clicks Cancel.
|
||||
window.__chat.send("Write me a long essay about espresso");
|
||||
// ... assistant has written 3000 characters ...
|
||||
window.__chat.stop(); // OR: server-side cancel_run
|
||||
|
||||
// User decides what they want next.
|
||||
window.__chat.send("keep going"); // OR: "what's 7+8?", or anything
|
||||
```
|
||||
|
||||
The cancelled run never wrote `onTurnComplete`. The snapshot is stale or absent. `session.out` has a half-written assistant message. `session.in` has the original user message (the run consumed it but never marked the turn complete) plus the new follow-up.
|
||||
|
||||
A naive continuation would either re-run the cancelled essay (the user already chose to stop) or drop everything (no context for the follow-up). Recovery boot handles this without either failure mode.
|
||||
|
||||
## The smart default
|
||||
|
||||
On a continuation boot, the runtime reads:
|
||||
|
||||
- **Snapshot** — settled turns persisted by the last successful `onTurnComplete`.
|
||||
- **`session.out` tail past the snapshot cursor** — closed assistant turns plus, optionally, a `partialAssistant` (the trailing message whose stream never received a `finish` chunk). `cleanupAbortedParts` has already stripped streaming-in-progress fragments.
|
||||
- **`session.in` tail past the last `turn-complete` cursor** — user messages the dead run hadn't acknowledged.
|
||||
|
||||
If both `partialAssistant` and `inFlightUsers` are non-empty, the runtime splices `[firstInFlightUser, partialAssistant]` onto the chain. The remaining in-flight users dispatch as fresh turns. The model sees:
|
||||
|
||||
```
|
||||
[ ...settledMessages, // chain through the last completed turn
|
||||
firstInFlightUser, // the question the dead run was answering
|
||||
partialAssistant, // the dead run's incomplete response
|
||||
followUpUser ] // the new turn the customer just sent
|
||||
```
|
||||
|
||||
Modern instruction-following models prioritize the latest user message. The follow-up determines the response:
|
||||
|
||||
| Follow-up | Model behavior |
|
||||
|---|---|
|
||||
| "keep going" / "continue" / "more" | Continues the partial essay from where it stopped. |
|
||||
| "actually, what's 7+8?" | Answers the new question. Prior context doesn't derail it. |
|
||||
| "scrap that, do something else" | Abandons the partial work and follows the new direction. |
|
||||
|
||||
No customer code needed for any of these.
|
||||
|
||||
## When to register `onRecoveryBoot`
|
||||
|
||||
The hook fires when recovery state is non-empty (either `partialAssistant` is defined or there's at least one in-flight user). Register it when you need a policy different from "preserve context":
|
||||
|
||||
- **Drop the partial entirely.** Your UX means "cancel discards the work — start fresh from the follow-up."
|
||||
- **Synthesize tool results.** The partial has tool calls in `input-available` state (HITL was mid-call when the run died). Return a chain that has fabricated `output-available` results so the model can continue.
|
||||
- **Emit a recovery banner.** Write a `data-chat-recovery` UIMessage chunk via `ctx.writer` so the frontend can render "Recovering interrupted response..." before the model speaks.
|
||||
- **Persist recovered state.** Use `beforeBoot` to flush the partial to your own database before the next turn starts.
|
||||
|
||||
```ts
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
onRecoveryBoot: async ({ partialAssistant, inFlightUsers, writer, cause, previousRunId }) => {
|
||||
writer.write({
|
||||
type: "data-chat-recovery",
|
||||
data: { cause, previousRunId, partialPresent: partialAssistant !== undefined },
|
||||
transient: true,
|
||||
});
|
||||
// Return nothing → fall through to smart default.
|
||||
},
|
||||
run: async ({ messages, signal }) =>
|
||||
streamText({ model, messages, abortSignal: signal }),
|
||||
});
|
||||
```
|
||||
|
||||
## Hook reference
|
||||
|
||||
### Fires when
|
||||
|
||||
The hook fires once on a continuation boot, AFTER both stream tails have been read, AND only when there's a partial assistant — the mid-stream-died signal:
|
||||
|
||||
```ts
|
||||
const shouldFire = partialAssistant !== undefined;
|
||||
```
|
||||
|
||||
In-flight users alone don't fire the hook. Graceful exits like `chat.requestUpgrade()` and `chat.endRun()` may leave an unacknowledged user on `session.in` (the message that triggered the upgrade, the next message after endRun), but no partial — that's a normal continuation, not recovery. The next message just dispatches as turn 1 on the new run via the normal session.in pump.
|
||||
|
||||
Skipped scenarios (where the hook does NOT fire):
|
||||
|
||||
- A clean continuation after `chat.endRun()` with no buffered follow-up.
|
||||
- A fresh chat (no continuation, attempt 1).
|
||||
- An OOM retry that booted onto a complete snapshot (no partial on the tail).
|
||||
- `chat.requestUpgrade()` graceful exit — predecessor ended cleanly before processing, no partial.
|
||||
- An agent with [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) registered. Customers using `hydrateMessages` own persistence — recovery decisions live in their own DB query.
|
||||
|
||||
### Event shape
|
||||
|
||||
```ts
|
||||
type RecoveryBootEvent<TUIM extends UIMessage = UIMessage> = {
|
||||
ctx: TaskRunContext;
|
||||
chatId: string;
|
||||
runId: string;
|
||||
previousRunId: string;
|
||||
cause: "cancelled" | "crashed" | "unknown";
|
||||
settledMessages: TUIM[];
|
||||
inFlightUsers: TUIM[];
|
||||
partialAssistant: TUIM | undefined;
|
||||
pendingToolCalls: Array<{
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
input: unknown;
|
||||
partIndex: number;
|
||||
}>;
|
||||
writer: ChatWriter;
|
||||
};
|
||||
```
|
||||
|
||||
<Note>
|
||||
`cause` is currently always `"unknown"` — the run engine doesn't yet plumb the
|
||||
real reason into the continuation payload. The enum is forward-looking; don't
|
||||
branch behavior on it for now.
|
||||
</Note>
|
||||
|
||||
### Return shape
|
||||
|
||||
Every field is optional. Returning `undefined` (or nothing) accepts the smart default for every field.
|
||||
|
||||
```ts
|
||||
type RecoveryBootResult<TUIM extends UIMessage = UIMessage> = {
|
||||
chain?: TUIM[];
|
||||
recoveredTurns?: TUIM[];
|
||||
beforeBoot?: () => Promise<void>;
|
||||
};
|
||||
```
|
||||
|
||||
- **`chain`** — replaces the seed chain. Defaults to `[...settledMessages, firstInFlightUser, partialAssistant]` when both partial and in-flight users exist, otherwise `settledMessages` alone.
|
||||
- **`recoveredTurns`** — user messages to dispatch as fresh turns after the chain is restored. Defaults to `inFlightUsers.slice(1)` when the smart default consumed the first user, otherwise `inFlightUsers`.
|
||||
- **`beforeBoot`** — runs after the writer flushes and before the first recovered turn fires. Use for blocking persistence (write the partial to your DB so a later turn can reference it). Errors bubble — wrap your own try/catch if you want to soft-fail.
|
||||
|
||||
## Examples
|
||||
|
||||
### Drop the partial — strict "cancel means discard"
|
||||
|
||||
The customer's UX treats cancel as "throw the work away":
|
||||
|
||||
```ts
|
||||
onRecoveryBoot: async ({ inFlightUsers, partialAssistant }) => {
|
||||
if (!partialAssistant) return; // No partial → nothing to drop
|
||||
return {
|
||||
chain: undefined, // Use settledMessages, don't splice partial
|
||||
recoveredTurns: inFlightUsers.slice(1) // Still skip the first user (the dead run was answering it)
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Synthesize tool results for a mid-call interruption
|
||||
|
||||
The dead run was processing a tool call when it died. The partial has tool parts in `input-available` state with no `output-available`. Synthesize a result so the model can keep going:
|
||||
|
||||
```ts
|
||||
onRecoveryBoot: async ({ partialAssistant, pendingToolCalls, settledMessages, inFlightUsers }) => {
|
||||
if (pendingToolCalls.length === 0) return;
|
||||
|
||||
// Rebuild the partial with synthetic outputs for any input-available tool call.
|
||||
const repaired = {
|
||||
...partialAssistant!,
|
||||
parts: partialAssistant!.parts!.map((part, i) => {
|
||||
const pending = pendingToolCalls.find(p => p.partIndex === i);
|
||||
if (!pending) return part;
|
||||
return {
|
||||
...part,
|
||||
state: "output-available" as const,
|
||||
output: { interrupted: true, reason: "previous run was cancelled" },
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
chain: [...settledMessages, inFlightUsers[0]!, repaired],
|
||||
recoveredTurns: inFlightUsers.slice(1),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Persist the partial before the next turn fires
|
||||
|
||||
```ts
|
||||
onRecoveryBoot: async ({ chatId, partialAssistant }) => {
|
||||
return {
|
||||
beforeBoot: async () => {
|
||||
if (partialAssistant) {
|
||||
await db.partial.create({
|
||||
data: { chatId, partialJson: JSON.stringify(partialAssistant) },
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Interaction with other features
|
||||
|
||||
### `hydrateMessages`
|
||||
|
||||
If your agent registers [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages), the runtime skips snapshot read, `session.out` replay, `session.in` replay, AND `onRecoveryBoot`. Your DB is the source of truth — recovery decisions live in your own query. To detect a cancel-recovery scenario yourself, persist a `runState: "in-progress"` flag in `onTurnStart` and check for it in `hydrateMessages`.
|
||||
|
||||
### `chat.requestUpgrade()`
|
||||
|
||||
[`chat.requestUpgrade()`](/ai-chat/patterns/version-upgrades) is a graceful exit — the old run doesn't crash, it returns cleanly. The new continuation run boots with a clean `session.out` tail (`partialAssistant` is undefined) and the upgrade-trigger message on `session.in` (one in-flight user). The smart default doesn't splice (it requires both partial AND in-flight users), so the chain is just `settledMessages` and the in-flight user dispatches as a fresh turn. `onRecoveryBoot` still fires (there's an in-flight user) — use it to emit an "upgraded" signal to the UI if you want.
|
||||
|
||||
### Hooks throwing
|
||||
|
||||
If the body of `onRecoveryBoot` throws (or rejects), the runtime logs a warning and falls back to the smart default — the run does not fail. Wrap your own try/catch if you want stricter handling.
|
||||
|
||||
`beforeBoot` is the exception: it's the contract you opted into for blocking persistence, so errors thrown there **bubble** and fail the run rather than dispatch recovered turns against half-persisted state. Wrap it yourself if you want to soft-fail.
|
||||
|
||||
## See also
|
||||
|
||||
- [OOM resilience](/ai-chat/patterns/oom-resilience) — `oomMachine` opt-in for automatic memory-driven recovery; uses the same recovery boot path.
|
||||
- [Persistence and replay](/ai-chat/patterns/persistence-and-replay) — the snapshot + dual-tail replay model that recovery boot sits on top of.
|
||||
- [Lifecycle hooks](/ai-chat/lifecycle-hooks) — where `onRecoveryBoot` sits in the broader hook taxonomy.
|
||||
@@ -0,0 +1,217 @@
|
||||
---
|
||||
title: "Agent Skills"
|
||||
sidebarTitle: "Agent Skills"
|
||||
description: "Ship reusable capabilities (folders with SKILL.md + scripts) that a chat agent discovers and invokes on demand."
|
||||
---
|
||||
|
||||
Agent skills are reusable capabilities you ship as folders — a `SKILL.md` describing when and how to use them, plus optional scripts, references, and assets. The chat agent sees a short description of each skill in its system prompt, loads the full instructions on demand via a `loadSkill` tool, and invokes the bundled scripts via `bash` — all without you wiring anything up manually.
|
||||
|
||||
Built on the [AI SDK cookbook pattern](https://ai-sdk.dev/cookbook/guides/agent-skills). Works with any provider (OpenAI, Anthropic, Gemini, etc.) — not tied to Anthropic's server-side skills.
|
||||
|
||||
## Why skills?
|
||||
|
||||
Compared to regular AI SDK tools:
|
||||
|
||||
- **Tools** are typed functions you pre-declare. Great when you know up-front exactly what capability the agent needs.
|
||||
- **Skills** are folders the model discovers and reads on demand. Great when the capability is a bundle of instructions + helper scripts that would be awkward to encode as a single tool.
|
||||
|
||||
PDFs are the canonical example: you don't want to ask the LLM to parse PDF bytes inline. You want it to `bash scripts/extract.py report.pdf` using a bundled `pdfplumber` wrapper. A skill ships the script, the instructions, and any reference notes together.
|
||||
|
||||
Dashboard-editable `SKILL.md` is on the roadmap so a platform team can tighten a skill's description or "when to use" text without a redeploy. Today, skills are SDK-only — defined in your task code and shipped with each deploy.
|
||||
|
||||
## Trust model
|
||||
|
||||
Skills are **developer-authored code**, not end-user-supplied. The same developer who writes the `chat.agent()` writes the skill bundle. The trust boundary is identical to any `tool.execute` handler the developer writes — scripts run directly in the Trigger.dev worker container, no sandboxing required.
|
||||
|
||||
This makes skills different from the Claude Code / end-user model where arbitrary user-provided skills need isolation. Don't accept skill paths from untrusted input.
|
||||
|
||||
## Skill folder layout
|
||||
|
||||
A skill is a directory under your project (conventionally `trigger/skills/{id}/`):
|
||||
|
||||
```
|
||||
trigger/skills/time-utils/
|
||||
├── SKILL.md # Required — frontmatter + instructions
|
||||
├── scripts/
|
||||
│ ├── now.sh
|
||||
│ └── add.sh
|
||||
├── references/
|
||||
│ └── timezones.txt
|
||||
└── assets/ # Optional — templates, data files, etc.
|
||||
```
|
||||
|
||||
### SKILL.md
|
||||
|
||||
Frontmatter is YAML-subset — only `name` and `description` are required:
|
||||
|
||||
```md
|
||||
---
|
||||
name: time-utils
|
||||
description: Compute and format dates/times in arbitrary timezones. Use when the user asks "what time is it", timezone conversions, or date math.
|
||||
---
|
||||
|
||||
# Time utilities
|
||||
|
||||
## When to use
|
||||
|
||||
- The user asks for the current time in a timezone
|
||||
- The user wants date math ("3 days from now")
|
||||
|
||||
## Scripts
|
||||
|
||||
### `scripts/now.sh [TZ]`
|
||||
Prints the current time in the given IANA timezone (default `UTC`).
|
||||
|
||||
### `scripts/add.sh DAYS [TZ]`
|
||||
Prints a date `DAYS` days from now.
|
||||
|
||||
## Tips
|
||||
- IANA timezone names only (`America/New_York`, not `EST`).
|
||||
- See `references/timezones.txt` for a cheat-sheet.
|
||||
```
|
||||
|
||||
The **description** is what the model sees in its system prompt — write it like you're explaining to the agent when to reach for the skill.
|
||||
|
||||
The **body** is loaded on demand via the `loadSkill` tool when the agent decides to use the skill. Write it like documentation for the agent.
|
||||
|
||||
## Defining and using a skill
|
||||
|
||||
```ts trigger/chat.ts
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { skills } from "@trigger.dev/sdk";
|
||||
import { streamText, stepCountIs } from "ai";
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
|
||||
const timeUtilsSkill = skills.define({
|
||||
id: "time-utils",
|
||||
path: "./skills/time-utils",
|
||||
});
|
||||
|
||||
export const agent = chat.agent({
|
||||
id: "docs-chat",
|
||||
onChatStart: async () => {
|
||||
chat.skills.set([await timeUtilsSkill.local()]);
|
||||
},
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({
|
||||
model: anthropic("claude-sonnet-4-5"),
|
||||
messages,
|
||||
abortSignal: signal,
|
||||
...chat.toStreamTextOptions(),
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`skills.define({ id, path })` does two things:
|
||||
|
||||
1. Registers the skill with the Trigger.dev build system so the CLI **automatically bundles the folder** into your deploy image at `/app/.trigger/skills/{id}/`. No `trigger.config.ts` changes, no build extension — it just works.
|
||||
2. Returns a `SkillHandle` you use at runtime.
|
||||
|
||||
`skill.local()` reads the bundled `SKILL.md` from disk and returns a `ResolvedSkill` with the parsed frontmatter + body + on-disk path.
|
||||
|
||||
`chat.skills.set([...])` stores the resolved skills for the current run. `chat.toStreamTextOptions()` spreads them into `streamText` automatically:
|
||||
|
||||
- The frontmatter `description` lands in the system prompt under "Available skills:".
|
||||
- Three tools are added: `loadSkill`, `readFile`, `bash` — scoped per skill.
|
||||
|
||||
## What gets auto-injected
|
||||
|
||||
When you spread `chat.toStreamTextOptions()` with skills set, the AI SDK call receives three tools:
|
||||
|
||||
### `loadSkill({ name })`
|
||||
|
||||
Returns the full `SKILL.md` body for the named skill. The model calls this first when it decides a skill is relevant, to load the full instructions.
|
||||
|
||||
### `readFile({ skill, path })`
|
||||
|
||||
Reads a file inside the skill's bundled folder. Paths are relative to the skill's root and are rejected if they attempt to escape via `..` or absolute paths. Output is capped at 1 MB per call.
|
||||
|
||||
Use for reference files and templates that the model should read literally:
|
||||
|
||||
```
|
||||
readFile({ skill: "time-utils", path: "references/timezones.txt" })
|
||||
```
|
||||
|
||||
### `bash({ skill, command })`
|
||||
|
||||
Runs a bash command with `cwd` set to the skill's root. Stdout and stderr are captured and returned (each capped at 64 KB per call, with tail truncation). The turn's abort signal propagates — cancelling the run kills the child process.
|
||||
|
||||
Use to invoke the skill's bundled scripts:
|
||||
|
||||
```
|
||||
bash({ skill: "time-utils", command: "bash scripts/now.sh America/Los_Angeles" })
|
||||
```
|
||||
|
||||
Script runtime expectations are yours to manage. If your skill uses `extract.py`, your deploy image needs Python — add it via your build config the same way you would for any other task dependency.
|
||||
|
||||
## How discovery works in the model
|
||||
|
||||
The model sees a short preamble appended to your system prompt:
|
||||
|
||||
```
|
||||
Available skills (call `loadSkill` to read the full instructions before using one):
|
||||
- time-utils: Compute and format dates/times in arbitrary timezones...
|
||||
- pdf-processing: Extract text from PDFs, fill forms...
|
||||
```
|
||||
|
||||
When the user asks something that matches a description, the model calls `loadSkill({ name: "time-utils" })` to load the body, then follows the body's instructions — typically by calling `bash` or `readFile` on the bundled scripts.
|
||||
|
||||
This is **progressive disclosure**: each skill costs ~100 tokens up front (its one-line description), and only the ones the model actually uses pay the full context cost.
|
||||
|
||||
## Mixing skills with custom tools
|
||||
|
||||
If you also define your own AI SDK tools, pass them through `chat.toStreamTextOptions()` so the merge is explicit:
|
||||
|
||||
```ts
|
||||
return streamText({
|
||||
model: anthropic("claude-sonnet-4-5"),
|
||||
messages,
|
||||
abortSignal: signal,
|
||||
...chat.toStreamTextOptions({
|
||||
tools: {
|
||||
webFetch, // your tool
|
||||
deepResearch, // your tool
|
||||
},
|
||||
}),
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
```
|
||||
|
||||
Your tools win on name conflicts. (Pick names that don't collide with `loadSkill` / `readFile` / `bash` to keep things predictable.)
|
||||
|
||||
Also declare those same tools on the agent's [`tools`](/ai-chat/tools) config. `toStreamTextOptions` merges them with the skill tools for the model call, while the config option threads them into history re-conversion so any `toModelOutput` survives across turns. The auto-injected skill tools (`loadSkill` / `readFile` / `bash`) don't define `toModelOutput`, so they don't need to be on the config.
|
||||
|
||||
## Bundling
|
||||
|
||||
Bundling is **built-in to the CLI** — there's no extension to import. When you run `trigger deploy` or `trigger dev`:
|
||||
|
||||
1. esbuild bundles your task code as usual.
|
||||
2. The CLI forks the indexer locally against the bundled output, collects every `skills.define({ path })` registration.
|
||||
3. Each skill's folder is copied to `{outputPath}/.trigger/skills/{id}/` via a recursive copy.
|
||||
4. The existing Dockerfile `COPY` picks up `.trigger/skills/` along with the rest of the bundle — no Dockerfile changes.
|
||||
|
||||
If you're running `trigger dev`, the same layout appears in the local dev output directory, so `skill.local()` works the same way.
|
||||
|
||||
## Path scoping rules
|
||||
|
||||
- `skill.path` always resolves to `${process.cwd()}/.trigger/skills/{id}/` at runtime. Don't hardcode paths elsewhere.
|
||||
- `readFile` rejects `..` segments and absolute paths — the tool only exposes files inside the skill's own directory.
|
||||
- `bash` runs with `cwd` set to the skill's root. Inside the script, relative paths resolve against the skill directory.
|
||||
- Cross-skill access isn't provided — each skill is isolated by design. If two skills need to share data, either duplicate the shared file or consolidate the skills.
|
||||
|
||||
## Current limitations
|
||||
|
||||
- `skill.resolve()` (backend-managed overrides) is not available yet — use `.local()` for now. Dashboard-editable `SKILL.md` is on the roadmap.
|
||||
- No per-skill metrics in the dashboard yet.
|
||||
- No Anthropic `/v1/skills` integration — use the portable path today; we're tracking the Anthropic optimization separately.
|
||||
|
||||
## Full example
|
||||
|
||||
See [`projects/ai-chat/src/trigger/skills/time-utils/`](https://github.com/triggerdotdev/references/tree/main/projects/ai-chat/src/trigger/skills/time-utils) in the [references repo](https://github.com/triggerdotdev/references) for a working skill that bundles two bash scripts and a reference cheat-sheet, wired into a `chat.agent` that answers timezone questions.
|
||||
|
||||
## Related
|
||||
|
||||
- [AI SDK cookbook — Agent Skills](https://ai-sdk.dev/cookbook/guides/agent-skills) — the userland pattern we build on
|
||||
- [Anthropic Agent Skills](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview) — Anthropic's codified version (server-side, optional future integration)
|
||||
@@ -0,0 +1,379 @@
|
||||
---
|
||||
title: "Sub-Agents"
|
||||
sidebarTitle: "Sub-Agents"
|
||||
description: "Delegate work to durable sub-agents from within a parent agent's tool calls, with streaming preliminary results."
|
||||
---
|
||||
|
||||
Sub-agents let a parent agent delegate work to other agents running as durable Trigger.dev tasks. The sub-agent's response streams back through the parent as preliminary tool results, so the frontend sees the sub-agent working inside the parent's tool call card.
|
||||
|
||||
This builds on the AI SDK's [async generator tool pattern](https://ai-sdk.dev/docs/agents/subagents) and Trigger.dev's [AgentChat](/ai-chat/server-chat) for server-side agent interaction.
|
||||
|
||||
## How it works
|
||||
|
||||
1. The parent LLM calls a tool (e.g., `researchAgent`)
|
||||
2. The tool's `execute` is an `async function*` (async generator)
|
||||
3. Inside, it creates an `AgentChat` and sends a message to the sub-agent
|
||||
4. `yield* stream.messages()` streams each accumulated `UIMessage` snapshot as a preliminary tool result
|
||||
5. The frontend renders the sub-agent's response building up inside the parent's tool card
|
||||
6. `toModelOutput` compresses the full output into a summary for the parent LLM
|
||||
|
||||
```
|
||||
Parent LLM
|
||||
│
|
||||
├─ calls researchAgent tool
|
||||
│ │
|
||||
│ ├─ AgentChat triggers sub-agent run
|
||||
│ ├─ sub-agent streams response (text, tool calls, etc.)
|
||||
│ ├─ yield* sends UIMessage snapshots as preliminary results
|
||||
│ └─ toModelOutput compresses for parent LLM
|
||||
│
|
||||
└─ parent LLM reads compressed summary, continues reasoning
|
||||
```
|
||||
|
||||
## Single-turn sub-agent
|
||||
|
||||
The simplest pattern: one tool call, one sub-agent turn, conversation closes.
|
||||
|
||||
```ts
|
||||
import { tool, stepCountIs } from "ai";
|
||||
import { AgentChat } from "@trigger.dev/sdk/chat";
|
||||
import { z } from "zod";
|
||||
import type { prReviewAgent } from "./trigger/pr-review";
|
||||
|
||||
const prReviewTool = tool({
|
||||
description: "Delegate a PR review to the PR review agent.",
|
||||
inputSchema: z.object({
|
||||
prNumber: z.number().describe("The PR number to review"),
|
||||
repo: z.string().describe("The GitHub repo URL"),
|
||||
}),
|
||||
execute: async function* ({ prNumber, repo }, { abortSignal }) {
|
||||
const chat = new AgentChat<typeof prReviewAgent>({
|
||||
agent: "pr-review",
|
||||
id: `review-${prNumber}`,
|
||||
clientData: { userId: "parent-agent", githubUrl: repo },
|
||||
});
|
||||
|
||||
const stream = await chat.sendMessage(`Review PR #${prNumber}`, { abortSignal });
|
||||
|
||||
// Each yield sends a UIMessage snapshot to the frontend
|
||||
yield* stream.messages();
|
||||
|
||||
await chat.close();
|
||||
},
|
||||
// The parent LLM only sees this compressed summary
|
||||
toModelOutput: ({ output: message }) => {
|
||||
const lastText = message?.parts?.findLast(
|
||||
(p: { type: string }) => p.type === "text"
|
||||
) as { text?: string } | undefined;
|
||||
return { type: "text", value: lastText?.text ?? "Review complete." };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Use this tool in a parent agent's `streamText` call:
|
||||
|
||||
```ts
|
||||
import { streamText } from "ai";
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
|
||||
const result = streamText({
|
||||
model: anthropic("claude-sonnet-4-6"),
|
||||
tools: { prReview: prReviewTool },
|
||||
prompt: "Review PR #42 on triggerdotdev/trigger.dev",
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
```
|
||||
|
||||
## Multi-turn sub-agent (LLM-driven)
|
||||
|
||||
The parent LLM drives a persistent conversation with a sub-agent across multiple tool calls. Each call with the same `conversationId` hits the same durable agent run.
|
||||
|
||||
```ts
|
||||
import { tool } from "ai";
|
||||
import { AgentChat } from "@trigger.dev/sdk/chat";
|
||||
import { z } from "zod";
|
||||
|
||||
// Track active sub-agent conversations
|
||||
const subAgents = new Map<string, AgentChat>();
|
||||
|
||||
const researchTool = tool({
|
||||
description:
|
||||
"Talk to a research agent. Use the same conversationId to continue " +
|
||||
"an existing conversation — the agent remembers full context.",
|
||||
inputSchema: z.object({
|
||||
conversationId: z
|
||||
.string()
|
||||
.describe("Unique ID for this research thread. Reuse to continue."),
|
||||
message: z.string().describe("Your message to the research agent"),
|
||||
}),
|
||||
execute: async function* ({ conversationId, message }, { abortSignal }) {
|
||||
let agent = subAgents.get(conversationId);
|
||||
if (!agent) {
|
||||
agent = new AgentChat({
|
||||
agent: "research-agent",
|
||||
id: conversationId,
|
||||
});
|
||||
subAgents.set(conversationId, agent);
|
||||
}
|
||||
|
||||
const stream = await agent.sendMessage(message, { abortSignal });
|
||||
yield* stream.messages();
|
||||
},
|
||||
toModelOutput: ({ output: message }) => {
|
||||
const lastText = message?.parts?.findLast(
|
||||
(p: { type: string }) => p.type === "text"
|
||||
) as { text?: string } | undefined;
|
||||
return { type: "text", value: lastText?.text ?? "Done." };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The parent LLM naturally calls this tool multiple times:
|
||||
|
||||
1. `researchAgent({ conversationId: "competitors", message: "Research competitors in AI agents" })` — first call triggers a new sub-agent run
|
||||
2. `researchAgent({ conversationId: "competitors", message: "Go deeper on pricing" })` — same run, sub-agent has full context
|
||||
3. `researchAgent({ conversationId: "new-topic", message: "..." })` — different ID = different sub-agent
|
||||
|
||||
### Cross-turn persistence
|
||||
|
||||
Sub-agent conversations persist across **parent turns** because the `Map` lives in the parent's process heap. When the parent suspends and restores via snapshot, the heap is preserved — the Map still has the conversations, the sessions still have the run IDs.
|
||||
|
||||
```ts
|
||||
export const orchestrator = chat
|
||||
.withClientData({ schema: z.object({ userId: z.string() }) })
|
||||
.customAgent({
|
||||
id: "orchestrator",
|
||||
run: async (payload, { signal: runSignal }) => {
|
||||
// These survive across parent turns via snapshot/restore
|
||||
const subAgents = new Map<string, AgentChat>();
|
||||
|
||||
const researchTool = tool({
|
||||
// ... closes over subAgents Map
|
||||
});
|
||||
|
||||
// Turn loop — subAgents persist across all turns
|
||||
for (let turn = 0; turn < 50; turn++) {
|
||||
// ... streamText with researchTool
|
||||
}
|
||||
|
||||
// Cleanup when parent exits
|
||||
await Promise.all(
|
||||
Array.from(subAgents.values()).map((a) => a.close().catch(() => {}))
|
||||
);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## How sub-agents clean up
|
||||
|
||||
Sub-agents clean up through three mechanisms:
|
||||
|
||||
1. **Explicit close**: Call `chat.close()` or `agent.close()` when done
|
||||
2. **Idle timeout**: The sub-agent's idle timeout expires, it suspends
|
||||
3. **Suspend timeout**: The sub-agent's suspend timeout expires, the run ends
|
||||
|
||||
For the multi-turn pattern, the parent should clean up sub-agents when it exits (in `onComplete` for managed agents, or at the end of the loop for custom agents). Without explicit cleanup, sub-agents close on their own via timeouts — no leaked resources or cost while suspended.
|
||||
|
||||
## What the frontend sees
|
||||
|
||||
Each `yield` from `stream.messages()` sends a complete `UIMessage` containing all the sub-agent's parts accumulated so far. The AI SDK delivers these as `tool-output-available` chunks with `preliminary: true`.
|
||||
|
||||
The frontend renders the tool part with:
|
||||
- `state: "output-available"` and `preliminary: true` while streaming
|
||||
- `state: "output-available"` and `preliminary: false` (or absent) when done
|
||||
|
||||
The tool output contains the full `UIMessage` with nested parts — text, the sub-agent's own tool calls and results, reasoning, etc.
|
||||
|
||||
### Controlling what the parent LLM sees
|
||||
|
||||
`toModelOutput` transforms the tool's output before it enters the parent LLM's context. The full UIMessage streams to the frontend, but the model only sees the compressed version:
|
||||
|
||||
```ts
|
||||
toModelOutput: ({ output: message }) => {
|
||||
// Extract just the final text — the model doesn't need
|
||||
// to see all the sub-agent's tool calls and intermediate work
|
||||
const lastText = message?.parts?.findLast(
|
||||
(p: { type: string }) => p.type === "text"
|
||||
) as { text?: string } | undefined;
|
||||
return { type: "text", value: lastText?.text ?? "Done." };
|
||||
},
|
||||
```
|
||||
|
||||
This is important for token efficiency: the sub-agent might use 100K tokens exploring and reasoning, but the parent LLM only consumes the summary.
|
||||
|
||||
<Warning>
|
||||
`toModelOutput` only runs when the SDK has your tools at conversion time. On a multi-turn parent, the SDK re-converts the persisted history at the start of each turn, so you must declare the sub-agent tool on the agent config (`chat.agent({ tools })`) for the compression to survive. Without it, the summary holds on turn 1 but turn 2 onward re-ingests the full sub-agent output. In a `chat.customAgent` loop you own the conversion, so pass the tools to `convertToModelMessages(uiMessages, { tools })` yourself. See [Tools: toModelOutput across turns](/ai-chat/tools#tomodeloutput-across-turns).
|
||||
</Warning>
|
||||
|
||||
## ChatStream.messages()
|
||||
|
||||
The `messages()` method on `ChatStream` wraps the AI SDK's `readUIMessageStream`. It reads the raw `UIMessageChunk` stream and yields complete `UIMessage` snapshots — each containing all parts received so far.
|
||||
|
||||
```ts
|
||||
const stream = await chat.sendMessage("Research this topic");
|
||||
|
||||
// Each yield is a complete UIMessage with all accumulated parts
|
||||
for await (const message of stream.messages()) {
|
||||
console.log(message.parts.length, "parts so far");
|
||||
}
|
||||
```
|
||||
|
||||
For the sub-agent pattern, use `yield*` to delegate all yields to the parent tool's generator:
|
||||
|
||||
```ts
|
||||
execute: async function* ({ topic }, { abortSignal }) {
|
||||
const stream = await chat.sendMessage(topic, { abortSignal });
|
||||
yield* stream.messages();
|
||||
},
|
||||
```
|
||||
|
||||
<Tip>
|
||||
`stream.messages()` consumes the stream. You can't also call `stream.text()` or iterate over chunks on the same stream. Pick one consumption mode.
|
||||
</Tip>
|
||||
|
||||
## Combining with chat.agent()
|
||||
|
||||
Sub-agent tools work inside both `chat.agent()` (managed) and `chat.customAgent()` (manual lifecycle):
|
||||
|
||||
```ts
|
||||
// Managed agent with sub-agent tool
|
||||
const tools = { research: researchTool };
|
||||
|
||||
export const myAgent = chat.agent({
|
||||
id: "orchestrator",
|
||||
tools, // declare here so toModelOutput survives across turns
|
||||
run: async ({ messages, tools, stopSignal }) => {
|
||||
return streamText({
|
||||
model: anthropic("claude-sonnet-4-6"),
|
||||
messages,
|
||||
tools,
|
||||
abortSignal: stopSignal,
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
For `chat.customAgent()`, define the tool and sub-agent Map inside the `run` closure so they survive across turns. Since you own the turn loop there, convert history with your tools in scope so `toModelOutput` is re-applied each turn: `convertToModelMessages(uiMessages, { tools })`. See [Tools: manual turn loops](/ai-chat/tools#manual-turn-loops-chatcustomagent).
|
||||
|
||||
## Streaming progress from a subtask to the parent chat
|
||||
|
||||
When a tool invokes a subtask via `triggerAndWait`, the subtask can stream custom data parts directly to the parent chat using `chat.stream.writer({ target: "root" })`. The frontend receives these as `DataUIPart` objects in `message.parts` on the **parent's** message stream:
|
||||
|
||||
```ts
|
||||
import { chat, ai } from "@trigger.dev/sdk/ai";
|
||||
import { schemaTask } from "@trigger.dev/sdk";
|
||||
import { streamText, tool, generateId } from "ai";
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
import { z } from "zod";
|
||||
|
||||
export const researchTask = schemaTask({
|
||||
id: "research",
|
||||
schema: z.object({ query: z.string() }),
|
||||
run: async ({ query }) => {
|
||||
const partId = generateId();
|
||||
|
||||
// Stream a data-* chunk to the root run's chat stream.
|
||||
const { waitUntilComplete } = chat.stream.writer({
|
||||
target: "root",
|
||||
execute: ({ write }) => {
|
||||
write({
|
||||
type: "data-research-status",
|
||||
id: partId,
|
||||
data: { query, status: "in-progress" },
|
||||
});
|
||||
},
|
||||
});
|
||||
await waitUntilComplete();
|
||||
|
||||
const result = await doResearch(query);
|
||||
|
||||
// Update the same part with the final status — same type + id replaces it.
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
const research = tool({
|
||||
description: researchTask.description ?? "",
|
||||
inputSchema: researchTask.schema!,
|
||||
execute: ai.toolExecute(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 (
|
||||
<div key={i}>
|
||||
{status === "done" ? `Found ${resultCount} results` : `Researching "${query}"...`}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// ...other part types
|
||||
})}
|
||||
```
|
||||
|
||||
The `target` option accepts:
|
||||
- `"self"` — current run (default)
|
||||
- `"parent"` — parent task's run
|
||||
- `"root"` — root task's run (the chat agent)
|
||||
- A specific run ID string
|
||||
|
||||
## Inside `ai.toolExecute`: accessing tool + chat context
|
||||
|
||||
When a subtask runs via `execute: ai.toolExecute(task)`, it can read the parent's tool call ID and chat context from inside the subtask body:
|
||||
|
||||
```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 }) => {
|
||||
// The AI SDK tool call ID — useful as a stable `data-*` chunk id
|
||||
const toolCallId = ai.toolCallId();
|
||||
|
||||
// Typed chat context — `clientData` is typed off your chat's `clientDataSchema`
|
||||
const { chatId, clientData } = ai.chatContextOrThrow<typeof myChat>();
|
||||
|
||||
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<typeof myChat>()` | `{ chatId, turn, continuation, clientData } \| undefined` | Chat context with typed `clientData`. Returns `undefined` if not in a chat context. |
|
||||
| `ai.chatContextOrThrow<typeof myChat>()` | `{ chatId, turn, continuation, clientData }` | Same as above but throws if not in a chat context |
|
||||
| `ai.currentToolOptions()` | `ToolCallExecutionOptions \| undefined` | Full tool execution options |
|
||||
|
||||
The subtask body also has read-only access to any [`chat.local`](/ai-chat/chat-local) values initialized in the parent — auto-hydrated from the parent's metadata on first access.
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: "Tool result auditing"
|
||||
sidebarTitle: "Tool result auditing"
|
||||
description: "Fire side effects exactly once per resolved tool call — audit logs, billing, notifications — using extractNewToolResults inside hydrateMessages or onTurnComplete."
|
||||
---
|
||||
|
||||
When a chat agent uses [tools](/ai-chat/tools) (especially [human-in-the-loop](/ai-chat/patterns/human-in-the-loop) tools that wait on `addToolOutput` from the frontend), you often need to fire side effects exactly once per resolved tool call:
|
||||
|
||||
- **Audit logs** — record every tool result for compliance.
|
||||
- **Billing** — charge per tool invocation.
|
||||
- **Notifications** — alert downstream systems when a specific tool resolves.
|
||||
- **Search-index updates** — reflect tool outputs into a derived store.
|
||||
|
||||
The naive approach — "log every tool part you see" — over-counts. The same assistant message gets re-shown across re-renders, replays, and retries. You want a function of the form **"is this tool result one I haven't already logged?"** That's exactly what [`chat.history.extractNewToolResults`](/ai-chat/backend#chat-history) returns.
|
||||
|
||||
## The pattern
|
||||
|
||||
```ts
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { auditLog } from "@/lib/audit";
|
||||
|
||||
export const myChat = chat.agent({
|
||||
id: "my-chat",
|
||||
hydrateMessages: async ({ chatId, incomingMessages }) => {
|
||||
for (const msg of incomingMessages) {
|
||||
for (const r of chat.history.extractNewToolResults(msg)) {
|
||||
await auditLog.record({
|
||||
chatId,
|
||||
toolCallId: r.toolCallId,
|
||||
toolName: r.toolName,
|
||||
output: r.output,
|
||||
errorText: r.errorText,
|
||||
});
|
||||
}
|
||||
}
|
||||
return await db.getMessages(chatId);
|
||||
},
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The hook fires per turn. `incomingMessages` is the new wire message (0-or-1-length, see [v4.5 wire format change](/ai-chat/upgrade-guide#v45-wire-format-change)). For each new tool result on that message, write one audit row. Then return the canonical chain from your DB.
|
||||
|
||||
`extractNewToolResults` compares the message against the current `chat.history` chain and returns only tool parts whose `toolCallId` is **not** already resolved. That's what makes the call exactly-once:
|
||||
|
||||
- A re-emitted message (same id, same toolCallId) returns `[]` — no duplicate log.
|
||||
- A genuinely new tool result on a known assistant message returns just the new ones.
|
||||
- A first-time tool result returns the full set.
|
||||
|
||||
## Why `hydrateMessages` is the right hook
|
||||
|
||||
The pattern works in any pre-merge callback, but `hydrateMessages` is the canonical spot for two reasons:
|
||||
|
||||
1. **It fires before the runtime merges** the incoming message into the accumulator. Once merged, the tool results are already on the chain, and `extractNewToolResults` returns `[]` for them.
|
||||
2. **It always fires per turn** — including HITL turns where the user resolved a tool with `addToolOutput`, which is the highest-volume audit event in most apps.
|
||||
|
||||
By the time `onTurnComplete` fires, the chain already contains `responseMessage`, so calling `extractNewToolResults(responseMessage)` there returns `[]`. Don't put audit logging there for the resolution path.
|
||||
|
||||
## Without `hydrateMessages` — `onTurnComplete` for self-emitted tool calls
|
||||
|
||||
If you don't use `hydrateMessages`, the runtime's snapshot+replay path handles persistence. You can still audit the agent's **own** tool executions in `onTurnComplete` — but compare against the prior message rather than the just-emitted one:
|
||||
|
||||
```ts
|
||||
onTurnComplete: async ({ chatId, newUIMessages }) => {
|
||||
// The assistant message from this turn is in newUIMessages.
|
||||
for (const msg of newUIMessages) {
|
||||
if (msg.role !== "assistant") continue;
|
||||
for (const part of msg.parts) {
|
||||
if (
|
||||
typeof part.type === "string" &&
|
||||
part.type.startsWith("tool-") &&
|
||||
((part as any).state === "output-available" ||
|
||||
(part as any).state === "output-error")
|
||||
) {
|
||||
await auditLog.record({
|
||||
chatId,
|
||||
toolCallId: (part as any).toolCallId,
|
||||
toolName: (part as any).type.slice("tool-".length),
|
||||
output: (part as any).output,
|
||||
errorText: (part as any).errorText,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
`newUIMessages` is just the messages this turn produced — no prior-chain noise. Each tool part shows up exactly once.
|
||||
|
||||
This works for tools the agent itself calls (no HITL pause). For HITL flows where the user resolves a tool with `addToolOutput`, the resolution arrives on the **next** turn's wire message, not in `newUIMessages` of the resolving turn — use `hydrateMessages` for those.
|
||||
|
||||
## Idempotency at the storage layer
|
||||
|
||||
Even with `extractNewToolResults`, transient failures (e.g. an audit-log POST that times out and is retried) can produce duplicates. Make the audit-log writer idempotent on `toolCallId`:
|
||||
|
||||
```ts
|
||||
await auditLog.upsert({
|
||||
where: { toolCallId: r.toolCallId },
|
||||
create: { /* ... */ },
|
||||
update: { /* timestamp, retry count, etc. */ },
|
||||
});
|
||||
```
|
||||
|
||||
`toolCallId` is unique per tool invocation (assigned by the AI SDK when the model emits the tool call) and stable across retries — perfect for an idempotency key.
|
||||
|
||||
## What `extractNewToolResults` returns
|
||||
|
||||
```ts
|
||||
type ChatNewToolResult = {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
output: unknown; // The tool's return value (carries the resolved value; in output-error state see errorText)
|
||||
errorText?: string; // Set iff the part is in output-error state
|
||||
};
|
||||
```
|
||||
|
||||
Tool parts in `input-available` state (the model called the tool but it hasn't resolved yet) are not returned — only **resolved** results count.
|
||||
|
||||
## Combining with HITL
|
||||
|
||||
[Human-in-the-loop](/ai-chat/patterns/human-in-the-loop) tools pause the turn waiting for `addToolOutput` from the frontend. When the user submits, the wire message carries an updated assistant message with the tool now in `output-available` state. `extractNewToolResults` against that message returns the just-resolved tool — exactly one audit row per user resolution:
|
||||
|
||||
```ts
|
||||
hydrateMessages: async ({ chatId, incomingMessages }) => {
|
||||
for (const msg of incomingMessages) {
|
||||
for (const r of chat.history.extractNewToolResults(msg)) {
|
||||
// Fires once per ask_user / approval / similar resolution
|
||||
await auditLog.record({ chatId, /* ... */ });
|
||||
}
|
||||
}
|
||||
return await db.getMessages(chatId);
|
||||
}
|
||||
```
|
||||
|
||||
This is the original motivator for the helper — see the [HITL pattern's net-new-tool-result section](/ai-chat/patterns/human-in-the-loop#acting-once-per-net-new-tool-result).
|
||||
|
||||
## See also
|
||||
|
||||
- [`chat.history`](/ai-chat/backend#chat-history) — full reference for `extractNewToolResults`, `getPendingToolCalls`, `getResolvedToolCalls`
|
||||
- [Human-in-the-loop](/ai-chat/patterns/human-in-the-loop) — the pattern this auditing hook complements
|
||||
- [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) — where pre-merge auditing lives
|
||||
- [Persistence and replay](/ai-chat/patterns/persistence-and-replay) — how the runtime rebuilds chains, and why `extractNewToolResults` works against them
|
||||
@@ -0,0 +1,333 @@
|
||||
---
|
||||
title: "Trusted edge signals"
|
||||
sidebarTitle: "Trusted edge signals"
|
||||
description: "How to safely deliver server-trusted signals (bot scores, JA4, ASN, ReCAPTCHA verdicts) to a chat.agent run via an edge proxy."
|
||||
---
|
||||
|
||||
A common need for chat-style endpoints is to drive agent behavior from **server-trusted signals** that the browser cannot be allowed to declare itself — bot management scores, JA4 fingerprints, ASN, ReCAPTCHA verdicts, or any other anti-abuse data only the edge can see. The agent's [`clientData`](/ai-chat/reference#withclientdata) channel is the right delivery mechanism, but `clientData` set in the browser is by definition spoofable. The fix is to move the value population out of the browser and into a trusted edge proxy.
|
||||
|
||||
This page documents the pattern using Cloudflare Workers as the proxy. The same shape applies to any edge layer (custom reverse proxy, Vercel Edge Middleware, AWS Lambda@Edge) — the trust comes from the deployment topology, not from Trigger.dev validating the source.
|
||||
|
||||
## Why headers don't work
|
||||
|
||||
It's tempting to ask whether `POST /realtime/v1/sessions/{id}/in/append` could carry the signal as an HTTP header. It cannot. The realtime route reads only `Authorization` and `X-Part-Id`; the remaining headers are dropped at the route boundary and the body is persisted to the durable stream as opaque bytes. There is no `headers → run payload` channel.
|
||||
|
||||
The trigger.dev wire payload, on the other hand, has a typed per-turn metadata channel ([`ChatTaskWirePayload.metadata`](/ai-chat/client-protocol#chattaskwirepayload)). It already flows from the wire into [`clientData`](/ai-chat/reference#withclientdata) on every hook (`onBoot`, `onChatStart`, `onTurnStart`, `run`, `onTurnComplete`). That field is where signals must land.
|
||||
|
||||
## The trust boundary
|
||||
|
||||
The pattern has one architectural requirement and one wire-shape convention.
|
||||
|
||||
**Topology**: the browser must not be able to reach `trigger.dev` directly. All four chat-related requests (`POST /api/v1/sessions`, `GET /realtime/v1/sessions/{id}/out`, `POST /realtime/v1/sessions/{id}/in/append`, `POST /api/v1/auth/jwt/claims`) flow through your edge proxy. The proxy holds the trust; trigger.dev simply persists whatever the proxy writes.
|
||||
|
||||
**Namespace**: pick a key your edge proxy owns exclusively — e.g. `__cf`, `__edge`, `__trust`. The proxy **strips** anything in that key on the way in and **injects** its own value on every request. Nothing else in your system should write that key. This is the convention that converts deployment topology into a guarantee the agent can rely on.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
participant Edge as Edge Proxy (CF Worker)
|
||||
participant Trigger as trigger.dev API
|
||||
participant Agent as chat.agent run
|
||||
|
||||
Browser->>Edge: POST /api/v1/sessions { triggerConfig.basePayload.metadata: {...} }
|
||||
Edge->>Edge: strip body.triggerConfig.basePayload.metadata.__cf<br/>inject body.triggerConfig.basePayload.metadata.__cf = { botScore, ja4, asn }
|
||||
Edge->>Trigger: POST /api/v1/sessions (rewritten body)
|
||||
Trigger-->>Agent: run boots with payload.metadata.__cf
|
||||
Browser->>Edge: POST /realtime/v1/sessions/{id}/in/append { kind: "message", payload: {...} }
|
||||
Edge->>Edge: strip payload.metadata.__cf<br/>inject payload.metadata.__cf
|
||||
Edge->>Trigger: POST /in/append (rewritten body)
|
||||
Trigger-->>Agent: chat.messages.wait() resolves with payload.metadata.__cf
|
||||
```
|
||||
|
||||
## Wire payload — the two endpoints to rewrite
|
||||
|
||||
The signal needs to land in **two** places. Both bodies are JSON; the edge proxy parses, mutates the namespaced key, and re-serializes.
|
||||
|
||||
### `POST /api/v1/sessions` — session create
|
||||
|
||||
The browser's session-create call carries the first-turn metadata under `triggerConfig.basePayload.metadata`. The proxy mutates that:
|
||||
|
||||
```ts
|
||||
// Before
|
||||
{
|
||||
"type": "chat.agent",
|
||||
"externalId": "conv-123",
|
||||
"taskIdentifier": "my-agent",
|
||||
"triggerConfig": {
|
||||
"basePayload": {
|
||||
"chatId": "conv-123",
|
||||
"trigger": "preload",
|
||||
"metadata": { "userId": "user-456" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After
|
||||
{
|
||||
"type": "chat.agent",
|
||||
"externalId": "conv-123",
|
||||
"taskIdentifier": "my-agent",
|
||||
"triggerConfig": {
|
||||
"basePayload": {
|
||||
"chatId": "conv-123",
|
||||
"trigger": "preload",
|
||||
"metadata": {
|
||||
"userId": "user-456",
|
||||
"__cf": { "botScore": 95, "ja4": "...", "asn": 13335, "country": "US" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /realtime/v1/sessions/{id}/in/append` — every follow-up turn
|
||||
|
||||
The body is a JSON-serialized `ChatInputChunk`. The proxy parses it, checks `kind === "message"`, and mutates `payload.metadata`:
|
||||
|
||||
```ts
|
||||
// Before
|
||||
{
|
||||
"kind": "message",
|
||||
"payload": {
|
||||
"message": { "id": "u-2", "role": "user", "parts": [{ "type": "text", "text": "..." }] },
|
||||
"chatId": "conv-123",
|
||||
"trigger": "submit-message",
|
||||
"metadata": { "userId": "user-456" }
|
||||
}
|
||||
}
|
||||
|
||||
// After
|
||||
{
|
||||
"kind": "message",
|
||||
"payload": {
|
||||
"message": { ... },
|
||||
"chatId": "conv-123",
|
||||
"trigger": "submit-message",
|
||||
"metadata": {
|
||||
"userId": "user-456",
|
||||
"__cf": { "botScore": 95, "ja4": "...", "asn": 13335, "country": "US" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Both bodies stay well under the [per-record cap on `/in/append`](/ai-chat/client-protocol#step-3-send-messages-stops-and-actions) — a typical trust object is ~200 bytes.
|
||||
|
||||
Other paths — `.out` SSE, `/api/v1/auth/jwt/claims`, anything else — pass through the proxy untouched. The SSE stream in particular must not be buffered; preserve the response body as-is.
|
||||
|
||||
## Cloudflare Worker reference implementation
|
||||
|
||||
A complete worker that proxies all paths to `TRIGGER_API_UPSTREAM` and injects `__cf` on the two body-write endpoints:
|
||||
|
||||
```ts
|
||||
export interface Env {
|
||||
TRIGGER_API_UPSTREAM: string; // e.g. "https://api.trigger.dev"
|
||||
}
|
||||
|
||||
type CfTrustData = {
|
||||
botScore: number;
|
||||
ja4: string;
|
||||
asn: number;
|
||||
country: string;
|
||||
};
|
||||
|
||||
function readCfTrustData(request: Request): CfTrustData {
|
||||
const cf = (request as Request & { cf?: Record<string, unknown> }).cf;
|
||||
const bm = cf?.botManagement as Record<string, unknown> | undefined;
|
||||
return {
|
||||
botScore: (bm?.score as number) ?? 0,
|
||||
ja4: (bm?.ja4 as string) ?? "",
|
||||
asn: (cf?.asn as number) ?? 0,
|
||||
country: (cf?.country as string) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function injectCf(metadata: Record<string, unknown> | undefined, cf: CfTrustData) {
|
||||
// Strip anything the client tried to send under our namespace,
|
||||
// then inject the edge-trusted value. Topology + convention =
|
||||
// trust.
|
||||
const stripped = { ...(metadata ?? {}) };
|
||||
delete stripped.__cf;
|
||||
return { ...stripped, __cf: cf };
|
||||
}
|
||||
|
||||
function rewriteSessionsCreate(body: string, cf: CfTrustData) {
|
||||
const parsed = JSON.parse(body) as Record<string, unknown>;
|
||||
const tc = (parsed.triggerConfig as Record<string, unknown>) ?? {};
|
||||
const bp = (tc.basePayload as Record<string, unknown>) ?? {};
|
||||
parsed.triggerConfig = {
|
||||
...tc,
|
||||
basePayload: { ...bp, metadata: injectCf(bp.metadata as Record<string, unknown>, cf) },
|
||||
};
|
||||
return JSON.stringify(parsed);
|
||||
}
|
||||
|
||||
function rewriteAppend(body: string, cf: CfTrustData) {
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(body);
|
||||
} catch {
|
||||
return body;
|
||||
}
|
||||
if (parsed.kind !== "message") return body;
|
||||
const payload = (parsed.payload as Record<string, unknown>) ?? {};
|
||||
parsed.payload = { ...payload, metadata: injectCf(payload.metadata as Record<string, unknown>, cf) };
|
||||
return JSON.stringify(parsed);
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const incoming = new URL(request.url);
|
||||
const target = new URL(incoming.pathname + incoming.search, env.TRIGGER_API_UPSTREAM);
|
||||
const cf = readCfTrustData(request);
|
||||
|
||||
const isSessionsCreate =
|
||||
request.method === "POST" && incoming.pathname === "/api/v1/sessions";
|
||||
const isAppend =
|
||||
request.method === "POST" &&
|
||||
/^\/realtime\/v1\/sessions\/[^/]+\/in\/append$/.test(incoming.pathname);
|
||||
|
||||
let body: BodyInit | null = null;
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
const raw = await request.text();
|
||||
if (isSessionsCreate && raw) body = rewriteSessionsCreate(raw, cf);
|
||||
else if (isAppend && raw) body = rewriteAppend(raw, cf);
|
||||
else body = raw;
|
||||
}
|
||||
|
||||
const headers = new Headers(request.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("content-length");
|
||||
|
||||
return fetch(target.toString(), {
|
||||
method: request.method,
|
||||
headers,
|
||||
body,
|
||||
redirect: "manual",
|
||||
});
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Browser-only deployments also need CORS on the worker — echo `Access-Control-Request-Headers` on preflight and set `Access-Control-Allow-Origin` to your frontend origin. The trigger.dev route itself allows all origins, but the worker becomes the visible cross-origin endpoint to the browser.
|
||||
|
||||
### Streaming and latency
|
||||
|
||||
The SDK's `baseURL` accepts a function (see [Browser transport configuration](#browser-transport-configuration)), so the recommended setup routes `.in/append` and session-create through the worker but lets `.out` SSE go direct to `api.trigger.dev`. Body-mutation only happens on the POST paths; the SSE stream is read-only, doesn't need rewriting, and routing it direct saves an edge hop on every reconnect.
|
||||
|
||||
If you do route `.out` through the proxy (e.g. you want a single origin in front of `api.trigger.dev` and don't care about the extra hop), the template above handles it correctly because the worker returns `response.body` as a `ReadableStream`. **Do not replace that with `await response.text()`** anywhere in your fork; doing so converts the streaming SSE response into a buffered read and breaks per-chunk delivery.
|
||||
|
||||
[Cloudflare Workers HTTP requests](https://developers.cloudflare.com/workers/platform/limits/) have no wall-clock duration limit while the client stays connected — the 60-second long-poll runs to completion on every plan, including Free. CPU-time limits (10 ms on Free, 30 s default on Paid) only apply to active computation; relaying bytes through `fetch` doesn't burn CPU. The two body-rewrite paths use sub-millisecond CPU for typical message sizes, well under either ceiling.
|
||||
|
||||
Network-wise the proxy adds one edge hop: roughly 10–50 ms per request round trip versus talking to `api.trigger.dev` directly. Routing SSE direct via the function-form `baseURL` eliminates that hop on the long-lived path.
|
||||
|
||||
## Agent side — declare the namespace in `clientDataSchema`
|
||||
|
||||
Mirror the namespace in the agent so every turn lands typed:
|
||||
|
||||
```ts
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { z } from "zod";
|
||||
|
||||
export const myAgent = chat
|
||||
.withClientData({
|
||||
schema: z.object({
|
||||
userId: z.string(),
|
||||
__cf: z.object({
|
||||
botScore: z.number(),
|
||||
ja4: z.string(),
|
||||
asn: z.number(),
|
||||
country: z.string(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.agent({
|
||||
id: "my-agent",
|
||||
run: async ({ messages, clientData, signal }) => {
|
||||
// Score-based routing. The values arrive from the edge proxy.
|
||||
if (clientData.__cf.botScore < 30) {
|
||||
return streamText({
|
||||
model: anthropic("claude-haiku-4-5"),
|
||||
messages: [{ role: "system", content: "Reject politely; do not engage." }],
|
||||
abortSignal: signal,
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
}
|
||||
|
||||
return streamText({
|
||||
model: anthropic("claude-sonnet-4-5"),
|
||||
messages,
|
||||
abortSignal: signal,
|
||||
// ...
|
||||
stopWhen: stepCountIs(15),
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Because the schema requires `__cf` on every turn, any request that *doesn't* go through the proxy fails at the agent boundary — the turn produces a `[ERROR]` span on the trace and an empty `turn-complete` on the wire (see [the client protocol error-detection note](/ai-chat/client-protocol#step-3-send-messages-stops-and-actions)). That gives you a server-side enforcement check for "did this request actually come through the trusted path?"
|
||||
|
||||
## Browser transport configuration
|
||||
|
||||
Point the `TriggerChatTransport` at the worker, not at `api.trigger.dev`:
|
||||
|
||||
`baseURL` accepts a function so you can route `.in/append` through the worker while keeping `.out` SSE direct to `api.trigger.dev`. The append path is where the body-mutation matters; the SSE stream is a read-only one-way channel that doesn't need to be proxied. Routing it direct saves an edge hop on every long-poll.
|
||||
|
||||
```tsx
|
||||
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
|
||||
|
||||
const WORKER = "https://worker.your-domain.com";
|
||||
const DIRECT = "https://api.trigger.dev";
|
||||
|
||||
const transport = useTriggerChatTransport({
|
||||
task: "my-agent",
|
||||
baseURL: ({ endpoint }) => (endpoint === "out" ? DIRECT : WORKER),
|
||||
// ... accessToken, startSession, etc.
|
||||
// NOTE: do not set __cf in clientData here. The browser cannot be
|
||||
// trusted to populate it — the worker is the source of truth.
|
||||
clientData: { userId: currentUserId },
|
||||
});
|
||||
```
|
||||
|
||||
If you'd rather route everything through the worker, pass a single string:
|
||||
|
||||
```tsx
|
||||
baseURL: "https://worker.your-domain.com",
|
||||
```
|
||||
|
||||
`baseURL` accepts the same string-or-function shape on `chat.createStartSessionAction`, so the Next.js server action that creates the session also flows through the worker — that's how the very first run's `basePayload.metadata.__cf` gets injected before reaching `api.trigger.dev`:
|
||||
|
||||
```ts
|
||||
// actions.ts — server-only
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
|
||||
export const startSession = chat.createStartSessionAction("my-agent", {
|
||||
tokenTTL: "1h",
|
||||
baseURL: ({ endpoint }) =>
|
||||
endpoint === "sessions" ? WORKER : DIRECT,
|
||||
});
|
||||
```
|
||||
|
||||
The session-create endpoint discriminator is `"sessions"` (POST `/api/v1/sessions`) or `"auth"` (POST `/api/v1/auth/jwt/claims`) — distinct from the chat transport's `"in"` / `"out"`. If you want everything proxied, pass a string.
|
||||
|
||||
## Threat model
|
||||
|
||||
Two important invariants follow from this design:
|
||||
|
||||
1. **Direct browser-to-trigger.dev requests cannot succeed**. As long as your agent's `clientDataSchema` requires the namespaced field, any request that doesn't go through the proxy fails schema validation and produces an empty turn. This is your gate.
|
||||
2. **Anything inside the namespaced key is trusted only as far as the proxy is the sole writer**. If a client could obtain the public access token and bypass the proxy, they could send arbitrary values under `__cf`. The schema would still validate (it only checks shape, not provenance). The mitigation is operational: the public access token must only be served to clients that reach trigger.dev through the proxy. In practice this means your Next.js server actions and your browser are both behind the same edge layer, and the worker is the only fetch destination for `trigger.dev` baked into either of them.
|
||||
|
||||
You can harden further with a shared-secret header the worker injects (e.g. `X-Edge-Signature`) and an agent-side check, but in most CDN deployments the deployment topology is already sufficient.
|
||||
|
||||
## Recipe summary
|
||||
|
||||
1. Pick a namespaced key the edge proxy owns (`__cf`, `__edge`, `__trust`).
|
||||
2. Deploy a proxy in front of `trigger.dev` that rewrites POST `/api/v1/sessions` and POST `/realtime/v1/sessions/{id}/in/append` to inject your trusted values under that key.
|
||||
3. Declare the namespace in the agent's `clientDataSchema` so missing or malformed signals fail at the agent boundary.
|
||||
4. Point your transport's `baseURL` at the proxy. Never expose `api.trigger.dev` directly to the browser.
|
||||
|
||||
## See also
|
||||
|
||||
- [Client Protocol](/ai-chat/client-protocol) — the full wire shape the proxy is rewriting.
|
||||
- [`withClientData`](/ai-chat/reference#withclientdata) — agent-side typed metadata channel.
|
||||
- [Large payloads](/ai-chat/patterns/large-payloads) — for when injected signals or hooks need to ship more than the 1 MiB stream cap allows.
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
title: "Version upgrades"
|
||||
sidebarTitle: "Version upgrades"
|
||||
description: "Gracefully migrate suspended chat agents to a new deployment using chat.requestUpgrade() and the continuation mechanism."
|
||||
---
|
||||
|
||||
Chat agent runs are pinned to the worker version they started on. When you deploy a new version, suspended runs resume on the **old** code. If your deploy includes breaking changes (new tools, changed schemas, updated API contracts), this can cause issues.
|
||||
|
||||
`chat.requestUpgrade()` lets the agent opt out of the current run so the transport triggers a new one on the latest version.
|
||||
|
||||
## How it works
|
||||
|
||||
When `chat.requestUpgrade()` is called in `onTurnStart` or `onValidateMessages`:
|
||||
|
||||
1. `run()` is **skipped** — no response is generated on old code
|
||||
2. The agent calls the server-side `endAndContinueSession` endpoint, which atomically swaps the Session's `currentRunId` to a freshly-triggered run on the latest deployment (optimistic-claim against `currentRunVersion`)
|
||||
3. The new run picks up the conversation and produces the response
|
||||
4. The transport's existing SSE subscription to `session.out` keeps receiving chunks across the swap — no client-side reconnect
|
||||
|
||||
The new run lives on the **same Session** as the old one. `chatId` is the durable identity; only the underlying `currentRunId` rotates. The audit log records the new run with `reason: "upgrade"`.
|
||||
|
||||
When called from inside `run()` or `chat.defer()`, the current turn completes normally first and the run exits afterward. The next message triggers the continuation on the same session.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Transport
|
||||
participant RunV1 as Run (v1)
|
||||
participant RunV2 as Run (v2)
|
||||
|
||||
User->>Transport: send message
|
||||
Transport->>RunV1: input stream
|
||||
RunV1->>RunV1: onTurnStart → requestUpgrade()
|
||||
RunV1-->>Transport: trigger:upgrade-required
|
||||
RunV1->>RunV1: exit (run() never called)
|
||||
Transport->>RunV2: trigger new run (continuation, same message)
|
||||
RunV2-->>Transport: response stream
|
||||
Transport-->>User: response (seamless)
|
||||
```
|
||||
|
||||
## Contract versioning
|
||||
|
||||
Define an explicit version for the contract between your frontend and agent. The frontend sends a `protocolVersion` via `clientData`, and the agent declares which versions it supports. When a breaking change ships (new tools, changed data parts, updated response format), bump the version.
|
||||
|
||||
This gives you full control — the frontend can be backwards-compatible across multiple agent versions, and the agent only upgrades when it sees a version it doesn't support.
|
||||
|
||||
```tsx title="app/components/Chat.tsx"
|
||||
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
|
||||
export function Chat() {
|
||||
const transport = useTriggerChatTransport({
|
||||
task: "my-chat",
|
||||
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
|
||||
startSession: ({ chatId, clientData }) =>
|
||||
startChatSession({ chatId, clientData }),
|
||||
// Bump this when you ship a breaking change to the chat UI or tools
|
||||
clientData: { userId: user.id, protocolVersion: "v2" },
|
||||
});
|
||||
|
||||
const { messages, sendMessage } = useChat({ transport });
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
On the agent side, declare which versions the current code supports:
|
||||
|
||||
```ts
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { streamText } from "ai";
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
|
||||
// The set of frontend protocol versions this agent code supports.
|
||||
// When you deploy a breaking change, remove old versions from this set.
|
||||
const SUPPORTED_VERSIONS = new Set(["v2", "v3"]);
|
||||
|
||||
export const myChat = chat
|
||||
.withClientData({
|
||||
schema: z.object({
|
||||
userId: z.string(),
|
||||
protocolVersion: z.string(),
|
||||
}),
|
||||
})
|
||||
.agent({
|
||||
id: "my-chat",
|
||||
onTurnStart: async ({ clientData }) => {
|
||||
if (clientData?.protocolVersion && !SUPPORTED_VERSIONS.has(clientData.protocolVersion)) {
|
||||
chat.requestUpgrade();
|
||||
}
|
||||
},
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The transport includes `clientData` in every payload — both the initial trigger and subsequent records on the session's `.in` channel — so the agent always has the current value.
|
||||
|
||||
This pattern is useful when:
|
||||
- Your frontend is backwards-compatible across several agent versions, but occasionally ships breaking changes
|
||||
- You want explicit control over when upgrades happen rather than upgrading on every deploy
|
||||
- Multiple frontend versions may be active at the same time (e.g., users with cached tabs)
|
||||
|
||||
## Auto-detect from build ID (Next.js / Vercel)
|
||||
|
||||
For automatic upgrade on every deploy, pass your platform's build ID via `clientData` instead of a manual version. The agent stores the ID from the first message and upgrades when it changes:
|
||||
|
||||
```tsx title="app/components/Chat.tsx"
|
||||
// Vercel sets this at build time, or use your own build ID
|
||||
const APP_VERSION = process.env.NEXT_PUBLIC_VERCEL_DEPLOYMENT_ID
|
||||
?? process.env.NEXT_PUBLIC_BUILD_ID
|
||||
?? "dev";
|
||||
|
||||
export function Chat() {
|
||||
const transport = useTriggerChatTransport({
|
||||
task: "my-chat",
|
||||
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
|
||||
startSession: ({ chatId, clientData }) =>
|
||||
startChatSession({ chatId, clientData }),
|
||||
clientData: { userId: user.id, appVersion: APP_VERSION },
|
||||
});
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
```ts title="trigger/chat.ts"
|
||||
const initialAppVersion = chat.local<{ version: string }>({ id: "appVersion" });
|
||||
|
||||
export const myChat = chat
|
||||
.withClientData({
|
||||
schema: z.object({
|
||||
userId: z.string(),
|
||||
appVersion: z.string(),
|
||||
}),
|
||||
})
|
||||
.agent({
|
||||
id: "my-chat",
|
||||
onBoot: async ({ clientData }) => {
|
||||
initialAppVersion.init({ version: clientData.appVersion });
|
||||
},
|
||||
onTurnStart: async ({ clientData }) => {
|
||||
if (clientData?.appVersion && clientData.appVersion !== initialAppVersion.version) {
|
||||
chat.requestUpgrade();
|
||||
}
|
||||
},
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({ model: anthropic("claude-sonnet-4-5"), messages, abortSignal: signal });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
This upgrades on **every** deploy, not just breaking changes. Good for fast-moving projects where you always want the latest code.
|
||||
|
||||
## Other agent types
|
||||
|
||||
- **`chat.agent()`** and **`chat.createSession()`** — use `chat.requestUpgrade()` as shown above
|
||||
- **`chat.customAgent()`** — you control the turn loop, so just `return` from `run()` when you want to exit
|
||||
|
||||
## Interaction with recovery boot
|
||||
|
||||
`chat.requestUpgrade()` is a graceful exit — the old run returns cleanly, never writing a partial assistant. The new continuation run boots with an empty `session.out` tail and the upgrade-trigger message on `session.in`. The trigger message dispatches as turn 1 on the new version via the normal continuation-wait path. [`onRecoveryBoot`](/ai-chat/patterns/recovery-boot) does NOT fire on this path — the hook is reserved for mid-stream interruptions (cancel / crash / OOM) where a partial assistant exists on the tail.
|
||||
|
||||
## See also
|
||||
|
||||
- [Lifecycle hooks](/ai-chat/lifecycle-hooks) — where `onTurnStart` and `onChatResume` fit in the turn cycle
|
||||
- [Recovery boot](/ai-chat/patterns/recovery-boot) — the sibling hook for mid-stream interruptions (does NOT fire on `requestUpgrade`)
|
||||
- [Database persistence](/ai-chat/patterns/database-persistence) — how continuations interact with session state
|
||||
- [Client Protocol](/ai-chat/client-protocol#step-4-handle-continuations) — how clients handle continuations at the wire level
|
||||
Reference in New Issue
Block a user