xstate-statemachine
The event-driven workflow toolkit for Python.
Design the flow. Make the rules explicit. Build systems you can explain.
From a two-state toggle to an event-driven order system, keep the workflow in one place.
Draw it in Stately, load XState-compatible JSON, or define it in Python.
Run it with an async or sync engine. Let xsm generate the typed code, tests and docs.
Add persistence, broker messaging and framework integrations when your application needs them.
Zero runtime dependencies in the core. MIT licensed. Infrastructure is optional.
Try it in 60 seconds → · Explore event-driven architecture · Compare your options · Read the docs
|
Design
Visual statecharts. |
Execute
Guards and services. |
Operate
Durable snapshots. |
Find your way — the complete guide, examples and API reference
New here? Start with the quick start. Evaluating for a project? Explore the use cases, event-driven architecture and comparisons. The full technical reference stays on this page.
| Section | What you get | |
|---|---|---|
| ⚡ | 60-Second Start · Install | Running before your coffee cools |
| 🧰 | Real problems this solves | 13 shipped, battle-tested examples: LLM agents with human approval, FastAPI behind N workers, Stripe webhooks, Django approvals, EDA, workers, breakers, durable timers |
| 🧠 | Why a statechart? · Mental model | Three bug classes to prevent and six concepts to learn |
| 📨 | Event-driven architecture | Charts that talk only through a broker: outbox, dedup, DLQ, sagas, AsyncAPI — runnable in-process |
| 🏭 | Automatic code generation | xsm gt turns JSON into typed Python, recorded tests and docs, with structural checks before writing |
| 🔗 | Stately ⇄ Python | Share a workflow definition; inspect the source-backed Stately diagrams and Python examples |
| 🧩 | Context · Guards · Actions · Services · Timers | The building blocks |
| 🌳 | Nested · Parallel · History · Actors | Real-world hierarchy and systems of machines |
| 💾 | Persistence | Stores, locks, idempotency, durable after, audit log, migrations |
| 🔌 | Integrations | FastAPI · Starlette · Litestar · Flask · Django (+DRF, Channels) · SQLAlchemy · Redis · Celery · five brokers |
| 🤖 | For LLM agents | The model proposes, the machine decides: tool allow-lists, budgets, durable human approval |
| 🔭 | Observability · Introspection & plugins · Pure API | OTel, Prometheus, the live Stately Inspector; observe and test |
| 🐍 | Pythonic API · CLI | No JSON required; the whole terminal toolkit |
| 📚 | Cookbook · Recipes · FAQ | Copy-paste solutions and eight CI-tested recipes |
| ⚖️ | How it compares | vs transitions, python-statemachine, django-fsm, LangGraph, Burr, @statelyai/agent, AWS Step Functions |
| 🛡️ | Production · Security · API reference · Troubleshooting | Failure semantics, threat model, every kwarg, every error |
⚡ The 60-Second Start
pip install xstate-statemachine
from xstate_statemachine import create_machine, SyncInterpreter
machine = create_machine(
{
"id": "toggle",
"initial": "inactive",
"states": {
"inactive": {"on": {"TOGGLE": "active"}},
"active": {"on": {"TOGGLE": "inactive"}},
},
}
)
light = SyncInterpreter(machine).start()
print(light.current_state_ids) # {'toggle.inactive'}
light.send("TOGGLE")
print(light.current_state_ids) # {'toggle.active'}
light.send("BANANA") # not a legal event here
print(light.current_state_ids) # {'toggle.active'} ← ignored, not crashed
light.stop()
🔗 Open this machine in the Stately editor — public, no account needed.
That dict, as the Stately editor draws it. Paste the JSON into stately.ai/editor and you get this picture; export from the editor and you get that dict.
You just declared the complete set of legal states and the only legal moves between
them. TOGGLE advances the machine. BANANA is ignored — not raised, not silently
mishandled — because the current state does not accept it. (Prefer it to raise?
SyncInterpreter(machine, strict=True) → UnknownEventError at the call site.)
You have a running machine: explicit states, named events and predictable transitions. The same model scales to workflows that cross process restarts, message brokers and teams.
Already using asyncio? Run the same machine with the async engine.
This is the async version of the toggle above, as a complete runnable program.
wait=True waits for the event to finish processing before you inspect the result.
import asyncio
from xstate_statemachine import Interpreter, create_machine
async def main() -> None:
"""Toggle a light with the asynchronous engine."""
machine = create_machine(
{
"id": "toggle",
"initial": "inactive",
"states": {
"inactive": {"on": {"TOGGLE": "active"}},
"active": {"on": {"TOGGLE": "inactive"}},
},
}
)
light = await Interpreter(machine).start()
try:
receipt = await light.send("TOGGLE", wait=True)
print(sorted(receipt.state_ids)) # ['toggle.active']
finally:
await light.stop()
asyncio.run(main())
|
👋 New to statecharts? Read Why a statechart? and the
Mental model (five minutes), then run |
Building a production workflow? Jump to Event-driven architecture for an in-process example of broker messaging, inbound deduplication and outbox delivery — then Persistence for the ordering guarantees, and Production for what is contained when things fail. |
Why choose xstate-statemachine?
| What you want to ship | What you get here |
|---|---|
| A workflow the whole team can understand | An XState-compatible definition you can visualize in Stately, review in Git and execute in Python. JSON, class-based, builder and functional APIs compile to the same model. |
| Business rules that live in one place | Guards, nested and parallel states, history, invoke, after and actors make the allowed transitions explicit. can(), tags and metadata let the UI ask the machine what is possible. |
| A fast path from diagram to working code | xsm generates implementation stubs, types, recorded tests and documentation, with structural round-trip checks for Python-generating templates. |
| A practical path to durable workflows | Optional stores, version checks, idempotency, migrations and persisted deadlines support workflows that outlive a request or worker. |
| Event-driven services with shared tooling | CloudEvents envelopes, inbound dispatch, an outbox, dead letters, sagas and AsyncAPI export, plus adapters for five broker families. |
| Confidence before and after deployment | A pure transition API, virtual time, replay, statechart coverage, lifecycle plugins, OpenTelemetry, Prometheus and live inspection. |
| Control over adoption and dependencies | Start in one Python process with no infrastructure. Add only the integrations you need; keep your definitions, data and deployment under your control. |
The advantage is the combination: visual statecharts, native Python execution and operational building blocks around the same definition. See How it compares for specific trade-offs and reproducible benchmarks.
🚀 Install
pip install xstate-statemachine # the core: zero runtime dependencies, Python 3.9 → 3.15
xsm info # verify the install
xsm update # later: upgrade with the installer that installed you
Everything else is an extra you opt into — pip install "xstate-statemachine[fastapi]",
[django], [redis], [kafka], [agents], [observability], [testing], … or [all].
The full table is under Integrations.
Match the docs to your version. This README covers the repository's 0.11.0 source version. The PyPI badge shows the published package, which may be older. Run
xsm infoto check your installation; use the source checkout below when evaluating features that are not in your installed release yet.
— validate machine JSON and keep generated code current in CI. Starting a service from scratch?
xsm new my_service --template fastapi scaffolds one from a tested example app.
uv · poetry · pipx · source checkout · Windows · supply chain
uv add xstate-statemachine
poetry add xstate-statemachine
pipx install xstate-statemachine # if you only want the `xsm` CLI
Try the development version from source:
git clone https://github.com/basiltt/xstate-statemachine.git
cd xstate-statemachine
python -m pip install -e .
python -m xstate_statemachine info
# Add only the extras you need, for example:
python -m pip install -e ".[fastapi]"
Windows, xsm.exe blocked by an Application Control policy? That is pip's unsigned
launcher stub being refused by WDAC / AppLocker, not the package. Run
python -m xstate_statemachine setup once: it parks the blocked launcher and installs a batch
shim, after which xsm works normally (re-run after pip install --upgrade; --undo reverts).
python -m xstate_statemachine … always works too.
Details: CLI → Windows.
Supply chain. Releases are published from GitHub Actions through PyPI Trusted Publishing (no long-lived token) and carry PEP 740 build provenance attestations binding each wheel and sdist to the exact run, commit and workflow that built it:
pip install pypi-attestations
pypi-attestations verify pypi --repository https://github.com/basiltt/xstate-statemachine \
pypi:xstate_statemachine-0.10.5-py3-none-any.whl # example published wheel; choose your release
The verifier prints OK: <file> on success. Use a filename from the published release
you installed; development versions do not have PyPI attestations before publication.
Want the code generator's output line-wrapped to match your linter? pip install "xstate-statemachine[format]"
adds black and isort; without them generated code is still valid, just not reformatted.
🧰 Real Problems This Solves
Start with a problem that looks like yours. The examples cover customer-facing APIs, background workflows, event-driven services and supervised AI agents. Each row links to a shipped example and its setup instructions; the associated suites exercise failure paths as well as the happy path.
Build an API with FastAPI orders or Django approvals. Connect services with event-driven fulfilment. Control an agent with the offline support bot.
Explore all 13 use cases — implementation, behavior and runnable code
| The problem you have | How the example handles it | Run it |
|---|---|---|
| An LLM agent that calls tools — and must not refund money on its own. Prompt-engineering "please ask first" does not hold. | The model proposes, the machine decides: per-state tool allow-lists, token/USD budgets that cut the loop, and a refund_order call that parks in awaiting_human — a persisted state with a one-hour escalation deadline — until a person approves. Works with pydantic-ai, LangGraph or a raw client. |
agents_support_bot · guide |
| A FastAPI order service behind N uvicorn workers; 200 concurrent payment requests race on one order. | The example's lock + version check admits one payment transition; the other requests get 409. Persisted after: 15 min deadlines survive restarts, and OpenAPI shows the events. Payment-provider idempotency is still required for external charges across failures. |
fastapi_orders · guide |
Stripe retries webhooks; the same invoice.paid arrives twice, out of order, a day late, or forged. |
Signature + timestamp verified at the edge; the Idempotency-Key inbox answers the replay with the stored receipt without touching the machine; an event the current state does not accept is a clean 200 no-op, never a double credit. |
stripe_webhooks · recipe |
| A Django approval flow: legal and finance review in parallel, each with its own role, visible in the admin, with a live status page. | Parallel regions on one row; StatechartField keeps the denormalised state columns; a DRF viewset with per-event permissions; Channels broadcasts each transition; the audit log is written in the same transaction as the state change. |
django_approvals · guide |
| Two services that must never call each other, but an order has to make it from checkout to shipped. | Charts talk only through a broker (Redis Streams, Kafka, RabbitMQ, NATS, SQS): a transactional outbox so no event is lost between commit and publish, consumer-side dedup, a dead-letter queue, and a compensating saga when the warehouse says no. | eda_fulfilment · guide |
| Background jobs (RQ, arq, Dramatiq) that each touch the same long-running workflow. | Load → send one event → save under an optimistic version; a stale worker's write is refused, not silently last-writer-wins; durable after timers are woken by a scanner, not by a worker that happened to be alive. |
task_queue_workers · recipe |
| A flaky upstream: retry with backoff, but stop hammering it when it is down. | Per-request retry with jittered backoff as a chart, a shared circuit breaker that opens on N failures and half-opens on a timer — every state visible, every transition testable without the network. | circuit_breaker_retry · recipe |
| A 7-day and a 14-day follow-up email that must fire even if the process was restarted in between. | after deadlines are persisted on the wall clock; DueTimerScanner (APScheduler, cron, anything) wakes exactly the instances that are due. |
apscheduler_timers · recipe |
| A multi-step signup / onboarding wizard with Back, validation and resumable sessions. | Steps, navigation and validation live in the chart; only a JSON snapshot lives in the session or the UI state, so Flask, Streamlit and Gradio all drive the same machine. | flask_wizard · form_wizard |
| A chatbot that has to collect N fields in any order and nudge for the missing ones. | A self-transition fills slots; an allSlotsFilled guard moves on; the chart, not the prompt, knows what is still missing. |
slot_filling · recipe |
| A feature rollout (internal → 1 % → 25 % → GA) that can be rolled back from any stage. | Staged states with timed auto-advance, a ROLLBACK handled at the parent so it works from every stage, and an audit trail of who moved it. |
feature_flag_rollout · recipe |
| A WebSocket client that reconnects with backoff and never ends up with two open sockets. | disconnected ↔ reconnecting → connected with RetryPolicy; the live socket is an invoked service, so leaving the state closes it — by construction. |
websocket_reconnect · recipe |
| An order row in SQLAlchemy whose state column must agree with its history. | The chart lives on the ORM row (StatechartMixin); the state column, the snapshot and the outbox are written in one commit(). |
sqlalchemy_orders · guide |
Each of these is also a battle-test in the suite: the FastAPI example is hammered with 200 concurrent payments across several worker processes; the Stripe example is fed 1,000 deliveries with forged signatures, tampered bodies and day-old timestamps; the agent example is given a model that tries to call a tool it was not offered. The guarantees above are the assertions.
Deployment matters. These are tested scenarios, not a promise of exactly-once external I/O. Use the documented store, locking, inbox and broker configuration; make payment calls and other external effects idempotent. The Guarantees page explains the crash windows explicitly.
🧠 Why a Statechart?
Every non-trivial flow starts as a few booleans. Then it grows.
|
😖 Boolean soup if is_loading and not is_error:
...
elif is_error and retry_count < 3:
...
elif is_authenticated and not is_loading:
...
Four booleans = 16 combinations. If only six are meaningful,
the remaining ten need to be ruled out explicitly — including
Without a shared model, every handler has to maintain those invariants independently. |
😌 A statechart "states": {
"idle": {"on": {"FETCH": "loading"}},
"loading": {"on": {"OK": "done",
"ERR": "failed"}},
"failed": {"on": {"RETRY": "loading"}},
"done": {"type": "final"},
}
Four states = exactly four possibilities. The impossible ones cannot be constructed, because you never wrote a path to them. Illegal events in the current state are simply ignored. |
Three bug classes the model helps prevent
| Bug | How booleans cause it | How a statechart prevents it |
|---|---|---|
| 🕳️ Impossible states | is_loading and is_error both true |
The machine is in exactly one state per region |
| 👻 Zombie callbacks | A late API response fires after the user cancelled | The event isn't handled in cancelled, so it's discarded |
| 🔁 Double submission | A second click before the first finishes | submitting has no SUBMIT handler — the click does nothing |
Now scale that up. In a distributed system the "late API response" is a redelivered message, the "second click" is a retried webhook, and the "boolean soup" is a status column plus six feature flags plus a cron job that nobody fully understands. The same design addresses the same problems — when paired with durable persistence, input deduplication and reliable output delivery. That is what the rest of this library provides building blocks for.
The rule — a machine is in exactly one state per region. Parallel states have multiple regions, so multiple states are active at once, which is why
current_state_idsreturns a set.
🧩 The Mental Model
Six concepts give you the foundation. The rest of the library builds on them.
| Concept | What it is | In JSON |
|---|---|---|
| State | A named mode the machine can be in | "states": {"idle": {}} |
| Event | A message you send in | interp.send("FETCH") |
| Transition | "In state X, event E moves to Y" | "on": {"FETCH": "loading"} |
| Context | Everything that isn't a state — the data | "context": {"retries": 0} |
| Guard | A condition that must hold for a transition | {"target": "x", "guard": "isReady"} |
| Action | A side effect that fires during a transition | {"target": "x", "actions": ["save"]} |
The split that matters: state is where you are, context is what you know.
retries is context. retrying is a state. Keeping that boundary clear makes workflows
easier to understand and change.
Two more words you'll meet constantly: an invoke runs a service (an API call, a Celery
task, a child machine) while a state is active and routes its result back as onDone /
onError; an after is a timer that fires a transition if you are still in the state
when it elapses. Everything else — nesting, parallel regions, history, actors, persistence,
brokers — is those six things composed.
📨 Event-Driven Architecture: the main event
Events describe what happened. Statecharts decide what is allowed to happen next.
A checkout publishes OrderPaid. The warehouse reacts by packing the order. A shipping
service reacts when packing completes. Each service owns its behavior and communicates
through an event contract, rather than reaching into another service's internals.
This is why event-driven architecture matters: it lets workflows grow beyond one request, one process and one team while keeping the hand-offs explicit.
Why build around events?
| Architectural advantage | What it means for your application |
|---|---|
| Looser coupling | Producers describe business facts; consumers decide how to respond. Add a notification or analytics consumer without embedding that behavior in the checkout workflow. |
| Independent scaling | Separate work by consumer and instance key. Scale the busy parts of the system without treating every workflow step as one synchronous request. |
| Resilience across interruptions | With a durable broker and correctly configured stores, committed state and pending outbound events can outlive a worker. Retry policy and recovery paths stay explicit. |
| A traceable business story | Named events, correlation and causation ids connect the lifecycle of an order across services. Statecharts show where each instance is waiting and why. |
| Testable contracts | Exercise the event flow with fake brokers and virtual time, then expose the chart's publish/consume declarations through AsyncAPI. |
You do not need microservices to benefit. Start with events inside one application; introduce a broker when independent consumers, buffering or cross-process delivery become useful. Event-driven systems introduce their own responsibilities: eventual consistency, duplicate delivery, ordering, schema evolution and operational monitoring. The library provides explicit tools for these concerns; it does not remove the need to design for them.
A statechart is a consumer (it reacts to events) and a producer (its transitions mean something to other services). Here is how the pieces fit together:
Producer → Broker → InboundDispatcher → Statechart
│ │
validate + dedup guards + actions
│
snapshot + inbox mark + outbox
one shared transaction
│
OutboxRelay
│
Broker → Next consumer
The transaction shown above requires a shared transactional store and the documented locking strategy. The incoming delivery is acknowledged after the state commit; the relay publishes committed outbox records separately and may retry.
Reliability is part of the workflow
| The problem | The failure to design for | The building block |
|---|---|---|
| A message is delivered twice | A duplicate applies the same business operation again | With an inbox configured, InboundDispatcher deduplicates on the envelope id before the machine sees it and returns the original receipt within the configured retention window |
| You save state, then the publish fails | The order is "paid" and nobody heard | With a shared transactional store and PessimisticLock, OutboxPlugin writes the outgoing event in the same transaction as the snapshot; OutboxRelay publishes afterwards and retries |
| A poison message loops forever | A worker restarts every 30 s | After max_attempts the envelope is a DeadLetter record; xsm dlq replay is a dry run by default |
| Two consumers handle one order at once | Lost update, or a global lock | Per-subject ordering in the dispatcher, plus OptimisticLock / fenced PessimisticLock on the store |
| Nobody knows what your service emits | A wiki page from last year | meta.publish on the chart is the contract; xsm asyncapi renders it as AsyncAPI 3.0 |
| The broker is Kafka today and SQS next quarter | Transport-specific logic spreads through the workflow | Shared async and sync adapter protocols; five broker families with a contract suite, and fake adapters for tests. Transport configuration and ordering still depend on the broker |
Two charts, one broker, no servers
The order-fulfilment chart below shows a richer workflow: payment, packing, shipping, escalation and cancellation, all visible in the model.
Start with a smaller, runnable version: an order chart and a warehouse chart that never
call each other. Every hand-off is an event. The broker, store, inbox and outbox are all
in-process, so you can try the full conversation without running a server. The demo's
PACKED event completes the order directly; it does not invoke the richer chart's
shipOrder service.
from xstate_statemachine import MachineLogic, assign, create_machine
from xstate_statemachine.eda import (
Envelope,
InboundDispatcher,
MemoryOutboxStore,
OutboxPlugin,
OutboxRelay,
SyncFakeBrokerAdapter,
)
from xstate_statemachine.persistence import MemoryInbox, MemoryStore
ORDER = {
"id": "order",
"initial": "placed",
"context": {"total": 0},
"states": {
"placed": {
"on": {
"PAY": {
"target": "paid",
"guard": "hasTotal",
"actions": assign(
{"total": lambda a: a["event"].payload["total"]}
),
"meta": {
"publish": {"type": "OrderPaid", "data": ["total"]}
}, # ← the contract
}
}
},
"paid": {"on": {"PACKED": "shipped"}},
"shipped": {"type": "final"},
},
}
WAREHOUSE = {
"id": "warehouse",
"initial": "waiting",
"states": {
"waiting": {
"on": {
"PACK": {
"target": "packed",
"meta": {"publish": "OrderPacked"},
}
}
},
"packed": {"type": "final"},
},
}
order = create_machine(
ORDER,
logic=MachineLogic(
guards={"hasTotal": lambda ctx, e: e.payload.get("total", 0) > 0}
),
)
warehouse = create_machine(WAREHOUSE)
store, inbox = (
MemoryStore(),
MemoryInbox(),
) # in-memory demo, not crash-durable
outbox, broker = MemoryOutboxStore(), SyncFakeBrokerAdapter()
publisher = OutboxPlugin(
outbox, topic="events"
) # meta.publish → staged outbox record
relay = OutboxRelay(outbox, broker) # committed outbox records → broker
ROUTES = { # envelope type → (machine, the event it means to it)
"xsm.order.PAY": (order, "PAY"),
"OrderPaid": (
warehouse,
"PACK",
), # the warehouse reacts to the order's event…
"OrderPacked": (
order,
"PACKED",
), # …and the order reacts to the warehouse's
}
dispatcher = InboundDispatcher(
store,
lambda t: ROUTES[t][0] if t in ROUTES else None,
event_type=lambda env: ROUTES[env.type][1],
key_for=lambda env, m: f"{m.id}:{env.subject}", # one order id, two machines
plugins=[publisher],
inbox=inbox, # dedup on envelope.id
)
# checkout publishes a command; nothing else is called directly
broker.publish(
"events",
Envelope.new(
type="xsm.order.PAY",
subject="order-1",
data={"total": 42},
source="checkout",
),
)
for _ in range(4): # PAY → OrderPaid → PACK → OrderPacked → PACKED
dispatcher.run_once_sync(broker, "events")
relay.relay_once_sync()
import json
print(json.loads(store.load("order:order-1").snapshot)["value"]) # shipped
print([e.type for e in broker.published_on("events")])
# ['xsm.order.PAY', 'OrderPaid', 'OrderPacked']
Take the same design to production in three steps:
- Make state and outgoing events durable. Use
SQLiteStore("app.db"),SQLiteInbox(store)andSQLiteOutboxStore(store)withPessimisticLock()passed toInboundDispatcher. Sharing the store and transaction is what makes snapshot, inbox mark and outbox commit together. Replacing onlyMemoryStoreis not enough. - Choose the broker and execution model. The sync loop can use
SyncRedisStreamsBroker(...)orSyncSqsBroker(...). ForRedisStreamsBroker(...),KafkaBroker(...),RabbitMQBroker(...),NatsBroker(...)orSqsBroker(...), use the async dispatcher and relay APIs. The machine definitions and event contracts stay the same; connection settings, queue setup and ordering requirements are broker-specific. - Exercise redelivery and recovery. Publish the same envelope twice: with the inbox
configured, the second delivery is a
duplicateand runs no actions. Configure a dead-letter store andmax_attempts=3, then inject a delivery-processing failure: an exhausted delivery becomes a DLQ record. An invoked service such asshipOrderinstead reports its failure throughonError; that alone does not make a broker delivery fail.
Delivery contract: outbox delivery is at least once, not exactly once. Keep external effects idempotent. Per-subject order needs the broker topology described in the guide; SQS Standard does not preserve it. Kafka has no broker-side delivery count, so the dispatcher's poison-attempt counter does not survive a consumer restart there.
What's in the box
| Piece | What it does |
|---|---|
Envelope |
A CloudEvents 1.0 envelope (id, type, subject, source, data, extensions) with size and nesting caps; redact_record() before anything is logged. The [cloudevents] extra adds SDK / HTTP-binding interop |
InboundDispatcher |
Consumes from any adapter: dedup by envelope id, per-subject ordering, bounded in-flight, max_attempts then dead-letter, unknown types dead-lettered (or ignored, your call). Sync and async APIs |
OutboxPlugin + OutboxRelay |
Transactional outbox from meta.publish when sharing the snapshot transaction: records are relayed after commit and retried on broker failure. SQLiteOutboxStore, SQLAlchemyOutboxStore, DjangoOutboxStore; MemoryOutboxStore for in-process use |
DeadLetter · SQLiteDeadLetterStore · BrokerDeadLetterSink |
Every refused or exhausted envelope is a record with its attempts and reason; xsm dlq list / show / replay / purge |
ChoreographyRouter · SagaBuilder |
Route a bus of many services' events to many charts by type; or orchestrate a saga with compensation steps and causationid dedup (xstate_statemachine.patterns) |
RetryPolicy · CircuitBreaker · DeadLetterPlugin |
Resilience as guards, delays and plugins — full/equal jitter, caps, half-open probes |
asyncapi_document() · xsm asyncapi |
An AsyncAPI 3.0 document from the charts' publish / consume declarations |
FakeBrokerAdapter · SyncFakeBrokerAdapter |
The whole pipeline in-process, with fail_next_publish(), crash()-style redelivery and published_on() for assertions — the same contract the real adapters pass |
| Celery bridge | celery_service (a task as an invoke), @statechart_task (a worker that runs the persisted loop), DurableTimerScheduler (Beat fires after deadlines), outbox_relay_task |
The complete worked example — two charts, five brokers behind one flag, Celery in eager
mode, Prometheus + OpenTelemetry + the live inspector, a poison message, a crashed consumer —
is examples/integrations/eda_fulfilment:
pip install "xstate-statemachine[redis,celery,observability]" "fakeredis[lua]" opentelemetry-sdk
cd examples/integrations && python -m eda_fulfilment --broker redis-streams
Guides → Event-driven architecture · Broker adapters · Celery · Guarantees (the exact order of save, outbox write, inbox mark and publish, and what a crash between any two of them means).
🏭 Automatic Code Generation
The chart already declares every action, guard, service and delay it needs, every event it accepts and every state it can be in. Typing that twice is where the bugs come from. So don't:
xsm generate-template subscription.json -t pythonic-class --with-tests --with-types -o ./billing
OK Generated logic file: billing/subscription_logic.py
OK Generated runner file: billing/subscription_runner.py
OK Generated pytest file: billing/test_subscription.py
OK Generated typed file: billing/subscription_types.py
🔗 Open this machine in the Stately editor — public, no account needed.
The input: the Stripe-subscription chart from the recipes.
What came out — abridged, but these are the real files:
|
class SubscriptionMachine(StateMachine):
machine_id = "subscription"
initial_context = {"failures": 0, "last_invoice": None}
incomplete = State(
"incomplete",
initial=True,
on={
"PAYMENT_SUCCEEDED": {
"target": "active",
"actions": ["recordPayment"],
},
"CANCELED": "canceled",
},
)
active = State("active", on={...}, tags=["billable"])
past_due = State("past_due", on={...}, tags=["billable", "dunning"])
canceled = State("canceled", tags=["terminal"])
@action
def record_payment(self, interpreter, context, event, action_def) -> None:
# TODO: implement action logic
...
|
def test_step_01_PAYMENT_SUCCEEDED(interp, clock):
interp.send("PAYMENT_SUCCEEDED")
assert sorted(interp.current_state_ids) == ["subscription.active"]
def test_every_declared_event_is_known(machine): ...
def test_snapshot_round_trip(interp, clock, machine): ...
EventType = Literal["CANCELED", "PAYMENT_FAILED", "PAYMENT_SUCCEEDED"]
StateId = Literal["subscription.active", "subscription.canceled", ...]
class Context(TypedDict): ...
|
The generator checks structural equivalence before writing. For templates that build the machine
in Python, xsm compiles the generated code, runs it, and compares the resulting machine
against create_machine(your.json). If anything diverges it prints what and exits non-zero —
nothing is written. Nesting, parallel regions, history, timers (numeric and named delays),
composite guards, invoke, tags and meta all round-trip exactly.
This verifies the generated machine's structure, not the business correctness of the implementations you add. Recorded tests start with stub logic; extend them with your own guard, action and service scenarios.
| Template | Produces |
|---|---|
pythonic-class · pythonic-builder · pythonic-functional |
The machine in one of the three Python styles + stubs |
class-json · function-json |
Stubs that load the JSON at runtime (the file stays the source of truth) |
pytest (--with-tests) |
A recorded test module, one test per reachable step |
typed (--with-types) |
TypedDict context, Literal events and state ids, typed stub signatures |
plugin (--with-plugin) |
A PluginBase wired for exactly the hooks the chart can fire |
Three more things fall out of the same idea — the chart knows itself:
xsm generate-template subscription.json -t pythonic-class -o ./billing --check # CI: has the code drifted from the JSON?
xsm docs machines/*.json -o docs/ # a Markdown reference page per machine
xsm diagram subscription.json -f mermaid # or plantuml / ascii — no graphviz
xsm paths subscription.json # one path to every reachable configuration
…and the [testing] pytest plugin turns that last list into a parametrised xsm_path
fixture, with pytest --xsm-coverage telling you which states and transitions your suite
never reached. Django projects get all of this as manage.py xsm_*.
Full reference → CLI · Templates deep dive · Hierarchical generation (parent + child charts in one go).
🔗 One JSON, Two Runtimes
Your frontend team models a checkout flow in Stately. They export
checkout.json and wire it into React with XState.
You take that exact file — unedited — and run it in Python:
import json
from xstate_statemachine import create_machine, MachineLogic, SyncInterpreter
with open("checkout.json") as f: # ← straight from the frontend repo
config = json.load(f)
machine = create_machine(
config,
logic=MachineLogic(
actions={"charge_card": charge_card}, # you supply the Python side
guards={"has_stock": has_stock},
),
)
checkout = SyncInterpreter(machine).start()
One definition. Two runtimes. The UI and backend can share the same workflow contract instead of reimplementing the state graph independently. Keep implementations and versions aligned, and continue to enforce authorization and business rules on the backend.
The statechart screenshots in this README come from the Stately editor — the source configs live in
docs/assets/images/machines/machines.json.
Examples show the corresponding workflow or an explicitly simplified subset.
xsm diagram and machine.to_mermaid() render a machine without leaving the terminal.
The round trip, the version key, meta conventions and the editor JSON Schema are in
Stately editor → Python.
How compatible is "compatible"? (real numbers)
The test suite includes 104 real-world machines exported from Stately.ai. 103 of them parse
structurally unmodified. The single exception has no top-level states key at all — it isn't a
well-formed machine.
Both XState v4 (cond) and v5 (guard) transition spellings are accepted.
Structural parsing is not a claim that every JavaScript API or runtime extension is supported;
the library targets XState v5 statechart semantics.
What is not supported: JS/TS action implementations embedded in the JSON. Those are code, not
data — you supply the Python equivalents via MachineLogic, which is the whole point of the
separation.
Note — this library implements the SCXML transition-selection algorithm (the W3C standard XState itself follows). That is what makes nested and parallel-region behaviour match XState rather than merely resemble it. It does not import or export
.scxmlfiles.
💾 Context — The Machine's Memory
Context is a plain dict. Update it declaratively with assign:
from xstate_statemachine import create_machine, SyncInterpreter, assign
cart = SyncInterpreter(
create_machine(
{
"id": "cart",
"initial": "shopping",
"context": {"items": 0, "total": 0.0},
"states": {
"shopping": {
"on": {
"ADD_ITEM": {
"actions": assign(
{
"items": lambda a: a["context"]["items"]
+ 1,
"total": lambda a: a["context"]["total"]
+ a["event"].payload["price"],
}
)
},
"CLEAR": {
"actions": assign(
lambda a: {"items": 0, "total": 0.0}
)
},
}
}
},
}
)
).start()
cart.send("ADD_ITEM", price=9.99)
cart.send("ADD_ITEM", price=5.01)
print(cart.context) # {'items': 2, 'total': 15.0}
assign takes either a dict of per-key updaters or a single callable returning a
partial dict. Each updater receives one mapping with "context" and "event" keys.
Tip — keyword arguments to
send()land inevent.payload.send("ADD_ITEM", price=9.99)→a["event"].payload["price"].
Keys that start with _xsm_ (PRIVATE_CONTEXT_PREFIX) are private context: they
round-trip through snapshots but are stripped by public_context() and never leave the
process through the web adapters. Want typed context? [pydantic] validates it on every
assign — see Integrations.
🛡️ Guards — Conditional Transitions
A guard is a pure function returning bool. List transitions in priority order; the first
whose guard passes wins.
from xstate_statemachine import create_machine, SyncInterpreter, MachineLogic
config = {
"id": "atm",
"initial": "idle",
"context": {"balance": 100, "frozen": False},
"states": {
"idle": {
"on": {
"WITHDRAW": [
{
"target": "approved",
"guard": {
"type": "and",
"params": {
"guards": [
"hasFunds",
{
"type": "not",
"params": {"guards": ["isFrozen"]},
},
]
},
},
},
{"target": "denied"}, # fallback — no guard
]
}
},
"approved": {},
"denied": {},
},
}
logic = MachineLogic(
guards={
"hasFunds": lambda ctx, e: ctx["balance"]
>= e.payload.get("amount", 0),
"isFrozen": lambda ctx, e: ctx["frozen"],
}
)
atm = SyncInterpreter(create_machine(config, logic=logic)).start()
atm.send("WITHDRAW", amount=50)
print(atm.current_state_ids) # {'atm.approved'}
Composite guards — and, or, not nest arbitrarily via params.guards. There's also
stateIn for "only if some other region is in state X":
{"guard": {"type": "stateIn", "params": {"state": "auth.loggedIn"}}}
Note — guards must be pure. They can be evaluated more than once, and a guard with side effects will surprise you. Put side effects in actions. A guard that raises is treated as
Falseby default (guardErrorPolicylets you change that), and a guard that is declared but never implemented fails loudly atcreate_machine().
🎬 Actions — Side Effects
Actions fire during a transition, or on entering/leaving a state.
"states": {
"loading": {
"entry": ["showSpinner"], # on the way in
"exit": ["hideSpinner"], # on the way out
"on": {"CANCEL": {"target": "idle", "actions": ["logCancel"]}},
}
}
Order is guaranteed: exit actions → transition actions → entry actions.
Built-in action creators
You rarely need to hand-write these — import them and go:
| Creator | Does |
|---|---|
assign |
Update context |
log |
Structured log line |
raise_ |
Send an event to this machine |
send_to |
Send to another actor by id or systemId |
send_parent |
Send to the machine that spawned you |
choose |
Run the first action list whose guard passes |
pure |
Compute actions from context at runtime |
enqueue_actions |
Imperatively queue actions in a callback |
spawn_child / stop_child |
Start / stop a child actor |
cancel |
Cancel a delayed send_to |
emit |
Emit an event to external subscribers |
escalate |
Raise an error to the parent |
forward_to |
Forward the current event to another actor |
Note — if an action raises, the error is logged and contained by default. The transition still completes and the interpreter keeps running; one buggy side effect can't take down a long-lived machine.
actionErrorPolicy: "rollback"or"fail"changes that per machine — see Failure semantics.
Worked examples — the ones that aren't obvious from the name
choose — first passing guard wins. The declarative form of if/elif/else:
"on": {"GO": {"target": "b", "actions": [choose([
{"guard": "isBig", "actions": [assign({"label": lambda a: "big"})]},
{"guard": "isSmall", "actions": [assign({"label": lambda a: "small"})]},
{"actions": [assign({"label": lambda a: "other"})]}, # no guard = default
])]}}
pure — decide the action list at runtime. Return actions, or nothing:
"actions": [pure(lambda a:
[assign({"n": lambda b: b["context"]["n"] * 2})]
if a["context"]["n"] < 10 else []
)]
raise_ — feed an event back to this machine. "And then immediately…" without a fake
external trigger:
"actions": [raise_("VALIDATE")]
send_to / send_parent — talk to other actors. Delayed sends are cancellable:
"actions": [send_to("timer", "TICK", delay=1000, send_id="tick")]
# elsewhere
"actions": [cancel("tick")]
emit — publish outward without coupling. The machine says what happened;
subscribers decide what to do:
"actions": [emit("saved")] # or emit({"type": "saved", "id": 7})
interpreter.on("saved", lambda ev: analytics.track(ev.type))
🔌 Services & Invoke
invoke runs an async or sync callable when a state is entered, and routes its result back
into the machine as onDone / onError. This is how you do I/O.
import asyncio
from xstate_statemachine import (
create_machine,
Interpreter,
MachineLogic,
assign,
wait_for,
)
config = {
"id": "fetch",
"initial": "idle",
"context": {"user": None, "error": None},
"states": {
"idle": {"on": {"FETCH": "loading"}},
"loading": {
"invoke": {
"src": "fetchUser",
"onDone": {
"target": "success",
"actions": assign({"user": lambda a: a["event"].data}),
},
"onError": {
"target": "failure",
"actions": assign(
{"error": lambda a: str(a["event"].data)}
),
},
}
},
"success": {"type": "final"},
"failure": {"on": {"RETRY": "loading"}},
},
}
async def fetch_user(interpreter, ctx, event):
await asyncio.sleep(0.01)
return {"id": 1, "name": "Ada"}
async def main():
machine = create_machine(
config, logic=MachineLogic(services={"fetch_user": fetch_user})
)
svc = await Interpreter(machine).start()
await svc.send("FETCH")
await wait_for(svc, lambda s: s.matches("fetch.success"), timeout=2)
print(svc.context["user"]) # {'id': 1, 'name': 'Ada'}
await svc.stop()
asyncio.run(main())
- Success →
onDone, with the return value onevent.data - Failure →
onError, with the exception object onevent.data - Leaving the state cancels the invocation and discards stale completion events. Cancellation is cooperative; it cannot forcibly stop a blocking thread or undo an external request
- A plain-
defservice under the async engine runs on a private thread pool (service_pool_size, or yourservice_executor) so a blocking call cannot stall the loop
Beyond callables, src can be actor logic: from_callback (push events in from a socket
or a thread), from_iterator / from_async_iterator (each item becomes a StreamEvent),
from_coroutine, from_interpreter (a whole child machine), or a Celery task via
celery_service.
Tip — use
wait_for(async) orwait_for_syncrather thanasyncio.sleep()guesses. It waits on a predicate with a real timeout, so tests do not depend on guessed delays.
⏱️ Timers & Delayed Transitions
after fires a transition if the machine is still in that state when the timer elapses.
Leave early and the timer is cancelled for you.
"connecting": {
"after": {5000: "timedOut"}, # 5000 ms
"on": {"OPEN": "online"}, # ...unless we connect first
}
Name your delays to keep magic numbers out of the config — and to compute them at runtime, which is exactly how you express exponential backoff:
logic = MachineLogic(
delays={
"TIMEOUT": 60_000,
"BACKOFF": lambda ctx, e: 2 ** ctx["attempt"]
* 1000, # 1s, 2s, 4s, 8s…
}
)
"retrying": {"after": {"BACKOFF": "loading"}}
Three things make timers production-grade here rather than a threading.Timer per state:
- No OS thread per timer. The async engine fires
afterthrough a priority lane the run loop checks ahead of its inbox; the sync engine fires a due timer on the caller's thread insidesend()ortick(). - Virtual time in tests.
SimulatedClock().increment(30_000)fires a 30-secondafterinstantly. - Durable across restarts. Deadlines persist as wall-clock instants;
persisted()resumes the remaining time andDueTimerScannerwakes a machine whose deadline passed while nothing was running — a 7-day dunning timer survives every worker dying. See Persistence.
🌳 Nested & Parallel States
Nested (compound) states
Group related substates so shared transitions live in one place:
"states": {
"loggedOut": {"on": {"LOGIN": "authenticated"}},
"authenticated": {
"initial": "browsing",
"on": {"LOGOUT": "loggedOut"}, # ← applies to EVERY substate
"states": {
"browsing": {"on": {"CHECKOUT": "paying"}},
"paying": {"on": {"DONE": "confirmed"}},
"confirmed": {},
},
},
}
LOGOUT works from browsing, paying, and confirmed. Write it once.
Parallel states — concurrent regions
Regions run independently. onDone fires exactly once, when all of them reach a final
state — fan-out and fan-in with no bookkeeping:
from xstate_statemachine import create_machine, SyncInterpreter
ci = SyncInterpreter(
create_machine(
{
"id": "ci",
"initial": "running",
"states": {
"running": {
"type": "parallel",
"onDone": "deployed",
"states": {
"build": {
"initial": "b",
"states": {
"b": {"on": {"BUILD_OK": "done"}},
"done": {"type": "final"},
},
},
"lint": {
"initial": "l",
"states": {
"l": {"on": {"LINT_OK": "done"}},
"done": {"type": "final"},
},
},
},
},
"deployed": {},
},
}
)
).start()
print(
sorted(ci.current_state_ids)
) # ['ci.running.build.b', 'ci.running.lint.l']
ci.send("BUILD_OK")
print(
sorted(ci.current_state_ids)
) # ['ci.running.build.done', 'ci.running.lint.l']
ci.send("LINT_OK")
print(sorted(ci.current_state_ids)) # ['ci.deployed'] ← fan-in fired
This is where current_state_ids returning a set finally makes sense.
🕰️ History & Final States
History remembers where you were, so an interruption doesn't lose progress — the classic "resume the wizard where the user left off":
"states": {
"steps": {
"initial": "step1",
"on": {"HELP": "helpModal"},
"states": {
"step1": {"on": {"NEXT": "step2"}},
"step2": {"on": {"NEXT": "step3", "BACK": "step1"}},
"step3": {"on": {"FINISH": "#wizard.done", "BACK": "step2"}},
"hist": {"type": "history", "history": "shallow"}, # or "deep"
},
},
"helpModal": {"on": {"CLOSE": "steps.hist"}}, # ← back to the exact step
"done": {"type": "final"},
}
Final states mark completion. A final state in a compound state fires its parent's
onDone; a top-level final state stops the machine and can produce output. Await it with
to_promise(interp) or interp.wait_done().
🎭 The Actor Model
Machines can spawn other machines. Each child gets its own state, context and lifecycle —
a supervision tree, not a callback pile. Register a child under a systemId and any machine
in the system can address it by name.
from xstate_statemachine import create_machine, SyncInterpreter, MachineLogic
# The child machine — an independent actor with its own context.
worker = {
"id": "worker",
"initial": "idle",
"context": {"jobs": 0},
"states": {
"idle": {"on": {"JOB": {"target": "idle", "actions": ["count"]}}}
},
}
worker_logic = MachineLogic(
actions={
"count": lambda i, ctx, e, a: ctx.__setitem__("jobs", ctx["jobs"] + 1),
}
)
parent = {
"id": "super",
"initial": "up",
"context": {},
"states": {
"up": {
"entry": [
{
"type": "spawnChild",
"params": {
"src": "worker",
"id": "pool",
"systemId": "pool",
},
}
],
"on": {
"DISPATCH": {
"actions": [
{
"type": "sendTo",
"params": {"to": "pool", "event": {"type": "JOB"}},
}
]
}
},
}
},
}
logic = MachineLogic(
services={
"worker": lambda i, ctx, e: create_machine(worker, logic=worker_logic),
}
)
sup = SyncInterpreter(create_machine(parent, logic=logic)).start()
print(list(sup.system.get_all())) # ['pool']
sup.send("DISPATCH")
sup.send("DISPATCH")
print(sup.system.get("pool").context["jobs"]) # 2
Children talk back with send_parent, escalate failures with escalate, and are torn down
with stop_child — or automatically when the parent stops. Spawned actors inherit the
parent's plugins and are captured in its snapshot.
Good fit for: LLM agent orchestration (each tool call a supervised child — see
spawn_agent), connection pools, per-user session machines, job workers.
💾 Persistence — Snapshots, Stores and Durable Timers
Serialize a running machine to JSON, store it anywhere, rebuild it later. Long-running flows survive deploys and restarts.
from xstate_statemachine import create_machine, SyncInterpreter
job = SyncInterpreter(create_machine(config)).start()
job.send("NEXT")
snapshot = job.get_snapshot() # a JSON string → Redis, Postgres, a file…
job.stop()
# …new process, hours later…
resumed = SyncInterpreter.from_snapshot(snapshot, create_machine(config))
print(resumed.current_state_ids) # {'job.step2'} ← exactly where it left off
resumed.send("NEXT")
State, context, pending events (with their lane), deferred events, armed delayed sends,
history, child actors, after deadlines and systemId registrations all round-trip.
Every snapshot carries an envelope (version — layout 4 — machine_id, machine_hash,
machine_version), so from_snapshot() refuses a structurally different machine with
SnapshotDriftError instead of silently resuming into it; a newer layout than this release
understands is SnapshotVersionError, never a guess.
The safe loop: load → act → persist → discard
Most apps never call from_snapshot by hand. xstate_statemachine.persistence ships the
whole loop with built-in stores — MemoryStore, FileStore (atomic writes, 0600),
SQLiteStore (WAL, stdlib sqlite3) — and RedisStore, SQLAlchemyStore /
AsyncSQLAlchemyStore and DjangoStore in their extras:
from xstate_statemachine import MachineLogic, create_machine
from xstate_statemachine.persistence import MemoryStore, persisted
cfg = {
"id": "order",
"initial": "cart",
"context": {"items": 0},
"states": {
"cart": {"on": {"ADD": {"actions": "add"}, "PAY": "paid"}},
"paid": {"type": "final"},
},
}
machine = create_machine(
cfg,
logic=MachineLogic(
actions={
"add": lambda i, ctx, e, a: ctx.__setitem__(
"items", ctx["items"] + 1
)
}
),
)
store = MemoryStore() # or SQLiteStore("orders.db"), RedisStore(...)
with persisted(
store, "order:42", machine
) as order: # loaded (or created), started
order.send("ADD")
order.send("ADD")
# saved with expected_version on clean exit; a concurrent writer gets ConflictError
with persisted(store, "order:42", machine) as order:
assert order.context["items"] == 2
assert order.send("PAY", wait=True).changed
assert store.load("order:42").version == 2
apersisted() is the async twin; persisted_retry() reloads and re-applies on
ConflictError. What the loop gives you, all documented on the
Guarantees page:
| Concern | What you get |
|---|---|
| Two workers, one instance | OptimisticLock (version check + jittered retry) or a fenced PessimisticLock — never a silent lost update |
| Duplicate webhooks | IdempotencyPlugin(inbox, principal=…) answers a replay with the original receipt before the machine sees it; scope is per tenant. MemoryInbox, SQLiteInbox, RedisInbox, DjangoInbox |
| Timers across restarts | after deadlines are persisted as wall-clock instants; persisted() resumes the remaining time, and DueTimerScanner wakes machines whose deadline passed while nothing was running (drive it from cron, APScheduler or Celery Beat) |
| Audit / replay | AuditPlugin + TransitionLogPlugin (who, what, when) into MemoryLog, JSONLinesLog, SQLiteLog or RedisLog; replay() a log into a fresh machine and assert_replay_consistent() in tests |
| Schema drift | @migrator.register("1.0", "1.1") steps upgrade in-flight instances across a deploy; xsm snapshots --stale finds the ones written by another machine version; unknown or newer layouts are refused, never guessed at |
| Adopting an existing table | from_state_ids(machine, ids, context) mints a valid snapshot from the state column you already have (how xsm_migrate_fsm moves a django-fsm model over) |
| Secrets | One redact() denylist applied by every built-in sink; a SnapshotCodec seam for encryption at rest; SnapshotTooLargeError caps blob size |
| The store is down | StoreUnavailableError → 503 in the web adapters; a failed save does not commit a new snapshot. External effects performed before the failure still need idempotency |
The honest boundary: the persistence layer's exactly-once transition guarantee applies
to idempotency-keyed events while deduplication evidence is retained, using the documented
store and locking setup. Actions' external side effects and outbox delivery are
at-least-once, and a memory-only inbox is not durable retention. Use invoke to model
I/O, use the transactional outbox for outgoing
messages, and make external effects idempotent. Full guide →
Persistence & Durability.
🔌 Integrations — Optional Extras
The core stays zero-dependency; every integration is an extra under xstate_statemachine.contrib
that you install explicitly (pip install "xstate-statemachine[fastapi]"). Each has a guide
page with a Guarantees box and a Threat model box — CI refuses a page without them —
and every framework version it claims is proven by a CI cell on the
Compatibility page.
| Extra | What you get | Guide |
|---|---|---|
[pydantic] |
Typed context validated on every assign, EventModel discriminated unions → event_schemas=, validate_machine_json(), JSON Schema |
Pydantic |
[redis] |
RedisStore / RedisInbox / RedisLog with fenced locks (Lua, fencing tokens) for multi-host deployments; RedisStreamsBroker |
Redis |
[sqlalchemy] |
StatechartType + StatechartMixin (state on your row, version_id_col locking, in_state()), SQLAlchemyStore / AsyncSQLAlchemyStore, transactional outbox (SQLAlchemyOutboxStore) |
SQLAlchemy |
[starlette] |
StatechartRegistry — the store-backed create → act → persist loop as ASGI middleware; receipt → HTTP status; principal-scoped Idempotency-Key; RFC 9457 problems; SSE and WebSocket transition streams |
Starlette |
[fastapi] |
StatechartRouter generates GET /{id}, POST /{id}/send (discriminated-union body), one route per event, /events, /diagram.mmd, /stream, /ws — with OpenAPI that reflects your chart; Depends(get_interpreter(...)); xsm new --template fastapi |
FastAPI |
[litestar] |
XStatePlugin + a generated Controller on the same registry |
Litestar |
[flask] |
init_app extension, statechart blueprint, SessionStore wizards, flask xsm CLI, Quart shim; xsm new --template flask |
Flask |
[django] |
StatechartField on your model (in_state(), __state lookups), send() in atomic() with lock="optimistic" or a row lock, pre_transition / post_transition / statechart_error signals, TransitionLog audit in the same transaction, PermissionGuard / RoleGuard / AnyOf / AllOf, DjangoStore, DjangoOutboxStore, admin transition buttons (CSRF-protected), manage.py xsm_inspect / xsm_diagram / xsm_docs / xsm_simulate / xsm_snapshots --stale / xsm_deadlines, xsm_migrate_fsm + FSMDualWriteMixin to leave django-fsm |
Django |
[drf] / [channels] |
StatechartViewSetMixin (an @action per event, closed by default, receipt → status, drf-spectacular schema, DjangoInbox for Idempotency-Key); StatechartConsumer (live transitions over WebSocket, requires AuthMiddlewareStack) |
DRF & Channels |
[celery] |
A Celery task as an invoke service (celery_service), @statechart_task worker act-loop, Celery Beat as the durable after scheduler (DurableTimerScheduler), outbox_relay_task; refuses pickle / YAML serializers |
Celery |
[kafka] [rabbitmq] [nats] [sqs] + [redis] |
Broker adapters (KafkaBroker, RabbitMQBroker, NatsBroker, SqsBroker / SyncSqsBroker, RedisStreamsBroker / SyncRedisStreamsBroker): at-least-once delivery, topology-dependent per-subject ordering and poison → DLQ. Broker redelivery counts become attempts where available; Kafka has no such count. Shared contract tests; xsm plugins lists the adapters |
Brokers |
(core) + [cloudevents] |
The event-driven core above; the extra adds CloudEvents SDK / HTTP binding interop | Event-driven |
[agents] |
LLM agents: TOOL_LOOP, per-state tool allow-lists, budgets (BudgetPlugin, budget_guards, Usage), durable human approval, spawn_agent; agent_logic, run_agent_sync, structured_output, handoff_guard, AgentTracePlugin, ToolDeniedError; OpenAI / Anthropic providers are soft imports |
LLM agents |
[observability] |
OpenTelemetryPlugin spans, PrometheusPlugin metrics, structlog / loguru context, Sentry breadcrumbs — instrument_all() in one line; label allow-list by default |
Observability |
[testing] |
A pytest plugin: xsm_* fixtures (machine, interpreter, simulated clock, recorded scenarios), xsm_path walks every engine-verified path, state / transition coverage with --xsm-fail-under-*, Hypothesis strategies (model_test, events_strategy), replay() / assert_replay_consistent(), fake brokers |
Testing |
[format] |
black + isort so xsm generate-template output matches your linter |
CLI |
[web] · [eda] · [all] |
Convenience bundles: selected web integrations · Celery/Redis/Kafka/RabbitMQ/CloudEvents/observability · all shipped integration and testing extras. Add [nats] or [sqs] to [eda] when needed; install [format] separately for formatting tools |
Integration extras |
from fastapi import FastAPI
from xstate_statemachine.contrib.fastapi import (
StatechartRouter,
instrument_app,
)
from xstate_statemachine.contrib.starlette import StatechartRegistry
from xstate_statemachine.persistence import SQLiteStore
# … order_machine, AddItem/Pay/Cancel event models and my_authorizer defined elsewhere
registry = StatechartRegistry(
SQLiteStore(DB_PATH), run_timers=True
) # or RedisStore for many hosts
registry.register(
"orders", order_machine, authorize=my_authorizer
) # closed by default
app = FastAPI()
instrument_app(app, registry) # lifespan, /_xsm/health, /_xsm/ready
app.include_router(
StatechartRouter(registry, "orders", event_models=[AddItem, Pay, Cancel])
)
# POST /orders/42/events/PAY → 200 {state, state_ids, changed, available_events, …}
# → 409 when a business rule (guard) refuses, 422 on a bad body
# → 503 when the store is unavailable
Every web integration is closed by default — authorize= is required, GET returns
state only unless you opt into a context_serializer, and error bodies never carry exception
text. The multi-worker model (why an interpreter cannot live in a uvicorn worker, where timers
run, how 4 workers × 200 concurrent PAY yields exactly one success) is the
FastAPI guide's first section.
Six runnable, README-driven apps live under
examples/integrations/
— fastapi_orders (N uvicorn workers, a 200-payment stress test, gateway-outage and
rolling-upgrade scenarios), django_approvals (parallel Legal/Finance review, roles, admin,
DRF, Channels), flask_wizard, sqlalchemy_orders, eda_fulfilment and
agents_support_bot. CI installs each one into a fresh venv from its own pip install line.
🤖 For LLM agents
An agent loop is a statechart the model does not get to rewrite. With
pip install "xstate-statemachine[agents]" the model becomes one invoked service that
proposes text or tool calls, and the chart decides what happens next. This is the
reference TOOL_LOOP chart the extra ships — every budget check, timeout, denial and
human-approval wait is a state you can see, test, persist and diagram:
- Per-state tool allow-lists (
meta.tools) are re-checked insiderun_tool— editing the guard out of the chart does not let a forbidden tool through. - Budgets are guards: turns, tokens and cost are context the chart refuses to exceed.
BudgetPluginrolls them up across a tree of spawned agents. - Timeouts are
afterdeadlines, retried with backoff throughtimed_out. - Human approval is a durable state.
awaiting_humanpersists with the exact pending call ids and an escalation deadline; the approval event must name the call it approves. - Structured output is validated (pydantic) with bounded retries; a bad answer is a state, not an exception in your handler.
- Multi-agent:
spawn_agentruns children as actors with per-child and global budgets;supervisor,pipelineanddebatereference charts ship alongsideTOOL_LOOP. - Offline by default:
FakeModelscripts the model's answers, so the whole loop runs in CI with no key. OpenAI and Anthropic providers are soft imports; it interoperates with LangGraph and pydantic-ai.
# pip install "xstate-statemachine[agents]" # … the extra's only hard dependency is pydantic
import asyncio
from xstate_statemachine.contrib.agents import (
FakeModel,
run_agent,
tool_registry,
)
def get_weather(city: str) -> str:
"""Current weather for a city."""
return f"sunny in {city}"
model = FakeModel(
[{"tool": "get_weather", "args": {"city": "Kochi"}}, {"text": "Sunny."}]
)
res = asyncio.run(
run_agent(
model, tools=tool_registry(get_weather), prompt="Weather?", max_turns=5
)
)
assert res.final_state == "toolLoop.done" and res.usage["turns"] == 2
AgentTracePlugin writes JSONL traces with gen_ai.* attributes and secret scrubbing. The
two-minute offline walkthrough is
examples/integrations/agents_support_bot
(python run.py --fake --prompt "refund order 42" — one refund, never two). Honest
comparisons with LangGraph, Burr and
@statelyai/agent include checked-version notes and scheduled drift checks.
🔭 Observability & Inspection
Three ways to see inside a running machine, from zero-dependency to full APM.
Built in — LoggingInspector prints every event, transition and action;
InspectorPlugin speaks the @statelyai/inspect protocol so xsm inspect --live (or an
SseSink in your app) streams any machine into the Stately Inspector in your browser;
JsonLinesSink records a session and xsm replay plays it back, live or as a transcript.
[observability] — one line:
from xstate_statemachine.contrib.observability import instrument_all
# OpenTelemetry spans per transition/service, Prometheus counters and histograms,
# structlog / loguru context vars, Sentry breadcrumbs — whichever SDKs are installed
instrument_all(interpreter) # … any running Interpreter / SyncInterpreter
Metric labels are an allow-list by default (state and event names, never payloads), and
every sink runs the same redact() denylist as the stores. For agents, AgentTracePlugin
writes JSONL traces with gen_ai.* attributes and secret scrubbing.
Programmatic — subscribe(), on(), send(wait=True) → Receipt, plugin hooks for every
lifecycle moment, and the pure API below. Guide →
Observability ·
Live inspector.
🧪 The Pure API — No Interpreter
Sometimes you want to ask "what would happen if…" without running anything. The pure API is a set of side-effect-free functions over immutable snapshots — ideal for tests, planning, and "preview the next step" UI.
from xstate_statemachine import (
MachineLogic,
create_machine,
initial_transition,
pure_transition,
)
machine = create_machine(
{
"id": "fetch",
"initial": "idle",
"states": {
"idle": {
"on": {"FETCH": {"target": "loading", "actions": "logStart"}}
},
"loading": {"on": {"OK": "done"}},
"done": {"type": "final"},
},
},
logic=MachineLogic(),
) # 📝 no implementations needed — nothing runs
snapshot, entry_actions = initial_transition(machine)
next_snapshot, actions = pure_transition(machine, snapshot, "FETCH")
print(snapshot.state_ids) # {'fetch.idle'}
print(next_snapshot.state_ids) # {'fetch.loading'}
print([a.type for a in actions]) # ['logStart'] — what WOULD have run
Both functions return (snapshot, actions). If you only want the next state,
get_next_snapshot(machine, snapshot, "FETCH") returns the snapshot alone.
A PureSnapshot exposes state_ids, context, status, output, configuration
and matches(). No timers start. No services fire. Nothing mutates.
The graph module goes further: reachable_states(), shortest_paths(), simple_paths()
and transition_coverage_targets() enumerate a machine — which is what xsm paths prints
and what the [testing] plugin's xsm_path fixture walks, one engine-verified path per test.
🔍 Introspection & Plugins
A running machine can answer questions about itself — which is what lets you drive a UI from it without duplicating its logic in your view layer.
from xstate_statemachine import (
create_machine,
MachineLogic,
SyncInterpreter,
assign,
emit,
)
editor = create_machine(
{
"id": "editor",
"initial": "clean",
"context": {"saves": 0},
"states": {
"clean": {
"tags": ["idle"],
"meta": {"hint": "Nothing to save"},
"on": {"EDIT": "dirty"},
},
"dirty": {"tags": ["unsaved"], "on": {"SAVE": "saving"}},
"saving": {
"tags": ["unsaved", "busy"],
"on": {
"OK": {
"target": "clean",
"actions": [
assign(
{"saves": lambda a: a["context"]["saves"] + 1}
),
emit("saved"),
],
}
},
},
},
},
logic=MachineLogic(),
)
ed = SyncInterpreter(editor).start()
ed.matches("editor.clean") # True — nested paths work: "a.b.c"
ed.can("EDIT") # True — would this event do anything *right now*?
ed.can("SAVE") # False — not handled in `clean`
ed.has_tag("idle") # True
ed.tags # {'idle'}
ed.get_meta() # {'editor.clean': {'hint': 'Nothing to save'}}
ed.context # {'saves': 0}
ed.is_running # True
can() — disable buttons without duplicating logic
The machine already knows which events are legal. Ask it, instead of re-deriving the rule in your template:
save_button.disabled = not ed.can("SAVE")
Tags — style many states with one check
saving and dirty are different states but share the unsaved tag, so a spinner
needs one condition rather than a growing or chain:
if ed.has_tag("busy"):
show_spinner()
subscribe() — react to every settled transition
The listener receives the interpreter, so read whatever you need from it:
unsubscribe = ed.subscribe(
lambda i: print(sorted(i.current_state_ids), i.context)
)
# … later
unsubscribe()
on() — listen for emitted events
emit publishes a named event outward. Subscribe to it by name:
ed.on("saved", lambda ev: print("saved!", ev.type))
Plugins — the whole lifecycle, one line
from xstate_statemachine import LoggingInspector
ed.use(LoggingInspector()) # complete transition audit trail
Subclass PluginBase for metrics, tracing, or persistence-on-every-transition. Every
hook is optional:
| Hook | Fires when |
|---|---|
on_interpreter_start / on_interpreter_stop |
Lifecycle boundaries (restored_from_snapshot tells a resume from a bring-up) |
on_before_send / on_event_received / on_event_processed |
An event is offered (and may be refused), arrives, and has run its macrostep |
on_event_refused / on_event_sent / on_resolve_error |
A later plugin refused an event you had already approved (release your claim); this actor addressed an event to another actor (sendTo / sendParent / forwardTo); a transition target failed to resolve |
on_transition |
A transition settles |
on_guard_evaluated / on_guard_error |
A guard returns — "why didn't it fire?" — or raised |
on_action_execute / on_action_error |
Before each action runs; an action raised. Failures are contained, so without this hook they are invisible |
on_service_start / on_service_done / on_service_error |
invoke lifecycle |
on_unhandled_event / on_invalid_event / on_event_dropped |
An event nobody handled, one strict or a schema refused, one a bounded inbox dropped |
on_transition_failed / on_chain_budget_exceeded / on_invocation_stranded |
A policy stopped a step, a runaway chain was cut, a cut parked a state whose service will never finish |
on_snapshot_error / on_plugin_error / on_receipt_dropped |
Serialization failed; another plugin raised (never stops the machine); a wait=True receipt was dropped unawaited |
on_done / on_error |
The machine reached a final state, or stopped with an error |
from xstate_statemachine import PluginBase
class Metrics(PluginBase):
def on_transition(self, interpreter, from_states, to_states, transition):
statsd.increment(f"fsm.{transition.event}")
def on_action_error(self, interpreter, action, error):
sentry.capture_exception(error) # otherwise silently contained
ed.use(Metrics())
register_global(plugin) attaches a plugin to every interpreter constructed afterwards —
including spawned children and from_snapshot() restores. Third-party packages can publish
plugins, stores and brokers under the xstate_statemachine.plugins / .brokers entry-point
groups; xsm plugins lists what is installed and attach_discovered(interp, allow=[...])
loads only the distributions you name (it never runs an unmarked callable — see
Security).
⚖️ How It Compares
Choose for the workflow you need, not the longest feature list. The distinctive combination here is XState JSON portability, Python statechart execution and optional persistence, web and event-driven integrations around the same model.
The seven detailed comparisons include source notes and checked versions. The repository also runs scheduled release/API drift checks; those are not a claim to test every feature of every competing product on every release: transitions · python-statemachine · django-fsm · LangGraph · Burr · @statelyai/agent · AWS Step Functions.
Python state-machine libraries
The feature comparison is scoped to the APIs recorded in the
comparison source notes, including transitions 0.9.3 and
python-statemachine 3.2.1. Application code and third-party extensions can add capabilities
beyond those listed. See the projects' own documentation for their current surface:
transitions and
python-statemachine.
| Capability | xstate-statemachine | transitions | python-statemachine |
|---|---|---|---|
| XState / Stately JSON | Native input; Python implements the named logic | Its own configuration format | Its own JSON/YAML/SCXML formats |
| Compound (nested) states | Built in | HierarchicalMachine |
StateChart |
| Parallel regions | Built in | Hierarchical extension | State.Parallel |
| History states | Shallow and deep | Application-managed in the checked API | Shallow and deep |
| Invoked services and completion/error events | invoke + onDone / onError |
Application callbacks | Built-in invoke, including child machines |
| Delayed transitions | after; durable with persistence + a scanner |
Timeout and thread-free AsyncTimeout; in-process |
delay= / timeout helpers; in-process |
| Actor model / spawning | Spawned actors, messaging and systemId registry |
Application-managed | Invoked children; not the same spawn/registry API |
| Snapshot persistence, stores, locks, idempotency | Integrated persistence layer | Model-state storage / serialization + application integration | Model-field persistence + application integration |
| Sync and async runtimes | Two explicit engines | Sync and async extensions | Sync and async engines |
| Framework adapters (FastAPI, Django, Flask, Celery, brokers) | Optional integration packages | Application / ecosystem integration | Django model mixin; other integrations are application-specific |
| Diagram export | Mermaid, PlantUML, ASCII; no rendering binaries required | Graphviz or Mermaid | Graphviz or Mermaid |
| CLI: generate, inspect, simulate, diagram, docs | xsm developer and operations toolkit |
Application / ecosystem tooling | Diagram CLI and documentation tooling |
| Live inspector (Stately Inspector protocol) | xsm inspect --live |
No bundled Stately bridge in the checked API | No bundled Stately bridge in the checked API |
| Virtual clock for tests | SimulatedClock |
Test-specific clock control | Test-specific clock control |
| Bounded inbox / backpressure | Async max_queue_size + overflow policies |
Different queueing model | Different queueing model |
| Core runtime dependencies | 0 | 1 (six) |
0 |
Pick transitions if you want a mature, flexible FSM attached to an existing class,
with extensions for hierarchy, async execution and diagrams.
Pick python-statemachine if you want a beautiful, pythonic declarative API and don't
need XState JSON interop. Its compound, parallel and history states, invoked services and
diagram tooling make it a substantive alternative.
Pick this library when you want XState/Stately JSON to run in Python unchanged, or you
want invoke, after, actors, persistence and framework adapters as first-class primitives
instead of patterns you assemble yourself.
Beyond a state-machine library
| Your priority | Consider | Why choose this library for that project? |
|---|---|---|
| A lightweight FSM on a Django model | django-fsm / django-fsm-2 | When the workflow needs nested or parallel states, durable deadlines, broker events and the broader statechart toolkit. A migration command and dual-write mixin support incremental adoption. |
| Graph-based AI orchestration, checkpoints and debugging | LangGraph or Burr | When explicit statechart semantics, per-state tool permissions and durable approval deadlines should govern the agent alongside ordinary application workflows. These tools can also be integrated rather than replaced. |
| XState-native agents in TypeScript | @statelyai/agent | When your services and business logic live in Python and you want to keep a portable statechart definition. |
| AWS-managed workflow orchestration | AWS Step Functions | When you want an embedded Python runtime and control over the hosting, database and broker. This library is not a managed service; you operate and scale the deployment. |
Speed
Same machine shape, each library through its own idiomatic API, all measured in one session on 0.11.0 (October 11, 2026, Python 3.14, median of 7 runs, GC disabled, setup excluded except for the construction scenario). These are a recorded benchmark snapshot, not live measurements or a throughput guarantee. Events per second; bold is fastest in the row.
| Scenario | xstate-statemachine (sync) | transitions 0.9.3 | python-statemachine 3.2.1 | sismic 1.6.14 |
|---|---|---|---|---|
| Flat toggle | 71,210 | 124,773 | 7,365 | 12,153 |
| 3-level nested | 28,502 | 7,806 | 2,901 | 4,669 |
| Parallel regions | 25,349 | 5,654 | 2,537 | 4,333 |
| Delayed transitions (timers/s) | 7,706 | 79 | 2,077 | 5,037 |
| Construction (machines/s) | 8,684 | 7,649 | 854 | 220 |
| 1,000 instances (inst/s) | 33,213 | 30,215 | 2,221 | 7,109 |
In this recorded run, transitions leads the flat scenario by approximately 1.8×.
xstate-statemachine leads the two measured nested/parallel scenarios by 3.7–4.5×
over the next library. Construction and the 1,000-instance scenario are 14% and 10%
ahead of transitions, while retaining build-time validation.
These are workload-specific microbenchmarks. They do not measure database writes, broker
delivery, network calls or model latency, and they do not establish a universal ranking.
Full table, method and caveats:
benchmarks/competitors/. The production numbers (throughput budget, after lateness under load) come from benchmarks/production_characteristics.py; run it with --json on your own hardware to gate CI on your figures.
When not to use this
For a three-state toggle with no I/O, a plain enum and an if is less machinery and easier
to read. Around five states, a sketch may be enough; at twenty interacting states, an
explicit model can be much easier to reason about. State count is only a heuristic:
concurrency, timeouts, cancellation and recovery paths are better reasons to adopt
a statechart. Use a managed workflow service when managed operations are the requirement,
and a task queue when all you need is independent background jobs.
📚 Cookbook
Real problems, small solutions.
🍳 Full recipes, each a runnable folder under
examples/recipes/and tested in CI — hostile inputs included: Stripe webhooks (HMAC verification,event.idas the idempotency key, 1,000 deliveries with forged / stale / tampered ones among them), APScheduler durable timers, RQ / arq / Dramatiq workers withConflictErrorretry, a Streamlit / Gradio wizard, chatbot slot filling, feature-flag rollout on aSimulatedClock, WebSocket reconnect withRetryPolicybackoff, and circuit breaker + retry for an HTTP client: Recipes →
Every recipe below is a fragment for readability. Here is one complete, runnable program first — a checkout that guards an empty cart, retries a declining card, and records the failure reason, in 40 lines:
🧾 A whole machine, end to end
from xstate_statemachine import (
MachineLogic,
SyncInterpreter,
assign,
create_machine,
)
ORDER = {
"id": "order",
"initial": "cart",
"context": {"items": 0, "attempts": 0, "error": None},
"states": {
"cart": {
"on": {
"ADD": {
"actions": assign(
{"items": lambda a: a["context"]["items"] + 1}
)
},
"CHECKOUT": {"target": "charging", "guard": "hasItems"},
},
},
"charging": {
"entry": assign(
{"attempts": lambda a: a["context"]["attempts"] + 1}
),
"invoke": {
"src": "chargeCard",
"onDone": "shipped",
"onError": {
"target": "failed",
"actions": assign(
{"error": lambda a: str(a["event"].data)}
),
},
},
},
"failed": {
"on": {"RETRY": {"target": "charging", "guard": "canRetry"}}
},
"shipped": {"type": "final"},
},
}
def charge_card(interpreter, context, event):
"""Fails the first time, succeeds on the retry."""
if context["attempts"] < 2:
raise RuntimeError("card declined")
return {"receipt": "r-123"}
logic = MachineLogic(
guards={
"hasItems": lambda ctx, e: ctx["items"] > 0,
"canRetry": lambda ctx, e: ctx["attempts"] < 3,
},
services={"charge_card": charge_card},
)
order = SyncInterpreter(create_machine(ORDER, logic=logic)).start()
order.send("CHECKOUT") # guard blocks — the cart is empty
print(sorted(order.current_state_ids)) # ['order.cart']
order.send("ADD")
order.send("CHECKOUT") # charges; the service raises
print(sorted(order.current_state_ids)) # ['order.failed']
print(order.context["error"]) # card declined
order.send("RETRY") # second attempt succeeds
print(sorted(order.current_state_ids)) # ['order.shipped']
print(order.context["attempts"]) # 2
Note what is absent: no try/except around the charge, no is_charging flag, no
"did we already ship?" check. A declined card is an onError edge and "cart is empty" is
a guard. A repeated CHECKOUT cannot start another local charge: shipped is final
and charging has no CHECKOUT handler. For a real payment provider, also use durable
persistence and provider-side idempotency to handle retries across failures.
🔁 Retry with exponential backoff and a give-up limit
The pattern that turns into unreadable nested loops when hand-written:
config = {
"id": "api",
"initial": "idle",
"context": {"attempt": 0},
"states": {
"idle": {"on": {"CALL": "loading"}},
"loading": {
"invoke": {
"src": "callApi",
"onDone": "success",
"onError": [
{"target": "waiting", "guard": "canRetry"},
{"target": "failed"}, # out of retries
],
}
},
"waiting": {
"entry": assign(
{"attempt": lambda a: a["context"]["attempt"] + 1}
),
"after": {"BACKOFF": "loading"},
},
"success": {"type": "final"},
"failed": {"type": "final"},
},
}
logic = MachineLogic(
services={"call_api": call_api},
guards={"canRetry": lambda ctx, e: ctx["attempt"] < 5},
delays={"BACKOFF": lambda ctx, e: 2 ** ctx["attempt"] * 1000},
)
Attempt counting, backoff math, and the give-up condition are each in exactly one place.
xstate_statemachine.patterns.RetryPolicy packages this (with full / equal jitter and a
max_ms cap) as ready-made guards and delays.
🛒 Checkout that blocks duplicate submissions
"states": {
"reviewing": {"on": {"SUBMIT": "charging"}},
"charging": { # ← no SUBMIT handler here
"invoke": {"src": "chargeCard",
"onDone": "confirmed", "onError": "declined"},
},
"confirmed": {"type": "final"},
"declined": {"on": {"SUBMIT": "charging"}},
}
The second click while charging does nothing because the state has no handler for it.
The local duplicate submission is blocked by the model. For the network-level twin
(the same webhook delivered twice), use IdempotencyPlugin; external payment calls
still need their own idempotency key.
🔌 Connection lifecycle with heartbeat
"states": {
"disconnected": {"on": {"CONNECT": "connecting"}},
"connecting": {
"invoke": {"src": "openSocket", "onDone": "connected", "onError": "backoff"},
"after": {"CONNECT_TIMEOUT": "backoff"},
},
"connected": {
"on": {"PONG": "connected", "CLOSE": "disconnected"}, # self-transition resets timer
"after": {"HEARTBEAT": "reconnecting"},
},
"backoff": {"after": {"RETRY_DELAY": "connecting"}},
"reconnecting": {"on": {"CONNECT": "connecting"}},
}
A late onDone from a cancelled connection attempt is discarded — disconnected doesn't
handle it. That's the zombie-callback class of bug, gone structurally. The full version,
with a from_callback actor wrapping the socket, is the
WebSocket reconnect recipe.
🤖 LLM agent loop with supervised tool calls
"states": {
"planning": {"invoke": {"src": "askModel",
"onDone": [{"target": "callingTool", "guard": "wantsTool"},
{"target": "answering"}]}},
"callingTool": {
"entry": [{"type": "spawnChild",
"params": {"src": "toolRunner", "id": "tool", "systemId": "tool"}}],
"on": {"TOOL_RESULT": "reflecting", "TOOL_FAILED": "recovering"},
"after": {"TOOL_TIMEOUT": "recovering"},
},
"reflecting": {"always": [{"target": "planning", "guard": "needsMoreWork"},
{"target": "answering"}]},
"recovering": {"always": [{"target": "planning", "guard": "canRetry"},
{"target": "givingUp"}]},
"answering": {"type": "final"},
"givingUp": {"type": "final"},
}
The agent's control flow is data you can inspect, diagram and test — not a while loop
with flags. The [agents] extra ships this as the TOOL_LOOP reference chart with
allow-lists, budgets and a persisted awaiting_human state — see
For LLM agents.
🧪 Testing a machine without mocks
SyncInterpreter needs no event loop, so tests stay plain:
def test_declined_card_allows_retry():
checkout = SyncInterpreter(
create_machine(config, logic=test_logic)
).start()
checkout.send("SUBMIT")
assert checkout.matches("checkout.charging")
checkout.send("SUBMIT") # double click
assert checkout.matches("checkout.charging") # …ignored
Or skip the interpreter entirely with the pure API, or
install [testing] and let xsm_path generate one test per reachable path with
pytest --xsm-coverage reporting which states and transitions your suite never reached.
🐍 Prefer Pure Python? Three More Ways to Define a Machine
JSON is the interop format, not an obligation. If you're not sharing definitions with a frontend, define machines in Python instead.
Class-based — declarative and readable
from xstate_statemachine import State, StateMachine, SyncInterpreter, action
class Checkout(StateMachine):
machine_id = "checkout"
initial_context = {"attempts": 0}
reviewing = State(initial=True)
charging = State()
confirmed = State()
submit = reviewing.to(charging, event="SUBMIT", actions=["recordAttempt"])
ok = charging.to(confirmed, event="PAID")
@action
def record_attempt(self, interpreter, ctx, evt, action_def):
ctx["attempts"] += 1
c = SyncInterpreter(Checkout.create_machine()).start()
c.send("SUBMIT")
print(c.current_state_ids, c.context) # {'checkout.charging'} {'attempts': 1}
c.send("SUBMIT") # double click → ignored
print(c.context) # {'attempts': 1}
Watch out —
@action,@guardand@serviceconvertsnake_casemethod names tocamelCasekeys. The methodrecord_attemptis referenced as"recordAttempt".
Compose multiple transitions for one event with |:
flip = off.to(on, event="TOGGLE") | on.to(off, event="TOGGLE")
Builder — fluent
from xstate_statemachine import MachineBuilder
machine = (
MachineBuilder("toggle")
.state("off", initial=True)
.state("on")
.transition("off", "TOGGLE", "on")
.transition("on", "TOGGLE", "off")
.build()
)
transition() takes (source, event, target), so states and transitions can be declared
in any order — handy when you're generating a machine from data.
Functional — build_machine()
Plain objects and explicit wiring. The style to reach for when the machine is data you are assembling, not a shape you are declaring:
from xstate_statemachine import (
State,
SyncInterpreter,
action,
build_machine,
)
@action
def record_attempt(interpreter, ctx, evt, action_def):
ctx["attempts"] += 1
reviewing = State(
"reviewing",
initial=True,
on={"SUBMIT": {"target": "charging", "actions": ["recordAttempt"]}},
)
charging = State("charging", on={"PAID": "confirmed"})
confirmed = State("confirmed", final=True, tags=["done"])
machine = build_machine(
id="checkout",
states=[reviewing, charging, confirmed],
context={"attempts": 0},
actions=[record_attempt],
)
c = SyncInterpreter(machine).start()
c.send("SUBMIT")
print(
sorted(c.current_state_ids), c.context
) # ['checkout.charging'] {'attempts': 1}
c.send("PAID")
print(
sorted(c.current_state_ids), sorted(c.tags)
) # ['checkout.confirmed'] ['done']
Everything the JSON format supports
All three styles compile to the same MachineNode, so none of them is a reduced
subset. Nesting, parallel regions, history, timers, tags and metadata are all
expressible:
State("online", initial=True, states=[configuring, running, resume],
on={"DISCONNECT": "offline"}, tags=["connected"])
State("resume", history="deep") // remembers the last active child
State("failed", meta={"alert": True}) // arbitrary data for your UI
State("regions", parallel=True, states=[...])
Machine-level properties — a global escape transition, root entry/exit, or a
parallel root — go on root=:
from xstate_statemachine import State, SyncInterpreter, build_machine
root = State("", on={"EMERGENCY": "halted"}, tags=["v2"])
machine = build_machine(
id="press",
states=[State("idle", initial=True), State("running"), State("halted")],
root=root,
)
p = SyncInterpreter(machine).start()
p.send("EMERGENCY") # works from ANY state
print(sorted(p.current_state_ids)) # ['press.halted']
MachineBuilder.root(...) and a machine_root class attribute do the same for
the other two styles.
Runnable examples for all three styles — building the same machine, with
invoke, timers, guards, tags and meta — live inexamples/sync/easy/pythonic_approach/.
🛠️ CLI Tool
xsm is the terminal companion to the library — a code generator, a validator, an inspector,
a live simulator, a diagram/docs exporter and an operations console in one zero-dependency
command. Run it bare on a terminal for an interactive launcher with a menu, recent files and
a generate wizard that previews before it writes; pipe it and every command degrades to clean
plain text (--plain, --json, NO_COLOR).
xsm # interactive launcher
xsm new my_service --template fastapi # scaffold a project from an example app
xsm gt checkout.json -t pythonic-class --with-tests --with-types -o ./app
xsm validate machines/*.json # build each file with the real library
xsm inspect checkout.json # tree, transitions, logic, policies
xsm simulate checkout.json # live: pick events, +clock, undo
xsm sim checkout.json --events SUBMIT,+2001 --json # scripted, for CI
xsm inspect checkout.json --live # stream to the Stately Inspector
xsm diagram checkout.json -f mermaid -o docs/
xsm docs machines/*.json -o docs/
xsm snapshots sqlite:///orders.db --stale --fail-if-stale # written by another machine version?
xsm dlq --dlq sqlite:///dlq.db list # dead letters; `replay` is a dry run by default
xsm asyncapi checkout.json -o asyncapi.json
| Command | Alias | Does |
|---|---|---|
generate-template |
gt |
Generate Python from a machine JSON — plus --with-tests, --with-types, --with-plugin companions; --check for CI drift |
list-templates |
lt |
The 10 templates, grouped |
validate |
val |
Build each file with the real library; list every finding (--lenient, --json) |
inspect |
ins |
State tree, transitions table, logic to implement, failure policies; --live streams to the Stately Inspector |
simulate |
sim |
Run a machine on a simulated clock — interactively or from --events / --script; --record writes a JSONL session |
replay |
Print or --live-stream a recorded inspector session |
|
diagram |
dia |
Mermaid, PlantUML or ASCII to stdout or a file |
docs |
A Markdown reference page per machine | |
paths |
One path to every reachable configuration — the [testing] plugin walks the same list |
|
coverage |
Render a statechart coverage report from pytest --xsm-coverage, with a fail-under gate |
|
new |
Scaffold a project from an example app (--list; fastapi, flask) |
|
snapshots |
List persisted snapshots in a store (sqlite:///, file:///); --stale / --fail-if-stale for deploy gates |
|
dlq |
Dead-letter store operations: list, show, replay (dry run unless --no-dry-run --yes), purge |
|
asyncapi |
An AsyncAPI 3.0 document from your machines' publish / consume declarations | |
plugins |
Third-party plugins, stores and brokers found via entry points; --strict exits 1 on a broken one |
|
info |
Library and Python version, feature summary | |
update |
Check PyPI and upgrade with the installer that installed you (pip / pipx / uv tool) | |
setup |
Windows: make xsm work where pip's xsm.exe launcher is blocked |
Code generation (generate-template, the companion templates and the proof-before-write
check) has its own section.
Full reference: CLI Tool ·
Templates deep dive ·
Hierarchical generation.
Django projects get the same commands as manage.py xsm_*.
Why generate instead of hand-write?
Because the machine already declares every logic name it needs. The generator reads them and
emits a stub for each — so a typo in a guard name becomes a missing-function error at
generation time rather than an ImplementationMissingError in production.
🏭 Running It in Production
Everything above is the happy path. Here is what matters once real traffic arrives.
Throughput, timers and threads — read this before sizing
Async interpreters attached to the same event loop share its thread and execution
budget. Adding more machines does not create more CPU capacity. The published reference
measurements report roughly 30k trivial events/s on a laptop, and approximately
45 ms after lateness at 500 busy machines, compared with approximately 180 ms before
0.8.0. These are historical workload measurements, not a deployment SLA.
after timers use a priority lane ahead of the async inbox. A sync timer runs on the
owner's thread when it calls send() or tick(). Neither engine creates an OS thread per
timer. Other work can use threads: blocking def services in the async engine run in its
service executor, and non-blocking spawned work has engine-specific delivery.
Pass Interpreter(machine, clock=SimulatedClock()) in tests to advance time without
sleeping. For measured tables, the benchmark runner and a sizing rule, read
Production Characteristics
before deploying, then measure your own machine shapes, handlers and persistence layer.
Failure semantics — know what is contained
Each is a per-machine policy. The default preserves the historical behaviour; production machines should opt in explicitly.
| What fails | Default | Opt-in policy (machine config key) | How to observe it |
|---|---|---|---|
| An action raises (entry, exit, or transition) | Contained; the transition still commits | actionErrorPolicy: "rollback" restores configuration and context · "fail" also stops the machine (status == "stopped", configuration cleared) with TransitionFailedError on .error |
on_action_error, on_transition_failed, interpreter.last_transition_ok |
| A guard raises | Treated as False |
guardErrorPolicy: "true" · "raise" (takes the next candidate transition first, then surfaces the exception) |
on_guard_error (distinct from a guard that returned False) |
| An invoked service raises | Routed to onError — a normal transition, not a crash |
— | onError target, on_service_error |
| An unknown event arrives | Ignored (XState semantics) | onUnhandled: "defer" replays it after the next state change · "error" stops with UnhandledEventError |
on_unhandled_event fires under every policy |
| A transition target does not resolve | Rejected at create_machine() |
strict_targets=False downgrades to a DeprecationWarning (removed in 1.0) |
InvalidConfigError lists every bad target at once |
| A config key is misspelled — at the root or inside any state, transition or invoke | Logged at WARNING with a "did you mean" hint and the path (m.a: 'entyr' (did you mean 'entry'?)) |
create_machine(..., strict_config=True) or config "strictConfig": true refuses with InvalidConfigError |
The build log; x-… keys and meta/description/tags are always accepted |
A self-generated chain runs away — a zero-delay raise/self-send cycle or an always loop |
Cut at maxIterations (default 1000); the machine stays running |
Tune maxIterations; a delayed self-send is a timer and is never counted |
Sticky: interpreter.chain_trips, interpreter.last_chain_error (cleared only by clear_chain_error(), survives a snapshot), on_chain_budget_exceeded once per trip; on_invocation_stranded if the cut parked a state whose service will never complete |
An action awaits its own send(wait=True) |
— | — | ReentrantWaitError at the call site instead of a silent deadlock (both engines); a plain-def action that drops the result gets a RuntimeWarning |
| The inbox is full | Unbounded (no limit) | max_queue_size=, overflow_policy=OverflowPolicy.RAISE (default once bounded) · BLOCK · DROP_NEWEST |
RAISE raises QueueOverflowError; DROP_NEWEST calls on_event_dropped; interpreter.queue_depth |
An undeclared event is sent under strict |
N/A — strict is opt-in |
Machine config strict: true or Interpreter(strict=True) |
UnknownEventError at the send() call site, before queueing; event_schemas= on create_machine() raises InvalidEventPayloadError for a bad payload regardless of strict. Both checks also apply to events restored from a snapshot — a refusal fires on_invalid_event (pass from_snapshot(..., plugins=[...]) to see it) and lands on last_error |
Containment by default is deliberate: a long-lived machine should not die because one side effect had a bad day. The cost is that failures are invisible unless you look, so wire up the hooks early — every one of them fires whatever policy you choose:
from xstate_statemachine import PluginBase
class ErrorReporter(PluginBase):
def on_action_error(self, interpreter, action, error):
sentry.capture_exception(error)
interp.use(ErrorReporter())
Note —
actionErrorPolicydefaults to"continue"today (with a one-shotDeprecationWarning); it flips to"rollback"in 1.0. Pin it explicitly if you need today's behaviour to survive the upgrade.
Asking the machine a question
send() normally fires and forgets. Pass wait=True to get a Receipt once that exact
event's macrostep has run — no polling, no wait_for():
receipt = await interp.send("SUBMIT", wait=True)
# Receipt(state_ids=frozenset({'checkout.paying'}), changed=True, error=None,
# deferred=False, denied=False)
await interp.send_priority(
"CANCEL"
) # ahead of the inbox, exempt from its bound
priority=True on send() does the same as send_priority(). A Receipt has five
fields — read them by attribute; deferred says the event was parked under
onUnhandled: "defer", denied that a handler existed but every guard said no.
Two rules for wait=True inside an action: don't await your own receipt on the
action's own task (the run loop can't advance until the action returns, so it raises
ReentrantWaitError rather than deadlocking), and don't drop it from a plain def
action (it warns). Hand it out — asyncio.ensure_future(i.send("GO", wait=True)) — or
send without wait; a helper task the action spawns may await freely.
Waiting for a machine to settle
Do not poll by hand or sleep() and hope:
from xstate_statemachine import wait_for, wait_for_sync, to_promise
# async
await wait_for(interp, lambda i: i.matches("job.done"), timeout=30)
result = await to_promise(
interp
) # resolves when the machine reaches a final state
# sync
wait_for_sync(interp, lambda i: i.matches("job.done"), timeout=30)
Choosing an interpreter
| Use | When |
|---|---|
Interpreter |
asyncio apps — FastAPI, aiohttp, bots, anything already async |
SyncInterpreter |
Django views, Celery tasks, CLI tools, scripts, tests |
Same machine JSON, same semantics, same guarantees. Timers, services and actors all work
on both engines; neither spawns an OS thread per timer — the sync engine delivers a due
after timer on the caller's own thread inside send() or tick(), and the async engine
delivers it through the priority lane described above. Blocking services on the async
engine use its service executor. A SyncInterpreter remains caller-driven; use
send_threadsafe() for cross-thread producers and drain its mailbox with send() or tick().
Long-running machines
- Persist at settled transition boundaries. Prefer the
persisted()/apersisted()loop and a durable store for the documented locking and crash-recovery behavior.get_persisted_snapshot()in asubscribe()callback gives you a snapshot for custom storage; the callback alone does not make the write atomic or durable. aftertimers across a restore. A snapshot carries each pending timer's wall-clock deadline. Thepersisted()loop and the web registries resume the remaining time (restart_timers="resume"), andDueTimerScannerfires a deadline that matured while nothing was running. A barefrom_snapshot()is a static rebuild:has_dormant_timersisTrueuntil you passrestart_timers="resume"(orTrueto re-arm from zero). A timer that had already fired is in the snapshot and replays. A delayed self-send (raise/sendwithdelay) is persisted with its remaining time and resumes where it would have been.- Invokes do not restart on restore either —
from_snapshot()is a static rebuild that starts nothing by default. Callpending_invocations()on the restored interpreter to see everyPendingInvocation(state_id, invoke_id, src)with no live service (has_dormant_invocationsis the boolean), andfrom_snapshot(..., restart_services=True)to re-invoke each of them from scratch. - Snapshots carry an envelope (
version— layout 4 —machine_id,machine_hash,machine_version) so a restore against a machine that no longer matches the one that produced the snapshot fails loud withSnapshotDriftErrorinstead of resuming into undefined behaviour. Passverify_machine_hash=Falseafter a deliberate migration. The hash is a drift check, not an authentication tag: if a blob crosses a trust boundary, sign it outside and pinfrom_snapshot(..., minimum_version=4, expected_machine_hash=...)so the payload cannot pick its own level of checking. - What else round-trips: pending events with their lane (priority events restore
ahead of the inbox on both engines), deferred events, armed delayed sends, history,
child actors, the
error, and the chain-trip latch (chain_trips/last_chain_error) — a restart is not an acknowledgement.strictandevent_schemasare applied to every restored event; a refusal is reported, not silently admitted. - Always
stop()— it cancels timers and stops spawned actors. In a web app, tie it to request teardown; in a worker, to the task'sfinally.
Testing
The pure API is the simplest way to test machine logic — no event loop, no mocks, no sleeping:
from xstate_statemachine import get_initial_snapshot, get_next_snapshot
snap = get_initial_snapshot(machine)
snap = get_next_snapshot(machine, snap, "SUBMIT")
assert snap.matches("checkout.paying")
Use a real interpreter for integration tests, where you want the actions to actually run.
For an after timer, don't sleep — inject a SimulatedClock and jump virtual time:
from xstate_statemachine import SyncInterpreter, SimulatedClock
clock = SimulatedClock()
interp = SyncInterpreter(machine, clock=clock).start()
clock.increment(30_000) # fires a 30 s `after` with no real delay
assert interp.matches("job.timedout")
🔐 Security & Trust Model
The short version, from SECURITY.md:
installed packages are trusted, your machine definition is trusted, events and snapshots
are not. Everything that crosses that line is validated.
| Boundary | What the library does |
|---|---|
| Snapshots | JSON only — never pickle; size-capped before json.loads (SnapshotTooLargeError), shape-validated, drift-checked (machine_hash), every state id must exist; a hostile blob is SnapshotCorruptError, never a bare RecursionError. expected_machine_hash= and minimum_version= are compared, never trusted from the payload |
| Events | strict refuses undeclared event types at the send() call site; event_schemas= validates payloads; a bounded inbox (max_queue_size, overflow_policy) caps memory under a hostile producer; maxIterations cuts a runaway self-send chain |
| Web adapters | Closed by default: authorize= is required, context is not served unless you opt in, error bodies never carry exception text, Idempotency-Key is scoped per principal, RFC 9457 problem responses |
| Stores and logs | One redact() denylist across every sink; FileStore writes 0600 atomically; SnapshotCodec seam for encryption at rest; Celery integration refuses pickle / YAML serializers |
| Plugins | Nothing under contrib loads implicitly; entry-point discovery is explicit (discover() / attach_discovered(allow=[...])), runs only PluginBase subclasses or @plugin_factory-marked callables, and XSM_DISABLE_PLUGIN_DISCOVERY=1 turns it off. Discovered plugins run with full privileges — pin allow= to distribution names |
| Supply chain | Zero runtime dependencies; every extra has a lower bound; pip-audit --strict over [all] in CI; GitHub Actions pinned to commit SHAs; PyPI Trusted Publishing with PEP 740 attestations |
The numbered baseline every integration is built against, each item mapped to the test that proves it: Security.
📘 API Reference
Core — building and running
| Name | Purpose |
|---|---|
create_machine(config, *, context_type=None, logic=None, logic_modules=None, logic_providers=None, strict_targets=True, event_schemas=None, strict_config=None, context_validator=None) |
Build a machine from a dict/JSON config. strict_targets=False downgrades unresolvable transition targets to a DeprecationWarning (removed in 1.0). event_schemas={'FILL': Fill} adds opt-in payload validation — a callable that raises to reject, a dataclass, or anything with a model_validate-style constructor — raising InvalidEventPayloadError at the send() call site regardless of strict. strict_config=True (or config "strictConfig": true) refuses an unknown key anywhere in the config with InvalidConfigError; the default logs a WARNING with a "did you mean" hint and the path. context_validator= validates changed context after actions |
MachineLogic(actions=, guards=, services=, delays=, *, strict=False) |
Bind names in the config to Python callables. snake_case and camelCase names match each other; two different callables whose names differ only by case/separators are rejected. strict=True refuses undecorated registrations |
Interpreter(machine, input=None, clock=None, max_queue_size=None, overflow_policy=OverflowPolicy.RAISE, strict=None, service_executor=None, service_pool_size=4) |
Async engine — await .start(children_timeout=2.0), .send(), .stop(). Plain-def services run on a private thread pool of service_pool_size workers (or your service_executor) so a blocking service cannot stall the loop |
SyncInterpreter(machine, input=None, clock=None, strict=None, max_queue_size=None, overflow_policy=None) |
Sync engine — no event loop anywhere. No inbox bound by design (send() runs each event to completion before returning); the two bound kwargs exist for parity and a non-None bound raises ValueError |
LogicLoader |
Auto-discover logic by name from modules |
MachineNode |
The parsed machine; has .to_mermaid() / .to_plantuml() |
strict (constructor arg, wins over the machine's strict config key) makes send() raise
UnknownEventError synchronously — before the event is queued — for any event type the machine
has never declared, with a difflib suggestion ('Did you mean FILL?').
max_queue_size bounds the inbox; once set, overflow_policy decides what happens when it's
full — see OverflowPolicy below.
You can also subclass MachineLogic and define actions, guards and services as methods —
they're registered automatically by arity: (ctx, event) is a guard,
(interpreter, ctx, event) a service, (interpreter, ctx, event, action) an action.
Interpreter surface
| Member | Purpose |
|---|---|
.start() / .stop(drain=False, timeout=None) |
Lifecycle (await both on Interpreter). stop(drain=True) processes the inbox to empty first (async also takes timeout=) |
.send(event, *, wait=False, priority=False, **payload) |
Send an event; kwargs become event.payload. wait=True returns (async: awaits) a Receipt; priority=True delivers ahead of the inbox, exempt from its bound |
.send_priority(event, **payload) |
Async only — shorthand for send(event, priority=True, wait=True, **payload) |
.send_threadsafe(event, internal=None, **payload) |
Async only — send from a foreign OS thread; returns a concurrent.futures.Future. send() from a foreign thread — including via asyncio.run_coroutine_threadsafe — raises WrongThreadError instead. internal=True charges the send to maxIterations as a self-send (an action that hands its own re-trigger to a plain thread) |
.send_threadsafe(event, **payload) |
Sync — enqueue from another thread into a locked mailbox; returns None. The owner processes it on its next send() / tick(), where admission checks run. The mailbox is unbounded; bound your producers. send() itself is not thread-safe |
.tick() |
Sync only — deliver any timer that has come due since the last call, outside of send() |
.current_state_ids / .active_state_ids |
Set of active leaf state ids |
.value |
Active configuration in XState's hierarchical form — a leaf key, {parent: child}, or one key per parallel region; {} before start() |
.context |
The live context dict |
.status / .is_running |
"uninitialized", "running", "stopped", "done" or "error"; .is_running checks whether status is "running" |
.matches(id_or_value) |
Is this state active? Accepts a string path or a partial .value-shaped dict |
.can(event) |
Would this event cause anything? |
.has_tag(tag) / .get_meta() |
Tags and merged meta of active states |
.subscribe(fn) |
Observe every transition |
.use(plugin) / .plugins |
Register plugins |
.system |
Actor registry — .get(system_id), .get_all() |
.queue_depth |
Current inbox depth (0 for an unbounded queue with nothing pending) |
.pending_events |
Accepted-but-unprocessed events, FIFO |
.deferred_count |
Events buffered by onUnhandled: "defer", awaiting replay |
.last_transition_ok / .last_error |
Per-step: False / the exception when the most recent step failed (a raising action under actionErrorPolicy, an unresolvable target, a chain cut). Reset by the next clean event — not a latch |
.chain_trips / .last_chain_error / .clear_chain_error() |
Sticky record that maxIterations cut work: a monotonic count and the latched RunawayChainError. Survive later events and a snapshot; only clear_chain_error() clears the latch |
.last_plugin_error |
(plugin_class, hook, error) for the most recent plugin hook that raised; plugin failures never stop the machine |
.has_dormant_invocations / .has_dormant_timers |
True after a static restore left an active invoke with no live service / an after timer not armed |
.pending_invocations() |
List[PendingInvocation] — every active state with no live service/child actor (e.g. after a static restore) |
.drain_pending() |
Remove and return every pending event without processing it — both lanes on the async engine, priority first (fired timers, completions, send_priority()); the receipt on a drained wait=True event is failed |
.dropped_receipts |
Async only — count of send(wait=True) receipts a def action dropped unawaited; the gateable form of the RuntimeWarning (see also on_receipt_dropped) |
.restored_from_snapshot |
True on an instance built by from_snapshot(); on_interpreter_start fires on resume too, so read this to tell it from bring-up |
.wait_done() |
Async only — a future that resolves the instant the machine reaches done/error |
.get_snapshot() / .get_persisted_snapshot() |
Serialize (JSON string / dict) — layout v4: version, machine_id, machine_hash, machine_version, taken_at, value, configuration, context, pending_events (with lane and engine provenance), deferred, scheduled_sends, history, actors, deadlines, error, chain_trips, last_chain_error. Raises SnapshotMidStepError mid-transition and SnapshotSerializationError for non-JSON data |
.from_snapshot(snap, machine, *, verify_machine_hash=True, restart_services=False, restart_timers=None, clock=None, minimum_version=0, expected_machine_hash=None, plugins=None) |
Restore (classmethod). Older layouts upcast transparently; SnapshotVersionError for a newer one or one below minimum_version; SnapshotDriftError on an id/hash mismatch; SnapshotCorruptError for a malformed blob. restart_services / restart_timers re-drive dormant work; plugins= registers plugins before restored events are admitted so a strict/schema refusal reaches on_invalid_event |
Config keys — every key the parser reads
The complete per-level key sets. Anything else is reported as unknown (WARNING by default, InvalidConfigError under strict_config=True); meta / description / tags and any x-… key are accepted at every level.
| Level | Keys |
|---|---|
| Root (everything a state accepts, plus) | context · version · strict · strictTargets · strictConfig · maxIterations · spawnBlockingTimeout · actionErrorPolicy (continue | rollback | fail) · guardErrorPolicy (false | true | raise) · onUnhandled (ignore | defer | error) |
| State | id · type (atomic | compound | parallel | final | history) · initial · states · entry · exit · on · always · after · invoke · onDone · history (shallow | deep) · target (a history state's default) · output (final state's done-data) · meta · description · tags |
| Transition | target · actions · guard (alias cond) · reenter (alias internal, inverted) · meta · description · tags |
| Invoke | src (a service name) · id · input · systemId · onDone · onError · meta · description · tags |
Constants: DEFAULT_CHILDREN_TIMEOUT (2.0 s, Interpreter.start(children_timeout=)), DEFAULT_SERVICE_POOL_SIZE (4, Interpreter(service_pool_size=)), ENGINE_EVENT_SHAPES / SYSTEM_EVENT_PREFIXES (the name shapes the engine mints — for documentation and build-time checks only; provenance is decided by is_system_event, not by name). BaseInterpreter is the shared base of both engines, for type annotations that accept either. Full semantics of every key: JSON Configuration.
Action creators
assign · log · raise_ · send_to · send_parent · choose · pure ·
enqueue_actions · ActionEnqueuer · spawn_child · stop_child · cancel · emit ·
escalate · forward_to
Clock
| Name | Purpose |
|---|---|
Clock |
Protocol every clock implements: .now(), .set_timeout(fn, delay_sec, owner=), .clear_timeout(handle), .pending |
RealClock() |
Default clock: .now() is monotonic for scheduling; .wall_now() is epoch time for durable deadlines. Delivers a fired after timer through a priority lane the async loop checks ahead of its inbox |
SimulatedClock(wall_start=...) |
Virtual time — nothing advances until you do. .now(), .set(ms), .increment(ms), .pump() (fire everything due, returns the count fired), .pending (count of armed timers). Await .set() / .increment() in async code; call them directly in sync code. wall_start models elapsed wall time across a restart |
Pass Interpreter(clock=) / SyncInterpreter(clock=); spawned and invoked children inherit the
parent's clock (and its strict setting). SyncInterpreter never spawns an OS thread for an
after timer or delayed send — a due deadline is delivered on the caller's thread at the top of
send(), in the macrostep loop, or by .tick().
Pure API & helpers
| Name | Purpose |
|---|---|
initial_transition(machine) |
→ (PureSnapshot, actions) for the initial state |
pure_transition(machine, snap, event) |
→ (PureSnapshot, actions) — no side effects |
get_next_snapshot(machine, snap, event) |
→ next PureSnapshot only |
get_initial_snapshot(machine) |
→ initial PureSnapshot |
PureSnapshot |
.state_ids .context .status .output .matches() |
wait_for(interp, pred, timeout=) |
Await a predicate (async) |
wait_for_sync(interp, pred, timeout=) |
Block on a predicate (sync) |
to_promise(interp) |
Await a machine reaching a final state |
Plugins, data classes & exceptions
Plugins — PluginBase hooks (LoggingInspector(redact_keys=, log_context=) implements the
transition/action/guard/service/lifecycle ones):
on_interpreter_start · on_interpreter_stop · on_transition · on_before_send ·
on_event_received · on_event_processed · on_event_refused · on_event_sent ·
on_action_execute · on_action_error · on_guard_evaluated · on_guard_error ·
on_service_start · on_service_done · on_service_error · on_transition_failed ·
on_unhandled_event · on_event_dropped · on_error · on_done ·
on_resolve_error · on_plugin_error · on_invalid_event · on_snapshot_error ·
on_invocation_stranded · on_chain_budget_exceeded · on_receipt_dropped
Hooks are synchronous callbacks; an async def hook is never awaited and is reported through
on_plugin_error / last_plugin_error.
Data classes:
| Name | Purpose |
|---|---|
Receipt(state_ids, changed, error, deferred, denied) |
Returned by send(wait=True) once the macrostep for that event has run. Five fields — read by attribute; a positional destructure written for fewer raises ValueError |
Event / DoneEvent / ErrorEvent / AfterEvent |
The event types an action or hook receives. Service and child failures arrive as ErrorEvent(type, error, src). is_system_event(ev) is True only for events the engine minted — a hand-built DoneEvent("done.invoke.x", ...) is user traffic and is refused under strict. ev._replace(...) on an engine event is a one-way demotion to user traffic; re_mint(ev, **fields) is the sanctioned way to patch a field and keep provenance (it accepts only an engine-minted input) |
OverflowPolicy |
RAISE (default once max_queue_size is set) · BLOCK · DROP_NEWEST |
PendingInvocation(state_id, invoke_id, src) |
An active state with no live service/child actor |
ActionDefinition(config) |
The 4th positional arg every action callable receives — .type (action name) and .params (static params from the config, if any) |
Exceptions: XStateMachineError (base) · InvalidConfigError (and its subclass
RootTargetError) · StateNotFoundError · ImplementationMissingError ·
ActorSpawningError · NotSupportedError · UnhandledEventError ·
TransitionFailedError · WrongThreadError · QueueOverflowError ·
InterpreterStoppedError · UnknownEventError · InvalidEventError (also a TypeError) ·
InvalidEventPayloadError · RunawayChainError · ReentrantWaitError · RestoredError ·
RestoredChainError (both a RestoredError and a RunawayChainError) ·
SnapshotDriftError · SnapshotVersionError · SnapshotCorruptError ·
SnapshotMidStepError · SnapshotSerializationError
Version: from xstate_statemachine import __version__ gives the installed
version string — the same value xsm -v / xsm info report.
🚨 Troubleshooting
The errors you are most likely to meet, and what each actually means.
ImplementationMissingError — "no implementation was found"
Your machine names an action, guard or service that nothing provides. This is a feature: a typo in a guard name becomes an error at load time instead of a transition that mysteriously never fires.
create_machine(
{"id": "a", "initial": "s", "states": {"s": {"entry": "logStart"}}}
)
# ImplementationMissingError: Action 'logStart' is defined in the machine
# but no implementation was found …
Fix — supply it, or opt out explicitly:
create_machine(config, logic=MachineLogic(actions={"logStart": my_fn}))
create_machine(config, logic=MachineLogic()) # accept the stubs; nothing runs
MachineLogic() with no arguments is the right choice for tests, diagram export,
and the pure API, where actions never execute.
StateNotFoundError — a transition points nowhere
{"s": {"on": {"GO": "ghost"}}} # 'ghost' is not a sibling of 's'
Targets are scope-relative, resolved from the source state outward. Common causes:
| Symptom | Cause |
|---|---|
| Target is a child of another state | Use "parent.child" or "#machineId.parent.child" |
| Target is in a different branch | Use an absolute "#machineId.path" reference |
.child did not resolve |
A leading dot resolves from the source's parent, not the source |
Run xsm validate machine.json to catch these before runtime.
InvalidConfigError — the machine itself is malformed
Missing states, a bad initial, an invoke.src that is not a service name
(an inline machine dict, say), or — under strict_config=True — a misspelled key anywhere
in the config. Without strict_config a misspelled key is a WARNING naming the path
and the likely intent, and the machine builds with that key ignored, which is why the
warning exists: "entyr" means an entry action that never runs, "onn" a transition that
does not exist.
create_machine({"id": "c"}) # InvalidConfigError: 'states' key is missing
create_machine(
{"id": "c", "initial": "a", "states": {"a": {"entyr": ["x"]}}},
strict_config=True,
)
# InvalidConfigError: Machine 'c' has unknown config key(s) -- c.a: 'entyr'
# (did you mean 'entry'?) ...
A corrupt snapshot string is SnapshotCorruptError, not this.
RunawayChainError — "exceeded N chained self-generated events"
An action raises (or sends to its own machine) the event that triggers it again with
no delay, or two always transitions target each other. The machine cuts the tail at
maxIterations (default 1000), discards it, and keeps running — so the only signals
are the ones you read:
interp.chain_trips # monotonic, survives a snapshot
interp.last_chain_error # the RunawayChainError, latched until clear_chain_error()
receipt.error # on the send(wait=True) that tripped it
last_error also carries it, but only until the next clean event. A delayed
self-send (raise with delay, or after) is a timer and never counts: a heartbeat of
any period runs indefinitely.
ReentrantWaitError — "awaited send(..., wait=True) on its own interpreter"
An action did await i.send("GO", wait=True). The receipt resolves when the run loop
processes GO, and the loop cannot advance until the action returns — a deadlock, so it
raises immediately instead. Send without wait (the event runs right after the current
step), or hand the receipt to another task:
async def act(i, ctx, event, action):
i.send("GO") # fine
fut = asyncio.ensure_future(
i.send("GO", wait=True)
) # fine: awaited elsewhere
await i.send("GO", wait=True) # ReentrantWaitError
A helper task the action spawns may await the machine freely — the rule is about the
action's own task. A plain def action that calls send(wait=True) and drops the result
gets a RuntimeWarning instead: it received an awaitable it cannot await.
My action ran but nothing happened
Action failures are contained — the transition completes and the machine keeps running. That is deliberate for long-lived machines, but it means a raising action is invisible unless you look:
class ErrorReporter(PluginBase):
def on_action_error(self, interpreter, action, error):
raise error # or log it, or ship it to Sentry
interp.use(ErrorReporter())
My event did nothing
An event that the current state does not handle is ignored, by design —
send("BANANA") is a no-op, never an exception. Three ways to find out why:
interp.can("SUBMIT") # False → not handled here at all
interp.current_state_ids # are you in the state you think you are?
interp.use(LoggingInspector()) # shows guards evaluating and rejecting
can() evaluates guards: False can mean either no handler or no passing guard. The
on_guard_evaluated hook distinguishes those cases. If can() is True but the state
does not change, inspect for a targetless or self-transition; it may legitimately run
actions without moving to another state. For async sends, await send(..., wait=True)
before inspecting the settled result.
My after timer never fired after restoring a snapshot
Correct for a static restore: from_snapshot() does not start timers by default,
and interp.has_dormant_timers reports them. Current snapshots do record wall-clock
deadlines. Pass restart_timers="resume" to resume the remaining time, or
restart_timers=True to restart the delay from zero. A timer event that had already
fired, and a delayed self-raise, are also persisted.
The persisted() loop and web registries use remaining-time resume; DueTimerScanner
wakes instances whose deadlines elapsed while no interpreter was running. A manual
deadline is still an option for an application-specific expiry rule:
"entry": assign({"deadline": lambda a: time.time() + 30}),
Compare that explicit deadline against wall time on resume. For ordinary statechart
timers, persisted after deadlines already provide this behavior.
❓ FAQ
Do I have to use JSON?
No. JSON is what makes frontend interop possible, but the class-based, builder and functional APIs are all first-class. Use JSON when you're sharing a definition; use Python when you're not.
Async or sync — which interpreter?
SyncInterpreter if your code isn't already async: Django/WSGI views, Celery tasks, CLI
tools, scripts, tests. It is genuinely synchronous — there is no hidden event loop, and it
raises NotSupportedError rather than silently starting one if you hand it async logic.
Cross-thread access is send_threadsafe() only; send() is single-threaded by contract.
Interpreter for asyncio applications, and whenever you need concurrent services or timers
that don't block.
Both share one correctness core, so a machine behaves identically on either.
Can I really run an unmodified Stately.ai export?
Structurally, yes — 103 of the 104 real-world exports in the test suite parse unchanged, and
both v4 cond and v5 guard spellings are accepted. The statechart screenshots link to
the editor, and their source configurations are kept in the repository. Structural parsing
is not a blanket promise of parity with every JavaScript runtime feature.
What doesn't transfer is JS/TS action implementations, because those are code rather than
data. You supply Python equivalents through MachineLogic. That separation is the point:
the shape of the flow is shared, the side effects are native to each platform.
How does this survive a deploy, a crash, or two workers at once?
Through the persistence layer, not luck. persisted() loads (or creates) an instance from a
store, runs your events, and saves with an expected_version — a concurrent writer gets
ConflictError, never a silent lost update. IdempotencyPlugin makes a redelivered webhook
return its original receipt. after deadlines are stored as wall-clock instants and
DueTimerScanner fires the ones that matured while nothing was running. The ordering of
side effect, save, inbox mark and timer fire — and what happens if the process dies between
any two of them — is written down on the
Guarantees page.
What happens if an action raises?
By default it's logged and contained: the transition completes and the interpreter keeps
running, so one bad side effect can't kill a long-lived machine. Per machine you can choose
actionErrorPolicy: "rollback" (configuration and context restored) or "fail" (the
machine stops with TransitionFailedError). Either way on_action_error fires.
Invoked services are different — their failures are routed back into the machine as
onError, which is the idiomatic way to model expected errors.
Is this production ready?
The 0.11.0 README reference snapshot records 8,601 tests and 93% coverage; those are
recorded figures, not a live badge. The repository enforces a 90% coverage gate.
CI covers Python 3.9–3.15 on Linux with Windows and macOS spot-checks, plus an
[all]-extras wheel smoke on all three OSes and a compatibility
matrix that installs each framework version the docs claim. Every integration and recipe has
been through an adversarial "battle" pass — a scenario test written from the user's point of
view, two independent reviewers attacking correctness and docs, and a regression file for
every defect found; the issues are labelled battle-tested on GitHub. The engine implements
the SCXML transition-selection algorithm with a dedicated suite pinning that behaviour, and
another pinning XState v5 parity.
The core's zero runtime dependencies reduce the third-party dependency surface and simplify deployment in slim containers or restricted environments. The library itself, Python and any optional extras still need security review and maintenance.
The package is currently classified Beta, and contrib integrations are provisional.
Evaluate the failure semantics,
security model and workload-specific tests for your deployment;
there is no managed-service SLA implied by the library.
Does it support SCXML files?
No. The engine implements the SCXML algorithm — which is why nested and parallel behaviour
matches XState rather than approximating it — but it does not read or write .scxml documents.
I'm on django-fsm / transitions today. How do I move?
For django-fsm: manage.py xsm_migrate_fsm converts the model, FSMDualWriteMixin keeps
both columns in sync during the transition, and persistence.from_state_ids() mints a valid
snapshot from your existing state column. The
django-fsm comparison
and Django guide
walk through it. For transitions and python-statemachine, the comparison pages map each
concept to its equivalent here.
🗺️ Diagrams
Every machine can draw itself, with no graphviz install — the diagrams in this README were
made by importing the same JSON into the Stately editor; to_mermaid() gives you the version
GitHub renders inline:
print(machine.to_mermaid()) # paste into GitHub, Notion, Obsidian…
print(machine.to_plantuml())
xsm diagram checkout.json -f mermaid # or plantuml / ascii
📖 Full Documentation
basiltt.github.io/xstate-statemachine
Guides · API reference · What's new and the upgrade notes · Changelog · Recipes · Comparisons
Versioning & support
Semantic Versioning. From 1.0, the core API, JSON machine format, xsm CLI and persistence layer (including the snapshot layout) carry the documented Semantic Versioning stability promise: breaking changes only in a new major. The current 0.x series is pre-1.0. contrib extras remain provisional, including in 1.0, and may change in a minor release. A deprecated API warns for at least one minor release and is removed no earlier than the next major under the published policy. See the Deprecation Policy and tested framework versions in Compatibility.
Install the integration stack: pip install "xstate-statemachine[all]" pulls the shipped integration and testing extras. Each is also installable alone; add [format] separately for Black/isort formatting tools. See Integrations & extras.
Supported Python: 3.9 – 3.15 (CPython), tested on Linux, plus Windows and macOS spot-checks, on every PR.
Contributing
Issues and PRs welcome — see CONTRIBUTING.md and AGENTS.md for the engine conventions. Every PR runs the full matrix: lint, the full test suite, a coverage gate, a packaging check, a dependency audit and the compatibility cells.
MIT Licensed · Built with precision. Tested with rigour.
Build something you can reason about.
Start with one workflow: an order, an approval, a retry loop or an agent tool call. Make its states explicit. Send the first event. Then add the integrations your project needs.
Run the quick start → · Choose a runnable example · Explore the documentation
If the project helps your team, a GitHub star helps other developers discover it. Feedback, bug reports and contributions are welcome.
Metadata
Release files for xstate-statemachine 0.11.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| xstate_statemachine-0.11.0.tar.gz | 6.3 MB | With provenance | Details for xstate_statemachine-0.11.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| xstate_statemachine-0.11.0-py3-none-any.whl | Python 3 | none | any | Details for xstate_statemachine-0.11.0-py3-none-any.whl |
Total release size: 7.6 MB
Release files / xstate_statemachine-0.11.0.tar.gz
| Download URL | xstate_statemachine-0.11.0.tar.gz |
|---|---|
| Size | 6.3 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
45669f104741b8a0f52cff7e0dc9f61a6592b884112322d5c5b1294ed06ff55d
|
|
BLAKE2b-256 checksum How to use checksums |
fa85e7db6b90ded202ef82e1cd12a59b49b7ea2e66000e2c1586268f663a2cfe
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.
Transparency logRelease files / xstate_statemachine-0.11.0-py3-none-any.whl
| Download URL | xstate_statemachine-0.11.0-py3-none-any.whl |
|---|---|
| Size | 1.3 MB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0294504091280d0a4bfa052e4e2dd8e22d2e9ebc9ff1319866c1e6008bc21c5b
|
|
BLAKE2b-256 checksum How to use checksums |
d20eed851855eeaf8fffc8b61c40836f8f38d7a1b17481416f25f0b21402b1f9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.
Transparency logSecurity for xstate-statemachine 0.11.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for xstate_statemachine-0.11.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.