xstate-statemachine vs Burr

An honest comparison of xstate-statemachine's [agents] extra and Burr: hierarchy, guards as policy, durable timeouts, human-in-the-loop, persistence, observability, and when to choose which.

Burr (Apache, incubating) models an application as actions and transitions over a state object, with persisters, a tracking UI and OpenTelemetry. It is the closest in spirit: explicit state machines for LLM apps.

This table is generated from docs/_data/comparisons.json, and corrections are welcome as PRs against that file. Every row carries a source note. It was checked against apache-burr 0.43.0 (PyPI; formerly burr, last 0.42.0), 2026-10-10; see Burr for the current state. A test (tests/test_battle_291_a.py) fails the nightly comparisons CI job when this check is more than 180 days old, or when the installed release’s major version differs from the one checked, so the table cannot silently go stale (a pull request is never blocked by a competitor’s release).

Feature table

Capability xstate-statemachine Burr Source
Hierarchy (nested states) Yes: compound states at any depth, XState v5 semantics Flat actions + transitions; sub-applications by composition apache-burr 0.43.0: burr.core.graph.GraphBuilder; sub-applications via burr.core.parallelism.RunnableGraph
Parallel regions Yes: type: parallel, done.state join Parallelism via MapStates / sub-applications apache-burr 0.43.0: burr.core.parallelism.MapStates / MapActions
Guards as policy Named guards in the chart; run_tool re-checks per-state tool allow-lists (X0.13) Conditions on transitions (when, expr) apache-burr 0.43.0: burr.core.when, expr, default
after timeouts Yes: durable deadlines for model, tool and human waits, persisted and woken by DueTimerScanner Not built in apache-burr 0.43.0: no timer / delay API in burr.core
Human-in-the-loop as durable state awaiting_human is a persisted state with an escalation deadline; approval names exact call ids Halt before/after an action, resume from persisted state apache-burr 0.43.0: Application.run(halt_before=, halt_after=) + persisters
Persistence / replay Snapshots in SQLite / Redis / SQLAlchemy (e.g. Postgres) stores, versioned layout, transition-log replay() Persisters + tracking UI replay (strong) apache-burr 0.43.0: burr.core.persistence, burr.integrations.persisters (Postgres, Redis, Mongo, SQLite)
Visual editor Opens in the Stately editor (the chart is XState JSON; tool policy in meta, plus one engine key, actionErrorPolicy) Burr UI (tracking, graph view) apache-burr 0.43.0: burr[tracking] UI
Typed context / events Pydantic context + event schemas ([pydantic]), strict tool-argument schemas State object; typed via the Pydantic integration apache-burr 0.43.0: burr.integrations.pydantic.PydanticTypingSystem
Observability JSONL trace with gen_ai.* fields, OTel spans (on_span="otel"), no content by default, secret-key redaction Burr UI + OTel apache-burr 0.43.0: burr.tracking, burr.integrations.opentelemetry
Multi-agent Reference supervisor / pipeline / debate charts; spawn_agent with per-child and global budgets Composition of applications apache-burr 0.43.0: RunnableGraph / sub-applications
Incremental adoption One LangGraph node (statechart_node), a graph as a service (langgraph_service), a pydantic-ai Tool Wraps any Python function apache-burr 0.43.0: @action decorates any function

When to choose Burr

  • You want the Burr UI for step-by-step tracking and replay out of the box.
  • A flat action graph with when conditions fits your app, and you do not need nested or parallel states.
  • You prefer defining the machine with Python decorators over a JSON chart.
  • You want Burr’s persisters for Postgres, Redis, MongoDB and SQLite, and MapStates / MapActions for fan-out, without writing a chart.

When to choose xstate-statemachine

  • You need hierarchy and parallel regions with standard statechart semantics (XState v5), not only flat transitions.
  • You want durable after deadlines: timeouts and escalations that survive restarts.
  • The chart should be portable XState JSON, editable in Stately and shareable with a TypeScript frontend.
  • You want per-state tool allow-lists that run_tool enforces regardless of the guards (X0.13).

Ours, in 20 lines

This is a support agent. The refund tool is legal only in the tool states and needs a human to approve it, and the whole run is capped at 5 turns and 10 cents. It runs offline with FakeModel:

import asyncio
from xstate_statemachine.contrib.agents import (
    FakeModel,
    load_chart,
    run_agent,
    tool,
    tool_registry,
)


def lookup(order_id: int) -> str:
    """Look up an order."""
    return f"order {order_id}: shipped"


def refund(order_id: int) -> str:
    """Refund an order."""
    return "refunded"


chart = load_chart()  # TOOL_LOOP, plain XState JSON
for s in ("awaiting_model", "awaiting_tool"):
    chart["states"][s]["meta"]["tools"] = [
        "lookup",
        "refund",
    ]  # policy lives in the chart
tools = tool_registry(
    tool(lookup, timeout_s=5), tool(refund, timeout_s=5, side_effect=True)
)
model = FakeModel(
    [
        {
            "tool": "lookup",
            "args": {
                "order_id": 42,
            },
        },
        {
            "tool": "refund",
            "args": {
                "order_id": 42,
            },
        },
    ]
)

res = asyncio.run(
    run_agent(
        chart,
        model=model,
        tools=tools,
        prompt="refund 42",
        budgets={
            "max_turns": 5,
            "max_usd": 0.10,
        },
    )
)
assert (
    res.waiting and res.final_state == "toolLoop.awaiting_human"
)  # durable: persist, resume later

See LLM agents for the full reference, including the LangGraph interop and pydantic-ai adapters.

New to the library? Start with the integrations journey: pick your path, then a fifteen-minute tutorial.