A graph is the shape of your agent: channels (state), nodes (work), edges
(what runs next). You assemble that shape with StateGraph, then compile it
once into an immutable CompiledGraph the runtime can actually execute.
Think of the builder as a recipe and compile as locking the oven settings. After compile, the topology does not change mid-run — which is what makes checkpoints and resumes meaningful.
Why “build then compile”?
Two phases buy you different things:
- Build — you can still change your mind: add a channel, rename a node, wire a cycle. Errors here are authoring errors.
- Compile — the runtime validates the topology, freezes it, and hands you something safe to share across threads and hosts. Rebuilding per request pays validation for nothing and makes “two almost-identical graphs” a real footgun.
CompiledGraph graph = new StateGraph()
// channels, nodes, edges...
.Compile(checkpointer, new CompileOptions { RecursionLimit = 25 });
RecursionLimit (default 25) is the intentional-loop budget. Cycles are
allowed; infinite cycles are not. Raise it when multi-round tool use is normal;
keep it low enough that a bug fails fast.
What you declare on the builder
Namespace: Voluta.Graph.Builder.
| Method | Why it exists |
|---|---|
AddChannel(name, kind) | State needs a merge policy before anyone writes |
AddChannels(schema) | Bulk register from [GraphState] so string keys don’t drift |
AddNode(name, handler) | Named work unit the edges can point at |
AddEdge(source, target) | Fixed succession (“always go tools → agent”) |
AddConditionalEdges(...) | Data-dependent succession (“tools or END?”) |
Compile(checkpointer, options?) | Validate + freeze → runnable graph |
Conditional edges come in two flavors: one next node, or a list of next nodes. One conditional registration per source — if routing is ambiguous in your head, it will be ambiguous in production.
What you run: CompiledGraph
Namespace: Voluta.Graph.
| API | When you reach for it |
|---|---|
StreamAsync(...) | You care about the journey (UI, logs, learning) |
InvokeAsync(...) | You only need the terminal event |
ResumeAsync(threadId, command, ...) | A previous run stopped for a human (or similar) |
Describe() | Export topology for ops UI / tooling |
Same compiled object, different observation styles. Don’t rebuild the graph to “switch modes.”
Start and End are sentinels, not nodes you implement
GraphConstants.Start // "__start__"
GraphConstants.End // "__end__"
Start is how the first ready set is seeded. End is how a branch says “this
path is finished.” You don’t register handlers for them.
Subgraphs: nest a compiled graph as a node
Subgraph.AsNode(CompiledGraph child, ...) returns a NodeHandler that runs a
child graph and maps selected channels back as parent writes.
Why bother? Shared sub-flows (retrieve → rank → format) stay one compiled unit
you can test and version, while the parent stays a readable high-level loop.
Child interrupts surface as parent NodeResult.Interrupt — HITL doesn’t invent
a second protocol at the boundary.
Practical shape
channels → what state means when many nodes write
nodes → pure-ish work units (LLM, tools, gates)
edges → control flow, including cycles
compile → one validated artifact per process (or DI singleton)
Next: Nodes for what a handler may return, and Edges for why cycles are first-class.