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 / and exit / actions and the service it invokes; a transition pill shows the event, its IF guard (an unguarded alternative after guarded ones reads ELSE, and alternatives of one event are numbered) and its actions; delays read after 5000 ms; an invoked service’s results read done: and error:; a final state carries a square mark; a history state an H badge; 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 an on EVENT row 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 onDone transition from compound states is included in the exported diagrams, showing the flow from the compound state to the next top-level state.