Getting Started

Install XState-StateMachine, verify your setup, build your first machine, and understand the full feature set.

πŸ“¦ Installation

Install from PyPI with pip:

pip install xstate-statemachine

Or with uv for faster installs:

uv pip install xstate-statemachine

Or with Poetry:

poetry add xstate-statemachine

Verify Your Installation

xsm --version
# Output: xsm 0.12.0

You can also verify the CLI tool is available:

xsm --help

Expected output (trimmed; run xsm --help for the current text):

usage: xsm [-h] [-v] [--plain] [--no-color] [--no-anim] [--verbose]
           {generate-template,gt,list-templates,lt,validate,val,paths,coverage,
            info,plugins,update,snapshots,dlq,asyncapi,setup,inspect,ins,replay,
            diagram,dia,simulate,sim,docs,new} ...

XState-StateMachine CLI β€” Generate Python code from XState JSON.

positional arguments:
    generate-template (gt)
                        Generate Python code from an XState JSON file.
    list-templates (lt)
                        List all available code generation templates.
    validate (val)      Validate an XState JSON config file.
    paths               List a path to every reachable configuration of a
                        chart.
    coverage            Render a statechart coverage report (pytest --xsm-
                        coverage).
    info                Show library version, Python version, and feature
                        summary.
    plugins             List installed third-party plugins, stores and
                        brokers.
    update              Upgrade xstate-statemachine to the latest release on
                        PyPI.
    snapshots           List persisted snapshots in a store; --stale finds
                        instances written by another machine version.
    dlq                 List, show, replay or purge dead-lettered messages.
    asyncapi            Generate an AsyncAPI 3.0 document from a machine.
    setup               Make the `xsm` command work on Windows machines that
                        block pip's xsm.exe launcher.
    inspect (ins)       Show a machine's state tree, transitions, logic and
                        policies.
    replay              Print or stream (--live) a recorded inspector session.
    diagram (dia)       Export a Mermaid, PlantUML or ASCII diagram.
    simulate (sim)      Run a machine live: pick events, advance the clock,
                        inspect context.
    docs                Generate a Markdown reference page per machine.
    new                 Scaffold a minimal project from an example app.

options:
  -h, --help            show this help message and exit
  -v, --version         Show program's version number and exit.
  --plain               Plain text: no colour, no box glyphs, no animation
                        (implied when piped).
  --no-color            Keep layout and animation but emit no colour escapes.
  --no-anim             Disable spinners and in-place redraws.
  --verbose             Show the library's INFO log on stderr while running.

A bare xsm on a terminal opens an interactive launcher. See the CLI Tool guide for every command.

πŸ“‹ Requirements

Requirement Details
Python 3.9 – 3.15 (CI runs every version on Linux, with Windows and macOS spot-checks)
Dependencies None β€” zero external dependencies beyond the standard library
OS Windows, macOS, Linux

Tip: The library uses only the Python standard library, so it works anywhere Python runs β€” containers, serverless, embedded systems, CI pipelines.

⚑ Your First State Machine (60 seconds)

Let’s build a simple toggle switch. It has two states (off and on) and toggles between them:

stateDiagram-v2
    direction LR
    [*] --> off
    off --> on : TOGGLE
    on --> off : TOGGLE
from xstate_statemachine import State, StateMachine


class Toggle(StateMachine):
    off = State(initial=True)
    on = State()
    toggle = off.to(on) | on.to(off)  # the attribute name is the event: TOGGLE


with Toggle() as switch:  # started, in `off`; stopped on exit
    switch.toggle()
    print(switch.state)
    # on

    switch.send("TOGGLE")
    print(switch.state)
    # off

The Quick Start walks through this style, and the Pythonic API page documents it in full. The styles below build the same machine.

Using JSON (XState-compatible):

from xstate_statemachine import create_machine, SyncInterpreter

config = {
    "id": "toggle",
    "initial": "off",
    "states": {
        "off": {
            "on": {
                "TOGGLE": "on",
            },
        },
        "on": {
            "on": {
                "TOGGLE": "off",
            },
        },
    },
}

machine = create_machine(config)
interp = SyncInterpreter(machine).start()

print(interp.active_state_ids)
# {'toggle.off'}

interp.send("TOGGLE")
print(interp.active_state_ids)
# {'toggle.on'}

interp.send("TOGGLE")
print(interp.active_state_ids)
# {'toggle.off'}

interp.stop()

Using Pure Python (Pythonic API):

