A tiny TypeScript utility for modeling conversation threads as a map of nodes with lightweight relationships:
parent→ points “up” the threadchild→ points to the active child (the currently selected branch)root→ thread identifier (root nodes haveroot === id)metadata→ optional extra data you want to store per message
It’s designed for chat UIs, branching conversations (“regenerate”), and tree-like message histories while keeping updates immutable (returns a new mappings object, preserves referential equality on no-ops where possible).
npm i message-nodes(or)
yarn add message-nodesEach message is a MessageNode:
export interface MessageNode<C = string, M = Record<string, any>> {
id: string;
role: string;
content: C;
root: string;
parent?: string | undefined;
child?: string | undefined;
metadata?: M | undefined;
}- All nodes live in a single object:
Record<string, MessageNode> - Multiple children may share the same
parent(branching), but:- the parent’s
childproperty is the active child in that branch set
- the parent’s
import {
addNode,
branchNode,
getConversation,
getRoots,
makeRoot,
updateContent,
deleteNode,
type MessageNode,
} from "message-nodes";
type Meta = { model?: string; tokens?: number };
let mappings: Record<string, MessageNode<string, Meta>> = {};
// Create a root message (no parent => root === id)
mappings = addNode(mappings, "root-1", "system", "New chat");
// Add first user message under the root
mappings = addNode(mappings, "u1", "user", "Hello!", undefined, "root-1");
// Add assistant response (as active child of u1)
mappings = addNode(mappings, "a1", "assistant", "Hi there đź‘‹", undefined, "u1", undefined, { model: "gpt" });
// Read the active conversation chain from the root
const convo = getConversation(mappings, "root-1"); // [u1, a1]
// Branch the assistant response (e.g., regenerate)
mappings = branchNode(mappings, "a1", "a2", "Alternative answer", { model: "gpt", tokens: 123 });
// Now u1.child points to "a2" (active branch)
const convo2 = getConversation(mappings, "root-1"); // [u1, a2]
// Update content (immutable)
mappings = updateContent(mappings, "a2", (prev) => prev + " âś…");
// Delete a node and all descendants
mappings = deleteNode(mappings, "u1");Returns true if the node exists.
Gets a node by id.
Walks parent pointers until the top-most node.
Note:
getRootfinds the “top of chain” byparentpointers, while therootfield is a thread identifier you can rewrite withmakeRoot.
Returns all thread roots (node.root === node.id).
Returns the active chain from rootId following .child pointers.
- If the root doesn’t exist →
[](and warns) - If the active chain contains a cycle → stops (and warns)
Returns [node, parent, grandparent, ...] walking parent pointers.
Detects cycles.
Returns all direct children where msg.parent === id.
Sets a parent’s active child pointer only if:
- parent exists
- child exists (if provided) and
child.parent === parentId
No-op if it wouldn’t change anything.
Moves the parent’s active child to the “next” sibling among getChildren(parentId).
The ordering is whatever
Object.values(mappings)produces forgetChildren, so if you need deterministic order, keep your own ordering strategy (e.g. storecreatedAtin metadata and sort externally).
Moves the parent’s active child to the “previous” sibling.
Adds a new node, optionally linking it.
Behavior:
- If
parentis not provided: the node becomes a root (root = id) - If
parentis provided:rootis inferred fromgetRoot(parent)(top of chain), and the parent’s activechildis set to the new node - If
childis provided: the child’sparentis set to the new node
Validates that referenced parent, child, and root exist (when applicable), otherwise returns unchanged and warns.
Creates a sibling under the same parent as existingId.
- Uses the existing node’s
role,root, andparent - Makes the new sibling the active child of the parent (via
addNodebehavior)
Great for “regenerate answer” branching.
Updates a node’s content (and optionally metadata) immutably.
contentcan be a value or(prev) => nextmetadatacan be a value or(prev) => next- Returns unchanged on no-op updates (content is
Object.isequal)
Deletes the node and all descendants (all nodes reachable via parent === id, recursively).
Also attempts to keep the parent thread usable:
- If the deleted node was the parent’s active child, it switches the parent’s
childto another sibling if one exists.
Cycle-safe.
Isolates the node:
- clears its
parent,child - sets
root = id - detaches it from its parent’s active child pointer (if pointing at it)
- detaches its child’s parent pointer (if pointing at it)
Converts a node into a root without deleting its subtree.
- Detaches it from its parent (parent’s active child cleared if it was pointing at this node)
- Rewrites
rooton the node and all descendants (following both the active.childchain and all direct children viagetChildren), so the whole sub-tree becomes a new thread.
// Suppose u1.child is currently "a1"
mappings = branchNode(mappings, "a1", "a2", "New answer");
mappings = updateContent(mappings, "a2", "Streaming...");const roots = getRoots(mappings); // list of root nodes (threads)type Meta = { selected?: boolean; createdAt?: number };
mappings = updateContent(
mappings,
"u1",
(c) => c,
(m) => ({ ...(m ?? {}), selected: true })
);getConversationfollows the active.childchain only. If you want “all nodes in a thread”, you can start from a root and traverse viagetChildrenrecursively.nextChild/lastChilddepend on the order returned bygetChildren(which is based on object iteration). For deterministic ordering, store ordering metadata and build your own sorted child list.- Functions generally warn and return the original mappings on invalid operations, preserving referential equality.
MIT License
Copyright (c) 2026 Dane Madsen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.