Statewright

Core Concepts

States, transitions, guards, and how enforcement actually works

Core Concepts

Statewright is a state machine that sits between your agent and its tools. Every tool call passes through the machine. If the current state doesn't allow the tool, the call is blocked.

How Enforcement Works

The MCP gateway intercepts every tool call. Before the tool executes, the gateway checks the current state's allowed_tools list. If the tool isn't listed, the gateway returns an error to the agent... the tool never runs.

This is not prompt engineering. The agent receives a structured error explaining what's available and how to transition.

Calls to unlisted tools are rejected before execution. After max_iterations tool calls, the agent must transition or get blocked. And transition guards evaluate conditions programmatically against context data, not against the agent's stated intent.

When Bash is allowed but Write/Edit are not, statewright still blocks bash commands that look like writes: redirects (>, >>), in-place edits (sed -i), destructive ops (rm, shred). It also supports allowed_commands for prefix-matched command whitelisting. This catches the common patterns agents use to bypass tool restrictions through shell.

States

A state defines what the agent can do right now.

{
  "planning": {
    "allowed_tools": ["Read", "Grep", "Glob"],
    "instructions": "Understand the codebase. Do not modify files.",
    "max_iterations": 10,
    "on": {
      "READY": "implementing",
      "FAIL": "failed"
    }
  }
}

allowed_tools controls which tools the agent can call. The instructions field gets injected into the agent's context each turn (use it for phase-specific guidance). Set max_iterations to force a decision point after N tool calls, and on maps event names to their target states.

If allowed_tools is omitted, all tools pass through. There are no tool restrictions for that state.

Transitions

The agent triggers transitions by calling the statewright_transition MCP tool with an event name and optional context data:

// MCP tool call: statewright_transition
{ "event": "READY", "data": { "rationale": "Bug found in line 42" } }

If the event exists in the current state's on map, the machine moves. If not, the call is rejected and the agent stays put. The data.rationale field is stored in run history for the audit trail.

Interrupts

The agent edits a database migration file mid-implementation and keeps going without validating it. Three states later, the migration is broken and everything built on top of it is wrong. Interrupts prevent this.

When the agent changes a file matching a glob pattern, the workflow automatically detours to a handler state. After validation, the agent returns to where it was.

{
  "interrupts": {
    "pb_check": {
      "trigger": { "file_pattern": "site/pb/**/*.js" },
      "target": "pb_validating"
    }
  }
}

The handler state uses $return as its success target to go back to the interrupted state:

{
  "pb_validating": {
    "allowed_tools": ["Bash"],
    "allowed_commands": ["task test:hooks"],
    "on": { "VALIDATED": "$return", "FAIL": "failed" }
  }
}

The gateway auto-transitions the agent and changes its available tools. The tool result includes a notice explaining the state change. No additional MCP tools are registered.

Fork/Join

Some tasks have independent steps that can run in parallel: lint, type-check, security scan. Fork transitions spawn multiple branches, each with its own state and tools. When all branches complete, the workflow joins and advances.

{
  "on": {
    "BUILD_DONE": {
      "fork": {
        "branches": {
          "lint": { "initial": "lint_run", "terminal": "lint_done" },
          "types": { "initial": "types_run", "terminal": "types_done" }
        },
        "join": "all",
        "on_complete": "deploying",
        "on_fail": "failed"
      }
    }
  }
}

Branch states are defined inline — same workflow, no sub-machines. The agent works each branch sequentially by default, or spawns parallel sub-agents with Claude Code's Agent tool. If any branch fails and the join strategy is all, the fork transitions to on_fail.

How the Agent Knows When to Transition

A common question: where does the agent learn which events to emit and when?

Two things work together. First, each state's instructions field tells the agent what to do in this phase and what outcomes to look for. Second, the statewright hook injects the full state context into every prompt turn, including the available transition event names and their targets.

What the agent sees on every turn (injected automatically):

Phase: testing. Tools: Read, Bash.
Transitions: DEPLOY -> deploying, FAIL -> failed.
Instructions: Run the test suite. If all tests pass, transition DEPLOY
with test_result: "pass". If any test fails, transition FAIL.

The agent reads this, does its work, and when it decides the conditions are met, calls statewright_transition(event='DEPLOY', data={...}). The engine then evaluates any guards on that transition deterministically.

The key distinction: the agent requests a transition based on the instructions. The engine decides whether the transition is valid based on guards and the current state. The agent can try to skip ahead — the engine won't let it.

Guards

Guards add conditions to transitions. A guarded transition only fires if the condition passes.

{
  "on": {
    "DEPLOY": {
      "target": "deploying",
      "guard": "tests_passed"
    }
  }
}
{
  "guards": {
    "tests_passed": {
      "field": "test_result",
      "op": "eq",
      "value": "pass"
    }
  }
}

The agent passes context when transitioning: { "event": "DEPLOY", "data": { "test_result": "pass" } }. The guard evaluates against the machine's context. If test_result doesn't equal "pass", the transition is blocked.

Available operators: eq, neq, gt, gte, lt, lte, in, contains, exists, not_exists.

Guards evaluate against the context that existed before the current transition — not the data being passed with it. If you need a guard to check a value, set that value in a previous transition's data field.

Context

The state machine carries a context object from state to state. It starts with whatever context is defined at the top level of your workflow JSON (defaults to {}). Each transition's data merges into context after the transition succeeds.

// Workflow defines initial context
{ "context": { "test_result": "pending", "attempts": 0 } }

// Agent transitions with data
{ "event": "TEST_DONE", "data": { "test_result": "pass", "attempts": 1 } }

// Context is now: { "test_result": "pass", "attempts": 1 }
// Guards in the next state can check these values

Context is how programmatic state (test results, coverage numbers, approval flags) flows through the workflow. Guards read from it. Agents write to it via transition data.

Final States

A state with "type": "final" ends the workflow. Enforcement deactivates. All tools become available.

{
  "completed": { "type": "final" },
  "failed": { "type": "final" }
}

Two final states is a common pattern — one for success, one for failure. Both end enforcement, but the distinction shows up in run history. Why two? Because "the agent gave up" is different from "the agent finished." Your run history should reflect that.

Schema reference has every field. Create Your Own walks through building one from scratch.

On this page