# Branching conversations

> Source: https://trigger.dev/docs/ai-chat/patterns/branching-conversations

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](https://trigger.dev/docs/ai-chat/reference#compatibility)
 and the [AI chat changelog](https://trigger.dev/docs/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.

[​](https://trigger.dev/docs/ai-chat/patterns/branching-conversations#data-model)

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.

[​](https://trigger.dev/docs/ai-chat/patterns/branching-conversations#backend-setup)

Backend setup
-----------------------------------------------------------------------------------------------------

### 

[​](https://trigger.dev/docs/ai-chat/patterns/branching-conversations#store-tree-operations)

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() },
      });
    }
    

### 

[​](https://trigger.dev/docs/ai-chat/patterns/branching-conversations#agent-hydration-+-actions)

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),
        });
      },
    });
    

[​](https://trigger.dev/docs/ai-chat/patterns/branching-conversations#frontend)

Frontend
-------------------------------------------------------------------------------------------

### 

[​](https://trigger.dev/docs/ai-chat/patterns/branching-conversations#sending-actions)

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>
      );
    }
    

### 

[​](https://trigger.dev/docs/ai-chat/patterns/branching-conversations#branch-navigation)

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,
              });
            }}
          >
            &lt;
          </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,
              });
            }}
          >
            &gt;
          </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`.

[​](https://trigger.dev/docs/ai-chat/patterns/branching-conversations#how-it-works)

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 |

[​](https://trigger.dev/docs/ai-chat/patterns/branching-conversations#design-notes)

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.

[​](https://trigger.dev/docs/ai-chat/patterns/branching-conversations#see-also)

See also
-------------------------------------------------------------------------------------------

*   [`hydrateMessages`](https://trigger.dev/docs/ai-chat/lifecycle-hooks#hydratemessages)
     — backend-controlled message history
*   [Actions](https://trigger.dev/docs/ai-chat/actions)
     — custom actions with `actionSchema` and `onAction`
*   [`chat.history`](https://trigger.dev/docs/ai-chat/backend#chat-history)
     — imperative history mutations
*   [Database persistence](https://trigger.dev/docs/ai-chat/patterns/database-persistence)
     — basic persistence pattern (linear)

Was this page helpful?

YesNo

[Previous](https://trigger.dev/docs/ai-chat/patterns/persistence-and-replay)
[Code sandboxWarm 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.\
\
Next](https://trigger.dev/docs/ai-chat/patterns/code-sandbox)

Ctrl+I

Assistant

Responses are generated using AI and may contain mistakes.
