Examples

Three runnable, fully-tested example scripts live in examples/ in the repository. All are shown in full below, kept in sync automatically with the actual source files.

Bakery process (examples/bread.py)

A small bakery process (idle mixing baking cooling done) that exercises guards, actions, an error_state fallback, and an always-transition that auto-advances cooling done once the oven has cooled — all triggered by a single TIMER_DONE signal.

Its graph, rendered with to_mermaid:

        stateDiagram-v2
    [*] --> idle
    idle --> mixing: START
    mixing --> baking: MIXED [ingredients_ready]
    mixing --> failed: error
    baking --> cooling: TIMER_DONE
    cooling --> done: always [oven_is_cool]
    
uv run python examples/bread.py
# ruff: noqa: ARG001
"""Runnable example: a bakery process modeled as a StateMachine.

Demonstrates:
- declaring config as a plain dict (on / always / entry / error_state)
- registering guards and actions by name
- an arbitrary, library-agnostic ``session`` object (here: a plain dataclass)
- the ``always``-transition auto-advance mechanism
"""

from __future__ import annotations

import asyncio
import logging
from dataclasses import dataclass, field

from statem import Context, Signal, StateMachine

logging.basicConfig(level=logging.INFO, format="%(message)s")


@dataclass
class BakingSession:
    oven_temp_c: int = 0
    ingredients_checked: bool = False
    log: list[str] = field(default_factory=list)


def check_ingredients(ctx: Context[BakingSession], signal: Signal) -> None:
    ctx.session.ingredients_checked = True
    ctx.session.log.append("ingredients checked")


def ingredients_ready(ctx: Context[BakingSession], signal: Signal) -> bool:
    return ctx.session.ingredients_checked


def preheat_oven(ctx: Context[BakingSession], signal: Signal) -> None:
    ctx.session.oven_temp_c = 180
    ctx.session.log.append("oven preheated to 180C")


def start_cooling(ctx: Context[BakingSession], signal: Signal) -> None:
    ctx.session.oven_temp_c = 25
    ctx.session.log.append("cake pulled, cooling started")


def oven_is_cool(ctx: Context[BakingSession], signal: Signal) -> bool:
    return ctx.session.oven_temp_c <= 30  # noqa: PLR2004


def plate_cake(ctx: Context[BakingSession], signal: Signal) -> None:
    ctx.session.log.append("cake plated")


CONFIG = {
    "idle": {
        "on": {"START": {"target": "mixing", "actions": ["check_ingredients"]}},
    },
    "mixing": {
        "on": {"MIXED": {"target": "baking", "guard": "ingredients_ready", "actions": ["preheat_oven"]}},
        "error_state": "failed",
    },
    "baking": {
        "on": {"TIMER_DONE": {"target": "cooling", "actions": ["start_cooling"]}},
    },
    "cooling": {
        "always": [{"target": "done", "guard": "oven_is_cool"}],
    },
    "done": {
        "entry": ["plate_cake"],
    },
    "failed": {},
}


async def main() -> None:
    machine = StateMachine.from_dict(
        CONFIG,
        action_dict={
            "check_ingredients": check_ingredients,
            "preheat_oven": preheat_oven,
            "start_cooling": start_cooling,
            "plate_cake": plate_cake,
        },
        guard_dict={
            "ingredients_ready": ingredients_ready,
            "oven_is_cool": oven_is_cool,
        },
    )

    session = BakingSession()
    state = "idle"
    for event in ("START", "MIXED", "TIMER_DONE"):
        state = await machine.run(
            run_id="bake-001",
            state_name=state,
            events=Signal(event=event),
            session=session,
        )

    print(f"final state: {state}")
    print("session log:")
    for line in session.log:
        print(f"  - {line}")


if __name__ == "__main__":
    asyncio.run(main())

Bank teller bot (examples/bank.py)

A richer example: a bank teller’s transaction-posting bot that exercises every hook in one conversation — on guard chains (a two-candidate check for supported vs. unsupported transaction types), a multi-hop always cascade (missing fields loop back to ask the teller, then re-resolve), and error_state recovering from a real exception raised inside an async action (a simulated ledger call), followed by a correction and retry.

