Skip to content
statelyaiPublic

About

Create state-machine-powered LLM agents using XState

Topics

Resources

Code of conduct

Contributing

Stars

484 stars

Watchers

15 watching

Forks

Latest commit

 

History

901 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Stately Agent

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.

Environment and policy:

  • The environment is code you own and can verify; the policy is something a model can write inside it.
  • The environment holds the rules and invariants as TypeScript states and guards, fixed and checked with canReach.
  • The policy is a JSON machine a model can author and improve from traces, gated before it runs and promoted only on a measured gain. See Environment and policy.

Stately Agent 2 is in alpha. APIs may change before the stable release.

Documentation · Examples · XState

Four starting points

  • 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 while loop into a machine: your SDK calls, tools, and retry code become the executors; the machine replaces only the control flow. See Migrating from a hand-rolled 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.
  • Let an agent improve its own logic. Keep the rules in a fixed TypeScript environment and let a model rewrite the JSON policy inside it, gated by verifyAgentConfig and promoted only when held-out scenarios improve. See Environment and policy and the improve-machine skill.

Install

pnpm add @statelyai/agent@alpha xstate@6.0.0-alpha.69 zod

XState is pinned while its experimental durable restoration API evolves.

For the optional Vercel AI SDK executor:

pnpm add ai@^7 @ai-sdk/openai@^4

For the optional raw OpenAI SDK executor (@statelyai/agent/openai):

pnpm add openai

For the optional TanStack AI executor (@statelyai/agent/tanstack-ai):

pnpm add @tanstack/ai @tanstack/ai-openai

Classification, grading, relevance, and yes/no checks are judgments: call the AI SDK's experimental_decide with @ai-sdk/typesafe-ai (pnpm add ai @ai-sdk/typesafe-ai) from an ordinary actor; see Judgments with the AI SDK.

Requirements:

  • Node 22.18 or newer, and XState 6.0.0-alpha.69.
  • The package is ESM-first. CommonJS builds are published too, so require() works.
  • 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.

Quick start

This agent reviews refund requests. The model may propose an automatic refund, but the state machine owns the $100 limit.

import { runAgent, setupAgent } from "@statelyai/agent";
import { z } from "zod";

const agentSetup = setupAgent({
  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,
  },
  // An executor is a plain function, so this run needs no API key and no
  // provider package.
  executors: { decide: async () => ({ event: { type: "AUTO_REFUND" } }) },
});

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.

To call a real model, leave the states unchanged. Declare the model once on the setup by adding models to the setupAgent call above, and hand the machine to the AI SDK adapter:

import { openai } from "@ai-sdk/openai";
import { createAiSdkExecutors } from "@statelyai/agent/ai-sdk";

// In the setupAgent({ ... }) call above:
//   models: { fast: openai("gpt-6-luna") },

const liveResult = await runAgent(refundMachine, {
  input: { request: "I was charged twice for the same order.", amount: 75 },
  executors: createAiSdkExecutors(refundMachine),
});

createAiSdkExecutors(refundMachine) reads the models declared in setupAgent({ models }), which also type the machine's model refs. createAiSdkExecutors({ models }) still works when the host owns the map. Executors are always explicit, and core does not import the AI SDK. See Models and providers and Hosts and executors.

For a machine with several requests, the executor routes on request.name, or MockLanguageModelV3 from ai/test stands in for the provider behind createAiSdkExecutors. See Evals.

A host that needs the transition loop itself (a durable object, events pushed mid-run, a custom checkpoint policy) uses createAgentRuntime directly. See Write your own host loop.

Architecture

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
Loading

The machine never talks to a model directly, so swapping createAiSdkExecutors for 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.

The core concepts (machines owning control flow, bounded model decisions, typed requests, host-run executors, and native XState snapshots) are in the documentation overview.

Examples

See all examples.

Related

About

Create state-machine-powered LLM agents using XState

Topics

Resources

Code of conduct

Contributing

Stars

484 stars

Watchers

15 watching

Forks

Releases

Packages

Used by

Contributors

Languages