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.
Checking the routing with Try it
Section titled “Checking the routing with Try it”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.
Why not let the model approve?
Section titled “Why not let the model approve?”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.
Tying it to a real pipeline
Section titled “Tying it to a real pipeline”- 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.
See also
Section titled “See also”- 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.