Its graph shows both failure mechanisms side by side – POST’s guard chain rejecting over-the-limit amounts up front, and the separate error-labeled edge from error_state catching the ledger call’s exception:

        stateDiagram-v2
    [*] --> idle
    idle --> txn_identify: START_TXN
    txn_identify --> resolve_fields: IDENTIFY [txn_type_supported]
    txn_identify --> resolution_failed: IDENTIFY [txn_type_unsupported]
    resolve_fields --> posting: always [all_fields_resolved]
    resolve_fields --> resolution_failed: always [has_missing_fields]
    resolution_failed --> collect_data: PROVIDE_DATA
    collect_data --> resolve_fields: always
    posting --> posting_failed: POST [exceeds_daily_limit]
    posting --> posting_pass: POST [within_daily_limit]
    posting --> posting_failed: error
    posting_failed --> collect_data: CORRECT
    
uv run python examples/bank.py
# ruff: noqa: ARG001
"""Runnable example: a bank teller's transaction-posting bot modeled as a StateMachine.

A teller walks a wire transfer through: identifying the transaction, resolving its required
fields (looping back to collect missing data from the teller when something's absent), and
posting it to the ledger -- including a real posting failure that gets corrected and retried.

Demonstrates every hook the engine has:
- `on` guard chains: `txn_identify` tries two candidates in order (supported vs. unsupported type).
- `always` auto-advance, including a *chain* of several always-transitions firing within a single
  turn (`resolve_fields` -> `resolution_failed`, or `resolve_fields` -> `posting`).
- `error_state`: a real `RuntimeError` raised deep inside an async action (`call_ledger_api`) is
  caught by the engine and routed to `posting_failed` automatically.
- `entry` actions used as teller-facing prompts (`prompt_confirm_post`, `ask_teller_for_missing_fields`).
- Both sync and async guards/actions, and a state (`collect_data`) re-entered from two different
  places in the graph (`resolution_failed` and `posting_failed`), showing this is a real graph,
  not a linear pipeline.
"""

from __future__ import annotations

import asyncio
import uuid
from dataclasses import dataclass, field

from statem import Context, Signal, StateMachine

SUPPORTED_TXN_TYPES = {"TRANSFER", "WITHDRAWAL", "DEPOSIT"}
REQUIRED_FIELDS = ("txn_type", "from_account", "to_account", "amount")
DAILY_LIMIT = 1000.0
FROZEN_ACCOUNTS = {"ACC-2002"}


@dataclass
class BankSession:
    txn_id: str | None = None
    txn_type: str | None = None
    from_account: str | None = None
    to_account: str | None = None
    amount: float | None = None
    missing_fields: list[str] = field(default_factory=list)
    last_error: str | None = None
    receipt_id: str | None = None
    log: list[str] = field(default_factory=list)


def create_txn(ctx: Context[BankSession], signal: Signal) -> None:
    ctx.session.txn_id = f"TXN-{uuid.uuid4().hex[:8].upper()}"
    ctx.session.log.append(f"transaction {ctx.session.txn_id} opened")


def capture_identify_fields(ctx: Context[BankSession], signal: Signal) -> None:
    ctx.session.txn_type = signal.data["txn_type"]
    ctx.session.from_account = signal.data["from_account"]
    ctx.session.log.append(f"identified as {ctx.session.txn_type} from {ctx.session.from_account}")


def log_unsupported_type(ctx: Context[BankSession], signal: Signal) -> None:
    ctx.session.log.append(f"bot: sorry, {signal.data.get('txn_type')!r} is not a supported transaction type")


def run_field_resolution(ctx: Context[BankSession], signal: Signal) -> None:
    session = ctx.session
    session.missing_fields = [name for name in REQUIRED_FIELDS if getattr(session, name) is None]


def log_missing_fields(ctx: Context[BankSession], signal: Signal) -> None:
    ctx.session.log.append(f"resolution incomplete, missing: {', '.join(ctx.session.missing_fields)}")


def ask_teller_for_missing_fields(ctx: Context[BankSession], signal: Signal) -> None:
    if ctx.session.missing_fields:
        ctx.session.log.append(f"bot: please provide {', '.join(ctx.session.missing_fields)}")
    else:
        ctx.session.log.append("bot: please provide a supported transaction type and try again")


