Skip to content

Guardrails: moves your agent can't make

Your agent shouldn’t be able to approve its own changes or deploy without a green test run — here’s how to make those moves impossible, not just discouraged.

If you run Claude Code or Cursor against real commands — git, npm test, deploys, migrations — you’ve watched it skip the tests, push on a red build, or merge the PR it just opened. The system prompt told it not to. It did it anyway.

A guardrail in flowgate isn’t a warning the agent can talk itself past. It’s a precondition on a move (a transition). The move with consequences — ship, deploy, merge — doesn’t become reachable until the gate is passed, and the runtime enforces that before any command (the executor — the thing the move actually runs) fires. You declare it in YAML. No glue code, no per-tool wrappers.

How airtight that is depends on how you run flowgate — see How to run it for the three modes and exactly what each enforces.

Everything on this page is one idea: a move is not offered until it’s earned. The canonical shape is the ship_guard from the introductionship exists only inside checked, and the only way into checked is to run the check. Guards are how you put a condition on that flip: not just “you reached this state,” but “you reached it and the tests actually passed.”

TDD is one instance (no write-implementation move until a test fails). Deploy-gating is another (no deploy move until the suite is green). Same machine, your labels. The rest of this page is the guard kinds you bolt onto those gates.

Guards are preconditions on a declared transition. They run after input schema validation but before the executor. If any guard fails, the move is rejected and the response comes back with the current legal moves attached, so the agent recovers on its own.

Note the two distinct refusals, because they answer different questions:

  • A move that isn’t declared in the current state returns INVALID_TRANSITION — the move doesn’t exist here. (This is the ship_guard case: ship from unchecked.)
  • A move that is declared but whose guard fails returns GUARD_REJECTED — the move exists, but the condition isn’t met yet (the tests haven’t passed).
states:
checked:
transitions:
deploy:
target: deployed
guards:
- kind: expr
expr: "$.context.testsPassed == true"
executor: { kind: cli, connection: shell, args: [./deploy.sh] }

That expr guard means: if the tests haven’t passed, the deploy never runs. The agent gets GUARD_REJECTED and links to whatever it can do instead — typically, go run the tests.

Guards run in declaration order. First failure stops the chain. Each evaluation emits a guard.evaluated audit event, so you always know what passed and what didn’t.

Throughout, context is the workflow’s shared state — the running bag of facts each step reads and writes (you may have seen it called a “blackboard” or “slots” elsewhere; here it’s just context). A guard like $.context.testsPassed == true reads from it.

For a single engineer running an agent locally, two guard kinds carry the weight: expr (a condition on the shared context) and evidence (proof a step really happened). Start there. The other two — permission and role — only matter if multiple people share one gateway; they’re at the bottom.

expr — a condition on the shared context

Section titled “expr — a condition on the shared context”

Evaluates a binary predicate against the workflow’s state. This is the guard you’ll reach for most: “tests passed,” “coverage is high enough,” “the branch is a feature branch.”

guards:
- kind: expr
expr: "$.context.testsPassed == true"

The expression has access to three scopes:

ScopeReads from
$.arguments.*The caller’s transition arguments
$.context.*The workflow’s accumulated shared state
$.workflow.input.*The input passed to flowgate.command({definitionId}) (start)

Operators: ==, != for any same-typed values. <, <=, >, >= for numbers. starts_with, contains for strings.

Operand types: paths, numbers, quoted strings ("foo"), booleans (true/false), and null.

Path-to-path comparisons work: "$.context.after > $.context.before" is valid.

Array access: use bracket notation items[0].name or dot notation items.0.name.

Some examples, all from a coding loop:

# Tests must have passed
- kind: expr
expr: "$.context.testsPassed == true"
# Coverage threshold before merge
- kind: expr
expr: "$.context.coverage >= 80"
# Only ship feature branches through this path
- kind: expr
expr: "$.arguments.branch starts_with 'feat/'"
# Enough reviews collected
- kind: expr
expr: "$.context.reviewCount >= $.context.requiredReviews"

evidence — proof a step actually happened

Section titled “evidence — proof a step actually happened”

expr trusts a flag in context. evidence is stronger: it requires proof a step really ran — e.g. the test executor actually executed and passed — and the agent can’t fake it. Evidence records come from successful executor results: the CLI executor records cli_output on every success, the human executor records human_request, and custom executors emit their own kinds. The agent can’t write an evidence record by asserting it in context; only a real executor run produces one.

guards:
- kind: evidence
requires:
- tests_passed
- acceptance_criteria_met

Every listed kind must have at least one evidence record for this workflow before the move fires. You can also require a specific count for quorum-style sign-off:

guards:
- kind: evidence
requires:
- { kind: human_request, count: 2 }

That means two separate human approvals must exist before this move can fire — useful for high-risk changes (a prod migration) that need more than one person.

permission and role — only if you run multi-tenant

Section titled “permission and role — only if you run multi-tenant”

These check the caller’s identity, so they only do anything when several humans share one gateway with different identities. In a single-user local setup they reject everyone — the bundled binary treats callers as anonymous, so a permission guard always fails. If that’s you, skip these and gate on expr, evidence, or a human-only move (below).

# permission -- caller must hold a named permission
guards:
- kind: permission
permission: deploy.production
# role -- caller must have a named role
guards:
- kind: role
role: approver

Roles are broad categories (“approver”, “admin”); permissions are specific actions (“deploy.production”). Reach for them when you’re running flowgate as a shared gateway for a team, not a laptop.

Guards can be combined using all_of, any_of, and not:

guards:
# Both must pass: tests green AND coverage high enough
- kind: all_of
guards:
- { kind: expr, expr: "$.context.testsPassed == true" }
- { kind: expr, expr: "$.context.coverage >= 80" }
# Either one is sufficient: a real test run, or a recorded override
- kind: any_of
guards:
- { kind: evidence, requires: [tests_passed] }
- { kind: evidence, requires: [override_approved] }
# Negation: must NOT be flagged as blocked
- kind: not
guard: { kind: expr, expr: "$.context.blocked == true" }

You can nest these arbitrarily. An allOf inside an anyOf inside another allOf works fine. But if you find yourself nesting three levels deep, consider whether the workflow should have more states instead.

The move your agent can’t make: human-only

Section titled “The move your agent can’t make: human-only”

This is the one that stops the agent approving its own PR. The actor field on a transition controls who can submit it. Four values:

ActorWho can submit
agentThe LLM (default)
humanA human — the runtime rejects agents with ACTOR_MISMATCH, and the move is never even offered to the agent
systemInternal system calls
deterministicThe runtime itself — auto-executes without waiting for anyone

The one that matters here is human:

transitions:
merge:
actor: human
target: merged
guards:
- kind: evidence
requires: [tests_passed]

When a move is marked actor: human, the agent literally cannot submit it — and the link is never handed to it in the first place. If it reaches for the move anyway, the runtime checks Principal::is_human() on the submitter and rejects with ACTOR_MISMATCH before the executor runs or the workflow advances. This isn’t a guard the agent can talk past — it’s a hard gate in the runtime. The agent can open the PR; a person merges it. (Whether the agent can sidestep flowgate entirely depends on your run mode — see How to run it.)

This is defense in depth. You might stack three independent layers on the same move:

  • Actor gate: rejects non-human principals at the runtime level
  • Guard: checks a condition (tests passed) is met before a human can even act
  • Human executor: records the approval request and returns pending

The human executor doesn’t execute anything. It records an approval request, emits a human.approval.requested audit event, and returns a pending status. The workflow stays in its current state until a human resolves the request.

transitions:
request_approval:
target: awaiting_approval
executor:
kind: human
queue: engineering-approvals

The queue field is a label — it tells your approval integration (Slack bot, Linear sync, CLI watcher) which queue this request belongs to. The gateway doesn’t manage queues itself; it just records the event.

A human resolves the request through your approval tooling, which calls back into the gateway to advance the workflow.

A complete guarded workflow: deploy with human approval

Section titled “A complete guarded workflow: deploy with human approval”

Here’s the kind of flow you’d put in front of a coding agent: it can run tests, build, and propose a production migration, but a human approves before anything touches prod. It combines all the guard kinds, and every gate is a coding move the agent might otherwise make on its own.

workflows:
deploy_with_approval:
description: Run tests, propose a prod migration, require human approval before it runs.
tags: [deploy, ci, guarded]
inputSchema:
type: object
required: [service, migration]
properties:
service: { type: string }
migration: { type: string }
initialState: building
initialContext:
testsPassed: null
coverage: null
states:
building:
onEnter:
executor:
kind: cli
connection: shell
args: [npm, test, --, --coverage]
output:
testsPassed: "$.output.passed"
coverage: "$.output.coveragePct"
linkFilter: byGuards
transitions:
to_review:
title: Tests green -- send the migration for review
target: awaiting_approval
guards:
- { kind: expr, expr: "$.context.testsPassed == true" }
- { kind: expr, expr: "$.context.coverage >= 80" }
tests_failed:
title: Tests red -- back to fixing
target: failed
guards:
- { kind: expr, expr: "$.context.testsPassed == false" }
awaiting_approval:
onEnter:
executor:
kind: human
queue: deploy-approvals
transitions:
approve:
title: Approve the migration for prod
actor: human
target: deploying
guards:
- kind: evidence
requires: [tests_passed]
reject:
title: Reject the migration
actor: human
target: failed
deploying:
onEnter:
executor:
kind: cli
connection: shell
args: [./run-migration.sh]
reliability:
retry:
maxAttempts: 3
backoff: exponential
initialDelayMs: 1000
transitions:
mark_done:
target: deployed
deployed:
terminal: true
failed:
terminal: true

Walk through what’s happening:

  1. buildingnpm test --coverage runs on entry (onEnter) and writes the result into the shared context. expr guards route on it: tests green and coverage at least 80 sends the migration to review; tests red sends it back. linkFilter: byGuards means the agent only sees the move that actually applies — it can’t pick the green path on a red build.

  2. awaiting_approval — the human executor records an approval request and parks the workflow. approve is actor: human (the agent literally can’t fire it — no self-approval) and carries an evidence guard requiring proof the tests really ran (tests_passed), so a stale testsPassed: true flag alone won’t unlock it.

  3. deploying — only reachable after a human approves. The CLI executor runs the migration with retry and exponential backoff.

  4. Terminal statesdeployed and failed have no outgoing moves. The workflow is done.

By default, every workflow response includes links for all transitions from the current state. The model picks one, and if the guards reject it, the error response includes the legal links for recovery.

If you’d rather the model only sees transitions it can actually take right now, set linkFilter: byGuards:

workflows:
demo:
linkFilter: byGuards # workflow-wide default
states:
triaged:
linkFilter: byGuards # or per-state (overrides the workflow setting)

When set, the runtime evaluates each transition’s guards silently against the current context and principal, and only returns links for transitions that would pass. This is useful when guards are mutually exclusive — the model doesn’t have to guess which one applies.

One caveat: guards that depend on $.arguments.* can’t be evaluated at link-generation time (no arguments exist yet), so those transitions typically filter out. Use linkFilter: byGuards when your guards depend on $.context.* and $.workflow.input.*.