A JSON specification for statecharts and workflows. Expressive enough for real-world state machines, with built-in expression evaluation and converters to XState.
npm install @statelyai/schema{
"key": "order",
"version": "1.0.0",
"queryLanguage": "jmespath",
"initial": "pending",
"context": { "retries": 0, "items": [] },
"states": {
"pending": {
"on": {
"SUBMIT": {
"target": "processing",
"guard": "{{ context.items }}"
}
}
},
"processing": {
"invoke": [{
"src": "processOrder",
"timeout": "PT30S",
"onTimeout": { "target": "failed" },
"onDone": { "target": "complete" },
"onError": [
{ "target": "pending", "guard": "{{ context.retries < 3 }}" },
{ "target": "failed" }
]
}]
},
"complete": { "type": "final" },
"failed": { "type": "final" }
}
}Values wrapped in {{ }} are evaluated at runtime using the configured queryLanguage:
| Language | Package | Sync | Example |
|---|---|---|---|
jmespath |
jmespath | Yes | {{ context.count }} |
jsonpath |
jsonpath-plus | Yes | {{ $.context.count }} |
jsonata |
jsonata | No (async) | {{ context.count + 1 }} |
Expressions receive { context, event } as their data root.
Evaluator factories are available as explicit package entrypoints:
import { createJmespathEvaluator } from '@statelyai/schema/jmespath';
import { createJsonataEvaluator } from '@statelyai/schema/jsonata';
import { createJsonpathEvaluator } from '@statelyai/schema/jsonpath';convertSpecToConfig() produces serializable XState v6 MachineJSON; expressions
become @expr values and remain unevaluated. convertSpecToMachine() requires a
synchronous evaluator because XState guards and transitions are synchronous. The
built-in async jsonata evaluator is therefore unavailable for machine creation;
use jmespath, jsonpath, or provide a synchronous evaluate implementation.
State and invoke timeout/onTimeout are supported. Workflow-level heartbeat
and declarative retry have no XState v6 equivalent and fail explicitly.
The conversion surface targets the pinned XState 6.0.0-alpha.25 MachineJSON
contract. Alpha upgrades are intentional compatibility changes and must pass the
cross-runtime round-trip tests in this repository.
The queryLanguage in the spec is used to automatically resolve the expression evaluator:
import { convertSpecToMachine } from '@statelyai/schema';
import { transition, initialTransition } from 'xstate';
const machine = convertSpecToMachine(spec);
const [state] = initialTransition(machine);
const [next] = transition(machine, state, { type: 'SUBMIT' });Convert in either direction without executing the machine:
import {
convertSpecToConfig,
fromXStateConfig,
} from '@statelyai/schema';
const machineJson = convertSpecToConfig(spec);
const restoredSpec = fromXStateConfig(machineJson, { key: 'order' });The reverse adapter accepts the serializable MachineJSON subset. It rejects
@code values because executable JavaScript is outside the runtime-neutral
specification.
These XState conversion helpers support machines with no declared profile or with
the xstate profile identifier, using either the registered short name or the
canonical URI. If a machine declares a different profile, conversion fails
explicitly instead of silently claiming support.
You can also check support ahead of time:
import {
getXStateConversionSupport,
canConvertToXState,
} from '@statelyai/schema';
const support = getXStateConversionSupport(spec);
if (!support.supported) {
console.error(support.reason);
}
canConvertToXState(spec); // booleanYou can override the query language or provide a custom evaluator:
import { convertSpecToMachine, convertSpecToConfig } from '@statelyai/schema';
import type { ExpressionEvaluator } from '@statelyai/schema';
// Override query language:
const machine = convertSpecToMachine(spec, { queryLanguage: 'jsonpath' });
// Bring your own evaluator:
const evaluate: ExpressionEvaluator = (expression, data) => { /* ... */ };
const machine = convertSpecToMachine(spec, { evaluate });
// Resolve named XState sources:
const sourcedMachine = convertSpecToMachine(spec, {
sources: { actions, guards, actors, delays },
});Zod schemas for runtime validation (requires zod peer dependency):
import { machineSchema } from '@statelyai/schema';
const result = machineSchema.safeParse(json);Use validateMachine() for the same structural validation plus conformance
warnings:
import { validateMachine } from '@statelyai/schema';
const result = validateMachine(json);
if (!result.success) console.error(result.errors);
else console.warn(result.warnings);JSON Schema files are also available for editor tooling:
import machineJsonSchema from '@statelyai/schema/machine.json';
import scxmlJsonSchema from '@statelyai/schema/scxml.json';JSON Schema captures portable structural constraints. Cross-node constraints,
including globally unique explicit IDs and canonical-path collisions, require
machineSchema or validateMachine().
See the full Stately Machine Specification for formal definitions, conformance requirements, and detailed semantics.
Structural validation and executable profile support are intentionally separate: a machine can validate against the core schema even if a given runtime does not implement the selected profile's action, guard, invoke, or trigger semantics.
Profile documents:
Registered short profile names exported by the package currently include xstate
serverlessworkflow, and scxml.
You can also use the exported profile helpers instead of hardcoding strings:
import {
XSTATE_PROFILE_SHORT_NAME,
XSTATE_PROFILE_URI,
normalizeRegisteredProfile,
matchesRegisteredProfile,
} from '@statelyai/schema';
normalizeRegisteredProfile(XSTATE_PROFILE_URI); // "xstate"
matchesRegisteredProfile('https://stately.ai/specifications/xstate', XSTATE_PROFILE_SHORT_NAME); // trueConverted Serverless Workflow examples are available in examples/serverlessworkflow. They use a Serverless Workflow profile URI and profile-specific invokes/actions while staying valid against the core machine schema.
Those examples are structural/profile examples only. The built-in
convertSpecToConfig() and convertSpecToMachine() helpers intentionally do
not claim support for the Serverless Workflow profile and reject those machines.
Semantic SCXML examples are available in examples/scxml.
They validate against scxmlDocumentSchema and preserve SCXML constructs
directly instead of lowering them into the core machine schema.
| Property | Type | Description |
|---|---|---|
id |
string |
Optional explicit global alias |
type |
"atomic" | "compound" | "parallel" | "history" | "final" | "choice" |
State type |
initial |
string |
Immediate child key to enter first |
states |
Record<string, State> |
Child states |
on |
Record<EventDescriptor, Transition> |
Event-driven transitions |
after |
Record<string, Transition> |
Delayed transitions (ms or ISO 8601 duration) |
always |
Transition |
Eventless transitions |
onDone |
Transition |
Transitions taken when the state reaches done status |
onError |
Transition |
Transition on descendant execution errors |
timeout |
number | string |
State timeout |
onTimeout |
Transition |
Required transition when timeout is set |
choice |
ChoiceBranch[] |
Ordered branches for choice states |
route |
expression | Route |
Profile-defined state routing |
entry |
Action[] |
Actions run on state entry |
exit |
Action[] |
Actions run on state exit |
invoke |
Invoke[] |
Actors spawned on entry |
tags |
string[] |
State tags |
output |
expression | JSON value |
Output for final states |
input |
expression | JSON value |
Input supplied when entering the state |
context |
Record<string, expression | JSON value> |
State context initialization |
history |
"shallow" | "deep" |
History type (when type: "history") |
target |
string |
Default target for history states |
description |
string |
Human-readable description |
meta |
Record<string, JSON value> |
Arbitrary metadata |
Extends State with:
| Property | Type | Description |
|---|---|---|
key |
string |
Required root key used as the canonical path root |
version |
string |
Machine version |
profile |
string |
Execution profile short name or URI |
queryLanguage |
string |
Expression language |
context |
Record<string, JSON value> |
Initial context values |
triggers |
Trigger[] |
Optional machine-level trigger metadata |
schemas |
{ input?, context?, events?, output? } |
JSON Schema definitions for input, context, event payloads, and output |
actions |
Record<string, Action | Action[]> |
Declarative named action definitions |
guards |
Record<string, { when: Guard }> |
Declarative named guard definitions |
actors |
Record<string, JSON value> |
Serializable actor definitions |
delays |
Record<string, Delay> |
Named delay definitions |
Triggers are optional root-level metadata objects. The core spec preserves them but does not interpret them.
{
"triggers": [
{ "type": "webhook", "path": "/api/orders" },
{ "type": "cron", "schedule": "0 9 * * *" }
]
}Each trigger must have a string type and may include additional JSON-valued
fields defined by a profile or runtime.
A transition is an object or an array of objects (for branching):
{
"on": {
"NEXT": { "target": "step2" },
"SUBMIT": {
"target": "processing",
"guard": "{{ context.isValid }}",
"context": { "submitted": true },
"actions": [{ "type": "@xstate.log", "args": ["submitted"] }]
},
"CHECK": [
{ "target": "high", "guard": "{{ context.value > 100 }}" },
{ "target": "low" }
]
}
}| Property | Type | Description |
|---|---|---|
target |
string | string[] |
Target state reference(s) |
guard |
expression | NamedGuard |
Condition for taking transition |
matches |
Record<string, JSON value> |
Shallow event payload pattern |
context |
Record<string, expression | JSON value> |
Context assignments (equivalent to core.assign) |
actions |
Action[] |
Actions to execute |
input |
expression | JSON value |
Input supplied to target states |
description |
string |
Human-readable description |
meta |
Record<string, JSON value> |
Arbitrary metadata |
order |
number |
Explicit transition priority |
reenter |
boolean |
Whether the transition re-enters target states |
The core specification defines core.assign for keyed context assignment:
{ "type": "core.assign", "assignments": { "count": "{{ context.count + 1 }}" } }Other actions use { "type": string, "params"?: JSON value, ...profileFields }; profiles or converters define their semantics and any additional JSON-valued fields.
The XState profile uses the canonical v6 MachineJSON built-ins:
{ "type": "@xstate.assign", "context": { "count": "{{ context.count + 1 }}" } }
{ "type": "@xstate.raise", "event": { "type": "DONE" } }
{ "type": "@xstate.cancel", "id": "pending" }
{ "type": "@xstate.log", "args": ["{{ context.status }}"] }
{ "type": "@xstate.emit", "event": { "type": "NOTIFY" } }Legacy xstate.assign, xstate.raise, xstate.log, and xstate.emit forms are
translated. Legacy xstate.sendTo is rejected because v6 MachineJSON has no
declarative equivalent; use a named custom action source.
Other actions pass through to XState and must be supplied through conversion
sources:
{ "type": "trackAnalytics", "params": { "event": "checkout" } }Expression guard:
{ "guard": "{{ context.count > 0 }}" }Named guard (resolved through conversion sources.guards):
{ "guard": { "type": "isValid", "params": { "min": 5 }, "config": { "strict": true } } }Profile-defined named guards may also include additional JSON-valued fields.
| Property | Type | Description |
|---|---|---|
src |
string |
Actor source (resolved through conversion sources.actors) |
id |
string |
Actor ID |
registryKey |
string |
Stable runtime registry key |
input |
expression | JSON value |
Input passed to actor |
onDone |
Transition |
Transition when actor completes |
onError |
Transition |
Transition when actor fails |
onSnapshot |
Transition |
Transition on actor snapshot |
timeout |
number | string |
Milliseconds, delay reference, or ISO 8601 duration |
onTimeout |
Transition |
Required transition when timeout is set |
heartbeat |
string |
ISO 8601 duration |
retry |
{ maxAttempts, interval?, backoff? } |
Retry policy on error |
Profile-defined invokes may also include additional JSON-valued fields.
Keys can be millisecond strings or ISO 8601 durations. The converter parses ISO 8601 to ms automatically:
{
"after": {
"1000": { "target": "next" },
"PT30S": { "target": "timeout" },
"PT1H": { "target": "expired" }
}
}pnpm install
pnpm verify
pnpm buildpnpm verify runs typechecking, linting, formatting checks, tests, and generated
schema drift detection.
MIT