def apply_teller_data(ctx: Context[BankSession], signal: Signal) -> None:
    for key, value in signal.data.items():
        setattr(ctx.session, key, value)
    ctx.session.log.append(f"teller provided: {signal.data}")


def prompt_confirm_post(ctx: Context[BankSession], signal: Signal) -> None:
    session = ctx.session
    ctx.session.log.append(
        f"bot: ready to post {session.txn_type} of {session.amount} from "
        f"{session.from_account} to {session.to_account} -- confirm?"
    )


def reject_over_limit(ctx: Context[BankSession], signal: Signal) -> None:
    ctx.session.last_error = f"amount {ctx.session.amount} exceeds daily limit {DAILY_LIMIT}"
    ctx.session.log.append(f"bot: rejected -- {ctx.session.last_error}")


async def call_ledger_api(ctx: Context[BankSession], signal: Signal) -> None:
    await asyncio.sleep(0)  # simulated network hop
    if ctx.session.to_account in FROZEN_ACCOUNTS:
        ctx.session.last_error = f"ledger rejected posting to {ctx.session.to_account} (account frozen)"
        raise RuntimeError(ctx.session.last_error)
    ctx.session.receipt_id = f"RCPT-{uuid.uuid4().hex[:8].upper()}"


def notify_failure(ctx: Context[BankSession], signal: Signal) -> None:
    ctx.session.log.append(f"bot: posting failed -- {ctx.session.last_error}")


def print_receipt(ctx: Context[BankSession], signal: Signal) -> None:
    session = ctx.session
    ctx.session.log.append(
        f"bot: posted! receipt {session.receipt_id} -- "
        f"{session.txn_type} {session.amount} {session.from_account} -> {session.to_account}"
    )


def txn_type_supported(ctx: Context[BankSession], signal: Signal) -> bool:
    return signal.data.get("txn_type") in SUPPORTED_TXN_TYPES


def txn_type_unsupported(ctx: Context[BankSession], signal: Signal) -> bool:
    return not txn_type_supported(ctx, signal)


def all_fields_resolved(ctx: Context[BankSession], signal: Signal) -> bool:
    return not ctx.session.missing_fields


def has_missing_fields(ctx: Context[BankSession], signal: Signal) -> bool:
    return bool(ctx.session.missing_fields)


def exceeds_daily_limit(ctx: Context[BankSession], signal: Signal) -> bool:
    return ctx.session.amount is not None and ctx.session.amount > DAILY_LIMIT


async def within_daily_limit(ctx: Context[BankSession], signal: Signal) -> bool:
    await asyncio.sleep(0)  # simulated fraud/limits-service lookup
    return not exceeds_daily_limit(ctx, signal)


CONFIG = {
    "idle": {
        "on": {"START_TXN": {"target": "txn_identify", "actions": ["create_txn"]}},
    },
    "txn_identify": {
        "on": {
            "IDENTIFY": [
                {"target": "resolve_fields", "guard": "txn_type_supported", "actions": ["capture_identify_fields"]},
                {"target": "resolution_failed", "guard": "txn_type_unsupported", "actions": ["log_unsupported_type"]},
            ]
        },
    },
    "resolve_fields": {
        "entry": ["run_field_resolution"],
        "always": [
            {"target": "posting", "guard": "all_fields_resolved"},
            {"target": "resolution_failed", "guard": "has_missing_fields", "actions": ["log_missing_fields"]},
        ],
    },
    "resolution_failed": {
        "entry": ["ask_teller_for_missing_fields"],
        "on": {"PROVIDE_DATA": {"target": "collect_data", "actions": ["apply_teller_data"]}},
    },
    "collect_data": {
        "always": [{"target": "resolve_fields"}],
    },
    "posting": {
        "entry": ["prompt_confirm_post"],
        "on": {
            "POST": [
                {"target": "posting_failed", "guard": "exceeds_daily_limit", "actions": ["reject_over_limit"]},
                {"target": "posting_pass", "guard": "within_daily_limit", "actions": ["call_ledger_api"]},
            ]
        },
        "error_state": "posting_failed",
    },
    "posting_failed": {
        "entry": ["notify_failure"],
        "on": {"CORRECT": {"target": "collect_data", "actions": ["apply_teller_data"]}},
    },
    "posting_pass": {
        "entry": ["print_receipt"],
    },
}

