Documentation Index
Fetch the complete documentation index at: https://trigger.dev/docs/llms.txt
Use this file to discover all available pages before exploring further.
The AI Agents and Prompts surface ships as part of the v4.5 release candidate. Install with @trigger.dev/sdk@rc (or pin 4.5.0-rc.0 or later) to use these features — they aren’t yet on the latest stable, and APIs may still change before the 4.5.0 GA. See supported AI SDK versions and the AI chat changelog for details.
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:
// 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:
// 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
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:
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.
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>
);
}
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.
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