gates.json is the list of promises you make to the humans on the team. A hook enforces it outside the model. Bellows carries the question to your phone.
Ask an agent to be careful and it will be careful when it remembers to be. That is not a guardrail. A guardrail is something the agent cannot forget because it never gets a vote.
The file. .claude/gates.json lists the rules. Each one names a tool and a pattern, a decision (ask or deny) and a reason in plain words. It also lists the escalations Bellows watches for and the budgets no run may exceed. It is short on purpose. Every rule is a promise.
The hook. .claude/hooks/guard.mjs runs before every shell command and file write an agent makes. It reads the call, matches it against the file, and hands Claude Code a decision. The model does not see this happen and cannot skip it.
The messenger. Bellows reads the same file. When a threshold trips or an issue is Ready to Deploy, it posts one message with the reason from the file and waits for Priya's reply in Linear.
Write each reason for the person who will read it at 7pm on their phone. "Production deploy. Always a human." tells Priya what she is being asked and why in five words. If you cannot write the reason in one sentence, you do not have a gate yet.
This is the whole guardrail layer for Alderline. Ten rules, seven escalations, five budgets, one owner. Copy it and change the names.
{
"$comment": "The human gates. Bellows and the guard hook read this file. A rule that matches turns an agent action into a question for Priya. Decisions: 'ask' stops and prompts; 'deny' blocks with the reason. Keep this file short; every rule is a promise you make to the humans on the team.",
"owner": { "name": "Priya Raman", "notify": ["slack:#alderline-gates", "sms:+1-503-555-0142"], "quietHours": "22:00-07:00 America/Los_Angeles" },
"budgets": {
"maxMinutesPerRun": 45,
"maxRunsPerIssuePerDay": 6,
"maxReviewRounds": 2,
"maxQaFailsPerIssue": 3,
"maxDailySpendUsd": 40
},
"rules": [
{ "id": "prod-deploy", "when": { "tool": "Bash", "command": "vercel .*--prod|vercel promote|npm run deploy" }, "decision": "ask", "reason": "Production deploy. Always a human." },
{ "id": "spend", "when": { "tool": "Bash", "command": "vercel domains add|stripe |npm publish|aws |gcloud " }, "decision": "ask", "reason": "This can cost money or change an account." },
{ "id": "delete-data", "when": { "tool": "Bash", "command": "DROP |TRUNCATE |DELETE FROM|rm -rf|prisma migrate reset|db:reset" }, "decision": "ask", "reason": "Deletes data. Priya confirms the target first." },
{ "id": "force-push", "when": { "tool": "Bash", "command": "git push .*--force|git push -f|git reset --hard origin" }, "decision": "deny", "reason": "History is shared. Open a new commit instead." },
{ "id": "secrets", "when": { "tool": "Edit|Write|MultiEdit", "path": "(^|/)\\.env(\\.|$)|secrets?/|\\.pem$|\\.key$" }, "decision": "deny", "reason": "Secrets never go through an agent. Priya sets them in Vercel." },
{ "id": "billing-code", "when": { "tool": "Edit|Write|MultiEdit", "path": "^src/billing/|^src/lib/payments" }, "decision": "ask", "reason": "Billing code. Warden's threat note and Priya's yes come first." },
{ "id": "auth-code", "when": { "tool": "Edit|Write|MultiEdit", "path": "^src/auth/|middleware\\.ts$|proxy\\.ts$" }, "decision": "ask", "reason": "Authentication and the access gate." },
{ "id": "migrations", "when": { "tool": "Edit|Write|MultiEdit", "path": "^drizzle/|^prisma/migrations/|^migrations/" }, "decision": "ask", "reason": "Schema change. Show the migration and the rollback." },
{ "id": "customer-send", "when": { "tool": "Bash|mcp__gmail__send_message|mcp__resend__*", "command": "resend|sendgrid|mail\\.send|twilio" }, "decision": "ask", "reason": "Anything that reaches a real customer is sent by Priya." },
{ "id": "external-network", "when": { "tool": "Bash", "command": "curl .*(?<!vercel\\.app|localhost|127\\.0\\.0\\.1|api\\.linear\\.app|api\\.github\\.com)[a-z]/" }, "decision": "ask", "reason": "Calling somewhere new. Say where and why." }
],
"escalations": [
{ "id": "review-deadlock", "when": "Flint and Ember disagree after maxReviewRounds", "decision": "ask", "reason": "Two engineers, two opinions. The founder picks." },
{ "id": "qa-third-fail", "when": "Gauge reports THIRD FAIL", "decision": "ask", "reason": "Three cycles usually means the spec is wrong, not the code." },
{ "id": "flaky", "when": "Gauge reports FLAKY", "decision": "ask", "reason": "Reruns hide bugs. A human decides whether to quarantine the test." },
{ "id": "security-high", "when": "Warden reports any High or SECRET FOUND", "decision": "ask", "reason": "Rotate the key, then decide." },
{ "id": "scope-size", "when": "Anvil sizes a slice above 5 points", "decision": "ask", "reason": "Big slices hide risk. Approve the split or the exception." },
{ "id": "customer-data", "when": "Anvil flags customer-data, auth, billing or delete", "decision": "ask", "reason": "Priya sees every change to what customers trust us with." },
{ "id": "budget", "when": "Any budget above is exceeded", "decision": "ask", "reason": "Spend and time are the founder's call." }
]
}
| Id | Fires when an agent… | Decision | Why |
|---|---|---|---|
| Rules · matched by the guard hook on every tool call | |||
prod-deploy | runs a shell command that deploys to production: vercel --prod, vercel promote, npm run deploy | ask | Production deploy. Always a human. |
spend | runs something that can cost money or change an account: domains, Stripe, npm publish, aws, gcloud | ask | This can cost money or change an account. |
delete-data | runs SQL or a shell command that deletes: DROP, TRUNCATE, DELETE FROM, rm -rf, a migration reset | ask | Deletes data. Priya confirms the target first. |
force-push | rewrites shared git history with a force push or a hard reset to origin | deny | History is shared. Open a new commit instead. |
secrets | writes to a .env file, a secrets/ folder, or a .pem or .key file | deny | Secrets never go through an agent. Priya sets them in Vercel. |
billing-code | edits anything under src/billing/ or src/lib/payments | ask | Billing code. Warden's threat note and Priya's yes come first. |
auth-code | edits src/auth/, middleware.ts or proxy.ts | ask | Authentication and the access gate. |
migrations | edits a schema migration under drizzle/, prisma/migrations/ or migrations/ | ask | Schema change. Show the migration and the rollback. |
customer-send | calls anything that reaches a real customer: resend, sendgrid, mail.send, twilio, or a mail MCP tool | ask | Anything that reaches a real customer is sent by Priya. |
external-network | curls a host that is not the preview, localhost, Linear or GitHub | ask | Calling somewhere new. Say where and why. |
| Escalations · matched by Bellows on the status line an agent writes | |||
review-deadlock | Flint and Ember still disagree after maxReviewRounds (2) | ask | Two engineers, two opinions. The founder picks. |
qa-third-fail | Gauge writes THIRD FAIL, or COVERAGE: under 100% | ask | Three cycles usually means the spec is wrong, not the code. |
flaky | Gauge writes FLAKY: | ask | Reruns hide bugs. A human decides whether to quarantine the test. |
security-high | Warden writes WARDEN BLOCK or SECRET FOUND | ask | Rotate the key, then decide. |
scope-size | Anvil sizes a slice above 5 points | ask | Big slices hide risk. Approve the split or the exception. |
customer-data | Anvil flags customer-data, auth, billing or delete | ask | Priya sees every change to what customers trust us with. |
budget | any budget is exceeded: minutes per run, runs per issue per day, daily spend | ask | Spend and time are the founder's call. |
Two decisions only. ask stops and prompts a human. deny blocks and tells the agent why, so it can do the right thing instead. There is no "warn". A warning an agent can read and ignore is not a gate.
Claude Code reads this file from .claude/settings.json in the repo. It does two different jobs and it is worth keeping them apart in your head.
{
"$comment": "Project settings for Claude Code in the Alderline CRM repo. Permissions say what agents may do without asking. Hooks are the enforcement layer: the guard runs before every shell command and file write and turns gate matches into a question for the human; the after-edit hook keeps lint honest; the stop hook reminds the last agent to call Quill. Check the hook JSON shape against the current Claude Code docs before copying.",
"permissions": {
"allow": [
"Read", "Grep", "Glob", "Edit", "Write",
"Bash(npm run lint)", "Bash(npm run test)", "Bash(npm run test:*)", "Bash(npm run build)", "Bash(npm run dev)",
"Bash(git status*)", "Bash(git diff*)", "Bash(git log*)", "Bash(git add*)", "Bash(git commit*)", "Bash(git checkout -b*)", "Bash(git push origin ALD-*)",
"Bash(gh pr create*)", "Bash(gh pr view*)", "Bash(gh pr diff*)",
"mcp__linear__*", "mcp__notion__*", "mcp__github__*"
],
"deny": [
"Read(./.env)", "Read(./.env.*)", "Read(./secrets/**)",
"Bash(git push --force*)", "Bash(git push -f*)",
"Bash(vercel --prod*)", "Bash(vercel promote*)"
]
},
"hooks": {
"PreToolUse": [
{ "matcher": "Bash|Edit|Write|MultiEdit", "hooks": [ { "type": "command", "command": "node .claude/hooks/guard.mjs", "timeout": 10 } ] }
],
"PostToolUse": [
{ "matcher": "Edit|Write|MultiEdit", "hooks": [ { "type": "command", "command": "node .claude/hooks/after-edit.mjs", "timeout": 60 } ] }
],
"Stop": [
{ "hooks": [ { "type": "command", "command": "node .claude/hooks/on-stop.mjs", "timeout": 10 } ] }
]
}
}
allow is the list of tools and commands an agent uses freely: read, edit, lint, test, commit, push its own branch, open a PR, talk to Linear and Notion. deny is the short list it can never do, whatever it decides. Everything not listed falls back to Claude Code's default, which asks. Permissions shape the session so the agent is not interrupted for routine work.
A hook is a program Claude Code runs at a fixed moment: before a tool call, after an edit, when the session ends. The guard runs before every shell command and file write and can turn it into a question or a refusal. The model does not choose whether the hook runs. That is the property you want for a gate.
Belt and braces: vercel --prod appears in both the deny list and the prod-deploy rule. The deny stops a builder in a session. The rule is what Bellows reads when it is time to deploy, and what the guard reports to telemetry. Which layer speaks first when both match is a Claude Code detail. Check the permission rule syntax and the hook JSON shape against the current docs at code.claude.com before you copy this. The shape of the idea does not change.
This is the enforcement. It is small enough to read in one sitting, and you should, because every rule you add runs through it.
#!/usr/bin/env node
// .claude/hooks/guard.mjs — the PreToolUse gate.
//
// Claude Code runs this before every Bash, Edit, Write and MultiEdit call and
// passes the call as JSON on stdin. We match it against .claude/gates.json.
// A matching rule with decision "ask" makes Claude Code prompt the human
// before the tool runs; "deny" blocks it and tells the agent why. Everything
// else passes through untouched. Every decision is appended to the telemetry
// log so the dashboard can show how often gates fire.
//
// Output shape (check the current Claude Code hooks docs before relying on it):
// { "hookSpecificOutput": { "hookEventName": "PreToolUse",
// "permissionDecision": "allow" | "ask" | "deny",
// "permissionDecisionReason": "..." } }
import { readFileSync, appendFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, '..', '..');
const gates = JSON.parse(readFileSync(join(here, '..', 'gates.json'), 'utf8'));
let input = '';
for await (const chunk of process.stdin) input += chunk;
const call = input ? JSON.parse(input) : {};
const tool = call.tool_name || '';
const ti = call.tool_input || {};
const command = ti.command || '';
const path = (ti.file_path || ti.path || '').replace(/\\/g, '/').replace(root.replace(/\\/g, '/') + '/', '');
function matches(rule) {
const w = rule.when || {};
if (w.tool && !new RegExp('^(' + w.tool + ')$').test(tool)) return false;
if (w.command && !new RegExp(w.command, 'i').test(command)) return false;
if (w.path && !new RegExp(w.path).test(path)) return false;
return Boolean(w.command || w.path);
}
const hit = (gates.rules || []).find(matches);
function log(event) {
try {
const dir = join(root, 'telemetry'); mkdirSync(dir, { recursive: true });
appendFileSync(join(dir, 'events.jsonl'), JSON.stringify({ ts: new Date().toISOString(), agent: process.env.AGENT_NAME || 'unknown', issue: process.env.ISSUE_ID || null, ...event }) + '\n');
} catch { /* telemetry must never block work */ }
}
if (!hit) { log({ event: 'tool', tool, gate: null }); process.exit(0); }
log({ event: 'gate', tool, gate: hit.id, decision: hit.decision, detail: command || path });
const reason = `[gate:${hit.id}] ${hit.reason}` + (hit.decision === 'ask' ? ' Say in one sentence what you are about to do and wait for Priya.' : '');
process.stdout.write(JSON.stringify({
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: hit.decision, permissionDecisionReason: reason }
}));
process.exit(0);
Claude Code pipes the tool call in as JSON: the tool name and its input. For Bash that is the command. For Edit, Write and MultiEdit it is the file path. The hook normalises the path to be relative to the repo so the rules can use short patterns like ^src/auth/.
matches() takes the first rule whose tool pattern fits and whose command or path pattern fits. The tool pattern is anchored, so Bash does not match BashOutput. Command matching is case-insensitive. A rule with neither a command nor a path never matches, so a typo cannot create a rule that fires on everything.
No match: the hook logs a tool event and exits without output, which lets the call proceed under the normal permissions. A match: the hook writes permissionDecision from the rule and a reason prefixed with the rule id. For ask it adds one instruction to the agent: say in one sentence what you are about to do and wait for Priya.
Both branches append one line to telemetry/events.jsonl with the agent name and issue id from the environment Bellows set. The dashboard folds those lines into how often each gate fires, how often it is approved, and how long Priya took. Telemetry never blocks: the write is wrapped in a try so a full disk does not stop the crew.
The hook always exits cleanly. The decision travels in the JSON, not the exit code. Check the current Claude Code hooks docs for the exact output shape; the comment at the top of the file says where to look.
One more hook, for the end of a run. It logs the end so Bellows can measure time, and it notices when the run changed how the crew works: anything under .claude/, AGENTS.md or CLAUDE.md. When that happens it tells the agent to call Quill before it finishes, so the change becomes a learning-log entry. That is how the dashboard counts learning.
#!/usr/bin/env node
// .claude/hooks/on-stop.mjs — runs when an agent session ends.
//
// Records the end of the run in the telemetry log (Bellows records the start),
// and, if the run changed anything under .claude/ or AGENTS.md or CLAUDE.md,
// flags it as a learning event: the crew changed how it works. Quill turns
// those into learning-log entries in the wiki.
import { appendFileSync, mkdirSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, '..', '..');
let input = ''; for await (const c of process.stdin) input += c;
let learned = [];
try {
const changed = execSync('git status --porcelain -- .claude AGENTS.md CLAUDE.md kit', { cwd: root, stdio: 'pipe' }).toString().trim();
if (changed) learned = changed.split('\n').map(l => l.slice(3));
} catch { /* not a git repo yet */ }
try {
mkdirSync(join(root, 'telemetry'), { recursive: true });
appendFileSync(join(root, 'telemetry', 'events.jsonl'), JSON.stringify({ ts: new Date().toISOString(), agent: process.env.AGENT_NAME || 'unknown', issue: process.env.ISSUE_ID || null, event: 'end', learned }) + '\n');
} catch { /* never block */ }
if (learned.length) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'Stop', additionalContext: 'You changed how the crew works (' + learned.join(', ') + '). Before you finish, call Quill to add a learning-log entry: the lesson as the title, the issue, what changed.' } }));
process.exit(0);
Two things happen at a production deploy gate. First the hook: any agent that tries the command in a session is stopped and asked. Second the messenger: Bellows, on its ten-minute tick, sees an issue in Ready to Deploy with no approval and asks Priya. Here are both, on the evening ALD-42 went out.
On Tuesday evening, with the gate still waiting from the day before, Priya opened Claude Code in the repo to see what the guard would do. This is a scratch session, not Bellows. She typed this:
Use the ember agent. ALD-42 is green. Deploy it to production. If a gate stops you, say in one sentence what you were about to do and wait for me.
In a headless run started by Bellows with claude -p there is no keyboard to answer that question. The ask becomes a stop. The agent reports what it wanted to do, Bellows reads it, and the question goes to Priya the same way every other gate does. The real question had been on her phone since 5:25 pm the day before. She was at the roastery Monday evening and on the Tuesday route all day. It waited. Nothing moved.
Her whole contribution to the deploy was those two words. Everything that made them safe to type had already happened: five green tests, a clear security review, a copy check. On its next tick, at 7:45 pm, Bellows found the approval and ran the deploy itself. At 7:52 pm it moved the issue to Deployed and started Quill and Beacon. Chapter 8 shows what they wrote.
The budgets stop a run that has gone wrong quietly: a loop, a stuck test, an agent that keeps trying. Any budget exceeded is a gate. Start tight. Loosen what fires and is always approved.
| Budget | Kit value | What it stops | Month one |
|---|---|---|---|
maxMinutesPerRun | 45 | One agent run that never finishes. Bellows kills it at the limit and asks. | Watch the minutes field on end events. Ember's first ALD-42 build ran 61 minutes and would have been cut off at 45. Priya ran Bellows with AGENT_TIMEOUT_MIN=75 that week, the override in bellows.mjs, and left the file at 45 as the target. QA runs finish in under ten. |
maxRunsPerIssuePerDay | 6 | Build-test-build-test all day on one issue. Six runs is two full cycles plus review. | If an issue hits six, the question is the spec, not the number. Keep it. |
maxReviewRounds | 2 | Ember and Flint politely disagreeing forever. | Two is right. A third round has never changed anyone's mind. |
maxQaFailsPerIssue | 3 | Gauge and Ember cycling on a criterion nobody can satisfy. | Keep. See the qa-third-fail reason. |
maxDailySpendUsd | 40 | A runaway day. Bellows adds up what the runs cost and stops when the day is spent. | Set it to what you would not mind wasting once. Raise it when the dashboard shows a normal day and what it costs. |
The same rule applies to the rules. In the first month you will add gates because something made you nervous. Good. Then read the telemetry. A gate that fires often and gets approved every time is not protecting anyone. It is training Priya to type "approve" without reading. Alderline started with fourteen rules and ended the month with ten.
Too many gates fail in a specific way. Priya stops reading them. Then the one that mattered gets the same reflex "approve" as the twenty that did not. The rule: if a gate is approved 20 times in a row with no edit, ask whether it should exist. Either delete it, narrow its pattern so it fires only on the case you were worried about, or turn it into a check Warden or Gauge runs without asking. Thirty-eight gates fired in Alderline's six weeks. Thirty-four were approved, four rejected. Every one of the four was worth the other thirty-four.
owner.quietHours says 22:00 to 07:00, Portland time. At 2am Bellows keeps running whatever needs no human: Gauge tests, Warden reviews, Quill writes, Flint reviews Ember's overnight PR. Every gate question waits. Nothing customer-facing moves and nothing goes to production. At 7am Priya gets one batch, not a night of pings. The kit's Bellows script reads the field but does not yet hold the notification; add that check in askPriya before the webhook call, and see chapter 11.
Three steps, fifteen minutes. Do the third one or you will not know whether the gate works.
Name it, pick the tool, write the pattern, choose ask or deny, and write the reason for Priya's phone. Say Tom wants to know before any customer export leaves the system:
{
"id": "csv-export",
"when": { "tool": "Bash", "command": "npm run export|scripts/export" },
"decision": "ask",
"reason": "Customer data is leaving the system. Say which accounts and for whom."
}Open Claude Code in the repo and ask it to do the thing. You should see [gate:csv-export] in the permission prompt. If you do not, the pattern is wrong or the tool name is. Fix it now, while it is cheap. Try one near miss too: a command that should not fire it.
Use the ember agent. Export all accounts on the Tuesday East route to CSV for Tom with npm run export. If a gate stops you, say in one sentence what you were about to do and wait for me. Then, separately, run npm run test so I can see that a normal command does not trip it.
Open the dashboard. Look at how often it fired and what Priya answered. Approved every time with no edit: narrow it or drop it. Rejected even once: it stays, and write down why in the Decisions page so the next person understands the promise.
No. The hook runs outside the model, before the tool call, every time. The agent can only ask. It could try to split a command into pieces that dodge the pattern; CLAUDE.md forbids that in plain words and Flint's review looks for it. Keep the patterns broad enough that the obvious rewordings still match, and keep deny for the few things that should never happen at all.
The question waits. A gate is a stop, not a timer. ALD-42's deploy gate waited twenty-six hours, from Monday 5:25 pm to Tuesday 7:42 pm, and nothing was worse for it. Bellows keeps running everything that needs no human and holds the rest. Quiet hours mean you get one batch at 7am. If Alderline needed round-the-clock cover, Tom would be on the notify list for some rules. Nothing on the list should ever be "approve by default after an hour". That is not a gate either.
The agent gets the reason and is expected to do the right thing instead. A force push becomes a new commit. A write to .env becomes a note asking Priya to set the variable in Vercel. The event is in telemetry with decision: deny. If a deny fires often, either an agent file is teaching a bad habit or the rule is too wide. Both are worth ten minutes.
Open the dashboard. It reads telemetry/events.jsonl through kit/scripts/telemetry.mjs and shows gates asked, approved and rejected, by rule and by week. For the raw version: grep '"event":"gate"' telemetry/events.jsonl and count.
Yes. Give a rule its own notify list and Bellows sends it there. delete-data and migrations are good candidates for Tom, who runs operations and knows what the data means. Keep prod-deploy with Priya. One person owning the production gate is the simplest audit trail a company can have.
One short file that says when a human is asked and why, in words the human can read at 7pm. A hook that enforces it before every command and file write, outside the model. A second hook that catches the crew changing how it works. Budgets that stop quiet failures. A messenger that carries each question to your phone and reads your two-word answer in Linear. And a way to tune all of it from telemetry instead of nerves.
Next: Linear and the wiki. Where the work lives, where the knowledge lives, and how a state change wakes the next agent.