ACTIONS = {
    "create_txn": create_txn,
    "capture_identify_fields": capture_identify_fields,
    "log_unsupported_type": log_unsupported_type,
    "run_field_resolution": run_field_resolution,
    "log_missing_fields": log_missing_fields,
    "ask_teller_for_missing_fields": ask_teller_for_missing_fields,
    "apply_teller_data": apply_teller_data,
    "prompt_confirm_post": prompt_confirm_post,
    "reject_over_limit": reject_over_limit,
    "call_ledger_api": call_ledger_api,
    "notify_failure": notify_failure,
    "print_receipt": print_receipt,
}

GUARDS = {
    "txn_type_supported": txn_type_supported,
    "txn_type_unsupported": txn_type_unsupported,
    "all_fields_resolved": all_fields_resolved,
    "has_missing_fields": has_missing_fields,
    "exceeds_daily_limit": exceeds_daily_limit,
    "within_daily_limit": within_daily_limit,
}

# (event, data, what the teller says this turn)
CONVERSATION = [
    ("START_TXN", {}, "I'd like to start a new transaction."),
    ("IDENTIFY", {"txn_type": "TRANSFER", "from_account": "ACC-1001"}, "It's a transfer from ACC-1001."),
    ("PROVIDE_DATA", {"amount": 500.0, "to_account": "ACC-2002"}, "Send $500 to ACC-2002."),
    ("POST", {}, "Go ahead and post it."),
    ("CORRECT", {"to_account": "ACC-3003"}, "Oh -- use ACC-3003 instead."),
    ("POST", {}, "Post it now."),
]


async def main() -> None:
    machine = StateMachine.from_dict(CONFIG, action_dict=ACTIONS, guard_dict=GUARDS)
    session = BankSession()
    state = "idle"
    cursor = 0

    for event, data, teller_says in CONVERSATION:
        print(f"\nTeller: {teller_says}")
        state = await machine.run(
            run_id="bank-demo-001",
            state_name=state,
            events=Signal(event=event, data=data),
            session=session,
        )
        for line in session.log[cursor:]:
            print(f"  {line}")
        cursor = len(session.log)
        print(f"  -> state: {state}")

    print(f"\nFinal state: {state}")
    print(f"Receipt: {session.receipt_id}")


if __name__ == "__main__":
    asyncio.run(main())

Pizza order bot (examples/pizza.py, streaming)

The largest example: an order-to-delivery pizza bot (payment, quality-check retries, delivery retries, CANCEL escape hatches at multiple points) that also demonstrates stream()run_streaming_demo() drives one signal through the machine and prints the raw AG-UI event sequence (STEP_STARTED, STATE_SNAPSHOT, ACTIVITY_SNAPSHOT, STATE_DELTA, STEP_FINISHED) as it happens, alongside two plain run() demos for comparison. Requires the agui extra (pip install statem[agui]) for the streaming portion only.

        stateDiagram-v2
    [*] --> order_received
    order_received --> payment_processing: CONFIRM
    payment_processing --> preparing: always [payment_approved]
    payment_processing --> payment_failed: always [payment_declined]
    payment_processing --> payment_failed: error
    payment_failed --> payment_processing: RETRY_PAYMENT
    payment_failed --> cancelled: CANCEL
    preparing --> baking: always [prep_complete]
    baking --> quality_check: BAKING_DONE
    quality_check --> ready: always [quality_passed]
    quality_check --> remaking: always [quality_failed]
    remaking --> preparing: always
    ready --> assigning_driver: DISPATCH
    assigning_driver --> out_for_delivery: always [driver_found]
    out_for_delivery --> delivered: DELIVERED
    out_for_delivery --> delivery_failed: FAILED
    out_for_delivery --> delivery_failed: error
    delivery_failed --> assigning_driver: RETRY_DELIVERY
    delivery_failed --> cancelled: CANCEL
    
