Skip to content

Letting a model gate a deploy

A model is good at reading a diff and saying “this is a version bump” or “this touches the migration”. This page shows two ways to use a model’s read of a change while a person keeps the final say.

Shape one: the model routes, a person approves

Section titled “Shape one: the model routes, a person approves”

The model’s verdict picks a path, and one of the paths is a person. The most it can do is decide something didn’t need a person. It can’t ship anything unattended that a person would have caught, because “ships unattended” is only ever one branch of a switch you wrote.

{
"version": "hyperspool-graph/v1",
"concurrency": { "group": "deploy-production" },
"nodes": [
{ "id": "review", "type": "anthropic.messages",
"retry": { "attempts": 3, "backoff": "10s" },
"config": {
"credential": "claude",
"with": { "model": "claude-sonnet-4-6" },
"input": {
"prompt": "You are reviewing a deploy. Reply with exactly one word, low or high, for the risk of shipping this without a human reading it. Anything touching migrations, auth, or payments is high.\n\n{{ input.changelog }}"
}
} },
{ "id": "triage", "type": "switch",
"config": {
"cases": [
{ "name": "auto", "when": "nodes.review.output.body.content[0].text == 'low'" }
],
"default": "needs_human"
} },
{ "id": "gate", "type": "approval",
"config": {
"prompt": {
"$template": "The model flagged this deploy as needing a look:\n\n{{ nodes.review.output.body.content[0].text }}\n\nApprove?"
}
} },
{ "id": "deploy", "type": "shell",
"if": "nodes.triage.output.branch == 'auto' || nodes.gate.status == 'success'",
"config": { "run": "./deploy.sh production" } }
],
"edges": [
{ "from": "review", "to": "triage" },
{ "from": "triage", "to": "gate", "when": "needs_human" },
{ "from": "triage", "to": "deploy" },
{ "from": "gate", "to": "deploy" }
]
}

The edge into gate names the needs_human branch, so the gate is skipped when the model says low. deploy sits below both paths, so its edges carry no branch: a routed edge from auto would skip it whenever the switch picked needs_human, approved or not. Instead its if: runs it when the model said auto, or when a person cleared the gate. A skipped step still satisfies its dependents, so deploy is reached down either path.

Run it with a low-risk change and the gate is skipped, deploy is success, and nobody was asked. Run it with a high-risk one and the run waits for approval until a person answers it, on the run page, on Today, or with spool approve <run-id>. A reject cancels the run, so deploy never runs. Approvals covers answering and rejecting.

The switch’s default is needs_human, so when the model returns something unexpected (an explanation instead of a word, an empty reply) the run goes to a person.

That default only protects you if the auto case matches the whole reply, which is why it uses ==. Ask a model for one word and it will sometimes write a sentence, and text.contains('low') ships every sentence with the letters l-o-w anywhere in it, including “this is NOT low risk, it touches auth” and “high, this allows unauthenticated access”. Both would auto-deploy. Match the whole reply, and let anything else fall to the default.

The top-level concurrency: block runs one deploy at a time: a second run of the graph queues until the first ends, whatever the model said. It takes the same settings as a pipeline’s; see run admission.

Shape two: the model advises, a person decides

Section titled “Shape two: the model advises, a person decides”

Simpler, and often enough: always ask a person, and put the model’s reading of the change in the prompt they’re answering.

{ "id": "gate", "type": "approval",
"config": {
"prompt": {
"$template": "Deploy {{ input.sha }} to production?\n\n---\n**Model's review**\n\n{{ nodes.review.output.body.content[0].text }}"
},
"fields": [
{ "name": "ticket", "type": "text", "required": true },
{ "name": "note", "type": "textarea", "required": false }
]
} }

No routing, no branch to get wrong. The approver reads a summary they didn’t have to write, and the decision is theirs. What they enter becomes the gate’s output, so a later step reads the ticket at nodes.gate.output.ticket. Start with this shape: watching how often you disagree with the model tells you whether its judgement is any good, and you can move to shape one once you trust it on a class of change.

Try it on the canvas dry-runs the draft: the model call and the deploy are stubbed with what they returned in the graph’s newest real run, the switch runs for real, and the gate answers itself with that run’s answer. So once the graph has run, you can check an edited case against a real reply without deploying anything. A required field with no default and no earlier answer, like shape two’s ticket, stops the dry run at the gate. See Try it.

The approval routes take a person’s authentication, never a running step’s. A step could call them with a personal access token, but then every approval in the history names that person as the approver. If you want this, write the class of change that doesn’t need a person as the auto case of shape one: the same decision, made once, in an expression a reviewer can read.

  • Branch routing keeps a docs push from reaching a deploy graph, so only the branches you care about get here.
  • The changelog the model reads can be a job’s output, so a CI pipeline hands off with a workflow: job naming this graph. See composition.
  • If the deploy must not run twice for one commit, claim the sha before deploying. See claiming a key.
  • Calling a model - credentials, the reply paths, and the shapes that don’t involve a gate.
  • Approvals - answering and rejecting a gate, from the console, the terminal or HTTP.
  • YAML reference - the approval: job kind and its fields.