--- 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 { 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 { 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 (
{editing ? (
{ transport.sendAction(chatId, { type: "edit", messageId: message.id, text: editText }); setEditing(false); }}> setEditText(e.target.value)} />
) : ( )}
); } ``` ### 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 (
{currentIndex + 1}/{siblings.length}
); } ``` 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 - [`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)