Skip to content

Quick start

You’re going to give your agent a rule it cannot break, then watch it try. In about a minute you’ll see the agent reach for an action, get refused, and watch the right move appear only once it’s earned. No prompt-wrangling — the rule is in the structure.

Save this as ship-guard.yaml:

version: "1.0.0"
workflows:
ship_guard:
initialState: unchecked
states:
unchecked:
transitions:
run_check: { target: checked } # the only move offered here
checked:
transitions:
ship: { target: shipped } # `ship` exists ONLY in this state
shipped: {} # terminal — done

A three-state machine. The rule lives in its shape: ship is declared only inside checked, and the only way into checked is run_check. So from unchecked, ship is not a move that exists. Right now run_check just advances the state — we make it run your real test suite below.

Terminal window
mcp-flowgate serve --config ship-guard.yaml

flowgate is now running as an MCP server over stdio, waiting for a host to connect.

Declare mcp-flowgate serve as an MCP server in your host’s config. The editor guide covers each host (Claude Code, Cursor, Zed, VS Code, Claude Desktop) in depth.

For Claude Desktop on macOS, the config lives at ~/Library/Application Support/Claude/claude_desktop_config.json:

{
"mcpServers": {
"flowgate": {
"command": "/absolute/path/to/mcp-flowgate",
"args": ["serve", "--config", "/absolute/path/to/ship-guard.yaml"]
}
}
}

Absolute paths — relative paths won’t resolve when the host launches the process.

Restart your agent (Claude Code, Cursor, etc.). It now has exactly two tools — flowgate.query (reads) and flowgate.command (writes) — regardless of how much you wire in later.

Now ask it: “Start the ship_guard workflow and ship the change.” Here’s what crosses the wire:

# It starts the workflow:
→ flowgate.command { "definitionId": "ship_guard" }
← { "workflow": { "id": "wf_01H…", "state": "unchecked", "version": 1 },
"result": { "status": "started" },
"links": [ { "rel": "run_check", "method": "flowgate.command",
"args": { "workflowId": "wf_01H…", "expectedVersion": 1, "transition": "run_check" } } ] }

ship is not in links. It isn’t blocked — it isn’t on the menu. If the agent reaches for it anyway:

→ flowgate.command { "workflowId": "wf_01H…", "expectedVersion": 1, "transition": "ship" }
← { "result": { "status": "rejected" },
"error": { "code": "INVALID_TRANSITION",
"message": "Transition 'ship' is not valid from state 'unchecked'.",
"attemptedTransition": "ship" },
"links": [ { "rel": "run_check", … } ] } # the refusal hands back the legal move

The agent follows the only legal move, and ship appears:

→ flowgate.command { "workflowId": "wf_01H…", "expectedVersion": 1, "transition": "run_check" }
← { "workflow": { "state": "checked", "version": 2 },
"result": { "status": "executed" },
"links": [ { "rel": "ship", … } ] } # now it's a move

That’s the whole idea: the wrong action wasn’t discouraged, it was unreachable — and the right one became available only once it was earned. You didn’t write a prompt. You wrote nine lines of YAML.

That’s TDD — red has no write-implementation move until a test fails. That’s deploy-gating — deploy isn’t a move until tests pass. Same machine, your labels.

Right now run_check is a placeholder that just advances. Point it at your actual test command so the gate has teeth — add a cli connection and give the transition an executor:

version: "1.0.0"
connections:
shell:
kind: cli
command: npm # or: cargo, pytest, /bin/bash …
workflows:
ship_guard:
initialState: unchecked
states:
unchecked:
transitions:
run_check:
target: checked
executor: { kind: cli, connection: shell, args: ["test"] }
checked:
transitions:
ship: { target: shipped }
shipped: {}

Now run_check runs npm test. If it fails, the transition fails and you stay in unchecked — so ship never becomes reachable. The agent cannot ship past a red suite, because the move to do so only exists on the other side of a green one.

The headline: the agent could only ever do what the workflow’s current state allowed. The illegal move wasn’t a thing it could call, and every response — including the refusal — handed back the moves that were legal, so it could recover on its own. That’s the guardrail, and it holds without a single line in the system prompt.

You also got, for free:

  • Audit — every step (start, the rejected ship, the run_check) emitted a structured JSON event. You have a complete, replayable trace of what the agent did.
  • Schema validation — malformed input is rejected before it reaches your executor.
  • A fixed tool surface — the model always sees two tools, whether you have one workflow or five hundred. (That’s also where the token savings come from at scale — see the cost analysis.)

Next: add an approval gate so some moves wait for a human (actor: human — the agent literally can’t submit them), see how discovery and search keep the surface flat, or compose a small cognitive architecture with skills and multi-step workflows.