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 |
model | string | client default | Model route requested for this state |
thinking_level | "high" | "medium" | "low" | "off" | client default | Reasoning effort requested for this state |
model_ladder | (string | object)[] | — | Ordered managed-client recovery candidates |
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 tools Statewright's integration permits in this state. Names such as Read, Edit, Bash, Grep, Glob, and Write are capabilities; a host can map them to its own tool names. See the host guide for its enforcement boundary.
{
"planning": {
"allowed_tools": ["Read", "Grep", "Glob"],
"on": { "READY": "implementing" }
}
}If allowed_tools is omitted, Statewright applies no tool restriction for that state. Statewright's own MCP tools (transition, get_state, etc.) remain available. This setting does not change the host's separate permissions.
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. A trailing namespace wildcard is supported for Taskfile tasks: "task test:*" permits a single task test:unit or task test:e2e invocation while rejecting other task namespaces, shell chaining, redirection, and command substitution.
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.
model_ladder
Managed clients can recover a turn that completed without advancing the workflow by moving through an ordered model ladder. Candidate objects may set model, thinking_level, health_url, requires_provider_switch, requires_approval, and max_active_seconds.
max_active_seconds is a safe-boundary escalation budget, not a hard wall-clock timeout. The persistent Codex supervisor accumulates monotonic time from turn/started through turn/completed for the same model and state epoch, subtracting time spent inside a task test:* command. When a completed turn leaves the workflow in the same state and the cumulative budget is exhausted, recovery starts with the next eligible candidate instead of retrying the same model. Approval waits do not run inside an active turn and therefore do not consume this budget. Clients without this capability treat the field as advisory.
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": "Validate the release checklist before deployment.",
"approval_reviewer": { "email": "release-manager@example.com" }
}
}
}| 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 |
approval_reviewer | object | No | An email or user_id identifying a verified Statewright user; omitted or {} defaults to the workflow creator |
Reviewers are assigned to individual transitions. A release gate can name a release manager while an engineering gate names a team lead. When the field is omitted or empty, the reviewer is the workflow creator. There is no workflow-level override or event-data override. At publication, email resolves to a user ID retained by that approval; later email changes do not transfer authority.
Approval resolution requires an active Pro, Team, or Enterprise subscription for
the workflow owner. The reviewer may be a different user. Both website decisions
and native terminal decisions enforce the same identity and entitlement checks.
A terminal identifies the required reviewer and only offers Approve/Deny when its
owning API key identifies that reviewer. Otherwise its workflow remains parked
without inference while the assigned reviewer resolves it from the website's
Workflow Runs page. Runtime receipts represent this as
approval_requirement: {mode, required_approvals, reviewers}. Version 0.3 supports
only mode: "single"; first-response and threshold policies are reserved for a
future schema revision.
Non-empty invalid or unverified reviewers leave a gate unresolved; they do not fall back to the creator. Existing pending approvals without a reviewer are assigned to their workflow creator during migration. Do not deploy the new authorization contract until approval-bearing workflow definitions name their reviewers and existing unresolved gates have an explicit operator recovery plan. The gateway, website migration/hooks, and managed native client must be updated together; older receipts without decision capability never enable a native dialog.
Approval evidence packets
An approval transition can carry a small, versioned review packet in its transition data. This gives the reviewer the decision context without exposing the agent's turn history:
{
"event": "FACTS_CONFIRMED",
"data": {
"evidence_packet": {
"schema": "statewright/evidence-packet/v1",
"title": "Client brief fact check",
"summary": "The final brief passed source, link, and rendering checks.",
"checklist": [
"Confirm the named contact and role.",
"Confirm the recommendation matches the cited evidence."
],
"artifacts": [
{
"local_path": "docs/brief-runs/acme/final-brief.pdf",
"label": "Final brief",
"description": "Candidate reviewed by this gate.",
"classification": "confidential"
},
{
"local_path": "docs/brief-runs/acme/final-qa.json",
"label": "Validation receipt",
"classification": "internal"
}
]
}
}
}Managed Codex and Claude clients resolve artifact paths inside the active project root, calculate a SHA-256 digest, and upload the bytes before presenting the approval. Paths outside the project, missing files, unsupported file types, and files larger than 20 MiB fail closed. A packet supports up to eight PDF, JSON, Markdown, text, PNG, JPEG, or WebP artifacts. The host performs the upload; the model never receives an upload credential or arbitrary file-upload capability.
The Codex approval dialog links to the authenticated review page; Claude's managed supervisor opens the same page when the gate is parked. That page shows the summary, checklist, reviewer policy, immutable artifact digests, inline PDF, image, and bounded text previews, plus the structured approval context. It does not include the conversation transcript. Evidence is protected and visible only to the workflow owner and designated reviewer. Current default retention is 7 days for Free, 30 days for Pro, 90 days for Team, and long-term for Enterprise accounts; expired files are removed independently of the approval decision record.
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": {
"enabled": true,
"task_type": "deployment",
"estimated_steps": 15,
"danger_level": "dangerous",
"requires_human_approval": true,
"capture_output": true
},
"states": { ... }
}| Field | Type | Description |
|---|---|---|
enabled | boolean | Controls whether the workflow can be listed, loaded, or resumed. Defaults to true when omitted. Set false to retire a workflow without deleting its definition or run history. |
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 for review in supported native clients and the web dashboard. 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.
Retiring a workflow
Set meta.enabled to false when a workflow should remain stored for audit or later recovery but must not be selectable by agents:
{
"meta": {
"enabled": false
}
}Disabled workflows are omitted from statewright_list_workflows and rejected by statewright_load_workflow, including resume attempts. A workflow already running when it is disabled is not terminated. Use statewright_get_workflow to retrieve the complete stored definition, then preserve every existing field when calling statewright_create_workflow(overwrite=true) to disable or re-enable it. The database record's Default setting selects the default among enabled workflows; it does not replace meta.enabled.
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": {
"enabled": true,
"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"
}
}
}