Eject to your stack
Take one agent machine from a local runAgent to an Express route to a Cloudflare Durable Object with zero machine changes. Only the executors are host-specific.
Alpha:
@statelyai/agent2.0 is in alpha. APIs can change between releases; pin an exact version. Feedback: github.com/statelyai/agent.
An agent machine is a portable blueprint. The machine decides, the host executes. The machine never names a provider, a server, or a runtime, so the same definition runs unchanged locally, behind an HTTP route, or on the edge. The only host-specific part is the executors, the functions that call a model.
This page takes one machine and drops it into three hosts without editing a line of it.
The machine (authored once)
A draft-and-review announcer: write a draft, settle idle for a human, publish on approval. Nothing here is host-aware.
import { z } from "zod";
import { setupAgent } from "@statelyai/agent";
const agentSetup = setupAgent({
context: z.object({ topic: z.string(), draft: z.string().nullable() }),
input: z.object({ topic: z.string() }),
output: z.object({ published: z.boolean(), draft: z.string() }),
events: { APPROVE: z.object({}), REJECT: z.object({ reason: z.string() }) },
requests: {
writeDraft: {
schemas: { input: z.object({ topic: z.string() }), output: z.string() },
model: "writer",
system: "You write short internal announcements, two or three sentences.",
prompt: ({ input }) => `Write a short announcement about: ${input.topic}`,
},
},
states: {
reviewing: { context: { draft: z.string() } },
published: { context: { draft: z.string() } },
},
});
export const announceMachine = agentSetup.createMachine({
id: "announce",
context: ({ input }) => ({ topic: input.topic, draft: null }),
initial: "drafting",
states: {
drafting: {
invoke: {
src: "writeDraft",
input: ({ context }) => ({ topic: context.topic }),
onDone: ({ output }) => ({ target: "reviewing", context: { draft: output } }),
},
},
// No invoke: the machine rests here until a human sends APPROVE / REJECT.
reviewing: {
on: {
APPROVE: { target: "published" },
REJECT: ({ context, event }) => ({
target: "drafting",
context: { topic: `${context.topic}\nRevision requested: ${event.reason}` },
}),
},
},
published: {
type: "final",
output: ({ context }) => ({ published: true, draft: context.draft ?? "" }),
},
},
});Two seams: controlled and uncontrolled
Every host binds executors through one of two seams. Same machine, same executors either way.
- Controlled (
runAgent): the library owns the loop. It drives the machine to a settle point (done|idle|error), handles idle pauses, and descends into child machines. Best when a host wants a request/response boundary and snapshot persistence. - Uncontrolled (
provideExecutors+createActor): you bind executors onto the machine and run it as a plain XState actor. No run loop, no idle settling; the machine drives itself and you observe it. Best when the host already owns an actor lifecycle (a React component, a Durable Object).
import { createAiSdkExecutors, defineModels } from "@statelyai/agent/ai-sdk";
import { openai } from "@ai-sdk/openai";
const models = defineModels({ writer: openai("gpt-5.4-mini") });
const executors = createAiSdkExecutors({ models });Host 1: local (runAgent)
Controlled. Run it end to end from a script, handling the idle pause inline.
import { runAgent } from "@statelyai/agent";
const result = await runAgent(announceMachine, {
input: { topic: "the new deploy pipeline" },
executors,
});
if (result.status === "idle") {
// Draft is ready; resume with the human's decision.
const resumed = await runAgent(announceMachine, {
snapshot: result.snapshot,
event: { type: "APPROVE" },
executors,
});
if (resumed.status === "done") console.log(resumed.output);
}The uncontrolled equivalent runs the same machine as a plain actor:
import { createActor } from "xstate";
import { provideExecutors } from "@statelyai/agent";
const actor = createActor(provideExecutors(announceMachine, executors), {
input: { topic: "the new deploy pipeline" },
});
actor.subscribe((snapshot) => {
if (snapshot.value === "reviewing") actor.send({ type: "APPROVE" });
if (snapshot.status === "done") console.log(snapshot.output);
});
actor.start();Host 2: Express route
Controlled. An idle settle plus a persisted snapshot is human-in-the-loop over HTTP: the process holds no live actor between requests, so any worker can pick up the resume. The machine is imported and untouched; only the executors and the persistence are host code.
import express from "express";
import {
getAcceptedEvents,
persistSnapshot,
runAgent,
} from "@statelyai/agent";
import type { Snapshot } from "xstate";
import { announceMachine } from "./announce-machine.js";
const snapshots = new Map<string, Snapshot<unknown>>();
const app = express();
app.use(express.json());
// Start a run. Settles idle (draft ready) or done.
app.post("/agent", async (req, res) => {
const result = await runAgent(announceMachine, {
input: { topic: String(req.body?.topic ?? "the new deploy pipeline") },
executors,
});
if (result.status === "idle") {
const id = crypto.randomUUID();
snapshots.set(id, persistSnapshot(result.snapshot));
return res.status(202).json({
id,
draft: result.snapshot.context.draft,
acceptedEvents: getAcceptedEvents(result.snapshot).map((e) => e.type),
});
}
if (result.status === "done") return res.json({ output: result.output });
return res.status(500).json({ status: result.status });
});
// Resume a persisted run with a human event.
app.post("/agent/:id/resume", async (req, res) => {
const snapshot = snapshots.get(String(req.params.id));
if (!snapshot) return res.status(404).json({ error: "unknown run id" });
const result = await runAgent(announceMachine, {
snapshot,
event: req.body?.event,
executors,
});
if (result.status === "done") return res.json({ output: result.output });
return res.status(202).json({ draft: result.snapshot.context.draft });
});
app.listen(3000);The full reference, including revision loops and typed state meta, is examples/express-host. The same shape ports directly to Hono, Next.js, and TanStack Start.
Host 3: Cloudflare Durable Object
Uncontrolled. A Durable Object already owns a long-lived actor and its own persistence, so bind executors and run a plain createActor. The persisted snapshot lives in Durable Object state, so the machine survives hibernation and resumes exactly where it left off. Model resolution is injected so the class stays provider-agnostic.
import { Agent, type Connection } from "agents";
import { createActor, type Actor, type Snapshot } from "xstate";
import { parseAgentEvent, provideExecutors } from "@statelyai/agent";
import { createAiSdkExecutors, defineModels } from "@statelyai/agent/ai-sdk";
import { createWorkersAI } from "workers-ai-provider";
import { announceMachine } from "./announce-machine.js";
interface Env {
AI: Ai;
}
interface State {
snapshot?: Snapshot<unknown>;
}
export class AnnounceAgent extends Agent<Env, State> {
initialState: State = {};
#actor: Actor<typeof announceMachine> | undefined;
onStart() {
const workersai = createWorkersAI({ binding: this.env.AI });
const models = defineModels({ writer: workersai("@cf/meta/llama-3.1-8b-instruct") });
const machine = provideExecutors(announceMachine, createAiSdkExecutors({ models }));
// Restore from the persisted snapshot if the DO was evicted mid-run.
this.#actor = createActor(machine, {
snapshot: this.state.snapshot,
input: { topic: "the new deploy pipeline" },
});
this.#actor.subscribe((snapshot) => {
// Durable persistence on every transition.
this.setState({ snapshot: this.#actor!.getPersistedSnapshot() });
this.broadcast(JSON.stringify({ type: "state", value: snapshot.value }));
});
this.#actor.start();
}
onMessage(connection: Connection, message: string) {
// Client messages are machine events; parseAgentEvent validates them
// against the snapshot's accepted events before they reach the actor.
const snapshot = this.#actor?.getSnapshot();
if (!snapshot) return;
this.#actor?.send(parseAgentEvent(snapshot, JSON.parse(message)));
}
}The shipped cloudflare-agent-host is a drop-in Durable Object class (with the wrangler.toml and subclass wiring spelled out); cloudflare-workers-ai-host drives the lower-level step path against a raw Workers AI binding for one-turn-per-request Workers.
What stayed the same
Across all three hosts, announceMachine was imported, never edited. The states, transitions, guards, and requests are identical bytes. Only three things changed per host:
- how executors are built (AI SDK locally, injected
resolveModelon the edge); - the seam (
runAgentfor controlled,provideExecutors+createActorfor uncontrolled); - where the snapshot is persisted (in-memory map, Durable Object state, or nothing for a straight-through local run).
That is the portability payoff: author the logic once, run it anywhere.
Related
- Hosts and executors: the executor contract, the shipped adapters, and writing your own.
- Agent patterns: copy-paste machines for ReAct, RAG, supervisor, and more, each ejectable this same way.
- Human in the loop: the idle-first pause and snapshot resume shown in the Express host.
- The step path: the lower-level per-model-call loop for durable hosts like the Workers AI example.