Machines as data
Author an agent machine as a JSON or YAML config and lower it into the same runnable XState machine that setupAgent builds in TypeScript.
Alpha:
@statelyai/agent2.0 is in alpha. APIs can change between releases; pin an exact version. Feedback: github.com/statelyai/agent.
An agent machine can be pure data. Describe it as a JSON or YAML config and hand it to setupAgent.fromConfig(...) (same import as setupAgent). It produces the same runnable XState machine setupAgent(...) builds by hand: states, choice routing, guard-expression transitions, emitted progress events, text requests, decisions, and idle steps. Only the authoring format changes.
import { setupAgent } from "@statelyai/agent";
const machine = setupAgent.fromConfig(config, { compileSchema });A config is portable: generate it from a model, store it in a database row, or edit it in a visual builder, and it runs exactly like a hand-authored machine.
Validating a config
The package ships a JSON Schema for validating and editing configs:
import workflowSchema from "@statelyai/agent/agent-workflow.json";Point an editor, form generator, or validation step at it to catch a malformed config before fromConfig(...). It describes the whole config surface: schemas (including events and emitted), context, requests, actors, initial, and states, down to choice states, transitions, invokes, and actions.
Example: a support ticket config
This config drives the examples below: the model triages a ticket (escalate or reply), drafts a reply, then waits for a human to approve or reject. It is a real .json file at examples/json-agent/workflow.json, run by examples/json-agent/index.ts. As YAML for readability:
id: support-ticket-json
schemas:
input:
type: object
properties: { ticket: { type: string } }
required: [ticket]
context:
type: object
properties:
ticket: { type: string }
reply: { type: string }
resolution: { type: string }
required: [ticket]
events:
ESCALATE:
type: object
properties: { reason: { type: string } }
required: [reason]
REPLY: { type: object, properties: {} }
APPROVE: { type: object, properties: {} }
REJECT: { type: object, properties: {} }
output:
type: object
properties:
resolution: { type: string }
reply: { type: string }
required: [resolution]
emitted:
TRIAGED:
type: object
properties: { route: { type: string } }
required: [route]
context:
ticket: "{{ input.ticket }}"
requests:
draftReply:
model: openai/gpt-5.4-mini
system: "Draft a short, courteous support reply to the customer's ticket."
prompt: "{{ context.ticket }}"
input:
type: object
properties: { ticket: { type: string } }
required: [ticket]
output:
type: object
properties: { reply: { type: string } }
required: [reply]
initial: triaging
states:
triaging:
invoke:
id: triageDecision
src: agent.decide
input:
model: openai/gpt-5.4-mini
system: "Decide whether this ticket needs human escalation or a drafted reply."
prompt: "{{ context.ticket }}"
allowedEvents: [ESCALATE, REPLY]
onError:
target: resolved
assign: { resolution: escalated }
on:
ESCALATE:
target: resolved
assign: { resolution: escalated }
actions: { emit: { type: TRIAGED, route: escalated } }
REPLY:
target: drafting
actions: { emit: { type: TRIAGED, route: reply } }
drafting:
invoke:
id: draft
src: draftReply
input: { ticket: "{{ context.ticket }}" }
onDone:
target: awaitingApproval
assign: { reply: "{{ event.output.reply }}" }
awaitingApproval:
on:
APPROVE: { target: resolved, assign: { resolution: replied } }
REJECT: { target: resolved, assign: { resolution: escalated } }
resolved:
type: final
output:
resolution: "{{ context.resolution }}"
reply: "{{ context.reply }}"Schema compilation
The fromConfig call requires a compileSchema option. A config carries JSON Schemas (context, events, input, output, and each request's input/output) that need a runtime validator, and the library bundles no JSON Schema engine. Supply a compileSchema that takes a JSON Schema object plus a name and returns a Standard Schema validator; fromConfig(...) calls it once per schema. Use Ajv, @cfworker/json-schema, or any compiler that returns Standard Schema. Ajv:
import Ajv from "ajv";
import { setupAgent, type SchemaCompiler, type StandardSchemaV1 } from "@statelyai/agent";
const ajv = new Ajv({ strict: false });
const ajvCompileSchema: SchemaCompiler = (jsonSchema, name): StandardSchemaV1 => {
const validate = ajv.compile(jsonSchema);
return {
"~standard": {
version: 1,
vendor: "ajv",
validate: (value) =>
validate(value)
? { value }
: {
issues: (validate.errors ?? []).map((e) => ({
message: `${name}${e.instancePath} ${e.message}`,
})),
},
// Expose the source JSON Schema so lint's serializability checks
// (`unserializable-context`, `final-without-output`) can read the shape.
jsonSchema: { input: () => jsonSchema },
},
};
};
const machine = setupAgent.fromConfig(config, { compileSchema: ajvCompileSchema });Running a config
A lowered machine runs through runAgent(...) like any other agent machine. Pass the machine input, the host executors, and on handlers for emitted events:
const result = await runAgent(machine, {
input: { ticket: "My download link 404s." },
executors: { decide, generateText },
on: { TRIAGED: (event) => console.log(event.route) },
});Executor return shapes:
decidereturns{ event: { type, ...payload } }, the chosen machine event. A bare{ type }throws a descriptive error.generateText/streamTextreturn{ output }, the structured result matching the request'soutputschema.
A run settles one of two ways:
{ status: 'done', output }: reached a final state.{ status: 'idle', snapshot }: paused at an idle state. Persistsnapshot, then resume when the event arrives:
result = await runAgent(machine, { snapshot, event: { type: "APPROVE" }, executors });Note: An idle state is any state with no
invoke: nothing runs, so the machine waits for an external event viaon. A state with aninvokeis doing work (a decision, a text request, or anagent.userInputpause).
Note: Two
prompt-shaped fields sit at different layers. Arequestsentry'spromptis the text sent to the model. Aninvoke'sinputis the data passed to the invoked source: a request's typed input, or anagent.decideinline input carrying its ownmodel/prompt/allowedEvents.
Expressions
The config is data, not code. Any value is a JSON literal or a whole-string "{{ }}" expression: a dot path resolved against input, context, and event. For example, "{{ context.ticket }}" reads context.ticket. No code, no eval: the resolver walks the path and returns the value. Because an expression can only read, a config from a model, database, or visual editor cannot do anything a hand-authored machine could not.
Decisions from JSON
A decision works from a config: invoke src: agent.decide with allowedEvents.
states:
choosing:
invoke:
src: agent.decide
input:
model: openai/gpt-5.4
prompt: "{{ context.ticket }}"
allowedEvents: [ESCALATE, REPLY]
onError:
target: escalated
on:
ESCALATE: { target: escalated }
REPLY: { target: drafting }Delivery of the chosen event is automatic: the decision actor sends it to the invoking actor when it resolves, in both TypeScript and JSON. Handle the chosen event with the state's on transitions. A decision has no output of its own, so an onDone on an agent.decide invoke can never fire: fromConfig(...) rejects it as a config error. Only onError (retries exhausted) applies.
Choice states and emitted events
Use type: choice plus choice: for pure routing states, matching TypeScript type: 'choice' authoring:
states:
checking:
type: choice
choice:
- guard: "{{ context.score }}"
target: passed
- target: failed
passed:
entry: { emit: { type: SCORED, value: "{{ context.score }}" } }
type: final
failed:
entry: { emit: { type: SCORED, value: "{{ context.score }}" } }
type: finalDeclare emitted event payloads under schemas.emitted. Hosts receive them through runAgent(..., { on: { SCORED: handler } }), same as hand-authored machines using enq.emit(...).
Limits of the data form
The data form is narrower than TypeScript authoring, by design:
- Expressions are simple dot paths (
{{ context.foo.bar }}), not arbitrary JavaScript. - Guard expressions are truthy-only: no
!=, no comparisons, no boolean operators. - Function-valued fields (
allowedEvents,guard,inputas functions) cannot appear in JSON.
For comparisons, computed guards, or function-valued fields, author in TypeScript with setupAgent(...) and Zod (or any Standard Schema).
Verifying a generated machine
A machine built from data can be checked before it runs: no API key, no model call. Lint it with lintAgentMachine, simulate a scripted playthrough, or enumerate its decision branches, all in a plain script that CI can run. See Verify.