Overview
Events don’t just stream in isolation — they form a conversation graph . Each event becomes a node, and edges link nodes based on runId and parentId. This graph captures the full structure of multi-agent conversations, including parallel tool execution and nested subagent branches.
The graph is:
Immutable : Events are append-only. Reducing a new event produces a new graph.
Directed : Edges go from parent to child, forming a DAG (directed acyclic graph).
Typed : Each node has a kind field that determines its shape.
Graph structure
The graph is defined in packages/ai/client/types.ts:27:
interface Graph {
nodes : Map < string , Node >; // All nodes, keyed by id
edges : Map < string , string []>; // Adjacency list: nodeId → childIds[]
lastNodeByRunId : Map < string , string >; // Tracks most recent node per runId
}
Nodes
Each event becomes a node with a deterministic ID:
type Node = { id : string ; runId : string } & (
| { kind : "text" ; content : string }
| { kind : "reasoning" ; content : string }
| { kind : "tool_call" ; name : string ; input : unknown }
| { kind : "tool_result" ; name : string ; output : unknown }
| { kind : "tool_progress" ; toolCallId : string ; name : string ; content : unknown }
| { kind : "user" ; content : string | ContentPart [] }
| { kind : "harness_start" ; agentId : string }
| { kind : "harness_end" ; agentId : string }
| { kind : "error" ; message : string }
| { kind : "usage" ; inputTokens : number ; outputTokens : number }
| { kind : "relay" ; relayKind : "permission" ; toolCallId : string ; tool : string ; params : Record < string , unknown > }
);
Node IDs
Node IDs are derived deterministically from events (packages/ai/client/graph.ts:38):
Event type Node ID text, reasoning, tool_call, relayevent.idtool_resultevent.id + ":result" (suffix to distinguish from tool_call)harness_startevent.runId + ":harness_start"harness_endevent.runId + ":harness_end"errorevent.runId + ":error"usageevent.runId + ":usage:" + counteruserevent.runId + ":user"
Edges
Edges link nodes in two ways:
Sequential edges : Within a runId, each node links to the next node from the same run.
Cross-run edges : The first node in a run with a parentId links from that parentId node.
Edge construction (from graph.ts:186)
// Cross-run edge: first event in this run with parentId
if ( ! prevInRun && parentId ) {
edges = addEdge ( edges , parentId , nodeId );
}
// Sequential edge: from previous node in this run to this node
if ( prevInRun ) {
edges = addEdge ( edges , prevInRun , nodeId );
}
Building the graph
Reduce events into a graph using reduceEvent:
import { createGraph , reduceEvent } from "./packages/ai/client/graph" ;
let graph = createGraph ();
for await ( const event of orchestrator . events ()) {
graph = reduceEvent ( graph , event );
}
// Now graph.nodes contains all events as nodes
// and graph.edges links them
console . log ( `Graph has ${ graph . nodes . size } nodes` );
Streaming updates
For text and reasoning events, the reducer appends content to existing nodes instead of creating new nodes:
Streaming accumulation (from graph.ts:166)
const existingNode = nodes . get ( nodeId );
if ( existingNode ) {
if (
( event . type === "text" && existingNode . kind === "text" ) ||
( event . type === "reasoning" && existingNode . kind === "reasoning" )
) {
// Append content, don't add new edges
nodes . set ( nodeId , {
... existingNode ,
content: existingNode . content + event . content
});
return { nodes , edges , lastNodeByRunId };
}
}
This means multiple text events with the same id produce a single node with accumulated content.
Querying the graph
Common queries on the graph structure:
Get children of a node
function getChildren ( graph : Graph , nodeId : string ) : Node [] {
const childIds = graph . edges . get ( nodeId ) ?? [];
return childIds . map ( id => graph . nodes . get ( id ) ! ). filter ( Boolean );
}
const children = getChildren ( graph , "tc-1" );
console . log ( `Tool call has ${ children . length } children` );
Get all nodes in a run
function getNodesInRun ( graph : Graph , runId : string ) : Node [] {
return Array . from ( graph . nodes . values ()). filter ( n => n . runId === runId );
}
const agentNodes = getNodesInRun ( graph , "agent-123" );
Get text content
function getText ( graph : Graph , runId : string ) : string {
const textNodes = Array . from ( graph . nodes . values ())
. filter ( n => n . runId === runId && n . kind === "text" );
return textNodes . map ( n => n . content ). join ( "" );
}
const response = getText ( graph , "agent-123" );
function getToolCalls ( graph : Graph , runId : string ) : Node [] {
return Array . from ( graph . nodes . values ())
. filter ( n => n . runId === runId && n . kind === "tool_call" );
}
const tools = getToolCalls ( graph , "agent-123" );
console . log ( `Agent made ${ tools . length } tool calls` );
Example graph
Here’s what a graph looks like for an agent with one tool call:
User message
runId: "user-1"
nodes:
- id: "user-1:user", kind: "user", content: "List files"
↓ (edge from user-1:user to agent-1:harness_start)
Agent run
runId: "agent-1"
parentId: "user-1:user"
nodes:
- id: "agent-1:harness_start", kind: "harness_start"
- id: "text-1", kind: "text", content: "I'll list the files..."
- id: "tc-1", kind: "tool_call", name: "bash", input: { command: "ls" }
- id: "usage-1", kind: "usage", inputTokens: 50, outputTokens: 20
- id: "relay-1", kind: "relay", toolCallId: "tc-1", tool: "bash"
- id: "tc-1:result", kind: "tool_result", name: "bash", output: { context: "file1.txt\nfile2.txt" }
- id: "text-2", kind: "text", content: "The directory contains..."
- id: "usage-2", kind: "usage", inputTokens: 70, outputTokens: 15
- id: "agent-1:harness_end", kind: "harness_end"
edges:
agent-1:harness_start → text-1
text-1 → tc-1
tc-1 → usage-1
usage-1 → relay-1
relay-1 → tc-1:result
tc-1:result → text-2
text-2 → usage-2
usage-2 → agent-1:harness_end
Subagent graph structure
Subagents introduce cross-run edges. When an agent spawns a subagent via the agent tool, the subagent’s first node links from the parent’s tool call:
Parent agent (runId: "a1")
- text "I'll search..."
- tool_call id:"tc-1" name:"agent" input:{task:"search for X"}
↓ (cross-run edge from tc-1 to a2:harness_start)
Subagent (runId: "a2", parentId: "tc-1")
- harness_start
- text "Searching..."
- tool_call id:"tc-2" name:"bash" input:{command:"grep ..."}
- tool_result id:"tc-2" output:{...}
- text "Found results"
- harness_end
↓ (back to parent, tool_result for tc-1)
Parent agent continues
- tool_result id:"tc-1" output:{result from subagent}
- text "Based on the search..."
The graph captures this naturally:
tc-1 (parent tool call) → a2:harness_start (subagent start) via cross-run edge
All of subagent a2’s nodes link sequentially
Parent resumes after subagent completes
See Subagents for details on spawning and coordinating subagents.
Projections
Raw graph nodes aren’t ideal for rendering. Projections transform the graph into view-specific formats:
Thread projection
Produces a linear thread of messages for chat UIs:
import { projectThread } from "./packages/ai/client/hypergraph/projections" ;
import { defaultActive } from "./packages/ai/client/hypergraph/active" ;
const active = defaultActive ( conversation );
const thread = projectThread ( conversation , active );
// thread is ViewNode[] with text, tool_call, tool_result blocks
thread . forEach ( node => {
console . log ( `[ ${ node . role } ]` , node . text );
node . toolCalls ?. forEach ( tc => console . log ( ` Tool: ${ tc . name } ` ));
});
Message projection
Produces an array of Message objects for feeding back into harnesses:
import { projectMessages } from "./packages/ai/client/hypergraph/projections" ;
const messages = projectMessages ( conversation , active );
// messages is Message[] ready for harness.invoke()
const response = await agent . invoke ({ model: "glm-4.7" , messages });
DAG projection
Produces a layout for graph visualization:
import { projectDAG } from "./packages/ai/client/hypergraph/projections" ;
const layout = projectDAG ( conversation , active );
// layout has nodes with (x, y) positions and edges for rendering
layout . nodes . forEach ( n => {
console . log ( ` ${ n . label } at ( ${ n . x } , ${ n . y } )` );
});
Hypergraph model
The new hypergraph model (active implementation in packages/ai/client/hypergraph/) extends the flat graph with a three-tier hierarchy:
Chunks : Individual content pieces (text, tool_call, tool_result)
Blocks : Groups of chunks from one harness invocation
Messages : Top-level conversation turns (user messages, agent responses)
Hyperedges connect these tiers:
chunk_to_block: Which block contains this chunk
block_to_message: Which message contains this block
parent_message: Reply-to relationships
This model enables:
Efficient message threading
Nested subagent rendering
Selective projection (hide reasoning, collapse tool calls)
Edit/branch operations
See packages/ai/client/hypergraph/CLAUDE.md in the source for hypergraph details. The flat graph API remains for compatibility.
Persistence
The graph is typically held in memory during a session, but events can be persisted:
Redis Streams : In-flight events cached via XADD for reconnection
Postgres : Completed messages stored with parent_message_id for the graph structure
Client : Graph rebuilt from events on reconnection via reduceEvent
Graph traversal
Common traversal patterns:
Depth-first walk
Breadth-first walk
Find path between nodes
function walkDFS ( graph : Graph , startId : string , visit : ( node : Node ) => void ) {
const node = graph . nodes . get ( startId );
if ( ! node ) return ;
visit ( node );
const children = graph . edges . get ( startId ) ?? [];
for ( const childId of children ) {
walkDFS ( graph , childId , visit );
}
}
walkDFS ( graph , "user-1:user" , ( node ) => {
console . log ( node . kind , node . id );
});
Next steps
Client Library Full graph API reference
Projections Transform graphs for rendering
Client Rendering Build UIs with graph projections
Events Return to event type reference