uv run python examples/pizza.py
# ruff: noqa: ARG001
"""Pizza Order Tracker - a Domino's-style order lifecycle modelled as a StateMachine

Demonstrates every engine feature on a problem everyone instantly recognises:

State graph (12 states)
-----------------------

stateDiagram-v2
    [*] --> order_received
    order_received --> payment_processing: CONFIRM

    payment_processing --> preparing: payment_approved
    payment_processing --> payment_failed: payment_declined
    payment_processing --> payment_failed: error

    payment_failed --> payment_processing: RETRY_PAYMENT
    payment_failed --> cancelled: CANCEL

    preparing --> baking: prep_complete
    baking --> quality_check: BAKING_DONE

    quality_check --> ready: quality_passed
    quality_check --> remaking: quality_failed
    remaking --> preparing: retry

    ready --> assigning_driver: DISPATCH
    assigning_driver --> out_for_delivery: driver_found

    out_for_delivery --> delivered: DELIVERED
    out_for_delivery --> delivery_failed: FAILED
    out_for_delivery --> delivery_failed: error

    delivery_failed --> assigning_driver: RETRY_DELIVERY
    delivery_failed --> cancelled: CANCEL

    delivered --> [*]
    cancelled --> [*]
Interesting paths in the demo
-----------------------------

Run 1 (happy path with quality-fail loop):
  CONFIRM -> [payment ok] -> preparing -> baking
  BAKING_DONE -> quality_check [score 5 -> fail] -> remaking -> preparing -> baking
  BAKING_DONE -> quality_check [score 9 -> pass] -> ready
  DISPATCH -> [driver found] -> out_for_delivery
  DELIVERED -> delivered ✓

Run 2 (payment declined -> retry -> success):
  CONFIRM(cash) -> [payment declined] -> payment_failed
  RETRY_PAYMENT(card) -> [payment ok] -> preparing -> ... -> delivered ✓
"""

from __future__ import annotations

import asyncio
import sys
import uuid
from dataclasses import dataclass, field
from typing import TYPE_CHECKING

from statem import Context, Signal, StateMachine
from statem.diagram import to_mermaid

if TYPE_CHECKING:
    from ag_ui.core import BaseEvent

if sys.stdout.encoding.lower() != "utf-8":  # notes below use emoji; avoid UnicodeEncodeError on cp1252 consoles
    sys.stdout.reconfigure(encoding="utf-8")

QUALITY_PASS_THRESHOLD = 7


# --- Session ---
@dataclass
class PizzaSession:
    order_id: str | None = None
    customer_name: str = ""
    pizza: str = ""
    address: str = ""
    payment_method: str = ""  # "card" | "cash"
    payment_result: str | None = None  # "approved" | "declined"
    prep_status: str | None = None  # "done" | None
    quality_score: int = 0  # 0-10; ≥7 passes
    quality_attempts: int = 0
    driver_id: str | None = None
    delivery_result: str | None = None
    notes: list[str] = field(default_factory=list)


# --- Actions ---
def create_order(ctx: Context[PizzaSession], sig: Signal) -> str:
    ctx.session.order_id = f"ORD-{uuid.uuid4().hex[:6].upper()}"
    for k, v in sig.data.items():
        setattr(ctx.session, k, v)
    ctx.session.notes.append(f"order {ctx.session.order_id} created for {ctx.session.customer_name}")
    return ctx.session.order_id


def notify_received(ctx: Context[PizzaSession], sig: Signal) -> None:
    ctx.session.notes.append(
        f"SMS -> {ctx.session.customer_name}: we got your {ctx.session.pizza} order! "
        f"Delivering to {ctx.session.address}"
    )


async def charge_card(ctx: Context[PizzaSession], sig: Signal) -> str:
    await asyncio.sleep(0)  # simulated gateway round-trip
    if ctx.session.payment_method == "cash":
        ctx.session.payment_result = "declined"
        return "declined"
    ctx.session.payment_result = "approved"
    ctx.session.notes.append(f"gateway: {ctx.session.payment_method} charged ✓")
    return "approved"


def notify_payment_success(ctx: Context[PizzaSession], sig: Signal) -> None:
    ctx.session.notes.append("SMS - payment confirmed, kitchen notified")


