Stately Agent: XState State Machines as the Control Flow for LLM Agents
Create state-machine-powered LLM agents using XState
At a glance
- What is it?
- Stately Agent puts an XState machine in charge of an LLM agent and lets the model pick only among legal events. It is an alpha-stage TypeScript package that fits teams already comfortable with statecharts, and it is not a drop-in replacement for a free-form agent loop.
- Who is it for?
- Adopt Stately Agent if your agent already has a shape you can draw as a statechart and you want the model to choose among a fixed set of events, with guards deciding what actually happens. Do not adopt it if your workflow is genuinely open-ended and you cannot enumerate the states ahead of time, or if you need a stable API today: the package is at 2.0.0-alpha.22 and the README says APIs may change before the stable release.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 4 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on September 15, 2026, and from our analysis. They are not legal advice.
DEEP OPEN-SOURCE ANALYSIS
The problem: a model that can do anything, including the wrong thing
Most agent frameworks give the model a toolbox and hope. The loop runs, the model picks a tool, the tool runs, and the loop continues until some stop condition fires. The failure mode is familiar: the model calls a refund API for an amount it was never authorized to refund, or retries a step that already succeeded. Guardrails are usually bolted on afterwards as prompt instructions, which are suggestions rather than constraints.
Stately Agent inverts that. The README states the machine owns control flow and the model only ever picks a legal event. The pitch line is blunt: make invalid agent actions impossible. That is a claim about where the authority sits, not about model quality. If the machine has no transition for an event in the current state, the event does not happen, regardless of what the model returns.
The audience follows from that. This is for engineers who can already describe their agent as a statechart, or who are willing to. It is less useful if your agent's behaviour genuinely cannot be enumerated in advance, because you would be authoring states you do not yet know you need.
How a model request becomes a constrained transition
The mechanism has three parts, and the README's architecture diagram lays them out: an agent machine containing states, guards, and requests; a runAgent host; and host executors that call the model. The machine never talks to a model directly. Requests flow out from the machine to runAgent, runAgent calls an executor, the executor calls the provider, and the result comes back as an event or an output that the machine consumes.
Concretely, a state invokes a source named agent.decide with an input object carrying a model reference, a system prompt, a prompt, and an allowedEvents array. The model's job is narrow: choose one of those allowed events. The machine then applies its own transition logic. In the refund example, the AUTO_REFUND transition is guarded by a function that returns a target only when context.amount is at most 100. If the model picks AUTO_REFUND for a larger amount, the guard rejects it and, per the README, the decision is tried again.
That retry-on-rejection detail is worth pausing on. It means a model that repeatedly proposes an illegal event can burn requests. The documentation does not state a retry ceiling in the material available here, so the practical bound on that loop is something to check in docs/hosts.md before you ship.
The separation also means the executor is just a function. The README notes that because of this, a run with a hand-written executor needs no API key and no provider package, which is what makes the machine testable in isolation.
Setup: the packages, the versions, and the peer trap
Install is a single command against the alpha channel:
pnpm add @statelyai/agent@alpha xstate@alpha zod
Optional executors are separate. For the Vercel AI SDK executor, the README gives pnpm add ai@^7 @ai-sdk/openai@^4. For the raw OpenAI SDK executor at the @statelyai/agent/openai entry point, pnpm add openai.
Two version constraints are stated explicitly and both matter. Node 22.18 or newer, and XState v6 alpha.46 or newer. The package is ESM-first, though CommonJS builds are published so require() works.
The peer dependency rule is the one that will bite people. Provider packages must match your ai major: @ai-sdk/openai@^4 pairs with ai@^7. A bare @ai-sdk/openai resolves to @latest, which can mismatch the ai peer. If you copy an install snippet from elsewhere without the version pin, you can end up with a resolution that the package authors have already flagged as a known mismatch.
Machine authoring goes through setupAgent, which takes zod schemas for context, input, and output, plus an events map. Events are declared either as an empty object for an event with no payload, or as a zod schema for one that carries data. The README shows REVIEW as z.object({ reason: z.string() }), which is how the typed request payload reaches the machine. setupAgent({ models }) accepts a defineModels registry, which types the machine's model refs and supplies the AI SDK executor by default; explicit executors override it.
Swapping executors is the whole testing story
The clearest design decision in this package is that the model boundary is a plain function. The README's quickstart runs the refund machine with an executor that ignores its input entirely and returns { event: { type: "AUTO_REFUND" } }. No API key, no provider package, no network. The machine still runs, the guard still evaluates, and the final output is { outcome: 'refunded' }.
For machines with several requests, createScriptedExecutors holds ordered answers keyed by request name, which the README points to under docs/evals.md. That is a scripted-response harness rather than a mock framework, and the distinction is real: you are supplying the sequence of decisions, not stubbing a transport.
Swapping to a live model is a change to the runAgent call only. createAiSdkExecutors({ models: { fast: openai("gpt-5.4-mini") } }) takes the place of the hand-written executors object, and the machine is untouched. The README makes the point directly: swapping createAiSdkExecutors for createScriptedExecutors, or for your own functions, changes nothing about the agent.
Core does not import the AI SDK, which is why the AI SDK is an optional peer rather than a hard dependency. That is a deliberate boundary and it is the main reason the package can be tested without credentials.
Where the state machine stops paying for itself
The constraint is the feature, and it is also the cost. You must be able to name your states, your events, and your guards before you write the agent. For a refund reviewer or a ticket triage flow, that is straightforward. For an agent whose useful behaviour emerges from open-ended exploration, authoring the statechart first is either impossible or a fiction you maintain alongside the real logic.
The second limitation is maturity. Stately Agent 2 is in alpha, and the README says so plainly: APIs may change before the stable release. The release history in the repository metadata bears that out, with 2.0.0-alpha.22 published in late August 2026 and alpha releases arriving days apart. If you need a frozen surface for a production system, this is not it yet.
The third is the retry behaviour noted earlier. A guard rejection causes the decision to be tried again. In a machine where the model has a poor prior on which event is legal, that can mean repeated model calls for a single state. The material here does not specify a retry limit, so treat the loop's cost as unverified until you read the hosts documentation.
Finally, the package is TypeScript and ESM-first. CommonJS builds exist, but a team on an older Node runtime is blocked at the stated Node 22.18 floor.
How this differs from a graph-based agent framework
LangGraph is the obvious comparison point, and the difference is in what the graph is allowed to do. A LangGraph-style agent is typically a directed graph of nodes and edges where the model's output selects the next edge, and conditional edges are ordinary code. The graph is a control structure, but it is usually a linear or near-linear one, and the runtime does not enforce a formal notion of which events are legal in which state.
XState brings hierarchical and parallel states, guarded transitions, and a native snapshot format. That last one has practical consequences the README calls out through its examples: the snapshot-migration example uses XState's native version and migrate contract, and the file-snapshot-store example persists native XState snapshots in application code. Resumability is therefore not a feature the agent package implements; it is inherited from the statechart runtime.
The trade is expressiveness against ceremony. A LangGraph node can do anything. An XState state can only respond to events it declares, which is the point, but it also means every new capability requires touching the machine definition. The README's own framing is that the machine defines what the agent can do, and your application chooses the model, runs the requests, and stores the state. That division of labour is the whole product.
Maintenance, licence, and what to check before adopting
The licence is MIT, which permits commercial use, modification, and redistribution with the copyright notice preserved. That is the standard permissive arrangement and it does not impose copyleft obligations on your application. This is a description of the licence text, not legal advice; if the distinction matters to your organisation, have counsel read the LICENSE file rather than this paragraph.
Upgrade cost is the real consideration. With alpha releases landing every few days, the practical approach is to pin the exact version rather than track @alpha, and to read each release note before bumping. The package also sits on XState v6 alpha, so an XState bump is a second moving part you inherit. Two alpha dependencies in the same upgrade path is a meaningful maintenance tax, and it is the strongest argument for waiting if your timeline is measured in quarters rather than weeks.
On the positive side, the repository is not archived and was pushed to recently, and the package ships a documented set of runnable patterns: ReAct, reflection, plan-and-execute, RAG, and supervisor, each described as a single runnable file. If you are evaluating whether the statechart model suits your problem, lifting one of those files and running it against a scripted executor is the cheapest way to find out. The examples directory also covers tool calling in the AI SDK's native message format and a machine defined as data, which are the two cases most likely to reveal whether the abstraction fits your existing code.
Editorial conclusion
Adopt Stately Agent if your agent already has a shape you can draw as a statechart and you want the model to choose among a fixed set of events, with guards deciding what actually happens. Do not adopt it if your workflow is genuinely open-ended and you cannot enumerate the states ahead of time, or if you need a stable API today: the package is at 2.0.0-alpha.22 and the README says APIs may change before the stable release. Before committing, verify that your Node and XState versions satisfy the stated minimums (Node 22.18 or newer, XState v6 alpha.46 or newer) and that your provider packages match the ai major you install, because a bare @ai-sdk/openai resolves to @latest and can mismatch the ai peer.
Community notes