Workflow Schema Reference
Every field in the Statewright workflow definition schema
Workflow Schema Reference
Workflow definitions are JSON documents conforming to the schema at https://statewright.ai/workflow-schema.json.
Validate your definitions with $schema:
{
"$schema": "https://statewright.ai/workflow-schema.json",
"id": "my-workflow",
"initial": "planning",
"states": { ... }
}Top-Level Fields
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique workflow identifier |
initial | string | Yes | Name of the starting state |
states | object | Yes | State definitions keyed by state name |
context | object | No | Initial context values for guard evaluation |
guards | object | No | Named guard predicates for conditional transitions |
interrupts | object | No | File-pattern triggers that auto-transition to handler states |
meta | object | No | Machine metadata for the agent layer |
Minimal Example
{
"id": "review",
"initial": "reading",
"states": {
"reading": {
"allowed_tools": ["Read", "Grep"],
"on": { "DONE": "complete" }
},
"complete": { "type": "final" }
}
}Full Example with Context and Guards
{
"id": "deploy-pipeline",
"initial": "testing",
"context": {
"test_result": null,
"coverage": 0
},
"states": {
"testing": {
"allowed_tools": ["Read", "Bash"],
"allowed_commands": ["pytest", "npm test"],
"on": {
"DEPLOY": {
"target": "deploying",
"guard": "tests_passed"
},
"FAIL": "failed"
}
},
"deploying": {
"allowed_tools": ["Bash"],
"on": { "DONE": "complete" }
},
"complete": { "type": "final" },
"failed": { "type": "final" }
},
"guards": {
"tests_passed": {
"field": "test_result",
"op": "eq",
"value": "pass"
}
}
}State Definition Fields
Each key in states maps to a state definition object.
| Field | Type | Default | Description |
|---|---|---|---|
type | "final" | — | Set to "final" for terminal states. Once reached, enforcement deactivates. |
allowed_tools | string[] | omitted = no enforcement | Tools the agent can use in this state. If omitted, all tools pass through. |
instructions | string | — | Natural language instructions injected into agent context each turn |
max_iterations | integer (>= 1) | unlimited | Maximum tool calls before forcing a transition decision |
safe_next | string | — | Fallback state for unrecognized transition events |
max_edit_lines | integer (>= 1) | unlimited | Maximum lines per edit operation |
max_files_per_state | integer (>= 1) | unlimited | Maximum files that can be edited in this state |
allowed_commands | string[] | — | Allowed shell command prefixes for Bash tool |
blocked_env | string[] | — | Environment variables denied in Bash commands. Alias: deny_env |
env_overrides | object | — | Environment variable overrides injected as context. Alias: env |
context_budget_bytes | integer | unlimited | Maximum accumulated tool result bytes in this state |
on | object | {} | Transition events keyed by event name |
allowed_tools
Controls which MCP tools the agent can call. Tool names must match exactly as the agent knows them (e.g., Read, Edit, Bash, Grep, Glob, Write).
{
"planning": {
"allowed_tools": ["Read", "Grep", "Glob"],
"on": { "READY": "implementing" }
}
}If allowed_tools is omitted, all tools pass through — no enforcement for that state. Statewright's own MCP tools (transition, get_state, etc.) are always available regardless.
instructions
Injected into the agent's context on every turn while in this state. Use for phase-specific guidance the agent should follow.
{
"implementing": {
"allowed_tools": ["Read", "Edit", "Write"],
"instructions": "Apply the minimal fix. Do not refactor unrelated code. Keep edits under 20 lines.",
"on": { "DONE": "testing" }
}
}max_iterations
Counts tool calls in the current state. When the limit is reached, the agent is forced to make a transition decision rather than continuing to loop.
{
"planning": {
"allowed_tools": ["Read", "Grep"],
"max_iterations": 10,
"on": { "READY": "implementing" }
}
}safe_next
Fallback when the agent emits an event that has no matching transition. Only fires on truly unknown events — explicitly defined transitions (including FAIL) are unaffected.
{
"planning": {
"allowed_tools": ["Read"],
"safe_next": "implementing",
"on": {
"READY": "implementing",
"FAIL": "failed"
}
}
}If the agent calls statewright_transition(event='GO') and GO has no definition, the machine transitions to implementing. Without safe_next, the transition is rejected.
allowed_commands
Restricts which shell commands the agent can run via the Bash tool. Values are prefix-matched.
{
"testing": {
"allowed_tools": ["Read", "Bash"],
"allowed_commands": ["pytest", "npm test", "cargo test"],
"on": { "PASS": "complete", "FAIL": "debugging" }
}
}The agent can run pytest -v tests/ but not rm -rf / or git push.
Instructions guide, allowed_commands enforce. Instructions tell the agent what to do — but the agent can choose to take shortcuts. allowed_commands removes the choice. If your interrupt handler says "run task test:hooks" in instructions but allows node -e in allowed_commands, the agent will use the shortcut. Narrow allowed_commands to only the commands whose output you'd trust as proof the check passed.
blocked_env and env_overrides
Control environment variable access within Bash commands.
{
"staging": {
"allowed_tools": ["Bash"],
"blocked_env": ["PROD_DB_URL", "AWS_SECRET_ACCESS_KEY"],
"env_overrides": {
"NODE_ENV": "staging",
"DATABASE_URL": "postgres://localhost/staging"
},
"on": { "DONE": "complete" }
}
}blocked_env (alias deny_env) prevents the agent from reading specified variables. env_overrides (alias env) injects overrides into the agent's context.
max_edit_lines and max_files_per_state
Scope constraints on file modifications.
{
"implementing": {
"allowed_tools": ["Read", "Edit", "Write"],
"max_edit_lines": 20,
"max_files_per_state": 3,
"on": { "DONE": "testing" }
}
}The agent can edit at most 3 files, with each edit operation limited to 20 lines. This prevents sprawling changes.
context_budget_bytes
Caps the total bytes of tool results the agent can accumulate in a state. Prevents runaway context from large file reads.
{
"analysis": {
"allowed_tools": ["Read", "Grep"],
"context_budget_bytes": 50000,
"on": { "READY": "implementing" }
}
}Transition Patterns
Transitions are defined in a state's on object. Each key is an event name, and the value is one of four patterns.
Pattern 1: Simple
Target state as a plain string. No conditions, no approval.
{
"on": {
"READY": "implementing",
"FAIL": "failed"
}
}Pattern 2: Guarded
Object with target and one or more guards. The transition only fires if all guards pass.
{
"on": {
"DEPLOY": {
"target": "deploying",
"guard": "tests_passed"
}
}
}With multiple guards (all must pass):
{
"on": {
"DEPLOY": {
"target": "deploying",
"guards": ["tests_passed", "coverage_adequate"],
"requires_approval": true,
"approval_message": "Deploy to production?"
}
}
}| Field | Type | Required | Description |
|---|---|---|---|
target | string | Yes | Destination state |
guard | string | No | Single guard name to evaluate |
guards | string[] | No | Multiple guard names (all must pass) |
requires_approval | boolean | No | Pause for human approval before transitioning |
approval_message | string | No | Message shown to the human reviewer |
Pattern 3: Branched (XState Pattern)
Array of guarded transitions. The engine evaluates guards in order and takes the first match.
{
"on": {
"EVALUATE": [
{ "target": "deploying", "guard": "coverage_high" },
{ "target": "improving", "guard": "coverage_low" },
{ "target": "failed" }
]
}
}The last entry with no guard acts as the default branch. If no guard matches and there is no default, the transition is rejected.
Each branch is an object:
| Field | Type | Required | Description |
|---|---|---|---|
target | string | Yes | Destination state |
guard | string | No | Guard name to evaluate |
guards | string[] | No | Multiple guard names (all must pass) |
Pattern 4: Invoke
Delegate to a sub-machine, then resume at on_complete.
{
"on": {
"RUN_TESTS": {
"invoke": "test-suite",
"on_complete": "deploying",
"on_fail": "debugging",
"input": { "suite": "integration" }
}
}
}| Field | Type | Required | Description |
|---|---|---|---|
invoke | string | Yes | Sub-machine definition to invoke |
on_complete | string | Yes | State to transition to on success |
on_fail | string | No | State to transition to on failure |
input | object | No | Input data for the sub-machine's initial context |
Pattern 5: Fork
Some phases have independent steps that don't depend on each other's output. Fork transitions spawn multiple branches, each with its own state and tool restrictions. The parent advances when the join condition is met.
{
"on": {
"BUILD_DONE": {
"fork": {
"branches": {
"lint": { "initial": "lint_run", "terminal": "lint_done" },
"types": { "initial": "types_run", "terminal": "types_done" },
"security": { "initial": "security_run", "terminal": "security_done" }
},
"join": "all",
"on_complete": "deploying",
"on_fail": "failed"
}
}
}
}Branch states are defined inline in the same workflow. Each branch has an initial state (where execution begins) and a terminal state (a final state that signals completion).
| Field | Type | Required | Description |
|---|---|---|---|
fork.branches | object | Yes | Named branches, each with initial and terminal state names |
fork.join | string | No | Join strategy: all (default, AND gate) or any (race) |
fork.on_complete | string | Yes | State to transition to when join condition is met |
fork.on_fail | string | No | State to transition to if any branch fails |
Fork supports two execution modes with the same schema:
Sequential (any client): The agent works each branch to completion, then calls statewright_transition(event='BRANCH_DONE:<branch_name>') to advance. After each completion, get_state returns the next branch's state and tools.
Parallel (Claude Code): The parent agent spawns sub-agents via the Agent tool, each calling statewright_load_workflow(name='ci-pipeline', branch='lint') to connect to their branch session. Each sub-agent gets independent tool enforcement. Sub-agents call BRANCH_DONE when finished; the gateway joins automatically.
Interrupts
Interrupts live at the top level of the workflow under interrupts. Each interrupt maps a file glob pattern to a handler state.
{
"interrupts": {
"pb_check": {
"trigger": { "file_pattern": "site/pb/**/*.js" },
"target": "pb_validating"
}
},
"states": {
"implementing": {
"allowed_tools": ["Read", "Edit", "Write", "Bash"],
"on": { "DONE": "testing", "FAIL": "failed" }
},
"pb_validating": {
"instructions": "A PocketBase hook was edited. Run 'task test:hooks' to validate.",
"allowed_tools": ["Bash", "Read", "Edit"],
"allowed_commands": ["task test:hooks"],
"on": {
"VALIDATED": "$return",
"FAIL": "failed"
}
}
}
}When the agent changes site/pb/hooks/auth.pb.js, the gateway auto-transitions to pb_validating. The agent's tools change immediately. After validation, the agent transitions VALIDATED and the $return target sends it back to whichever state was interrupted.
Interrupt Fields
| Field | Type | Required | Description |
|---|---|---|---|
trigger.file_pattern | string | Yes | Glob pattern matched against changed file paths |
target | string | Yes | State to transition to when the pattern matches |
Behavior
| Rule | Detail |
|---|---|
| Fires from | Any non-final state when a matching file is changed via Edit or Write tools. Bash file writes (>, sed -i) do not trigger interrupts. |
| Re-entrancy | Suppressed until the agent returns via $return. Editing another matching file while in the handler does not fire a second interrupt. |
| Agent notice | The tool result includes a [STATEWRIGHT INTERRUPT] message explaining the state change |
The $return Target
Use $return as a transition target in interrupt handler states. It resolves to whichever state the agent was in when the interrupt fired. Only valid while an interrupt is active — using $return without a prior interrupt trigger returns an error.
In the example above, VALIDATED uses $return to resume at the interrupted state, while FAIL targets failed directly. You can mix $return with regular targets in the same state.
Glob Patterns
| Pattern | Matches |
|---|---|
*.js | JS files with no directory separators (app.js yes, src/app.js no) |
**/*.js | JS files at any depth |
site/pb/**/*.js | JS files anywhere under site/pb/ |
**/*.env* | .env, .env.local, .env.production at any depth |
src/?.rs | Single-character Rust files in src/ |
Guard Definitions
Guards live at the top level of the workflow under guards. Each guard checks a value in the state machine's context.
{
"guards": {
"tests_passed": {
"field": "test_result",
"op": "eq",
"value": "pass"
},
"coverage_adequate": {
"field": "coverage",
"op": "gte",
"value": 80
}
}
}| Field | Type | Required | Description |
|---|---|---|---|
field | string | Yes | Context field to check |
op | string | Yes | Comparison operator |
value | any | No | Value to compare against (type depends on operator) |
Guard Operators
| Operator | Description | Example |
|---|---|---|
eq | Equal | {"field": "status", "op": "eq", "value": "pass"} |
neq | Not equal | {"field": "status", "op": "neq", "value": "fail"} |
gt | Greater than | {"field": "coverage", "op": "gt", "value": 80} |
gte | Greater than or equal | {"field": "coverage", "op": "gte", "value": 80} |
lt | Less than | {"field": "errors", "op": "lt", "value": 5} |
lte | Less than or equal | {"field": "errors", "op": "lte", "value": 0} |
in | Value is in a list | {"field": "env", "op": "in", "value": ["staging", "prod"]} |
contains | Field contains value | {"field": "tags", "op": "contains", "value": "approved"} |
exists | Field exists and is not null | {"field": "review_id", "op": "exists"} |
not_exists | Field is missing or null | {"field": "error", "op": "not_exists"} |
Guards read from context. The agent writes to context via the data parameter of statewright_transition:
statewright_transition(event='DEPLOY', data={test_result: 'pass', coverage: 92})Guard Timing
Guards evaluate against the context that existed before the current transition, not the data being passed with it. If you put a guard on TESTS_GREEN that checks test_result == "pass" and the agent passes {test_result: "pass"} in the same call... the guard fails, because the data hasn't merged yet.
Data from statewright_transition(event, data) merges into context after the transition completes (regardless of whether that transition has guards). That merged data is then available for guards on subsequent transitions.
To use guards effectively, set the guard field in one transition and check it in the next:
{
"states": {
"implementing": {
"on": {
"TESTS_GREEN": "refactoring"
}
},
"refactoring": {
"on": {
"CLEAN": {
"target": "pre_deploy",
"guard": "tests_still_pass"
}
}
}
},
"guards": {
"tests_still_pass": {
"field": "test_result",
"op": "eq",
"value": "pass"
}
}
}Step by step:
- Agent is in
implementing. Tests pass. Agent callsstatewright_transition(event="TESTS_GREEN", data={test_result: "pass"}). TESTS_GREENhas no guard, so it fires. Data merges:context.test_resultis now"pass".- Agent is now in
refactoring. Does cleanup work. Callsstatewright_transition(event="CLEAN"). CLEANhas guardtests_still_pass. Guard checkscontext.test_result == "pass"(set in step 2). Passes.
If the agent skipped step 1 and tried to jump straight to CLEAN with data={test_result: "pass"}, the guard would fail because it checks pre-transition context where test_result is still null.
Workflow Operations
Pause and Resume
statewright_pause saves the current state and context to the run record and deactivates enforcement. The run status changes to paused.
To resume in a new session:
statewright_load_workflow(name="tdd-feature", resume=true)This finds the latest paused run for that workflow, restores the saved state and context, and sets the run back to running. Iteration count resets to 0, but transition count and context are preserved.
If no paused run exists, resume=true falls back to starting fresh from the initial state.
Debug Mode
Set "debug": true in the workflow meta to enable statewright_force_state. This jumps to any state and optionally merges context values (not a full replace, it patches on top of existing context).
statewright_force_state(state="refactoring", context={test_result: "pass", build_ok: true})Guards, transitions, and iteration counts are all bypassed. Use this for development recovery when you need to fix forward after a crash, or to skip past states you've already completed in a prior session. Not for production workflows.
Meta Fields
Optional metadata at the top level under meta. Used by the agent layer and observability tooling, not by the state machine engine itself.
{
"id": "deploy-pipeline",
"initial": "testing",
"meta": {
"task_type": "deployment",
"estimated_steps": 15,
"danger_level": "dangerous",
"requires_human_approval": true,
"capture_output": true
},
"states": { ... }
}| Field | Type | Description |
|---|---|---|
task_type | string | Categorization hint (e.g., "bugfix", "deployment", "refactor") |
estimated_steps | integer | Rough estimate of total tool calls for the workflow |
danger_level | "safe" | "moderate" | "dangerous" | Risk classification for the workflow |
requires_human_approval | boolean | Whether the workflow requires human sign-off at some point |
capture_output | boolean | Enable output capture to the run history log |
approval_mode | "ui" | "none" | When "ui", transitions with requires_approval park in the web dashboard for human review. Default: "none" (advisory only) |
debug | boolean | Enable statewright_force_state tool for jumping to arbitrary states. For development and recovery. |
Additional arbitrary fields are allowed under meta for forward compatibility.
Complete Reference Example
A full workflow definition using every feature:
{
"$schema": "https://statewright.ai/workflow-schema.json",
"id": "deploy-pipeline",
"initial": "planning",
"context": {
"test_result": null,
"coverage": 0,
"approved": false
},
"meta": {
"task_type": "deployment",
"estimated_steps": 25,
"danger_level": "dangerous",
"requires_human_approval": true,
"capture_output": true
},
"states": {
"planning": {
"allowed_tools": ["Read", "Grep", "Glob"],
"instructions": "Understand the change. Read tests and deployment config.",
"max_iterations": 10,
"context_budget_bytes": 50000,
"safe_next": "testing",
"on": {
"READY": "testing",
"FAIL": "failed"
}
},
"testing": {
"allowed_tools": ["Read", "Bash"],
"allowed_commands": ["pytest", "npm test", "cargo test"],
"blocked_env": ["PROD_DB_URL"],
"max_iterations": 15,
"on": {
"EVALUATE": [
{ "target": "deploying", "guard": "deploy_ready" },
{ "target": "fixing", "guard": "tests_failed" },
{ "target": "failed" }
]
}
},
"fixing": {
"allowed_tools": ["Read", "Edit"],
"max_edit_lines": 20,
"max_files_per_state": 3,
"instructions": "Fix only the failing tests. Minimal changes.",
"on": {
"DONE": "testing",
"FAIL": "failed"
}
},
"deploying": {
"allowed_tools": ["Bash"],
"allowed_commands": ["kubectl", "helm"],
"env_overrides": { "KUBECONFIG": "/etc/kube/staging.yaml" },
"on": {
"DONE": {
"target": "complete",
"requires_approval": true,
"approval_message": "Deployment finished. Approve to mark complete?"
},
"FAIL": "failed"
}
},
"complete": { "type": "final" },
"failed": { "type": "final" }
},
"guards": {
"deploy_ready": {
"field": "test_result",
"op": "eq",
"value": "pass"
},
"tests_failed": {
"field": "test_result",
"op": "eq",
"value": "fail"
}
}
}