def notify_payment_failure(ctx: Context[PizzaSession], sig: Signal) -> None:
    ctx.session.notes.append("SMS - payment failed, please retry or cancel")


def reset_payment(ctx: Context[PizzaSession], sig: Signal) -> None:
    ctx.session.payment_result = None
    new_method = sig.data.get("payment_method", ctx.session.payment_method)
    ctx.session.payment_method = new_method
    ctx.session.notes.append(f"payment method updated to {new_method}")


def start_preparation(ctx: Context[PizzaSession], sig: Signal) -> None:
    ctx.session.prep_status = "done"  # kitchen is fast in this restaurant
    ctx.session.notes.append(f"kitchen: started {ctx.session.pizza} (attempt {ctx.session.quality_attempts + 1})")


def log_prep_complete(ctx: Context[PizzaSession], sig: Signal) -> None:
    ctx.session.notes.append("kitchen: prep done, going into oven")


def start_baking(ctx: Context[PizzaSession], sig: Signal) -> None:
    ctx.session.notes.append("oven: baking started 🔥")


def run_quality_check(ctx: Context[PizzaSession], sig: Signal) -> int:
    ctx.session.quality_attempts += 1
    # First attempt: underbaked (score 5). Subsequent: perfect (score 9).
    ctx.session.quality_score = 9 if ctx.session.quality_attempts > 1 else 5
    ctx.session.notes.append(
        f"QC attempt {ctx.session.quality_attempts}: score {ctx.session.quality_score}/10 - "
        f"{'pass' if ctx.session.quality_score >= QUALITY_PASS_THRESHOLD else 'fail - remaking'}"
    )
    return ctx.session.quality_score


def log_quality_fail(ctx: Context[PizzaSession], sig: Signal) -> None:
    ctx.session.notes.append("QC: sending back to kitchen for remake")


def reset_for_remake(ctx: Context[PizzaSession], sig: Signal) -> None:
    ctx.session.prep_status = None  # will be set again by start_preparation
    ctx.session.quality_score = 0


def notify_ready(ctx: Context[PizzaSession], sig: Signal) -> None:
    ctx.session.notes.append(f"SMS - {ctx.session.customer_name}: your {ctx.session.pizza} is ready! 🍕")


async def find_driver(ctx: Context[PizzaSession], sig: Signal) -> str:
    await asyncio.sleep(0)  # simulated dispatch API
    ctx.session.driver_id = f"DRV-{uuid.uuid4().hex[:4].upper()}"
    ctx.session.notes.append(f"dispatch: driver {ctx.session.driver_id} assigned")
    return ctx.session.driver_id


def notify_dispatch(ctx: Context[PizzaSession], sig: Signal) -> None:
    ctx.session.notes.append(f"SMS - {ctx.session.customer_name}: {ctx.session.driver_id} is on the way! 🛵")


def complete_delivery(ctx: Context[PizzaSession], sig: Signal) -> None:
    ctx.session.delivery_result = "delivered"
    ctx.session.notes.append(f"✓ delivered to {ctx.session.address} - enjoy your {ctx.session.pizza}!")


def handle_delivery_failure(ctx: Context[PizzaSession], sig: Signal) -> None:
    ctx.session.delivery_result = "failed"
    ctx.session.driver_id = None
    ctx.session.notes.append(f"✗ delivery failed at {ctx.session.address} - notifying customer")


def reset_driver(ctx: Context[PizzaSession], sig: Signal) -> None:
    ctx.session.driver_id = None
    ctx.session.delivery_result = None
    ctx.session.notes.append("retrying driver assignment")


def notify_cancellation(ctx: Context[PizzaSession], sig: Signal) -> None:
    ctx.session.notes.append(f"SMS - {ctx.session.customer_name}: order cancelled, refund issued")


# --- Guards ---


def payment_approved(ctx: Context[PizzaSession], sig: Signal) -> bool:
    return ctx.session.payment_result == "approved"


def payment_declined(ctx: Context[PizzaSession], sig: Signal) -> bool:
    return ctx.session.payment_result == "declined"


def prep_complete(ctx: Context[PizzaSession], sig: Signal) -> bool:
    return ctx.session.prep_status == "done"


