Stately

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/agent 2.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 a compileSchema that 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 for createAgentRuntime(...).
  • schemas: the compiled AgentSchemaPack. It exposes context, events, emitted, input, output, and meta as 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.
  • compileSchema takes 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.

FieldValueEffect
toolsMap of tool name to { description?, inputSchema?, outputSchema? }, where the schemas are JSON SchemasPasses the tools to the model alongside the request.
toolChoice"auto", "none", "required", or { type: "tool", name }Controls whether the model must call a tool.
includeReasoningtrueOpts 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 guard without {{ }} is a named guard reference. Implement it in fromConfig(config, { guards }). The implementation is called with { context, event } and returns a boolean.
  • An action { type, params } is a named action reference. Implement it in fromConfig(config, { actions }). The implementation is called with the template-resolved params as its only argument. To pass context or event data, use {{ }} templates on params.
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.

ExecutorReturnsNotes
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. Persist snapshot, 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. A requests entry's prompt is the text sent to the model. An invoke's input is the data passed to the invoked source. That is either a request's typed input, or an agent.decide inline input carrying its own model, prompt, and allowedEvents.

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: final

Declare 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. Pass snapshot to mark the active states with *.
  • Transitions: one line each under the state they leave: on, always, after, invoke done and error, and choice branches. 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 compileSchema must 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: true to 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 a Helpers section. 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 allowedEvents and input when written as functions.
  • meta and toolChoice are 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.

On this page