Agents that loop until they're done — and survive the process that ran them. Voluta is a low-level orchestration runtime for .NET: you describe a graph of nodes and edges, some of them cyclic, and it executes the graph in Pregel-style supersteps with typed state, durable checkpoints, streaming, and human-in-the-loop interrupts.
Our design bets:
→ cycles not DAGs
→ typed channels not dictionary soup
→ checkpoints not in-process memory
→ .NET-native not a Python port
→ AOT-ready core not a kitchen sink
Important
Pre-release. On main: Pregel engine, InMemory + File checkpointers, Send / subgraph helpers,
source generator, Testing, MicrosoftAi helpers, MapVolutaUI, five samples, BenchmarkDotNet.
Nothing is on NuGet yet; the 0.1 tag is the next milestone
(epic #1). Until then, reference projects
from source — see Quick Start.
A ReAct agent that calls tools, loops back to think again, and stops on its own. This is the real
output of dotnet run --project samples/01-HelloWorld (middle rounds trimmed):
Voluta sample 01 — simulated ReAct (agent ⇄ tools)
Thread: react-sample-1
[agent] round 0: requesting tools
stream step=1 kind=Updates nodes=[agent]
write status = tools
write messages = agent: call get_weather (round 1)
[tools] executing simulated tool (round 1)
stream step=2 kind=Updates nodes=[tools]
write tool_rounds = 1
write messages = tools: observation — temp=12C (round 1)
write status = agent
…
[agent] enough tool data — finishing
stream step=5 kind=Updates nodes=[agent]
write status = done
write messages = agent: final answer — cloudy, 12°C in Oslo
stream step=5 kind=End nodes=[-]
Final status: Done
Messages:
- user: what's the weather in Oslo?
- agent: call get_weather (round 1)
- tools: observation — temp=12C (round 1)
- agent: call get_weather (round 2)
- tools: observation — temp=12C (round 2)
- agent: final answer — cloudy, 12°C in Oslo
Nothing above is a framework convention you have to learn. messages accumulates because it was
declared Append; status replaces because it was declared LastValue; the loop exists because
one edge is conditional:
var graph = new StateGraph()
.AddChannel("messages", ChannelKind.Append)
.AddChannel("status", ChannelKind.LastValue)
.AddNode("agent", AgentNodeAsync)
.AddNode("tools", ToolsNodeAsync)
.AddEdge(GraphConstants.Start, "agent")
.AddConditionalEdges( // ← the cycle
"agent",
static context => context.Read<string>("status") == "tools" ? "tools" : GraphConstants.End)
.AddEdge("tools", "agent")
.Compile(checkpointer, new CompileOptions { RecursionLimit = 32 });Pausing for a human, then resuming — days later, in another process
A node returns an interrupt instead of writes. The run stops, the checkpoint holds the payload, and
ResumeAsync picks it up with a decision. Real output of samples/02-InterruptResume:
=== Invoke (expect interrupt) ===
stream step=0 kind=Start nodes=[-]
[gate] interrupting for human approval
stream step=1 kind=Interrupt nodes=[gate]
payload = { action = transfer, amount = 50, currency = USD }
Checkpoint status after invoke: Interrupted
Interrupt payload: { action = transfer, amount = 50, currency = USD }
=== Resume with Command.Kind = approve ===
stream step=1 kind=Start nodes=[-]
[gate] resumed with payload=ok — approving
stream step=2 kind=End nodes=[-]
Checkpoint status after resume: Done
Messages:
- user: transfer $50
- gate: transfer approved
The node decides by looking at context.ResumePayload — no exceptions used for control flow:
static Task<NodeResult> GateNodeAsync(GraphContext context, CancellationToken cancellationToken)
{
if (context.ResumePayload is null)
{
return Task.FromResult<NodeResult>(
NodeResult.Interrupt(new { action = "transfer", amount = 50, currency = "USD" }));
}
return Task.FromResult<NodeResult>(
NodeResult.Continue(new ChannelWrite("messages", "gate: transfer approved")));
}ResumeAsync(threadId, command) is a separate call against the same thread id, so the approval can
arrive from an HTTP handler long after the original run ended — or from a different process.
Typed state instead of string keys
String channel names work, but you don't have to live with them. Annotate a partial class and the source generator emits the schema plus a partial update type:
[GraphState]
public partial class ReviewState
{
[Channel(ChannelKind.Append)]
public IList<object?> Notes { get; set; } = new List<object?>();
[Channel(ChannelKind.LastValue)]
public string? Verdict { get; set; }
}You get ReviewState.CreateSchema() and ReviewState.ReviewStateUpdate, where unset properties
emit no write at all — an explicit null is a clear, not "unchanged":
var graph = new StateGraph()
.AddChannels(ReviewState.CreateSchema())
.AddNode(
"review",
static (context, cancellationToken) => Task.FromResult<NodeResult>(
NodeResult.Continue(new ReviewState.ReviewStateUpdate { Verdict = "approved" }.ToWrites())))
.AddEdge(GraphConstants.Start, "review")
.AddEdge("review", GraphConstants.End)
.Compile(new InMemoryCheckpointer());Interface-typed properties need OptionalValue<IList<object?>>.Of(value) — C# forbids user-defined
conversions involving interfaces. The generator refuses to run on a non-partial class or one with
no [Channel] properties, and tells you which.
Testing a graph without fighting it
Voluta.Testing ships the doubles you'd otherwise write by hand:
RecordingCheckpointer— records everyPut/Get/Liston any inner checkpointer.FaultInjectingCheckpointer— fails the n-th write, so you can assert the run survives it.CheckpointerConformance.RunAllAsync— the suite everyICheckpointermust pass, interrupt fields and pending writes included. Bring your own storage and run it.GraphFixtures.Linear()/.Cycle()andStreamCapture— graphs and stream drains for tests.
- Cycles are the point — an agent that reconsiders is a loop, not a pipeline. Conditional edges
plus a
RecursionLimitgive you loops that terminate on purpose instead of by accident. - The run outlives the process — every superstep boundary is a checkpoint. A thread can be
interrupted, inspected, resumed, or replayed from storage;
ICheckpointeris the only seam. - Multi-writer state is defined, not hoped for — when two nodes in the same superstep write the
same channel, the reducer decides the outcome.
Appendaccumulates,LastValuereplaces. - The core stays small — runtime and abstractions are
IsAotCompatiblewith zero third-party dependencies. No LLM SDK, no DI container, no logging framework in the hot path.
Requires the .NET 10 SDK (10.0.100 or newer). Check with dotnet --version.
Nothing is published yet, so start from source:
git clone https://github.com/dot-stbl/voluta.git
cd voluta
dotnet build voluta.slnxRun a sample to see a live graph:
dotnet run --project samples/01-HelloWorldThen point your own project at the runtime with
dotnet add reference path/to/voluta/src/Voluta/Voluta.csproj.
Here is a complete graph — a writer and a critic that loop until the score clears the bar. It
prints End after 4 supersteps:
using Voluta;
using Voluta.Abstractions.Channels;
using Voluta.Abstractions.Results;
using Voluta.Abstractions.Runtime;
using Voluta.Checkpoint;
using Voluta.Graph;
using Voluta.Graph.Builder;
using Voluta.Graph.Options;
var graph = new StateGraph()
.AddChannel("draft", ChannelKind.LastValue) // writes replace
.AddChannel("notes", ChannelKind.Append) // writes accumulate
.AddChannel("score", ChannelKind.LastValue)
.AddNode("write", WriteAsync)
.AddNode("critique", CritiqueAsync)
.AddEdge(GraphConstants.Start, "write")
.AddEdge("write", "critique")
.AddConditionalEdges(
"critique",
static context => (context.Read<int?>("score") ?? 0) < 8 ? "write" : GraphConstants.End)
.Compile(new InMemoryCheckpointer(), new CompileOptions { RecursionLimit = 16 });
var final = await graph.InvokeAsync(
[new ChannelWrite("draft", "checkpoints are nice")],
new RunOptions { ThreadId = "post-42" });
Console.WriteLine($"{final.Kind} after {final.Step} supersteps");
static Task<NodeResult> WriteAsync(GraphContext context, CancellationToken cancellationToken)
{
var round = (context.Read<int?>("score") ?? 0) / 4;
return Task.FromResult<NodeResult>(
NodeResult.Continue(
new ChannelWrite("draft", $"revision {round + 1}"),
new ChannelWrite("notes", $"writer: produced revision {round + 1}")));
}
static Task<NodeResult> CritiqueAsync(GraphContext context, CancellationToken cancellationToken)
{
var score = (context.Read<int?>("score") ?? 0) + 4;
return Task.FromResult<NodeResult>(
NodeResult.Continue(
new ChannelWrite("score", score),
new ChannelWrite("notes", $"critic: scored {score}/10")));
}Swap InvokeAsync for StreamAsync to observe the run as it happens
(StreamMode.Values / Updates / Events), and pass a real ICheckpointer when you want the
thread to outlive the process. Under a host, services.AddVoluta(provider => …) compiles the
graph once and registers it as a singleton.
One tick of the engine, in order: collect every node made ready by the previous tick, run them concurrently, barrier, merge their writes through the channel reducers, persist a checkpoint, then evaluate edges to decide who runs next. Two consequences worth internalizing:
- Nodes in the same superstep never see each other's writes — they see the state as of the barrier. That is what makes concurrent nodes deterministic to reason about.
- A conditional edge is evaluated after the merge, on committed state, so routing decisions can't race with the writes they depend on.
Behavior contracts live in
openspec/specs/
(12 capabilities). Planning history:
openspec/changes/archive/2026-08-14-architecture-runtime-core/.
vs. LangGraph (Python, MIT) — The origin of this
execution model and still the richest ecosystem around it. Voluta borrows the ideas
(supersteps, channels, checkpoint-first persistence) and rebuilds the surface on .NET generics,
typed reducers, and IAsyncEnumerable — no TypedDict reflection. Not a port; a peer.
vs. Microsoft Agent Framework — MAF is the better answer for multi-agent conversations and function calling, and it has Microsoft behind it. It doesn't give you cyclic graphs with durable per-thread state. These compose: run MAF agents inside Voluta nodes.
vs. Durable Functions / Durable Task — Battle-tested durability with far more storage providers, and the right tool for business workflows. Its programming model is orchestrator code with replay semantics; Voluta's is a graph with explicit state channels, which fits an agent's think-act-observe loop more directly and keeps the loop bound visible.
vs. rolling your own while loop — Works until you need to answer "what was the state at
step 7?", "how do I resume after the pod restarted?", or "two nodes wrote the same field, now
what?". Those three questions are the entire library.
| Package | Role | Status |
|---|---|---|
Voluta.Abstractions |
Contracts: channels, checkpoints, NodeResult, Send, streaming |
on main |
Voluta |
Pregel runtime + InMemory + Subgraph.AsNode + Describe() |
on main |
Voluta.DependencyInjection |
AddVoluta for IServiceCollection |
on main |
Voluta.Generators |
[GraphState] source generator |
on main |
Voluta.Testing |
Test doubles + checkpointer conformance suite | on main |
Voluta.Checkpoints.File |
JSON file-system checkpointer | on main |
Voluta.MicrosoftAi |
IChatClient helpers for Microsoft.Extensions.AI |
on main |
Voluta.UI |
Ops console: MapVolutaUI (inspector / HITL / topology) |
on main |
Voluta.Checkpoints.EF / S3 |
Extra durable providers | planned |
Native AOT applies to the core tier only — Voluta, Abstractions, and
DependencyInjection are IsAotCompatible, with a publish smoke test in samples/03-AotSmoke.
File checkpoints, UI, and MicrosoftAi are regular-CLR packages and do not claim AOT.
| Sample | What it shows |
|---|---|
01-HelloWorld |
Simulated ReAct loop (agent ⇄ tools), streaming updates |
02-InterruptResume |
HITL interrupt and Command resume |
03-AotSmoke |
Native AOT publish smoke test |
04-ReviewBot |
CLI review harness: plan → sandboxed tools → review |
05-DocQ |
Docs Q&A over a sandboxed folder |
Stated plainly so you can judge the fit:
- No published packages. Source references only until the 0.1 tag.
- No EF / S3 checkpointers. File + InMemory ship on
main; EF/S3 still planned. - UI is a first cut.
MapVolutaUIcovers checkpoint inspect, HITL resume, topology — not live SSE stream or multi-host thread discovery. - PublicAPI ship gate open. Surface can still move before
v0.1.0.
dotnet build voluta.slnx # 0 warnings, 0 errors — the gate
dotnet test voluta.slnx # xUnit + Shouldly + NSubstitute
dotnet format voluta.slnx --severity hidden # style drift check
dotnet run -c Release --project benchmarks/Voluta.BenchmarksTreatWarningsAsErrors and EnforceCodeStyleInBuild are on for every project, so a clean build
is the style review. Benchmarks (LinearInvoke, CycleFiveTicks, ParallelAppend,
CheckpointPutGet) are not gated on PR CI — see
#10.
Small fixes go straight to a PR. For anything non-trivial, open an issue first so we can agree on the shape before you write it — the runtime's contracts are still moving.
git config core.hooksPath .githooks # once per cloneThe commit-msg hook strips AI attribution trailers, so don't hand-add them; commits follow
[voluta](feat/scope): subject. AI-generated code is welcome when it's tested and you understand
it. Full setup, conventions, and the PR checklist:
CONTRIBUTING.md.
The execution model comes from LangGraph (MIT) — Pregel-style supersteps, channel/reducer state, checkpoint-first persistence. The API diverges substantially, and any place where Voluta is wrong about this design space is our own fault, not theirs.
MIT — see LICENSE.