def quality_passed(ctx: Context[PizzaSession], sig: Signal) -> bool:
    return ctx.session.quality_score >= QUALITY_PASS_THRESHOLD


def quality_failed(ctx: Context[PizzaSession], sig: Signal) -> bool:
    return 0 < ctx.session.quality_score < QUALITY_PASS_THRESHOLD


def driver_found(ctx: Context[PizzaSession], sig: Signal) -> bool:
    return ctx.session.driver_id is not None


# --- Config ---

CONFIG: dict = {
    "order_received": {
        "on": {
            "CONFIRM": {
                "target": "payment_processing",
                "actions": ["create_order", "notify_received"],
            }
        }
    },
    "payment_processing": {
        "entry": ["charge_card"],
        "always": [
            {"target": "preparing", "guard": "payment_approved", "actions": ["notify_payment_success"]},
            {"target": "payment_failed", "guard": "payment_declined", "actions": ["notify_payment_failure"]},
        ],
        "error_state": "payment_failed",
    },
    "payment_failed": {
        "on": {
            "RETRY_PAYMENT": {"target": "payment_processing", "actions": ["reset_payment"]},
            "CANCEL": {"target": "cancelled", "actions": ["notify_cancellation"]},
        }
    },
    "preparing": {
        "entry": ["start_preparation"],
        "always": [
            {"target": "baking", "guard": "prep_complete", "actions": ["log_prep_complete"]},
        ],
    },
    "baking": {
        "entry": ["start_baking"],
        "on": {"BAKING_DONE": {"target": "quality_check"}},
    },
    "quality_check": {
        "entry": ["run_quality_check"],
        "always": [
            {"target": "ready", "guard": "quality_passed"},
            {"target": "remaking", "guard": "quality_failed", "actions": ["log_quality_fail"]},
        ],
    },
    "remaking": {
        "entry": ["reset_for_remake"],
        "always": [{"target": "preparing"}],
    },
    "ready": {
        "entry": ["notify_ready"],
        "on": {"DISPATCH": {"target": "assigning_driver"}},
    },
    "assigning_driver": {
        "entry": ["find_driver"],
        "always": [
            {"target": "out_for_delivery", "guard": "driver_found", "actions": ["notify_dispatch"]},
        ],
    },
    "out_for_delivery": {
        "on": {
            "DELIVERED": {"target": "delivered", "actions": ["complete_delivery"]},
            "FAILED": {"target": "delivery_failed", "actions": ["handle_delivery_failure"]},
        },
        "error_state": "delivery_failed",
    },
    "delivered": {},
    "delivery_failed": {
        "on": {
            "RETRY_DELIVERY": {"target": "assigning_driver", "actions": ["reset_driver"]},
            "CANCEL": {"target": "cancelled", "actions": ["notify_cancellation"]},
        }
    },
    "cancelled": {},
}

ACTIONS = {
    "create_order": create_order,
    "notify_received": notify_received,
    "charge_card": charge_card,
    "notify_payment_success": notify_payment_success,
    "notify_payment_failure": notify_payment_failure,
    "reset_payment": reset_payment,
    "start_preparation": start_preparation,
    "log_prep_complete": log_prep_complete,
    "start_baking": start_baking,
    "run_quality_check": run_quality_check,
    "log_quality_fail": log_quality_fail,
    "reset_for_remake": reset_for_remake,
    "notify_ready": notify_ready,
    "find_driver": find_driver,
    "notify_dispatch": notify_dispatch,
    "complete_delivery": complete_delivery,
    "handle_delivery_failure": handle_delivery_failure,
    "reset_driver": reset_driver,
    "notify_cancellation": notify_cancellation,
}

GUARDS = {
    "payment_approved": payment_approved,
    "payment_declined": payment_declined,
    "prep_complete": prep_complete,
    "quality_passed": quality_passed,
    "quality_failed": quality_failed,
    "driver_found": driver_found,
}

# --- Demo runs ---


async def run_happy_path_with_quality_fail() -> None:
    """Happy path: Card payment, quality fails once -> remake -> pass -> delivered."""
    print("\n" + "=" * 60)
    print("RUN 1 - Happy path (quality fail -> remake loop)")
    print("=" * 60)

    machine = StateMachine.from_dict(CONFIG, action_dict=ACTIONS, guard_dict=GUARDS)
    session = PizzaSession(customer_name="Alice")
    state = "order_received"
    cursor = 0

    signals = [
        (
            "CONFIRM",
            {"customer_name": "Alice", "pizza": "Pepperoni", "address": "42 Baker St", "payment_method": "card"},
        ),
        ("BAKING_DONE", {}),  # first bake -> quality fail -> remake
        ("BAKING_DONE", {}),  # second bake -> quality pass -> ready
        ("DISPATCH", {}),
        ("DELIVERED", {}),
    ]

    for event, data in signals:
        state = await machine.run(
            state_name=state,
            events=Signal(event, data),
            session=session,
        )
        new_notes = session.notes[cursor:]
        cursor = len(session.notes)
        print(f"\n[{event}] -> {state}")
        for note in new_notes:
            print(f"  {note}")

    print(f"\n Final: {state} | order {session.order_id} | receipt via driver {session.driver_id}")


async def run_payment_declined_retry() -> None:
    """Payment declined on cash, retried with card, then delivered."""
    print("\n" + "=" * 60)
    print("RUN 2 - Payment declined -> retry with card")
    print("=" * 60)

    machine = StateMachine.from_dict(CONFIG, action_dict=ACTIONS, guard_dict=GUARDS)
    session = PizzaSession(customer_name="Bob")
    state = "order_received"
    cursor = 0

    signals = [
        ("CONFIRM", {"customer_name": "Bob", "pizza": "Margherita", "address": "7 Elm Rd", "payment_method": "cash"}),
        ("RETRY_PAYMENT", {"payment_method": "card"}),
        ("BAKING_DONE", {}),
        ("BAKING_DONE", {}),  # quality fail -> remake path again
        ("DISPATCH", {}),
        ("DELIVERED", {}),
    ]

    for event, data in signals:
        state = await machine.run(
            state_name=state,
            events=Signal(event, data),
            session=session,
        )
        new_notes = session.notes[cursor:]
        cursor = len(session.notes)
        print(f"\n[{event}] -> {state}")
        for note in new_notes:
            print(f"  {note}")

    print(f"\n Final: {state} | order {session.order_id}")


async def run_streaming_demo() -> None:
    """Show raw AG-UI event stream for one signal turn."""
    print("\n" + "=" * 60)
    print("RUN 3 - AG-UI stream() events for CONFIRM signal")
    print("=" * 60)

    machine = StateMachine.from_dict(CONFIG, action_dict=ACTIONS, guard_dict=GUARDS)
    session = PizzaSession(customer_name="Carol")

    async for event in machine.stream(
        state_name="order_received",
        events=Signal(
            "CONFIRM",
            {"customer_name": "Carol", "pizza": "BBQ Chicken", "address": "99 Pine Ave", "payment_method": "card"},
        ),
        session=session,
        run_id="pizza-stream-demo",
        thread_id="thread-carol-001",
    ):
        print(f" {event.type.value:<28} {_event_summary(event)}")


def _event_summary(event: BaseEvent) -> str:
    from ag_ui.core import EventType  # noqa: PLC0415 -- lazy so plain `import statem` never needs ag-ui-protocol

    if event.type == EventType.STATE_SNAPSHOT:
        return f"snapshot={event.snapshot}"
    if event.type == EventType.STEP_STARTED:
        return f"step={event.step_name}"
    if event.type == EventType.STEP_FINISHED:
        return f"step={event.step_name}"
    if event.type == EventType.ACTIVITY_SNAPSHOT:
        return event.content
    if event.type == EventType.STATE_DELTA:
        return str([f"{p['op']} {p['path']}={p['value']}" for p in event.delta])
    return ""


async def main() -> None:
    print(to_mermaid(StateMachine.from_dict(CONFIG, action_dict=ACTIONS, guard_dict=GUARDS), initial="order_received"))
    await run_happy_path_with_quality_fail()
    await run_payment_declined_retry()
    await run_streaming_demo()


if __name__ == "__main__":
    asyncio.run(main())