Your coding agent shipped before the tests passed. Again. flowgate
makes that move not exist: ship only appears after
the check runs, so the agent literally can't reach it from
unchecked. Guardrails you declare in YAML — and, because the
whole thing rides on two fixed tools, a smaller prompt as a bonus.
version: "1.0.0"
workflows:
ship_guard:
initialState: unchecked
states:
unchecked:
transitions:
run_check: { target: checked } # only move here
checked:
transitions:
ship: { target: shipped } # ship exists ONLY here
shipped: {} # from unchecked, ship is not a move You told the agent to run the tests first. It ran the deploy. You told it not to approve its own PR. It approved its own PR. The system prompt is a suggestion, and Claude Code or Cursor is free to ignore it the moment the context fills up. A rule that lives in prose is a rule the model can talk itself out of.
The usual reflex is to hand the agent more tools — a tool to check CI, a tool to gate the deploy, a tool to request review. But every MCP tool you register lands in the model's system prompt. Ten tools? Fine. Fifty? You're spending 5,000+ tokens per call just to describe what's available, and the model still has to reason about all of them to pick one — more tools, more wrong choices, more wasted round trips.
So you get the worst of both: a guardrail the model can ignore, bolted onto a tool list that keeps growing. A flat list and a prayer.
flowgate exposes exactly two MCP tools no matter how many actions — capabilities, the things your agent can actually do — you wire in behind them. The split is reads vs. writes, so your host can auto-approve reads and gate writes. The model's tool list never grows, and what it's allowed to do next is set by the workflow's current state, not by the prompt. (For the curious: the read/write split is the CQRS pattern.)
flowgate.query all reads Browse the catalog and search for an action by what it does.
Describe an action's full schema on demand.
Check workflow state and look up shared terms (the lexicon).
flowgate.command all writes Start a guarded workflow.
Fire a transition to move the workflow's state forward.
Define shared terms — every write is schema-validated and audited.
Your 50 actions surface through search results and the links each response hands back — loaded one at a time, only when they're a legal next move. (For the curious: that "the response tells you what you can do next" pattern is HATEOAS.)
Every one of these is YAML. No glue code, no per-tool wrappers, no host-specific routing.
Tag the approve transition actor: "human" and
the link is never offered to the model. If it tries anyway, the runtime
rejects it with ACTOR_MISMATCH. Not a hint — a hard gate.
Gate a transition on evidence, permission,
role, or an expr — compose them with
allOf, anyOf, not. No green check,
no deploy move.
Define inputSchema on any action. Malformed arguments are
rejected before they touch the thing that actually runs (the executor).
Tag steps actor: "deterministic" — lint, test, build. The
runtime chains through them automatically. Zero LLM round trips
for computable work.
Every action emits a structured JSON event — including the moves that got refused. Route to file, stdout, or your observability stack.
MCP servers, shell commands, REST APIs. Import existing tools
with proxy.import — no rewriting.
Timeout, retry with exponential backoff, fallback executors. Declare it in YAML, not code.
Send SIGHUP to reload config without restarting. In-flight workflows continue uninterrupted.
Claims deserve evidence. Here's the actual wire format when the agent
tries to ship before the check has run — the ship_guard workflow.
1. The agent searches — not scans
→ flowgate.query { "query": "ship the change" }
← { "items": [{
"id": "workflow:ship_guard",
"title": "Ship guard — check before ship"
}] } One hit. Not 50 tool definitions.
2. Start the workflow — it lands in unchecked
→ flowgate.command {
"definitionId": "ship_guard"
}
← { "workflow": { "state": "unchecked", "version": 1 },
"result": { "status": "started" },
"links": [{
"rel": "run_check",
"method": "flowgate.command",
"args": { /* prefilled */ }
}] }
The only link offered is run_check. ship isn't in
the list — from this state, it isn't a move that exists.
3. It reaches for ship anyway — and can't
→ flowgate.command { "transition": "ship" } # tries to skip the check
← { "result": { "status": "rejected" },
"error": { "code": "INVALID_TRANSITION" },
"links": [{
"rel": "run_check" # refusal hands back the legal move
}] } INVALID_TRANSITION — not blocked, unreachable. And the refusal
hands back the one legal move, so the agent recovers on its own: run the
check, and only then does ship appear.
You just watched the model search before it acted. Fair question to ask next: if every capability starts with a search, what does that indirection cost? Here's the honest math — and why it stays small.
Direct tool list
1 hop
…but every tool's schema sits in the prompt, always — growing with each tool you add.
Flowgate, first use
2 hops
Search, then call. Paid once — the first time the model reaches for a capability this session.
Flowgate, after that
1 hop
The discovered link is already in context. The model reuses it — no second search.
So the tax is one search, the first time the model uses a capability in a session. After that it's free — the link lives in context and gets reused. It's a model round trip, not gateway latency; it shows up on one-shot capability calls, not workflows; and unlike a flat tool list, it never grows with your capability count.
The floor is that one first-use search — and we won't go under it by pushing a tool list back into your prompt, because that's the whole thing Flowgate removes. See the full accounting →
Linting, testing, building artifacts — these are computable. Tag them
actor: "deterministic" and the runtime chains through
them automatically.
states:
lint:
transitions:
run_lint:
target: test
actor: deterministic
executor: { kind: cli, command: lint-check }
test:
transitions:
run_tests:
target: build
actor: deterministic
build:
transitions:
build_artifact:
target: ready_to_deploy
actor: deterministic
ready_to_deploy:
goal: Confirm deployment
transitions:
deploy:
target: deployed
actor: agent # chain stops here
The model calls flowgate.command. The runtime chains
lint, test, build automatically. Three executor calls,
zero LLM round trips. The response arrives at
ready_to_deploy with a chain trace
and guidance for the decision ahead.
One YAML file. Two tools. Moves your agent can't make. Free and open source.