from xstate_statemachine import State, build_machine, SyncInterpreter

off = State(
    "off",
    initial=True,
    on={
        "TOGGLE": "on",
    },
)
on = State(
    "on",
    on={
        "TOGGLE": "off",
    },
)

machine = build_machine(id="toggle", states=[off, on])
interp = SyncInterpreter(machine).start()

interp.send("TOGGLE")
print(interp.active_state_ids)
# {'toggle.on'}

interp.stop()

Using Class-Based Style (explicit events):

from xstate_statemachine import State, StateMachine, SyncInterpreter


class ToggleMachine(StateMachine):
    machine_id = "toggle"

    off = State(
        "off",
        initial=True,
        on={
            "TOGGLE": "on",
        },
    )
    on = State(
        "on",
        on={
            "TOGGLE": "off",
        },
    )


machine = ToggleMachine.create_machine()
interp = SyncInterpreter(machine).start()

interp.send("TOGGLE")
print(interp.active_state_ids)
# {'toggle.on'}

interp.stop()

Using Builder Style:

from xstate_statemachine import MachineBuilder, SyncInterpreter

machine = (
    MachineBuilder("toggle")
    .state(
        "off",
        initial=True,
        on={
            "TOGGLE": "on",
        },
    )
    .state(
        "on",
        on={
            "TOGGLE": "off",
        },
    )
    .build()
)

interp = SyncInterpreter(machine).start()
interp.send("TOGGLE")
print(interp.active_state_ids)
# {'toggle.on'}

interp.stop()

All five approaches produce identical runtime behavior. Choose the style that fits your project.

2️⃣ Your Second Machine: With Actions and Context

Let’s add logic β€” a counter that tracks how many times the switch is toggled:

from xstate_statemachine import create_machine, SyncInterpreter, MachineLogic

config = {
    "id": "counter",
    "initial": "active",
    "context": {
        "count": 0,
    },
    "states": {
        "active": {
            "on": {
                "INCREMENT": {
                    "actions": "increment",
                },
                "DECREMENT": {
                    "actions": "decrement",
                },
                "RESET": {
                    "actions": "reset",
                },
            },
        },
    },
}


class CounterLogic(MachineLogic):
    def increment(self, interpreter, context, event, action_def):
        context["count"] += 1

    def decrement(self, interpreter, context, event, action_def):
        context["count"] = max(0, context["count"] - 1)

    def reset(self, interpreter, context, event, action_def):
        context["count"] = 0


machine = create_machine(config, logic=CounterLogic())
interp = SyncInterpreter(machine).start()

interp.send("INCREMENT")
interp.send("INCREMENT")
interp.send("INCREMENT")
print(interp.context["count"])
# 3

interp.send("DECREMENT")
print(interp.context["count"])
# 2

interp.send("RESET")
print(interp.context["count"])
# 0

interp.stop()

πŸ“¦ What’s Included

Component Description
Runtime Library Async (Interpreter) + Sync (SyncInterpreter) engines for executing state machines
Pythonic API Define machines in pure Python β€” class-based, builder, or functional style
JSON Support Full XState JSON format compatibility for cross-platform machine definitions
CLI Tool (xsm) Generate production-ready Python from XState JSON with type hints and docstrings
Plugin System Observable hooks for logging, metrics, debugging, and custom extensions
Snapshot System Save and restore machine state for persistence, testing, and time-travel debugging
Diagram Export Generate Mermaid or PlantUML diagrams from machine definitions (and ASCII through xsm diagram)

πŸ—ΊοΈ Feature Overview

Here’s what XState-StateMachine supports β€” every feature you’d expect from a production statechart library:

States & Transitions

  • Simple states β€” flat state machines with event-driven transitions
  • Hierarchical (nested) states β€” compound parent states with child substates
  • Parallel states β€” orthogonal regions running concurrently
  • Final states β€” terminal states that emit done events
  • Self-transitions β€” re-enter the same state (with timer reset)

Logic & Data

  • Context β€” mutable data attached to the machine instance
  • Guards β€” conditional transitions (guard or cond key)
  • Actions β€” side effects on transitions (entry, exit, on transition)
  • Services / Invoke β€” async or sync service calls with onDone/onError
  • Delayed transitions β€” timer-based auto-transitions (after)
  • Eventless transitions β€” auto-transitions based on conditions (always)

