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.
This page covers authoring an agent machine as a JSON or YAML config instead of TypeScript.
Describe the machine as a config and pass it to setupAgent.fromConfig(...), imported from the same module as setupAgent. It produces the same runnable XState machine that setupAgent(...) builds by hand. Configs support states, choice routing, guard-expression transitions, emitted progress events, text requests, decisions, and human waits. Only the authoring format changes.
import { setupAgent } from "@statelyai/agent";
const { machine, schemas } = setupAgent.fromConfig(config, {
compileSchema: ajvCompileSchema,
});Bring your own
compileSchema. The library bundles no JSON Schema engine.fromConfig(...)does not run without acompileSchemathat you supply. Use Ajv,@cfworker/json-schema, or anything that returns a Standard Schema. See Schema compilation.
fromConfig(...) returns two values:
machine: the runnable XState machine, ready forcreateAgentRuntime(...).schemas: the compiledAgentSchemaPack. It exposescontext,events,emitted,input,output, andmetaas Standard Schema validators, for host-side validation and tooling.
A config is portable. You can generate it from a model, store it in a database row, or edit it in a visual builder. It runs the same way as a hand-authored machine.
Config validation
The package ships a JSON Schema for validating and editing configs:
import workflowSchema from "@statelyai/agent/agent-workflow.json";Point an editor or form generator at this schema. It describes the whole config surface: schemas including events and emitted, plus context, requests, actors, initial, and states, down to choice states, transitions, invokes, and actions.
To validate a config in code, call validateAgentConfig(config) from @statelyai/agent/validate. It checks the value against the same shipped schema and returns { valid, errors }. This subpath uses ajv as an optional peer dependency, so install ajv to use it.
import { validateAgentConfig } from "@statelyai/agent/validate";
const { valid, errors } = validateAgentConfig(config);
// errors: { severity: 'error', path: '/states/idle/invoke', message, keyword }[]Validation covers shape only. It does not check that a named guard, action, or actor is implemented, which fromConfig(...) does when it lowers the config.
Division of labor
Run validateAgentConfig first. fromConfig(...) does not validate the config against the JSON Schema, so a malformed config reaches lowering unchecked.
fromConfig(...) throws plain Errors for lowering problems such as an unknown
state target, a missing guard/action implementation, an onDone on an
agent.decide invoke, a reserved '.' in a key, or a malformed choice branch.
Reserved key prefix
The agent. prefix belongs to the library. setupAgent throws when a requests or actors key starts with agent., including the builtin names agent.generateText, agent.streamText, and agent.decide. Rename the key without the prefix. To override a builtin deliberately, do it on the created machine with machine.provide({ actors }).
Expressions
The config is data, not code. Every value is either a JSON literal or a whole-string "{{ }}" expression. An expression is a dot path resolved against input, context, and event. For example, "{{ context.ticket }}" reads context.ticket. The resolver walks the path and returns the value. There is no code evaluation and no eval. An expression can only read values, so a config produced by a model, a database, or a visual editor cannot do anything a hand-authored machine could not do.
Two fields are exempt from template evaluation. State, invoke, and transition meta, and a request's toolChoice, are passed through verbatim. A {{ }} string in those fields stays a literal string.
Example: a support ticket config
The config below drives the examples on the rest of this page. The model triages a ticket as escalate or reply, drafts a reply, then waits for a human to approve or reject. The equivalent JSON ships at examples/json-agent/workflow.json and is run by examples/json-agent/index.ts. Model IDs are illustrative. Substitute your provider's current models. The config is shown 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-6-luna
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-6-luna
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.result.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 for context, events, input, output, and each request's input and output. Those schemas need a runtime validator, and the library bundles no JSON Schema engine.
compileSchematakes a JSON Schema object and a name, and returns a Standard Schema validator.fromConfig(...)calls it once per schema.- Use Ajv,
@cfworker/json-schema, or any compiler that returns a Standard Schema.
Expose the source JSON Schema on the validator you return, as jsonSchema: { input: () => schema }. lintAgentMachine reads it to run the checks that need the declared shape, unserializable-context and final-without-output. Those checks are skipped when it is absent.
With 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}`,
})),
},
jsonSchema: { input: () => jsonSchema },
},
};
};
const { machine } = setupAgent.fromConfig(config, { compileSchema: ajvCompileSchema });Request tools and reasoning
A requests entry can also declare these fields.
| Field | Value | Effect |
|---|---|---|
tools | Map of tool name to { description?, inputSchema?, outputSchema? }, where the schemas are JSON Schemas | Passes the tools to the model alongside the request. |
toolChoice | "auto", "none", "required", or { type: "tool", name } | Controls whether the model must call a tool. |
includeReasoning | true | Opts into the reasoning field of the provider's structured output. |
requests:
lookupOrder:
model: openai/gpt-6-luna
prompt: "{{ context.ticket }}"
includeReasoning: true
toolChoice: auto
tools:
searchOrders:
description: Find orders by customer email.
inputSchema:
type: object
properties: { email: { type: string } }
required: [email]
outputSchema:
type: object
properties: { orderIds: { type: array, items: { type: string } } }
input: { type: object, properties: { ticket: { type: string } } }
output: { type: object, properties: { summary: { type: string } } }Named guards and actions
A config cannot carry functions. Anything beyond a truthy dot-path guard uses a named reference plus a host implementation.
- A string
guardwithout{{ }}is a named guard reference. Implement it infromConfig(config, { guards }). The implementation is called with{ context, event }and returns a boolean. - An action
{ type, params }is a named action reference. Implement it infromConfig(config, { actions }). The implementation is called with the template-resolvedparamsas its only argument. To pass context or event data, use{{ }}templates onparams.
awaitingApproval:
on:
APPROVE:
target: resolved
guard: isReady
actions: { type: notify, params: { ticket: "{{ context.ticket }}" } }const { machine } = setupAgent.fromConfig(config, {
compileSchema: ajvCompileSchema,
guards: { isReady: ({ context }) => Boolean(context.reply) },
actions: { notify: (params) => console.log("approved", params.ticket) },
});A named reference with no implementation is a build-time error. The guard or action is never silently dropped.
Host-provided actors
The top-level actors key declares actor sources that the config does not implement, such as a database read or a queue round trip. Each entry takes an optional input and output JSON Schema and a description. States invoke the source by its key.
actors:
fetchOrder:
description: Load the order referenced by the ticket.
input:
type: object
properties: { ticket: { type: string } }
output:
type: object
properties: { orderId: { type: string } }fromConfig(...) lowers each entry to a placeholder that throws when invoked, naming the key. Supply the implementation on the returned machine.
const { machine } = setupAgent.fromConfig(config, { compileSchema: ajvCompileSchema });
const bound = machine.provide({
actors: { fetchOrder: createAsyncLogic({ run: ({ input }) => loadOrder(input.ticket) }) },
});Use actors for work the host executes directly. Use requests for model calls, which the executors bind automatically.
Running configs
A machine built by fromConfig(...) runs through createAgentRuntime like any
other agent machine. Pass executors and observation handlers when creating the
runtime; pass machine input when starting it.
const result = await runToQuiescence(
createAgentRuntime(machine, {
executors: { decide, generateText },
on: { TRIAGED: (event) => console.log(event.route) },
}),
{ input: { ticket: "My download link 404s." } },
);Executors return these shapes.
| Executor | Returns | Notes |
|---|---|---|
decide | { event: { type, ...payload } } | The chosen machine event. A bare { type } throws a descriptive error. |
generateText, streamText | { result, messages? } | result matches the request's output schema; messages are the provider's response messages. |
A run settles in one of two ways.
{ status: 'done', output }: the machine reached a final state.{ status: 'idle', snapshot }: the machine paused at an idle state. Persistsnapshot, then resume when the event arrives.
result = await runToQuiescence(createAgentRuntime(machine, { executors }), {
snapshot,
event: { type: "APPROVE" },
});Note: A run settles idle only after its mailbox is empty and no child, request, or in-process timer remains active.
isAgentIdle(snapshot)tells you whether that settled snapshot is waiting for an external event or interaction.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. That is either a request's typed input, or anagent.decideinline input carrying its ownmodel,prompt, andallowedEvents.
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-6.1-sol
prompt: "{{ context.ticket }}"
allowedEvents: [ESCALATE, REPLY]
onError:
target: escalated
on:
ESCALATE: { target: escalated }
REPLY: { target: drafting }The chosen event is delivered automatically. When the decision actor resolves, it sends the event to the invoking actor, 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 applies, and it fires when retries are exhausted.
Choice states and emitted events
Use type: choice with a choice array for routing states that do no work. This matches type: 'choice' authoring in TypeScript.
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
createAgentRuntime(machine, { on: { SCORED: handler } }), the same as for
hand-authored machines that use enq.emit(...).
Machine descriptions for models
describeMachine(machine, { snapshot?, source?, helpers? }) renders any machine, from a config or from TypeScript, as plain text a model can read. Use it as the prompt for a model that proposes an improved machine, or as extra context for a decider.
import { describeMachine } from "@statelyai/agent";
const prompt = `Propose an improved machine for this goal.\n\n${describeMachine(machine)}`;The machine's description is its goal, so it leads the text. An excerpt:
Machine ticket
Goal: Answer the customer's ticket so they never write back.
States (initial: triaging):
triaging - Decide how to handle the ticket
on ESCALATE → escalated
on REPLY → drafting; sets tone - Draft a reply
invoke agent.decide
error → escalated
checking (choice)
choice if context.approved → sent
choice if isRetryable → drafting
choice else → escalated
sent (final) [tags: done]
escalated (final) - A human owns it now
Events:
REPLY - Answer the ticket directly
tone: "formal" | "casual" - Reply tone- States: one line each, indented by depth, with the type, tags,
meta.interaction.label, and description. Passsnapshotto mark the active states with*. - Transitions: one line each under the state they leave:
on,always,after, invokedoneanderror, andchoicebranches. Each line shows the guard (if …), the target, and the context keys it sets (sets tone). - Events: each event schema's description and fields. Event descriptions come from the schema's JSON Schema, so a
compileSchemamust implement the Standard Schema JSON Schema extension for a config's event descriptions to appear. - Config machines: read from the lowered config, so every target, guard, and context patch is known.
- Transition functions: parsed from their source, following every return path: ternaries,
&&and||, early returns, and local constants. A function shows each target it can return, marked(conditional)when it can also stay, return no transition, or pick among several. It shows(stays)when no path names a target, and?for a target its own source does not spell out: a variable's value, or a transition a helper returns. - Source: pass
source: trueto print each transition function under its line, so one read shows both where it goes and what it computes. Entry and exit actions print too. - Helpers: pass the functions transitions call by name, such as
helpers: { withinLimit }, to print each one's source in aHelperssection. A guard passed into a machine factory is then readable from the same text.
The output is deterministic. The same machine always produces the same text.
Limits of the data form
The data form is narrower than TypeScript authoring.
- Expressions are dot paths such as
{{ context.foo.bar }}, not arbitrary JavaScript. - Guard expressions are truthy-only. They support no
!=, no comparisons, and no boolean operators. For anything else, use a named guard. See Named guards and actions. - Function-valued fields cannot appear in JSON. This includes
allowedEventsandinputwhen written as functions. metaandtoolChoiceare not template-evaluated.
For comparisons, or for function-valued fields that have no named-reference equivalent, author the machine in TypeScript with setupAgent(...) and Zod or any other Standard Schema library.
Related
- Testing and verification: check a lowered machine before it runs, with no API key and no model call. Lint it with
lintAgentMachine, simulate a scripted playthrough, or enumerate its decision branches, all from a script CI can run. - Generating machines with an LLM: the generate → validate → lint → simulate pipeline.
- Agent machines: the TypeScript authoring form a config lowers to.
- Decisions:
agent.decidefrom a config. - examples/json-agent: a hand-written config, run.