Diagram Export
Generate PlantUML and Mermaid diagrams from any machine definition.
Every MachineNode can export its structure as a PlantUML or Mermaid diagram. This lets you visualize your state machines, generate documentation, and keep diagrams in sync with your code automatically.
🖼️ Overview
flowchart LR
M["🧠 machine<br/><small>JSON or Pythonic</small>"] --> A["to_mermaid()"] --> R1["GitHub · docs · Notion render it live"]
M --> B["to_plantuml()"] --> R2["PlantUML server · IDE plugins → PNG / SVG"]
The library provides two export methods on every MachineNode:
| Method | Format | Use Case |
|---|---|---|
machine.to_plantuml() |
PlantUML | Rich diagrams, IDE plugins, PDF generation |
machine.to_mermaid() |
Mermaid | GitHub README, Markdown docs, online editors |
Both methods return a string that you can print, save to a file, or embed in documentation.
Viewing diagrams in these docs
The guides show two kinds of chart, both drawn in your browser by Mermaid:
- Machine diagrams (captioned statechart · name) are generated from the
same JSON the example or guide runs, in the notation of the Stately editor:
a state box lists its
entry /andexit /actions and the service it invokes; a transition pill shows the event, itsIFguard (an unguarded alternative after guarded ones readsELSE, and alternatives of one event are numbered) and its actions; delays readafter 5000 ms; an invoked service’s results readdone:anderror:; a final state carries a square mark; a history state anHbadge; the regions of a parallel state have dashed borders. Small linear machines are laid out left to right, the rest top to bottom, as the Stately editor does. A transition that stays inside its own state (no target, a self-target, or a target up or down its own branch) is listed as anon EVENTrow inside the box instead of an arrow. Open in Stately under a chart opens the same machine in the Stately editor, which asks you to sign in. - Mermaid illustrations are the exporter’s own output (
to_mermaid()), shown as is so you can see what a README or wiki renders.
Choose Full screen on any figure to open a page-sized viewer in the current tab, then use +, − or Fit; Ctrl (or ⌘) with the mouse wheel zooms as well. Escape or Close returns to your place in the guide. The diagrams follow the site theme and need no connection to Stately; the Mermaid library itself is loaded from the jsDelivr CDN.
🌿 PlantUML Export
Basic Example
from xstate_statemachine import create_machine
config = {
"id": "trafficLight",
"initial": "red",
"states": {
"red": {
"on": {
"TIMER": "green",
},
},
"green": {
"on": {
"TIMER": "yellow",
},
},
"yellow": {
"on": {
"TIMER": "red",
},
},
},
}
machine = create_machine(config)
plantuml = machine.to_plantuml()
print(plantuml)
Sample Output
@startuml
hide empty description
state "trafficLight" as trafficLight {
[*] --> trafficLight_red
state "red" as trafficLight_red
state "green" as trafficLight_green
state "yellow" as trafficLight_yellow
}
[*] --> trafficLight_red
trafficLight_red --> trafficLight_green : TIMER
trafficLight_green --> trafficLight_yellow : TIMER
trafficLight_yellow --> trafficLight_red : TIMER
@enduml
How to Render PlantUML
There are several ways to render PlantUML diagrams:
1. Save to file and use the PlantUML CLI:
pip install plantuml
# Save the diagram
with open("traffic_light.puml", "w") as f:
f.write(machine.to_plantuml())
# Render to PNG
plantuml traffic_light.puml
2. Use the PlantUML online server:
Paste the output at https://www.plantuml.com/plantuml/uml/
3. VS Code extension:
Install the “PlantUML” extension and preview .puml files directly in the editor.
🧜 Mermaid Export
Basic Example
from xstate_statemachine import create_machine
config = {
"id": "trafficLight",
"initial": "red",
"states": {
"red": {
"on": {
"TIMER": "green",
},
},
"green": {
"on": {
"TIMER": "yellow",
},
},
"yellow": {
"on": {
"TIMER": "red",
},
},
},
}
machine = create_machine(config)
mermaid = machine.to_mermaid()
print(mermaid)
Sample Output
stateDiagram-v2
[*] --> trafficLight
state "trafficLight" as trafficLight {
[*] --> red
state "red" as red
state "green" as green
state "yellow" as yellow
}
red --> green : TIMER
green --> yellow : TIMER
yellow --> red : TIMER
The top-level machine is wrapped in its own state "<id>" as <id> { ... } block. The overall entry arrow points to that wrapper, and its inner entry arrow points to the initial child. Every leaf state is declared inside its parent before transitions are emitted, so non-initial and unconnected states retain their correct containment. Nested compound states follow the same pattern.
Mermaid alias limitation: The exporter uses local state keys as diagram IDs. Use distinct keys across the whole chart, including the machine ID. Repeated local keys in different parents are ambiguous in this format; use the PlantUML exporter or Stately for those charts. This visualization limitation does not affect interpreter state resolution.
Embedding in GitHub README
Mermaid diagrams render natively in GitHub Markdown. Wrap the output in a fenced code block:
```mermaid
stateDiagram-v2
[*] --> trafficLight
state "trafficLight" as trafficLight {
[*] --> red
state "red" as red
state "green" as green
state "yellow" as yellow
}
red --> green : TIMER
green --> yellow : TIMER
yellow --> red : TIMER
```
Tip: You can automate this by writing a script that regenerates your README’s diagram section from your machine definition. This ensures your documentation always matches your code.
📄 Export from JSON Machines
Both export methods work with any machine created via create_machine():
import json
from xstate_statemachine import create_machine
# Load config from a JSON file
with open("my_machine.json") as f:
config = json.load(f)
machine = create_machine(config)
# Export both formats
with open("docs/machine.puml", "w") as f:
f.write(machine.to_plantuml())
with open("docs/machine.mmd", "w") as f:
f.write(machine.to_mermaid())
print("Diagrams exported!")
🐍 Export from Pythonic API Machines
Machines built with the Pythonic API export diagrams the same way — StateMachine.create_machine(), build_machine(), and MachineBuilder.build() all return a MachineNode:
from xstate_statemachine import State, StateMachine
class DoorLock(StateMachine):
machine_id = "doorLock"
locked = State("locked", initial=True)
unlocked = State("unlocked")
unlock = locked.to(unlocked, event="UNLOCK")
lock = unlocked.to(locked, event="LOCK")
machine = DoorLock.create_machine()
# PlantUML
print(machine.to_plantuml())
# Mermaid
print(machine.to_mermaid())
Mermaid output:
stateDiagram-v2
[*] --> doorLock
state "doorLock" as doorLock {
[*] --> locked
state "locked" as locked
state "unlocked" as unlocked
}
locked --> unlocked : UNLOCK
unlocked --> locked : LOCK
The functional build_machine() and fluent MachineBuilder produce the same kind of MachineNode:
from xstate_statemachine import State, build_machine
idle = State("idle", initial=True)
running = State("running")
machine = build_machine(
id="switch",
states=[idle, running],
transitions=[idle.to(running, event="START")],
)
print(machine.to_mermaid())
from xstate_statemachine import MachineBuilder
machine = (
MachineBuilder("switch")
.state("idle", initial=True)
.state("running")
.transition("idle", "START", "running")
.build()
)
print(machine.to_mermaid())
Both produce:
stateDiagram-v2
[*] --> switch
state "switch" as switch {
[*] --> idle
state "idle" as idle
state "running" as running
}
idle --> running : START
🪆 Nested State Diagrams
Both exporters handle hierarchical (compound) states with proper nesting:
from xstate_statemachine import create_machine
config = {
"id": "editor",
"initial": "idle",
"states": {
"idle": {
"on": {
"OPEN": "editing",
},
},
"editing": {
"initial": "drafting",
"states": {
"drafting": {
"on": {
"PREVIEW": "previewing",
"SAVE": "saving",
},
},
"previewing": {
"on": {
"EDIT": "drafting",
},
},
"saving": {
"on": {
"SAVE_DONE": "drafting",
},
},
},
"on": {
"CLOSE": "idle",
},
},
},
}
machine = create_machine(config)
print(machine.to_plantuml())
PlantUML output (nested):
@startuml
hide empty description
state "editor" as editor {
[*] --> editor_idle
state "idle" as editor_idle
state "editing" as editor_editing {
[*] --> editor_editing_drafting
state "drafting" as editor_editing_drafting
state "previewing" as editor_editing_previewing
state "saving" as editor_editing_saving
}
}
[*] --> editor_idle
editor_idle --> editor_editing : OPEN
editor_editing_drafting --> editor_editing_previewing : PREVIEW
editor_editing_drafting --> editor_editing_saving : SAVE
editor_editing_previewing --> editor_editing_drafting : EDIT
editor_editing_saving --> editor_editing_drafting : SAVE_DONE
editor_editing --> editor_idle : CLOSE
@enduml
🔎 What the diagram shows
Both exporters draw more than plain event edges. The chart below has a timer, an invoked service, an eventless branch and a final state:
from xstate_statemachine import MachineLogic, create_machine
config = {
"id": "upload",
"initial": "idle",
"states": {
"idle": {
"on": {
"START": "sending",
},
},
"sending": {
"after": {
"5000": "timedOut",
},
"invoke": {
"src": "send",
"onDone": "checking",
"onError": "failed",
},
},
"checking": {
"always": [
{
"target": "done",
"guard": "isOk",
},
{
"target": "failed",
},
],
},
"timedOut": {
"on": {
"RETRY": "sending",
},
},
"failed": {
"on": {
"RETRY": "sending",
},
},
"done": {
"type": "final",
},
},
}
logic = MachineLogic(
guards={
"isOk": lambda ctx, event: True,
},
services={
"send": lambda interpreter, ctx, event: 1,
},
)
print(create_machine(config, logic=logic).to_mermaid())
stateDiagram-v2
[*] --> upload
state "upload" as upload {
[*] --> idle
state "idle" as idle
state "sending" as sending
state "checking" as checking
state "timedOut" as timedOut
state "failed" as failed
state "done" as done
done --> [*]
}
idle --> sending : START
sending --> timedOut : after 5000ms
sending --> checking : invoke onDone
sending --> failed : invoke onError
checking --> done : always [isOk]
checking --> failed : always
timedOut --> sending : RETRY
failed --> sending : RETRY
| Chart feature | Drawn as |
|---|---|
after |
an edge labelled after 5000ms (after NAME for a named delay) |
invoke |
one edge for onDone and one for onError, labelled invoke onDone / invoke onError |
always |
an edge labelled always, with the guard appended (always [isOk]) |
a final state |
an edge to [*] inside its parent |
a parallel state |
its regions separated by a -- divider (see below) |
xsm diagram upload.json -f ascii prints the same facts as text, a state tree followed by one from --EVENT-> to line per transition (the glyphs are o for a state and @ for a final state):
+ upload initial=idle
|-- o idle
|-- o sending after 5000 invoke send
|-- o checking always
|-- o timedOut
|-- o failed
`-- @ done
upload.idle --START-> upload.sending
upload.sending --after 5000-> upload.timedOut
upload.sending --invoke 'send' onDone-> upload.checking
upload.checking --always [isOk]-> upload.done
🎛️ Parallel State Diagrams
Parallel states are exported with a -- divider between their regions, in both Mermaid and PlantUML. Each region is a nested composite state:
from xstate_statemachine import create_machine
config = {
"id": "player",
"initial": "playing",
"states": {
"playing": {
"type": "parallel",
"states": {
"video": {
"initial": "loading",
"states": {
"loading": {
"on": {
"VIDEO_READY": "streaming",
},
},
"streaming": {
"on": {
"BUFFER": "loading",
},
},
},
},
"audio": {
"initial": "muted",
"states": {
"muted": {
"on": {
"UNMUTE": "audible",
},
},
"audible": {
"on": {
"MUTE": "muted",
},
},
},
},
},
"on": {
"STOP": "stopped",
},
},
"stopped": {
"type": "final",
},
},
}
machine = create_machine(config)
print(machine.to_mermaid())
Mermaid output:
stateDiagram-v2
[*] --> player
state "player" as player {
[*] --> playing
state "playing" as playing {
state "video" as video {
[*] --> loading
state "loading" as loading
state "streaming" as streaming
}
--
state "audio" as audio {
[*] --> muted
state "muted" as muted
state "audible" as audible
}
}
state "stopped" as stopped
stopped --> [*]
}
playing --> stopped : STOP
loading --> streaming : VIDEO_READY
streaming --> loading : BUFFER
muted --> audible : UNMUTE
audible --> muted : MUTE
📚 Using Diagrams for Documentation
From the command line
For machines you keep as JSON, the CLI wraps these exporters — no script needed:
xsm diagram checkout.json # Mermaid to stdout
xsm diagram checkout.json -f plantuml -o docs/ # docs/checkout.puml
xsm docs machines/*.json -o docs/ # a Markdown page per machine, diagram embedded
xsm diagram calls the same to_mermaid() / to_plantuml() shown above, so the output is identical to the Python API. -f ascii is CLI-only: a state tree plus one from --EVENT-> to line per transition (see What the diagram shows). See the CLI Tool guide.
Auto-Generate Documentation Script
Create a script that generates diagrams for all your machines:
"""generate_docs.py — Export diagrams for all state machines."""
import json
from pathlib import Path
from xstate_statemachine import create_machine
MACHINES_DIR = Path("configs")
DOCS_DIR = Path("docs/diagrams")
DOCS_DIR.mkdir(parents=True, exist_ok=True)
for config_file in MACHINES_DIR.glob("*.json"):
with open(config_file) as f:
config = json.load(f)
machine = create_machine(config)
machine_id = config["id"]
# Export PlantUML
puml_path = DOCS_DIR / f"{machine_id}.puml"
puml_path.write_text(machine.to_plantuml())
# Export Mermaid
mmd_path = DOCS_DIR / f"{machine_id}.mmd"
mmd_path.write_text(machine.to_mermaid())
print(f"Exported: {machine_id} -> {puml_path}, {mmd_path}")
print(f"\nAll diagrams written to {DOCS_DIR}/")
python generate_docs.py
Complete Example: Complex Machine with All Features
from pathlib import Path
from xstate_statemachine import MachineLogic, create_machine
config = {
"id": "orderSystem",
"initial": "browsing",
"context": {
"cart": [],
"total": 0,
},
"states": {
"browsing": {
"on": {
"ADD_ITEM": {
"actions": "addToCart",
},
"CHECKOUT": {
"target": "checkout",
"guard": "cartNotEmpty",
},
},
},
"checkout": {
"initial": "shipping",
"states": {
"shipping": {
"on": {
"SUBMIT_ADDRESS": "payment",
},
},
"payment": {
"on": {
"SUBMIT_PAYMENT": "processing",
},
},
"processing": {
"invoke": {
"src": "processPayment",
"onDone": "confirmation",
"onError": "payment",
},
},
"confirmation": {
"type": "final",
},
},
"on": {
"CANCEL": "browsing",
},
"onDone": "complete",
},
"complete": {
"type": "final",
},
},
}
# Diagrams need only the SHAPE; stub the named logic so the build-time
# check for missing implementations passes.
machine = create_machine(
config,
logic=MachineLogic(
actions={
"addToCart": lambda *a: None,
},
guards={
"cartNotEmpty": lambda c, e: True,
},
services={
"processPayment": lambda *a: None,
},
),
)
# Save both diagram formats
Path("order_system.puml").write_text(machine.to_plantuml())
Path("order_system.mmd").write_text(machine.to_mermaid())
# Print Mermaid for quick preview
print(machine.to_mermaid())
Note: The
onDonetransition from compound states is included in the exported diagrams, showing the flow from the compound state to the next top-level state.