Architecture

  • Actor Model β€” spawn independent child machines with spawn_ prefix
  • Plugins β€” observable hooks for logging, metrics, custom extensions
  • Snapshots β€” serialize/restore machine state with get_snapshot() / from_snapshot() (see Snapshots)
  • Dual interpreters β€” Interpreter (async) and SyncInterpreter (sync)

Developer Tools

  • CLI tool β€” generate (10 templates), inspect, simulate, diagram and document machines from XState JSON; interactive launcher on a terminal
  • Diagram export β€” Mermaid, PlantUML (and ASCII through xsm diagram)
  • LoggingInspector plugin β€” built-in state transition logging
  • Zero dependencies β€” pure Python standard library

Production & Safety

  • Bounded inbox β€” max_queue_size with OverflowPolicy.RAISE / BLOCK / DROP_NEWEST caps how many events an interpreter will buffer. See Interpreters.
  • Strict mode β€” strict=True (and strictTargets) rejects unknown events or transition targets instead of silently ignoring them. See Interpreters.
  • Unhandled-event policy β€” onUnhandled: "defer" | "error" controls what happens to events the machine never declared a handler for. See Core Concepts.
  • Action/guard error policy β€” actionErrorPolicy and guardErrorPolicy (e.g. "rollback") control how a raising action or guard affects the in-flight transition. See Actions.
  • Injectable Clock β€” Clock, RealClock, and SimulatedClock let after timers and delayed sends run deterministically in tests. See Delayed Transitions.

πŸ§‘β€πŸ’» Development Installation

To contribute or work from source:

git clone https://github.com/basiltt/xstate-statemachine.git
cd xstate-statemachine
uv pip install -e . --group dev --group lint --group test

This installs the library in editable mode with all development, linting, and testing dependencies.

Running the Tests

# Run all tests
python -m pytest tests/ -v

# Run specific test module
python -m pytest tests/test_pythonic.py tests/test_pythonic_dsl_v2.py -v

# Run with coverage
python -m pytest tests/ --cov=xstate_statemachine --cov-report=html

πŸ—‚οΈ Project Structure

xstate-statemachine/
β”œβ”€β”€ src/xstate_statemachine/
β”‚   β”œβ”€β”€ __init__.py            # Public API exports
β”‚   β”œβ”€β”€ models.py              # MachineNode, StateNode, config parser, Mermaid/PlantUML export
β”‚   β”œβ”€β”€ factory.py             # create_machine()
β”‚   β”œβ”€β”€ base_interpreter.py    # Engine-agnostic semantics shared by both engines
β”‚   β”œβ”€β”€ interpreter.py         # Interpreter (async)
β”‚   β”œβ”€β”€ sync_interpreter.py    # SyncInterpreter (blocking)
β”‚   β”œβ”€β”€ pythonic.py            # Public Pythonic API (re-exports the _pythonic_* modules)
β”‚   β”œβ”€β”€ _pythonic_class.py     # StateMachine class DSL
β”‚   β”œβ”€β”€ _pythonic_builder.py   # MachineBuilder
β”‚   β”œβ”€β”€ _pythonic_core.py      # State, Transition, build_machine, decorators
β”‚   β”œβ”€β”€ machine_logic.py       # MachineLogic container
β”‚   β”œβ”€β”€ logic_loader.py        # Auto-discovery of actions, guards and services
β”‚   β”œβ”€β”€ actions.py             # Built-in action creators (assign, raise, sendTo, ...)
β”‚   β”œβ”€β”€ plugins.py             # PluginBase + LoggingInspector
β”‚   β”œβ”€β”€ clock.py               # RealClock, SimulatedClock
β”‚   β”œβ”€β”€ graph.py               # Graph algorithms (shortest_paths, reachable_states, ...)
β”‚   β”œβ”€β”€ helpers.py             # wait_for, to_promise, pure transition()
β”‚   β”œβ”€β”€ testing_utils.py       # Test helpers (stub_logic, ...)
β”‚   β”œβ”€β”€ persistence/           # Stores, locking, idempotency, snapshots, durable timers
β”‚   β”œβ”€β”€ patterns/              # RetryPolicy, dead_letter, CircuitBreaker, sagas
β”‚   β”œβ”€β”€ eda/                   # CloudEvents envelope, outbox, dead letters, AsyncAPI
β”‚   β”œβ”€β”€ inspect/               # Live inspector (Stately Inspector protocol)
β”‚   β”œβ”€β”€ contrib/               # Optional extras: fastapi, django, sqlalchemy, redis, ...
β”‚   └── cli/                   # xsm: generate / inspect / simulate / diagram / docs / ...
β”‚       β”œβ”€β”€ __main__.py        # Entry point (xsm command)
β”‚       β”œβ”€β”€ args.py            # Argument parsing
β”‚       β”œβ”€β”€ commands/          # One module per sub-command
β”‚       β”œβ”€β”€ extractor.py       # JSON feature extraction
β”‚       └── strategies/        # 10 code generation templates
β”œβ”€β”€ tests/                     # Unit, integration and docs tests (90% coverage gate)
β”œβ”€β”€ docs/                      # GitHub Pages documentation
└── pyproject.toml

