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.
Machines as data
An agent machine can be pure data. Describe it as a JSON or YAML config and hand it to setupAgent.fromConfig(...). The lowering produces the same kind of runnable XState machine that setupAgent(...) builds by hand: states, choice routing, transitions with guard expressions, emitted progress events, text requests, decisions, and human/idle steps included. 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.
The published JSON Schema
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 it reaches 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.
Running example: a support ticket
The rest of this page uses one config: the model triages a ticket (escalate or reply), drafts a reply when replying, 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. Here it is 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
A config carries JSON Schemas (context, events, input, output, and each request's input/output), and those need a runtime validator. The library bundles no JSON Schema engine, so it does not guess how strictly to validate: you bring the engine. compileSchema takes a JSON Schema object and a name and returns a Standard Schema validator; fromConfig(...) calls it once per schema.
fromConfig requires a compileSchema option. Core intentionally ships no
JSON Schema engine; bring Ajv, @cfworker/json-schema, or another compiler that
returns Standard Schema. Ajv recipe:
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.message}` })) },
},
};
};
const machine = setupAgent.fromConfig(config, { compileSchema: ajvCompileSchema });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. "{{ context.ticket }}" reads context.ticket; "{{ event.output.reply }}" reads event.output.reply. There is no code and no eval; the resolver walks the path and returns the value.
Because an expression can only read a value, a config generated by a model, stored in a database, or produced by a visual editor cannot do anything a hand-authored machine could not do.
Decisions from JSON
A decision works from a config too: 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. That event's transition usually exits the invoking state, cancelling the invoke, so onDone normally never fires; it is optional and only observed when the chosen event's transition stays in-state. Only onError (retries exhausted) is commonly configured.
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 } }), the same as hand-authored machines using enq.emit(...).
Honest limits
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 at all.
When you need comparisons, computed guards, or function-valued fields, author in TypeScript with setupAgent(...) and Zod (or any Standard Schema).