Skip to content

Commit 100634f

Browse files
committed
docs: add hydrateMessages, chat.history, and actions documentation
1 parent 1451aeb commit 100634f

File tree

6 files changed

+355
-0
lines changed

6 files changed

+355
-0
lines changed

docs/ai-chat/backend.mdx

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,56 @@ export const myChat = chat.agent({
181181
`onValidateMessages` fires **before** `onTurnStart` and message accumulation. If you need to validate messages loaded from a database, do the loading in `onChatStart` or `onPreload` and let `onValidateMessages` validate the full incoming set each turn.
182182
</Note>
183183

184+
#### hydrateMessages
185+
186+
Load the full message history from your backend on every turn, replacing the built-in linear accumulator. When set, the hook's return value becomes the accumulated state — the normal accumulation logic (append for submit, replace for regenerate) is skipped entirely.
187+
188+
Use this when the backend should be the source of truth for message history — abuse prevention, branching conversations (DAGs), or rollback/undo support.
189+
190+
| Field | Type | Description |
191+
| ------------------ | ----------------------------------------------------- | --------------------------------------------------------- |
192+
| `chatId` | `string` | Chat session ID |
193+
| `turn` | `number` | Turn number (0-indexed) |
194+
| `trigger` | `"submit-message" \| "regenerate-message" \| "action"` | The trigger type for this turn |
195+
| `incomingMessages` | `UIMessage[]` | Validated wire messages from the frontend (empty for actions) |
196+
| `previousMessages` | `UIMessage[]` | Accumulated UI messages before this turn (`[]` on turn 0) |
197+
| `clientData` | Typed by `clientDataSchema` | Custom data from the frontend |
198+
| `continuation` | `boolean` | Whether this run is continuing an existing chat |
199+
| `previousRunId` | `string \| undefined` | The previous run ID (if continuation) |
200+
201+
```ts
202+
export const myChat = chat.agent({
203+
id: "my-chat",
204+
hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
205+
const record = await db.chat.findUnique({ where: { id: chatId } });
206+
const stored = record?.messages ?? [];
207+
208+
// Append the new user message and persist
209+
if (trigger === "submit-message" && incomingMessages.length > 0) {
210+
const newMsg = incomingMessages[incomingMessages.length - 1]!;
211+
stored.push(newMsg);
212+
await db.chat.update({
213+
where: { id: chatId },
214+
data: { messages: stored },
215+
});
216+
}
217+
218+
return stored;
219+
},
220+
run: async ({ messages, signal }) => {
221+
return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal });
222+
},
223+
});
224+
```
225+
226+
**Lifecycle position:** `onValidateMessages`**`hydrateMessages`**`onChatStart` (turn 0) → `onTurnStart``run()`
227+
228+
After the hook returns, any incoming wire message whose ID matches a hydrated message is auto-merged — this makes [tool approvals](/ai-chat/frontend#tool-approvals) work transparently with hydration.
229+
230+
<Note>
231+
`hydrateMessages` also fires for [action](#actions) turns (`trigger: "action"`) with empty `incomingMessages`. This lets the action handler work with the latest DB state.
232+
</Note>
233+
184234
#### onTurnStart
185235

186236
Fires at the start of every turn, after message accumulation and `onChatStart` (turn 0), but **before** `run()` executes. Use it to persist messages before streaming begins — so a mid-stream page refresh still shows the user's message.
@@ -785,6 +835,96 @@ export const myChat = chat.agent({
785835
example, and how it differs from pending messages.
786836
</Tip>
787837

838+
### Actions
839+
840+
Custom actions let the frontend send structured commands (undo, rollback, edit) that modify the conversation state before the LLM responds. Actions use the same input stream as messages, so they wake the agent from suspension and trigger a full turn.
841+
842+
Define an `actionSchema` for validation and an `onAction` handler that uses `chat.history` to modify state:
843+
844+
```ts
845+
import { z } from "zod";
846+
847+
export const myChat = chat.agent({
848+
id: "my-chat",
849+
actionSchema: z.discriminatedUnion("type", [
850+
z.object({ type: z.literal("undo") }),
851+
z.object({ type: z.literal("rollback"), targetMessageId: z.string() }),
852+
z.object({ type: z.literal("edit"), messageId: z.string(), text: z.string() }),
853+
]),
854+
855+
onAction: async ({ action }) => {
856+
switch (action.type) {
857+
case "undo":
858+
chat.history.slice(0, -2); // Remove last user + assistant exchange
859+
break;
860+
case "rollback":
861+
chat.history.rollbackTo(action.targetMessageId);
862+
break;
863+
case "edit":
864+
chat.history.replace(action.messageId, {
865+
id: action.messageId,
866+
role: "user",
867+
parts: [{ type: "text", text: action.text }],
868+
});
869+
break;
870+
}
871+
},
872+
873+
run: async ({ messages, signal }) => {
874+
return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal });
875+
},
876+
});
877+
```
878+
879+
**Lifecycle flow:** Wake → parse action against `actionSchema``hydrateMessages` (if set) → **`onAction`** → apply `chat.history` mutations → `onTurnStart``run()``onTurnComplete`
880+
881+
On the frontend, send actions via the transport:
882+
883+
```ts
884+
// Browser — TriggerChatTransport
885+
const stream = await transport.sendAction(chatId, { type: "undo" });
886+
887+
// Server — AgentChat
888+
const stream = await agentChat.sendAction({ type: "rollback", targetMessageId: "msg-3" });
889+
```
890+
891+
The action payload is validated against `actionSchema` on the backend — invalid actions throw and abort the turn. The `action` parameter in `onAction` is fully typed from the schema.
892+
893+
<Note>
894+
Actions always trigger `run()` — the LLM responds to the modified state. For silent state changes that don't need a response (e.g. injecting background context), use [`chat.inject()`](/ai-chat/background-injection) instead.
895+
</Note>
896+
897+
### chat.history {#chat-history}
898+
899+
Imperative API for modifying the accumulated message history. Works from any hook (`onAction`, `onTurnStart`, `onBeforeTurnComplete`, `onTurnComplete`) or from `run()` and AI SDK tools.
900+
901+
| Method | Description |
902+
|--------|-------------|
903+
| `chat.history.all()` | Read the current accumulated UI messages (returns a copy) |
904+
| `chat.history.set(messages)` | Replace all messages (same as `chat.setMessages()`) |
905+
| `chat.history.remove(messageId)` | Remove a specific message by ID |
906+
| `chat.history.rollbackTo(messageId)` | Keep messages up to and including the given ID (undo) |
907+
| `chat.history.replace(messageId, message)` | Replace a specific message by ID (edit) |
908+
| `chat.history.slice(start, end?)` | Keep only messages in the given range |
909+
910+
```ts
911+
// Undo the last exchange in onAction
912+
onAction: async ({ action }) => {
913+
if (action.type === "undo") {
914+
chat.history.slice(0, -2);
915+
}
916+
},
917+
918+
// Trim history in onTurnComplete
919+
onTurnComplete: async ({ uiMessages }) => {
920+
if (uiMessages.length > 50) {
921+
chat.history.slice(-20);
922+
}
923+
},
924+
```
925+
926+
Mutations use the same deferred mechanism as `chat.setMessages()` — they are applied at lifecycle checkpoints (after hooks return). Multiple mutations in the same hook compose correctly.
927+
788928
### prepareMessages
789929

790930
Transform model messages before they're used anywhere — in `run()`, in compaction rebuilds, and in compaction results. Define once, applied everywhere.

docs/ai-chat/changelog.mdx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,69 @@ sidebarTitle: "Changelog"
44
description: "Pre-release updates for AI chat agents."
55
---
66

7+
<Update label="April 15, 2026" description="0.0.0-chat-prerelease-20260415152704" tags={["SDK"]}>
8+
9+
## `hydrateMessages` — backend-controlled message history
10+
11+
Load message history from your database on every turn instead of trusting the frontend accumulator. The hook replaces the built-in linear accumulation entirely — the backend is the source of truth.
12+
13+
```ts
14+
chat.agent({
15+
id: "my-chat",
16+
hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
17+
const stored = await db.getMessages(chatId);
18+
if (trigger === "submit-message" && incomingMessages.length > 0) {
19+
stored.push(incomingMessages[incomingMessages.length - 1]!);
20+
await db.persistMessages(chatId, stored);
21+
}
22+
return stored;
23+
},
24+
});
25+
```
26+
27+
Tool approval updates are auto-merged after hydration — no extra handling needed.
28+
29+
See [hydrateMessages](/ai-chat/backend#hydratemessages).
30+
31+
## `chat.history` — imperative message mutations
32+
33+
Modify the accumulated message history from any hook or `run()`:
34+
35+
```ts
36+
chat.history.rollbackTo(messageId); // Undo — keep up to this message
37+
chat.history.remove(messageId); // Remove one message
38+
chat.history.replace(id, newMsg); // Edit a message
39+
chat.history.slice(0, -2); // Remove last 2 messages
40+
chat.history.all(); // Read current state
41+
```
42+
43+
See [chat.history](/ai-chat/backend#chat-history).
44+
45+
## Custom actions — `actionSchema` + `onAction`
46+
47+
Send typed actions (undo, rollback, edit) from the frontend via `transport.sendAction()`. Actions wake the agent, fire `onAction`, then trigger a normal `run()` turn.
48+
49+
```ts
50+
chat.agent({
51+
id: "my-chat",
52+
actionSchema: z.discriminatedUnion("type", [
53+
z.object({ type: z.literal("undo") }),
54+
z.object({ type: z.literal("rollback"), targetMessageId: z.string() }),
55+
]),
56+
onAction: async ({ action }) => {
57+
if (action.type === "undo") chat.history.slice(0, -2);
58+
if (action.type === "rollback") chat.history.rollbackTo(action.targetMessageId);
59+
},
60+
});
61+
```
62+
63+
Frontend: `transport.sendAction(chatId, { type: "undo" })`
64+
Server: `agentChat.sendAction({ type: "undo" })`
65+
66+
See [Actions](/ai-chat/backend#actions) and [Sending actions](/ai-chat/frontend#sending-actions).
67+
68+
</Update>
69+
770
<Update label="April 14, 2026" description="0.0.0-chat-prerelease-20260414181032" tags={["SDK"]}>
871

972
## `chat.response` — persistent data parts

docs/ai-chat/client-protocol.mdx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,34 @@ The agent matches the incoming message by its `id` against the accumulated conve
290290
The message `id` must match the one the agent assigned during streaming. If you're using `TriggerChatTransport`, IDs are kept in sync automatically. Custom transports should use the `messageId` from the stream's `start` chunk.
291291
</Note>
292292
293+
## Custom actions
294+
295+
Send a custom action (undo, rollback, edit) to the agent using the same `chat-messages` input stream. Actions use `trigger: "action"` and carry a custom payload in the `action` field:
296+
297+
```bash
298+
POST /realtime/v1/streams/{runId}/input/chat-messages
299+
Authorization: Bearer <publicAccessToken>
300+
Content-Type: application/json
301+
302+
{
303+
"data": {
304+
"messages": [],
305+
"chatId": "conversation-123",
306+
"trigger": "action",
307+
"action": { "type": "undo" },
308+
"metadata": { "userId": "user-456" }
309+
}
310+
}
311+
```
312+
313+
Actions wake the agent from suspension (same as messages), fire the `onAction` hook, then trigger a normal `run()` turn. The `action` payload is validated against the agent's `actionSchema`.
314+
315+
After sending, subscribe to the output stream to receive the agent's response — the same flow as [Step 2](#step-2-subscribe-to-the-output-stream).
316+
317+
<Note>
318+
`messages` is empty for actions. The agent's `onAction` handler modifies the conversation state via `chat.history.*`, and the LLM responds to the updated state. See [Actions](/ai-chat/backend#actions) for backend setup.
319+
</Note>
320+
293321
## Pending and steering messages
294322
295323
You can send messages to the agent **while it's still streaming a response**. These are called pending messages — the agent receives them mid-turn and can inject them between tool-call steps.

docs/ai-chat/frontend.mdx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,42 @@ function Chat({ chatId, transport }) {
370370
for tool approval updates.
371371
</Info>
372372

373+
## Sending actions
374+
375+
Send custom actions (undo, rollback, edit) to the agent via `transport.sendAction()`. Actions wake the agent, fire the `onAction` hook, and trigger a normal response — the LLM responds to the modified state.
376+
377+
```tsx
378+
function ChatControls({ chatId }: { chatId: string }) {
379+
const transport = useTriggerChatTransport({ task: "my-chat", accessToken });
380+
381+
return (
382+
<div>
383+
<button onClick={() => transport.sendAction(chatId, { type: "undo" })}>
384+
Undo last exchange
385+
</button>
386+
<button onClick={() => transport.sendAction(chatId, { type: "rollback", targetMessageId: "msg-5" })}>
387+
Rollback to message
388+
</button>
389+
</div>
390+
);
391+
}
392+
```
393+
394+
The action payload is validated against the agent's `actionSchema` on the backend — invalid actions are rejected. See [Actions](/ai-chat/backend#actions) for the backend setup.
395+
396+
<Note>
397+
`sendAction` returns a `ReadableStream<UIMessageChunk>` — the agent's response to the modified state. If you're using `useChat`, the response is handled automatically through the transport.
398+
</Note>
399+
400+
For server-to-server usage, `AgentChat` has the same method:
401+
402+
```ts
403+
const stream = await agentChat.sendAction({ type: "undo" });
404+
for await (const chunk of stream) {
405+
if (chunk.type === "text-delta") process.stdout.write(chunk.delta);
406+
}
407+
```
408+
373409
## Self-hosting
374410

375411
If you're self-hosting Trigger.dev, pass the `baseURL` option:

docs/ai-chat/patterns/database-persistence.mdx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,38 @@ chat.agent({
113113
});
114114
```
115115

116+
## Alternative: `hydrateMessages`
117+
118+
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/backend#hydratemessages) instead of relying on the frontend's accumulated state.
119+
120+
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`):
121+
122+
```ts
123+
export const myChat = chat.agent({
124+
id: "my-chat",
125+
hydrateMessages: async ({ chatId, trigger, incomingMessages }) => {
126+
const record = await db.chat.findUnique({ where: { id: chatId } });
127+
const stored = record?.messages ?? [];
128+
129+
if (trigger === "submit-message" && incomingMessages.length > 0) {
130+
stored.push(incomingMessages[incomingMessages.length - 1]!);
131+
await db.chat.update({ where: { id: chatId }, data: { messages: stored } });
132+
}
133+
134+
return stored;
135+
},
136+
onTurnComplete: async ({ chatId, uiMessages }) => {
137+
// Persist the response
138+
await db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } });
139+
},
140+
run: async ({ messages, signal }) => {
141+
return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal });
142+
},
143+
});
144+
```
145+
146+
This replaces the `onTurnStart` persistence pattern — the hook handles both loading and persisting the new message in one place.
147+
116148
## Design notes
117149

118150
- **`chatId`** is stable for the life of a thread; **`runId`** changes when the user starts a **new** run (timeout, cancel, explicit new chat). Session rows must always reflect the **current** run.

0 commit comments

Comments
 (0)