⬆️ Upgrading from Older Versions

pip install --upgrade xstate-statemachine

From v0.11.x to v0.12.0:

  • State.to() / State.internal() no longer raise when event= is omitted; inside a StateMachine the attribute name becomes the event (cycle β†’ CYCLE). Compiling a transition that still has no event (functional API) raises InvalidConfigError at build_machine().
  • A StateMachine subclass is a running machine when instantiated. If you had a custom __init__, call super().__init__() (or keep using create_machine(), which is unchanged).
  • send, state, configuration, context, status, can, matches, allowed_events, machine, interpreter, engine, strict, plugins, validate_logic are reserved attribute names in a StateMachine body; a state or transition using one raises InvalidConfigError (keep the event name with event=). A transition named start or stop is allowed but gets no sm.stop() sugar.
  • Undecorated methods whose names match a referenced guard / action / service are bound automatically; a method named on_enter_<state>, on_<event>, before_<event> or after_<event> is now a hook. Rename helpers that collide.
  • A declared invoke.input factory now receives the triggering event, as in XState. A state that declares no input still passes the service {} (as XState passes nothing); declare "input": event_payload to forward the event payload. A declared falsy input (0, "", False, {}) is now passed through.
  • xstate_statemachine.pythonic re-exports only its own names; import MachineLogic, MachineNode, InvalidConfigError and the rest from xstate_statemachine. Submodules are lazy, so xstate_statemachine.helpers is no longer an attribute after a bare import xstate_statemachine; import it explicitly.
  • self.status = ... (or any other reserved name) on a StateMachine instance now raises AttributeError, because those names are read-only properties of the instance faΓ§ade.

Engine behaviour that changed for existing charts (XState v5 conformance). None of these touch a chart that never depended on the old answer; tests that pin action order in parallel or nested charts may need new expected lists. Each is spelled out, with who notices and how to migrate, in the changelog:

  • Exit actions run in reverse document order (descendants first; in a parallel state the last-declared region first); states are entered in document order.
  • A transition into a sibling region of a parallel state re-enters every region; reenter: true re-enters the source even when the target is its child.
  • Completing the machine at a top-level final state runs the remaining exit actions. stop() still runs none.
  • "*" and "prefix.*" handlers also receive engine events (done.invoke.*, error.platform.*, after.*, done.state.*).
  • target may be a list of states in different regions of one parallel state (multi-target transitions).
  • Transitions for one event form one microstep: all exits, then all actions, then all entries; conflicting transitions are dropped.
  • done.state.* goes onto the internal queue, so a parent’s onDone is taken before an external event that was already waiting.
  • Guards are evaluated in document order; several assign actions of one transition compute against the same previous context; history is recorded when its owner exits; subscribe() listeners fire once per microstep.

From v0.10.x to v0.11.0:

The library core is additive: everything new (persistence, patterns, EDA, the inspector and every contrib extra) is opt-in, and existing machines run unchanged. One thing to know:

  • Snapshot layout is v4 (machine_version, deadlines). Older blobs upcast transparently on load; a v4 blob does not load on 0.10.x. See Snapshots.

From v0.8.0 to v0.9.0:

