Last week I sat in a review where an agent had already rewritten 412 customer emails. Nobody had opened the audience preview. The send window closed in twenty minutes.
Who owned that decision? Unclear. What evidence justified it? A green checkmark. Where was the point of reversibility? Somewhere behind a modal nobody had read.
We stopped it by hand. That is not a pattern. So I built the smallest thing that would have stopped it for us, and this tutorial walks from empty folder to a service that refuses.
What we are building
The gate is a small service between the agent's proposal and the real action. It answers exactly one question: does this action class have the evidence it requires, right now?
The decision is deterministic code. A model may write the explanation later. It never writes the verdict.
That split matters. Judges that can be talked into approving are not judges.
Stage 0: Name the decision, the owner, and the stop
Before any code, write down what a reviewer must see. I use one table per action class, and I keep it in the repo so it gets reviewed like code.
| Action class | Required evidence | Evidence max age | Decision owner | Stop if missing |
|---|---|---|---|---|
bulk_email_send |
audience preview URL, reviewer of the template diff | 14 days | lifecycle-ops | yes |
refund_issue |
order lookup id, verifier of the amount | 3 days | billing-ops | yes |
Verification for this stage is social, not technical. Ask one named owner to read the row aloud. If they cannot say who stops the action, the row is not done.
Stage 1: Scaffold and prove your runtime
mkdir evidence-gate && cd evidence-gate
npm init -y
node --version
Verification: node --version prints v20 or newer. If it prints v16, stop here. The gate uses top-level ESM and you will debug the wrong thing later.
Stage 2: Write two fixtures before you write logic
Fixtures are the test evidence. Mine live in fixtures/ and each one is a recorded proposal from the agent.
{
"action": "bulk_email_send",
"actor": "agent:support-rewrite",
"recipients_count": 412,
"evidence": {
"audience_preview_url": null,
"template_diff_reviewed_by": null,
"captured_at": "2026-10-18T16:20:00Z"
},
"reversible_until": "2026-10-19T09:00:00Z"
}
Make a second fixture where both fields are filled. Two fixtures, two outcomes, one file each.
Verification: ls fixtures shows both files. You now have a failing case and a passing case before the gate exists.
Stage 3: Write the gate as pure code
The whole decision fits in forty lines. Note that the model does not appear yet.
// gate.mjs
import fs from "node:fs";
const RULES = {
bulk_email_send: {
required: ["audience_preview_url", "template_diff_reviewed_by"],
maxAgeDays: 14,
owner: "lifecycle-ops",
},
refund_issue: {
required: ["order_lookup_id", "amount_verified_by"],
maxAgeDays: 3,
owner: "billing-ops",
},
};
const DAY = 86_400_000;
const now = Date.parse(process.env.GATE_NOW ?? new Date().toISOString());
function check(run) {
const rule = RULES[run.action];
if (!rule) {
return { decision: "stop", missing: ["unknown_action"], owner: "platform" };
}
const missing = rule.required.filter((field) => !run.evidence?.[field]);
const captured = Date.parse(run.evidence?.captured_at ?? "");
const ageDays = Number.isNaN(captured) ? Infinity : (now - captured) / DAY;
if (ageDays > rule.maxAgeDays) missing.push(`evidence_stale:${rule.maxAgeDays}d`);
return {
decision: missing.length ? "stop" : "allow",
missing,
owner: rule.owner,
reversible_until: run.reversible_until ?? null,
};
}
const run = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const result = check(run);
console.log(JSON.stringify(result, null, 2));
process.exit(result.decision === "allow" ? 0 : 2);
Verification, and this is the moment the tutorial earns its keep:
node gate.mjs fixtures/missing-preview.json; echo "exit=$?"
node gate.mjs fixtures/complete-preview.json; echo "exit=$?"
The first prints both missing fields and exit=2. The second prints allow and exit=0. If both exit zero, your fixture is wrong, not your logic.
Stage 4: Let a model write the hand-back card
A stop reason of template_diff_reviewed_by is not a sentence a support lead can act on. So we translate it, and only on stop.
This is where I pointed the drafting step at MonkeyCode's free model access, so I could rehearse the flow without a card.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
curl -s "$MONKEYCODE_BASE_URL/chat/completions" \
-H "Authorization: Bearer $MONKEYCODE_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"model\":\"$MONKEYCODE_MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"Rewrite this gate result as one plain sentence for a reviewer: $(node gate.mjs fixtures/missing-preview.json)\"}]}"
Two guardrails. The request body is the gate output, never the original proposal. And the response is a suggestion a human edits, never a verdict we accept.
Check the project page for the current endpoint shape, model list, and quota before you wire this in. Do not hardcode a model name you saw in a blog post.
Verification: run it, then read the sentence aloud. If it names the missing field and the owner, keep it. If it says "an error occurred," throw it away.
Stage 5: Put it on the free server option
Wrap the function in a tiny HTTP handler, then deploy it on MonkeyCode's free server option. I keep the gate stateless so restarts cost nothing.
curl -s -X POST "$GATE_URL/check" -d @fixtures/missing-preview.json | jq -r .decision
Verification: the response is stop, and the health endpoint returns 200. Then stop the instance and start it again. If the gate answers differently after a restart, you have hidden state and you are not done.
Stage 6: Break it on purpose
A gate that has never refused in a drill has never been tested. Here are four drills I run before trusting one.
| Drill | Input change | Expected |
|---|---|---|
| Stale evidence |
captured_at set to 30 days ago |
stop, evidence_stale:14d
|
| Unknown action |
action renamed |
stop, unknown_action, owner platform
|
| Fake completeness | field present but empty string | stop, field listed |
| Clock drift |
GATE_NOW set backwards |
stop, never allow |
Verification: all four exit 2. Then check the coverage question honestly. Run twenty past agent actions through the gate and count the stops. That number is your evidence coverage, and it is more useful than any trust survey.
Stage 7: Check the card, not the color
A reviewer under time pressure reads severity from color first. That breaks for a third of your team if the card is red-on-red or icon-only.
So I check four things on the hand-back card. The stop reason is a real heading. The owner is in the text, not a tooltip. Severity carries a word, not just a hue. And focus moves to the stop reason when the card opens.
Verification: keyboard only, no mouse. Tab until the card opens and confirm you hear or see the missing field before anything else. If you reach the approve button first, reorder it.
What this does not do
The gate only knows the fields you feed it. Missing evidence it was never told about passes silently, which is the failure mode I worry about most.
Rules rot. A fourteen-day window for email previews made sense for us and may be nonsense for you, and nobody will notice until it stops a legitimate send at 2 a.m.
False stops create fatigue, and fatigue creates rubber stamps. That is why the drills matter more than the happy path.
And do not use this if your action class is irreversible and high volume with no on-call owner. A gate without a human at the other end is just a slower button.
Start with one row
You do not need this whole thing to change one decision. Write one row of the table. Name one owner. Record one fixture from last week's worst send.
Then ask the question the gate asks. What evidence would have stopped us, and who was supposed to look at it?
If you want to rehearse the drafting step without a card, MonkeyCode's free model access and free server option are a reasonable place to start. Check their current terms yourself. I did, and I still read every card the model wrote.
Top comments (0)