Make invalid agent actions impossible.
Agent logic as state machines: deterministic, inspectable, resumable, runs anywhere. The machine owns control flow; the model only ever picks a legal event. Testing, inspection, and visualization fall out for free.
Stately Agent adds model requests and decisions to XState:
- The machine defines what the agent can do.
- Your application chooses the model, runs the requests, and stores the state.
- The model proposes an event; the machine decides whether it is allowed and what happens next.
Stately Agent 2 is in alpha. APIs may change before the stable release.
Documentation · Examples · XState
- Author a new agent. Build a machine from states, decisions, and typed requests; run it locally with
runAgent, test it with no API key, then use it in any framework or runtime with zero machine changes. See the Quickstart and Use in any stack. - Retrofit an existing agent. Turn a
whileloop into a machine: your SDK calls, tools, and retry code become the executors; the machine replaces only the control flow. See Migrating from a loop. - Copy a known pattern. ReAct, reflection, plan-and-execute, RAG, supervisor, and more, each a single runnable file you lift in 60 seconds. See Agent patterns.
pnpm add @statelyai/agent@alpha xstate@alpha zod ai@^6 @ai-sdk/openai@^3Requirements:
- Node 22.18 or newer, and XState v6 alpha.25 or newer.
- The package is ESM-first. CommonJS builds are published too, so
require()works. - Provider packages must match your
aimajor:@ai-sdk/openai@^3pairs withai@^6. A bare@ai-sdk/openairesolves to@latest, which can mismatch theaipeer.
This agent reviews refund requests. The model may propose an automatic refund, but the state machine owns the $100 limit.
import { openai } from "@ai-sdk/openai";
import { runAgent, setupAgent } from "@statelyai/agent";
import { createAiSdkExecutors, defineModels } from "@statelyai/agent/ai-sdk";
import { z } from "zod";
const models = defineModels({
fast: openai("gpt-5.4-mini"),
});
const agentSetup = setupAgent({
models,
context: z.object({
request: z.string(),
amount: z.number(),
}),
input: z.object({
request: z.string(),
amount: z.number(),
}),
output: z.object({
outcome: z.enum(["refunded", "review"]),
}),
events: {
AUTO_REFUND: {},
REVIEW: z.object({ reason: z.string() }),
},
});
const refundMachine = agentSetup.createMachine({
context: ({ input }) => input,
initial: "deciding",
states: {
deciding: {
invoke: {
src: "agent.decide",
input: ({ context }) => ({
model: "fast",
system: "Choose AUTO_REFUND for eligible requests. Otherwise choose REVIEW.",
prompt: `${context.request}\nAmount: $${context.amount}`,
allowedEvents: ["AUTO_REFUND", "REVIEW"],
}),
},
on: {
AUTO_REFUND: ({ context }) => (context.amount <= 100 ? { target: "refunded" } : undefined),
REVIEW: { target: "review" },
},
},
refunded: {
type: "final",
output: () => ({ outcome: "refunded" }),
},
review: {
type: "final",
output: () => ({ outcome: "review" }),
},
},
});
const result = await runAgent(refundMachine, {
input: {
request: "I was charged twice for the same order.",
amount: 75,
},
executors: createAiSdkExecutors({ models }),
});
if (result.status === "done") {
console.log(result.output);
}When the machine reaches refunded, the result is:
{ outcome: 'refunded' }
The model chooses between the events allowed in deciding. The AUTO_REFUND transition only works when the amount is at most $100. If the model chooses it for a larger amount, the guard rejects the choice and the decision is tried again.
createScriptedExecutors plays back canned answers through the same executor contract, so the machine above runs end to end before a model is involved. No API key needed:
import { createScriptedExecutors } from "@statelyai/agent";
const result = await runAgent(refundMachine, {
input: { request: "I was charged twice for the same order.", amount: 75 },
executors: createScriptedExecutors({ decisions: [{ type: "AUTO_REFUND" }] }),
});Swap in createAiSdkExecutors({ models }) when you want a real model. The scripted set is what your tests keep using.
flowchart LR
M["Agent machine<br/>states · guards · requests"] -->|request| R["runAgent"]
R -->|executor call| E["Host executors<br/>generateText · streamText · decide"]
E -->|API call| L["Model"]
L -->|result| E
E -->|result| R
R -->|event or output| M
The machine never talks to a model directly, so swapping createAiSdkExecutors for createScriptedExecutors (or your own functions) changes nothing about the agent.
The example above has one model decision and two final outcomes. Real machines add approval states, retries, parallel work, child agents, and long-running waits without changing how control flow is represented.
- Machines own control flow. States, events, transitions, and guards define what can happen.
- Models make bounded decisions.
agent.decideasks a model to choose one of the events accepted by the current state. - Requests are typed. Inputs, outputs, context, and events use Standard Schema. Zod works out of the box.
- Your code runs the model.
runAgentaccepts executor functions. The example uses the Vercel AI SDK adapter, but the machine does not depend on a provider. - Snapshots can be stored. An agent can stop for human input, save its XState snapshot, and resume later in another process.
- Runs export verified replay entries. Every
runAgentresult carries a JSON-safeAgentLogEntry[]with event identity, timestamp, machine version, and state/effect hashes; pass it toreplayorverifyReplaywithout repeating model or tool calls. - Machines can be checked without model calls. Lint their structure, simulate scripted decisions, and explore paths without an API key.
- Agents are XState machines. Guards, actors, parallel states, inspection, testing, and visualization work as usual.
- Twenty Questions shows a model choosing legal events in a loop.
- Go Fish pits a model against a human while the machine enforces hidden-information game rules.
- Human in the loop pauses, stores a snapshot, and resumes after review.
- Ticket triage returns structured data from a model request.
- JSON agent runs a machine defined as data.
See all examples.