A hardening release: every defect reported across eleven re-verification rounds, each reproduced before it was fixed and pinned by a regression test. It is a minor bump because a handful of changes are visible to code that was correct on 0.8.0:

  • Receipt has five fields. send(wait=True) now resolves a Receipt(state_ids, changed, error, deferred, denied). A positional destructure written for 0.8.0 β€” state_ids, changed, error = receipt β€” raises ValueError; read fields by attribute.
  • actionErrorPolicy: "fail" leaves status == "stopped", not "error", with the configuration cleared (#145). Check "stopped" or interp.error is not None; "error" remains the status for a service that died.
  • guardErrorPolicy: "raise" takes the fallback candidate before surfacing the exception (#152), instead of aborting the whole array.
  • Awaiting your own receipt inside an action raises ReentrantWaitError (#219) instead of deadlocking; the sync engine refuses the same shape. Send without wait, or await the receipt from outside the step.
  • Unknown config keys are reported at every level (#216, #220) β€” a WARNING by default, InvalidConfigError under strict_config=True. A config carrying ad-hoc keys will start logging; move them under meta or an x- prefix.
  • asyncio.run_coroutine_threadsafe(interp.send(...), loop) is rejected with WrongThreadError (the thread check runs before the coroutine is scheduled). Use interp.send_threadsafe(...) from other threads β€” this was already the documented path in 0.8.0.
  • Snapshot layout is v3. Older blobs upcast transparently on load; a v3 blob does not load on 0.8.0.

Everything else is additive or a bug fix: new typed exceptions (RunawayChainError, ErrorEvent, SnapshotMidStepError, SnapshotCorruptError, SnapshotSerializationError, InvalidEventError β€” also a TypeError β€” and RootTargetError), new plugin hooks (on_resolve_error, on_plugin_error, on_invocation_stranded, on_chain_budget_exceeded, on_invalid_event, on_snapshot_error), from_snapshot(clock=, restart_timers=, minimum_version=), sticky chain_trips / last_chain_error, Interpreter(service_pool_size=), MachineLogic(strict=True) and redaction in LoggingInspector. See What’s New in 0.11.0, 0.10.0, 0.9.0 and 0.9.1 below.

From v0.5.x to v0.6.0:

v0.6.0 closes the remaining XState v5 feature gaps and repairs a family of correctness defects. Existing JSON configs and MachineLogic patterns keep working β€” but three behavioural changes are worth knowing:

  • Action errors are contained. If an action raises, the error is logged, the transition still completes, and the interpreter keeps running. .send() no longer re-raises.

    ⚠️ If you wrapped send() in try/except, that handler will no longer fire. The machine advances as though the action succeeded β€” so a checkout machine can report paid when the charge actually raised. Two supported replacements: catch the error inside the action and record it on context (then guard on it), or register a plugin implementing on_action_error to route failures to Sentry or a metric.

    In 0.5.0 the exception did propagate, but it also left the machine with an empty state configuration β€” permanently dead while still reporting running. The old handler was catching an already-corrupted machine.

    See Actions.

  • A stopped interpreter cannot be restarted. start() after stop() now raises instead of silently returning a dead instance. Build a new interpreter, or restore one with from_snapshot().
  • Ambiguous state keys are rejected. A key containing . whose first segment is also a sibling state (e.g. "x.y" next to "x") now raises at parse time, because both resolved to the same id. Unambiguous dotted keys such as "v1.0" still work.

New in this release: built-in action creators (assign, choose, enqueue_actions, sendTo, …), the actor system with systemId, and the pure API and waiting helpers.

From v0.4.x to v0.5.0:

  • The Pythonic API (State, build_machine, StateMachine, MachineBuilder) is new in v0.5.0. Existing JSON-based code continues to work unchanged.
  • The --style CLI flag is deprecated in favor of --template. Both still work, but --style will be removed in a future release.
  • All existing create_machine() and MachineLogic patterns remain fully supported.

From v0.3.x to v0.4.x:

  • CLI tool introduced (xsm generate-template)
  • after transition support added to SyncInterpreter
  • No breaking changes

⚑ Async Support

If your project uses asyncio, you can use the async Interpreter instead:

import asyncio
from xstate_statemachine import create_machine, Interpreter

config = {
    "id": "asyncToggle",
    "initial": "off",
    "states": {
        "off": {
            "on": {
                "TOGGLE": "on",
            },
        },
        "on": {
            "on": {
                "TOGGLE": "off",
            },
        },
    },
}


async def main():
    machine = create_machine(config)
    interp = await Interpreter(machine).start()

    await interp.send("TOGGLE", wait=True)
    print(interp.active_state_ids)
    # {'asyncToggle.on'}

    await interp.stop()


asyncio.run(main())

Note: Plain await interp.send(...) only awaits the event being enqueued β€” it does not wait for the macrostep to run. Pass wait=True to get back a Receipt that resolves once the transition has actually been processed (see Receipts and priority sends).

Tip: Use SyncInterpreter for scripts, CLI tools, and testing. Use Interpreter for web servers, event loops, and real-time applications.

πŸ†• What’s New in 0.12.0

  • Pythonic DSL, second generation β€” a StateMachine subclass is a running machine (sm = Light(); sm.cycle(); sm.state, sm.can(), sm.allowed_events), events are named by the attribute, naming-convention hooks (on_enter_<state>, before_<event>, …), signature-based argument injection, plain methods bound by name, @state.service, strict = True, build-time logic validation and from_state. See the Pythonic API.
  • XState v5 conformance β€” the statechart engine now follows XState v5 for exit/entry order, parallel transition domains, reenter, multi-target transitions, done.state.*, wildcards and microsteps. The library reads XState v5 JSON; engine event names keep the v4 spelling (done.invoke.<id>).
  • Tooling β€” xsm gt --fixtures, richer Mermaid/PlantUML (delayed, invoke, always and final-state edges), the [testing] model-based tester. The full list is the changelog.

πŸ†• What’s New in 0.11.0

The durable, testable, web-ready release. The core stays zero-dependency; everything that needs a third-party library is an explicit extra under xstate_statemachine.contrib, each with a guide page that carries a Guarantees box and a Threat model box.

  • Persistence & durability (xstate_statemachine.persistence, core) β€” MemoryStore / FileStore / SQLiteStore behind one persisted() load β†’ act β†’ persist β†’ discard loop; OptimisticLock (version + jittered retry) or a fenced PessimisticLock, so two workers never lose an update; IdempotencyPlugin + inbox (a replayed webhook gets the original receipt; scope is per tenant); AuditPlugin / TransitionLogPlugin with replay(); SnapshotMigrator for in-flight instances across a deploy; durable after timers β€” deadlines persist as wall-clock instants, persisted() resumes the remaining time and DueTimerScanner wakes machines whose deadline passed while nothing was running. What is exactly-once and what is at-least-once is specified on the Guarantees page; the trust model on Security.
  • Resilience patterns (xstate_statemachine.patterns, core) β€” RetryPolicy (exponential backoff + jitter as an after delay), dead_letter transition helper, CircuitBreaker chart + @circuit_breaker.
  • Actor logic helpers β€” from_callback, from_async_iterator, from_iterator, from_coroutine, from_callable, from_interpreter: XState’s fromPromise / fromCallback / fromObservable parity as ordinary services, on both engines.
  • Graph algorithms β€” shortest_paths / simple_paths / reachable_states / transition_coverage_targets, executed by the real engine (parallel regions, history and timers behave exactly as at runtime); xsm paths; an engine-backed reachability finding in xsm inspect.
  • [pydantic] β€” typed context validated on every assign, EventModel discriminated unions β†’ event_schemas=, validate_machine_json(), JSON Schema. [redis] β€” RedisStore / RedisInbox / RedisLog with fenced locks for multi-host deployments.
  • [starlette] Β· [fastapi] Β· [litestar] β€” StatechartRegistry puts the persisted loop behind HTTP: receipt β†’ 200/202/409/422, principal-scoped Idempotency-Key, RFC 9457 problem responses that never carry exception text, SSE and WebSocket transition streams, lifespan-managed timers. StatechartRouter generates one route per event with OpenAPI built from your Pydantic events; Depends(get_interpreter(...)) for hand-written handlers. Closed by default: authorize= is required. Start with the FastAPI guide and the runnable examples/integrations/fastapi_orders app.
  • [sqlalchemy] Β· [flask] β€” StatechartType column + StatechartMixin (send() under SELECT … FOR UPDATE or an optimistic version), SQLAlchemyStore / inbox / log / outbox sharing your session, Alembic helpers; a Flask extension + blueprint (one POST per event, CSRF-aware forms, flask xsm CLI) and Quart parity. Example apps sqlalchemy_orders and flask_wizard. See SQLAlchemy and Flask.
  • [django] Β· [drf] Β· [channels] β€” StatechartField (JSON snapshot plus indexed _state / _version columns and state__in lookups), StatechartModelMixin.send() inside transaction.atomic() + select_for_update() (or optimistic + send_with_retry), DjangoStore, durable deadlines via manage.py xsm_deadlines; pre_transition / post_transition signals, TransitionLog written in the same transaction, PermissionGuard / has_event_permission, a DjangoOutboxStore; an admin mixin with CSRF-protected transition buttons (permission re-checked on POST, confirm + reason, history inline) and xsm_* management commands; a DRF viewset mixin (one @action per event, explicit permission_classes required, user-scoped Idempotency-Key, drf-spectacular schema) and a Channels consumer re-authorised on every push; xsm_migrate_fsm migrates a django-fsm-2 model mechanically. Example app django_approvals. See Django and DRF & Channels.
  • [testing] β€” a pytest plugin with xsm_* fixtures, a SimulatedClock fixture, state and transition coverage with --xsm-fail-under-state-coverage / --xsm-fail-under-transition-coverage, an xsm_path fixture that walks every engine-verified path, Hypothesis strategies for events and paths, replay() / assert_replay_consistent() for audit logs, in-memory fake brokers. See Testing.
  • Event-driven architecture (xstate_statemachine.eda, core) β€” CloudEvents 1.0 Envelope with correlation / causation ids, BrokerAdapter protocols, InboundDispatcher (per-subject order, inbox dedup, poison β†’ dead letters), a transactional outbox (OutboxPlugin commits with the snapshot, OutboxRelay publishes), SQLiteDeadLetterStore + xsm dlq list|show|replay|purge, SagaBuilder / ChoreographyRouter, xsm asyncapi, [cloudevents] interop. See EDA.
  • Brokers & Celery β€” [kafka], [rabbitmq], [nats], [sqs] and Redis Streams ([redis]) adapters, each passing one contract suite and a real-broker restart test, with the broker’s redelivery count carried on the envelope so poison reaches the DLQ across restarts; [celery] gives celery_service (an invoke bridge), @statechart_task, DurableTimerScheduler for Beat and an outbox relay task; pickle and YAML serializers are refused. See Brokers and Celery.
  • [observability] + live inspector β€” OpenTelemetry spans and Prometheus metrics with label hygiene (state and event names from the chart allow-list, never payloads or keys), structlog / loguru / Sentry hooks, gen_ai.* spans for agents; a stdlib InspectorPlugin speaking the Stately Inspector wire protocol with SSE, JSONL and replay sinks and xsm inspect --live. See Observability and Inspector.
  • [agents] β€” a TOOL_LOOP chart where the model proposes and the machine decides: per-state tool allow-lists, run_tool safety, budgets and timeouts as guards, durable human approval, structured output with retry, LangGraph / pydantic-ai interop, AgentTracePlugin. Example app agents_support_bot. See Agents and vs LangGraph.
  • Recipes, comparisons, adoption kit β€” eight tested recipes (Stripe webhooks, APScheduler, RQ, slot filling, …); comparison pages vs transitions, python-statemachine, django-fsm and LangGraph; xsm new templates, Stately export and the integrations journey.
  • 1.0 hardening β€” explicit plugin discovery (plugins.discover, entry-point groups incl. xstate_statemachine.brokers), a proven compatibility matrix (oldest and newest of every extra on Python 3.9 and 3.13), a deprecation policy and the security baseline (X0) re-verified per group.
  • Core prerequisites β€” snapshot layout v4 (machine_version, deadlines), interpreter.wall_now(), a global plugin registry, the context_validator seam, the __xstate_event__ adapter, SyncInterpreter.send_threadsafe(), MachineNode.version.
  • Programme baseline β€” SECURITY.md, a pip-audit CI job over every shipped extra, SHA-pinned Actions, a no-pickle/eval tree guard, and measured performance budgets (benchmarks/budgets.json, nightly).

See the Changelog for the full list.

πŸ†• What’s New in 0.10.0

A CLI release. The library is unchanged (one additive create_machine(..., strict_config=) overload), and xsm grows from a code generator into a terminal toolkit β€” still with zero runtime dependencies:

  • Interactive launcher β€” a bare xsm on a terminal opens a menu with recent files and a generate wizard that previews the module before writing.
  • xsm inspect β€” state tree, transitions table, logic to implement and failure policies for a machine, built with the real library.
  • xsm simulate β€” run a machine live on a SimulatedClock: pick enabled events, fire timers, flip guards, undo, print history and snapshots. The same engine replays --events A,+500,B, --script file.json and emits --json for CI.
  • xsm diagram and xsm docs β€” Mermaid / PlantUML / ASCII diagrams and a Markdown reference page per machine, to stdout or files.
  • Companion templates β€” --with-tests (a pytest module recorded from the engine), --with-types (TypedDict context, Literal events, typed stubs) and --with-plugin (a PluginBase wired for the hooks the chart can fire), alongside any primary template or on their own via --template.
  • xsm validate now builds with create_machine(strict_config=True), reports unreachable states and the library’s own warnings, and has --json and --lenient.
  • xsm update β€” upgrade to the latest release with the installer that installed you (pip / pipx / uv tool); refuses editable and conda installs with the right command instead. --check for scripts.
  • Windows: xsm where pip’s launcher is blocked β€” machines governed by Application Control / AppLocker / Smart App Control refuse the unsigned xsm.exe pip generates. Run python -m xstate_statemachine setup once and xsm works normally (--check, --undo); python -m xstate_statemachine … always works.
  • Presentation β€” truecolor/256/16-colour tiers, box-drawing, spinners and step lists on a terminal; deterministic plain text when piped or with --plain; --no-color / NO_COLOR, --no-anim, --verbose; --json on validate, inspect, simulate, list-templates and info.

See the CLI Tool guide.

πŸ†• What’s New in 0.9.1

A patch release from the thirteenth re-verification round. Nothing changes for code that was correct on 0.9.0; two additions are worth knowing about:

  • drain_pending() now returns every pending event on the async engine β€” the priority lane (fired timers, completions, send_priority()) as well as the inbox. Before, the documented drain β†’ persist β†’ stop() recipe silently lost the lane.
  • on_interpreter_start fires on a restored interpreter too; read interpreter.restored_from_snapshot to tell resume from bring-up.
  • Restored last_chain_error is a RestoredChainError β€” a RunawayChainError and a RestoredError β€” so an isinstance guard survives a restart. Malformed chain_trips / last_chain_error in a blob are SnapshotCorruptError.
  • interpreter.dropped_receipts and the on_receipt_dropped hook are the gateable form of the dropped-receipt RuntimeWarning.
  • SyncInterpreter(max_queue_size=, overflow_policy=) are accepted for parity and refused with a ValueError β€” the sync engine has no inbox to bound.
  • re_mint(event, **fields) patches a field of an engine-minted event and keeps its provenance; _replace remains a one-way demotion.
  • benchmarks/production_characteristics.py --json for CI gates; releases carry PEP 740 attestations (pypi-attestations verify pypi …).

πŸ†• What’s New in 0.9.0

The 0.9.0 release is the follow-through on 0.8.0: every finding from eleven independent re-verification rounds fixed, with the engines brought into lock-step. Highlights:

  • Engine parity β€” the async Interpreter and SyncInterpreter now agree on when a plain-sync invoke completes, what an unhandled child failure does to the parent, which hooks fire on stop() and on a send to a stopped machine, and the init on_transition record.
  • Snapshots you can trust β€” a mid-macrostep snapshot is refused (SnapshotMidStepError) instead of persisting an inert machine; fired timers are persisted; malformed blobs raise SnapshotCorruptError; from_snapshot(clock=, restart_timers=) and has_dormant_timers.
  • Provenance, not names β€” wildcards, onUnhandled and strict decide β€œengine event” by who minted it (is_system_event), and the marker survives deepcopy, pickle and wait=True.
  • Observability β€” on_event_dropped fires on both engines for every loss site with a typed reason; new on_resolve_error and on_plugin_error; LoggingInspector redacts secrets by default.
  • Build-time safety β€” a transition to the machine root, a self-referential config, an ambiguous bare stateIn and a non-str event are all typed errors now.
  • maxIterations is a chain budget β€” an external producer sending during a slow step is never charged to it, and engine completions are never cut by it.

See the full changelog for every change in this release.

πŸ†• What’s New in 0.8.0

The 0.8.0 release closes a production-adoption audit spanning 34 defects across three waves. Highlights:

  • Production Hardened β€” actionErrorPolicy: "rollback" restores configuration and context after a raising action instead of committing a half-built transition.
  • Unhandled events, on purpose β€” onUnhandled: "defer" | "error" replaces silently dropping events the machine never declared a handler for.
  • Strict mode β€” strict=True rejects an unknown event at the send() call site, with a difflib suggestion, before it’s ever queued.
  • Bounded inbox β€” max_queue_size= with OverflowPolicy.RAISE / BLOCK / DROP_NEWEST caps how many events an interpreter will buffer.
  • Receipts and priority sends β€” send(wait=True) resolves a Receipt once the macrostep runs, and send(priority=True) jumps the inbox β€” no more polling active_state_ids.
  • Injectable Clock β€” SimulatedClock drives after timers and delayed sends deterministically in tests; no more sleeping in your test suite.
  • Snapshot envelope v1 β€” persisted snapshots carry a version, machine id, and structural hash, and from_snapshot() can detect drift or restart services on restore.

See the full changelog for every change in this release.

➑️ Next Steps

Now that you’re set up, explore the features: