Stop modeling order, payment, and incident lifecycles with switch statements and ad-hoc FSMs. Statekit is a typed statechart library for Go: hierarchical states, guards, actions, delayed and parallel transitions, with built-in visualization and lint.
machine, _ := statekit.NewMachine[Order]("checkout").
WithInitial("cart").
State("cart").On("CHECKOUT").Target("processing").Done().
State("processing").On("PAID").Target("shipped").Done().
State("shipped").Final().Done().
Build()
interp := statekit.NewInterpreter(machine)
defer interp.Close()
interp.Start()
interp.Send(statekit.Event{Type: "CHECKOUT"})A working machine in 10 lines. Hierarchy and parallel states scale from there. statekit viz renders any machine to ASCII, Mermaid, an interactive HTML simulator, or a TUI.
Statekit fits two adjacent shapes that look similar from outside:
- Backend domain workflows — order lifecycles, payment sagas, incident management, KYC. The kind of state most teams scatter across
switch event.Type { ... }and accumulate bugs around partial failure, retry, and idempotency. Seeexamples/stripe_webhookfor a webhook-saga template with idempotency, retry budget, and the outbox pattern. - Deterministic AI agent runtime — RAG pipelines, tool-call workflows, multi-step agents with human-in-the-loop. Statecharts give you reproducibility (event sourcing replays the run without re-calling the model), bounded blast radius (OnError routing), and audit-grade observability (prompt + token snapshots). See
examples/llm_agentand theai+aipluginpackages.
Both jobs benefit from the same primitives — typed context, hierarchy, lint, visualization, snapshots — so one library, one mental model.
If you're using one of the incumbent Go FSM libraries and have hit a ceiling, this is the migration path:
- Need persistence that doesn't choke on unexported fields? Statekit's
Snapshotround-trips throughencoding/jsonandencoding/gob(see snapshot serialization tests). - Need recovery after a service or context error without ending up in a stuck-transition limbo? Statekit's
OnErrorroutes cleanly; the interpreter accepts events again immediately (see cancellation recovery tests). - Need hierarchy (compound, parallel, history)? First-class. Lint catches structural bugs the flat-FSM libraries can't even check for.
- Need typed everything?
[C any]context threads through actions, guards, and services — nointerface{}casts at handler time. - Need a Mermaid / ASCII / interactive HTML diagram?
statekit vizships them.
Step-by-step migration guides:
- Type-safe over typed —
[C any]context, typed events, typed actions and guards. Nointerface{}casts at action time. - Statecharts, not just FSMs — compound, parallel, history, and delayed transitions handle the workflows that flat FSM libraries can't model without manual bookkeeping.
- Visualization as a feature — every machine renders to multiple formats from one source of truth. XState v5 export for Stately Studio round-trip editing.
- Static analysis —
lint.Lint(machine)catches unreachable states, dead ends, non-determinism, missing OnError on Invoke, and more — at build time. - Determinism for tests — inject a
FakeClockto make timer-driven behavior reproducible. Notime.Sleepflakes.
- Hierarchical states with event bubbling
- History states (shallow and deep)
- Delayed transitions and parallel/orthogonal regions
- Eventless (
Always) transitions,Raiseinternal events, and stateTags - Wildcard (
*) event handlers, internal transitions, andChooseconditional actions - Guards, actions, entry/exit hooks
- Reflection DSL — define machines with struct tags
- Build-time validation
- Visualization: ASCII, Mermaid, interactive HTML, TUI
- Static analysis (
lint) - Snapshot / Restore
- Plugin system with lifecycle hooks
- Testing utilities (
statetest) - HTTP integration, OpenTelemetry tracing, Prometheus metrics, Kubernetes health probes
- Code generation from Native JSON
Advanced (Tier 2 — see stability tiers)
These ship in v1.0 but reserve room to iterate within v1.x:
- Actor model —
Spawn, supervision strategies (Escalate / Recover / Restart / Stop) - Persistent interpreter — event-sourced state, snapshot-on-final, configurable strategies
- Distributed execution —
StreamLockinterface (Redis / etcd / PostgreSQL) +DistributedInterpreter - Machine composition —
InvokeMachinefor typed child-machine composition - MCP integration —
mcp.NewServerfor AI-assisted authoring;mcp.ExposeInterpreterto drive a running machine from an agent - AI plugins —
aiplugin.TokenCounter,aiplugin.PromptRecorderfor LLM observability and replay-based debugging
go get go.klarlabs.de/statekitRequires Go 1.25 or later.
package main
import (
"fmt"
"go.klarlabs.de/statekit"
)
func main() {
machine, _ := statekit.NewMachine[struct{}]("traffic").
WithInitial("green").
State("green").On("TIMER").Target("yellow").Done().
State("yellow").On("TIMER").Target("red").Done().
State("red").On("TIMER").Target("green").Done().
Build()
interp := statekit.NewInterpreter(machine)
defer interp.Close()
interp.Start()
fmt.Println(interp.State().Value) // "green"
interp.Send(statekit.Event{Type: "TIMER"})
fmt.Println(interp.State().Value) // "yellow"
}Nested states with event bubbling and proper entry/exit ordering:
machine, _ := statekit.NewMachine[struct{}]("editor").
WithInitial("editing").
State("editing").
WithInitial("idle").
On("SAVE").Target("saved").End(). // Parent handles SAVE
State("idle").On("TYPE").Target("dirty").End().End().
State("dirty").On("CLEAR").Target("idle").End().End().
Done().
State("saved").Final().Done().
Build()
interp := statekit.NewInterpreter(machine)
defer interp.Close()
interp.Start()
fmt.Println(interp.State().Value) // "idle"
fmt.Println(interp.Matches("editing")) // true
interp.Send(statekit.Event{Type: "SAVE"}) // Bubbles to parent
fmt.Println(interp.State().Value) // "saved"Remember and restore previous states:
machine, _ := statekit.NewMachine[struct{}]("player").
WithInitial("playing").
State("playing").
WithInitial("track1").
On("PAUSE").Target("paused").End().
History("hist").Shallow().Default("track1").End().
State("track1").On("NEXT").Target("track2").End().End().
State("track2").On("NEXT").Target("track3").End().End().
State("track3").End().
Done().
State("paused").
On("PLAY").Target("hist"). // Resume last track
Done().
Build()- Shallow history — Remembers immediate child state
- Deep history — Remembers exact leaf state
Timer-based automatic transitions:
machine, _ := statekit.NewMachine[struct{}]("loading").
WithInitial("loading").
State("loading").
After(5*time.Second).Target("timeout").
On("LOADED").Target("ready").
Done().
State("timeout").Done().
State("ready").Done().
Build()
interp := statekit.NewInterpreter(machine)
defer interp.Close() // Always clean up timers
interp.Start()
// Timer starts automatically, canceled if LOADED receivedMultiple regions active simultaneously:
machine, _ := statekit.NewMachine[struct{}]("editor").
WithInitial("active").
State("active").Parallel().
Region("bold").WithInitial("off").
State("off").On("TOGGLE_BOLD").Target("on").EndState().
State("on").On("TOGGLE_BOLD").Target("off").EndState().
EndRegion().
Region("italic").WithInitial("off").
State("off").On("TOGGLE_ITALIC").Target("on").EndState().
State("on").On("TOGGLE_ITALIC").Target("off").EndState().
EndRegion().
Done().
Build()
interp := statekit.NewInterpreter(machine)
defer interp.Close()
interp.Start()
interp.Send(statekit.Event{Type: "TOGGLE_BOLD"})
// bold: on, italic: off (independent regions)Always transitions fire automatically on state entry (and after every
transition), choosing the first whose guard passes — ideal for conditional
routing without an explicit event. Raise enqueues an internal event that
is processed in the same step, before control returns and before any external
event. Tags categorize states for lightweight querying via HasTag.
machine, _ := statekit.NewMachine[Ctx]("checkout").
WithInitial("validating").
WithGuard("ok", func(c Ctx, e statekit.Event) bool { return c.Valid }).
State("validating").
Tags("busy").
Always().Target("approved").Guard("ok").End().
Always().Target("rejected").End(). // guardless fallback
Done().
State("approved").
On("SHIP").Target("shipping").Raise("NOTIFY").End(). // raise internal event
Done().
State("shipping").
On("NOTIFY").Target("done").End(). // handled in the same step
Done().
State("rejected").Final().Done().
State("done").Final().Done().
Build()
interp := statekit.NewInterpreter(machine)
interp.Start()
// validating → approved|rejected resolved automatically via Always
fmt.Println(interp.HasTag("busy")) // true while in a tagged active stateKey behaviors:
Alwaystransitions are evaluated in declaration order; the first enabled wins. A target is required (build-time validated) to prevent infinite loops.- A macrostep settles all eventless transitions and drains raised events before
Start()/Send()returns (bounded to guard against always-true cycles). HasTagmatches the active leaf, its ancestors, and active parallel-region leaves.- All three round-trip through the Native JSON and XState v5 exporters (
always,tags, andxstate.raiseaction descriptors).
Wildcard * catches any event not handled by a specific transition (exact
matches always win; it bubbles like any handler). Internal() runs a
transition's actions without exiting or re-entering the state — no entry/exit
hooks, no state change — in contrast to an external self-transition. Choose
is a conditional-action combinator: it runs the first branch whose guard passes.
machine, _ := statekit.NewMachine[Ctx]("ops").
WithInitial("running").
WithAction("audit", statekit.Choose(
statekit.ChooseBranch[Ctx]{When: isAdmin, Then: logAdmin},
statekit.ChooseBranch[Ctx]{Then: logUser}, // else
)).
State("running").
On("TICK").Internal().Do("audit").End(). // no exit/entry, no state change
On("*").Target("unknown").End(). // catch-all fallback
On("STOP").Target("stopped").End(). // exact match beats "*"
Done().
State("unknown").Final().Done().
State("stopped").Final().Done().
Build()Key behaviors:
- Wildcard
*is lowest priority within a state and honors guards; child handlers still take priority over ancestors. - Internal transitions accept an empty target (or the owning state); build-time validated.
Chooseis a plainAction[C]— register it and reference it anywhere an action is used; a branch with a nilWhenis the else.- Wildcard and internal transitions round-trip through both exporters (
on["*"],internal: true).
Define machines using struct tags:
type OrderMachine struct {
statekit.MachineDef `id:"order" initial:"pending"`
Pending statekit.StateNode `on:"SUBMIT->processing:hasItems"`
Processing statekit.StateNode `on:"COMPLETE->shipped"`
Shipped statekit.FinalNode
}
type OrderContext struct {
Items []string
}
registry := statekit.NewActionRegistry[OrderContext]().
WithGuard("hasItems", func(ctx OrderContext, e statekit.Event) bool {
return len(ctx.Items) > 0
})
machine, _ := statekit.FromStruct[OrderMachine, OrderContext](registry)Conditional transitions and side effects:
type Context struct{ Count int }
machine, _ := statekit.NewMachine[Context]("counter").
WithInitial("idle").
WithContext(Context{Count: 0}).
WithAction("increment", func(ctx *Context, e statekit.Event) {
ctx.Count++
}).
WithGuard("hasCount", func(ctx Context, e statekit.Event) bool {
return ctx.Count > 0
}).
State("idle").
OnEntry("increment").
On("NEXT").Target("done").Guard("hasCount").
Done().
State("done").Final().Done().
Build()Statekit provides a native viz command to visualize state machines from Go source code or JSON.
Try the Live Visualizer to paste your JSON and interact with it. The landing page has the project overview.
# Interactive HTML simulation
statekit viz --go-package ./examples/order_workflow --format html -o machine.html
# Mermaid diagram
statekit viz --go-package ./examples/order_workflow --format mermaidIt supports multiple output formats:
- HTML: Interactive simulator with Cytoscape graph.
- Mermaid: Markdown-friendly state diagrams.
- ASCII: Terminal box diagrams.
- TUI: Interactive terminal UI.
To export JSON programmatically:
import "go.klarlabs.de/statekit/export"
exporter := export.NewNativeExporter(machine)
jsonStr, _ := exporter.ExportJSONIndent("", " ")
fmt.Println(jsonStr)Statekit includes a built-in Model Context Protocol server for AI-assisted state machine development. Create, manage, and visualize machines directly from Claude Code or any MCP host.
# Add to your MCP configuration
go install go.klarlabs.de/statekit/cmd/statekit-mcp@latest{
"mcpServers": {
"statekit": {
"command": "statekit-mcp"
}
}
}Available tools:
| Tool | Description |
|---|---|
create_machine |
Create a machine from a Native JSON definition |
list_machines |
List all running machine instances |
get_state |
Get current state, done status, and state path |
send_event |
Send an event to trigger a transition |
get_context |
Get the machine's context data |
visualize_machine |
Get visualization data with interactive MCP App |
validate_machine |
Validate a definition using lint rules |
export_machine |
Export as JSON, Mermaid, or ASCII |
Inverting the loop: if you already have a typed *statekit.Interpreter[C] running in your service, mcp.ExposeInterpreter registers <prefix>.send_event, <prefix>.get_state, <prefix>.get_context, and <prefix>.matches so an MCP-speaking agent can drive your machine from outside.
The visualize_machine tool includes an interactive Vue.js + Cytoscape.js visualizer that MCP Apps hosts render inline — with dark mode, transition animations, and a full state history log. All JS dependencies are bundled inline for CSP-compatible rendering in any MCP host.
| Package | Description |
|---|---|
ai |
LLM-driven transitions — Drive + Tool schema (Tier 2) |
aiplugin |
AI plugins — TokenCounter, PromptRecorder, TransitionBudget (Tier 2) |
mcp |
MCP server + ExposeInterpreter for agent-driven workflows (Tier 2) |
statetest |
Testing utilities: assertions, recorders, helpers |
debug |
Runtime inspection and state graph analysis |
metrics |
Prometheus metrics for monitoring |
health |
Kubernetes liveness/readiness probes |
lint |
Static analysis for detecting structural issues |
export |
Native + XState v5 JSON exporters |
generate |
Go code generation from Native JSON |
http |
HTTP handlers and middleware |
otel |
OpenTelemetry tracing |
See the examples directory:
| Example | Description |
|---|---|
| traffic_light | Simple FSM with cyclic transitions |
| pedestrian_light | Hierarchical states with event bubbling |
| order_workflow | Reflection DSL for business workflows |
| incident_lifecycle | Complex IT incident management |
| llm_agent | Deterministic RAG pipeline with HITL approval |
See the full API documentation on pkg.go.dev.
// Machine construction
statekit.NewMachine[C](id string) *MachineBuilder[C]
statekit.FromStruct[M, C](registry) (*MachineConfig[C], error)
// Runtime
statekit.NewInterpreter[C](machine) *Interpreter[C]
.Start() // Enter initial state
.Send(event Event) // Process event
.Stop() // Cancel timers, cleanup
.State() State[C] // Current state
.Matches(id StateID) bool // Check state or ancestor
.Done() bool // In final state?- Go-first Execution — Explicit, deterministic, testable
- Statecharts over FSMs — Hierarchy, parallel, history enable complex behavior without manual bookkeeping
- Visualization as a Feature — Multiple renderers + XState v5 export for Stately Studio round-trip
- Determinism for tests — Inject
FakeClockto remove timer flakes - Stable core, experimental edge — Tier-1 surface follows semver; Tier-2 features (actor, persistent, distributed, MCP, AI) reserve room to iterate
The docs index organizes everything by Diataxis category (tutorials, how-to, reference, explanation). Quick links:
- Getting Started
- Choosing an API — builder vs reflection DSL vs codegen
- Hierarchical States
- Guards & Actions
- XState Migration
- Migration from looplab/fsm
- Migration from qmuntal/stateless
- API Stability Tiers
- Reflection DSL
- Testing
- Observability
- Static Analysis (Lint)
- API Reference
Contributions are welcome! Please read our Contributing Guide.
MIT © Felix Geelhaar