Skip to main content
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:
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:
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:

Agent: hydration + actions

Frontend

Sending actions

Wire up edit, undo, and branch switching to the transport:

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.
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

OperationWhat happens
Send messagehydrateMessages appends the new message as a child of the current leaf, returns the active path
Edit messageonAction 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
RegenerateSame as edit — create a new assistant sibling. The AI SDK’s regenerate() handles this via trigger: "regenerate-message"
UndoonAction removes the last two nodes. chat.history.set() updates the accumulator. LLM responds to the earlier state
Switch branchonAction 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