AgentLabs
All insights

Artificial Intelligence · 13 min read

Gates, not guidelines: building a product with AI agents that cannot cut corners

The pitch is that you wire up one agent and go ten times faster. That is not what happens. Here is the setup, tool by tool, that actually let AI agents write every line of my product’s code without quietly breaking a database full of customer data.

Bram van Gestel · Published 2026-07-25

The pitch is that you wire up one agent and go ten times faster. That is not what happens. Here is the setup, tool by tool, that actually let AI agents write every line of my product’s code without quietly breaking a database full of customer data.

Two comparison cards: 'written as a guideline,' with the rule 'scope every query to the tenant' and the result 'the agent talks its way past it,' beside 'written as a gate,' with the rule 'rls plus a test on every commit' and the result 'the agent cannot skip it,' under the line 'an agent does what you build, not what you ask'.
The same rule, two ways.
The short version
I build a multi-tenant product with sensitive customer data, on my own. Every line of the code is written by AI agents. I spec, review, and merge.
It holds because the environment enforces it: over a hundred tests before any feature, tenant isolation checked on every commit, and nothing reaches main without clearing the gates.
You probably need less than this. The post shows how to work out which parts your product actually needs.

The ten times faster myth

I nearly shipped a safety check that was checking nothing. It ran on every commit and passed on every commit, and all the while it was silently inspecting an entire category of files: zero of them. It looked green. It was doing nothing. I only caught it because that check had its own test, and the test failed. That is the gap this whole post is about. With an agent, “it looks done” and “it is done” are two different claims, and only one of them is safe to trust.

If you run a team, you have heard the other version of this. Point an agent at the repo, describe the feature, ship by lunch, ten times faster.

I have spent about twenty years leading engineering teams, through more than one hype cycle. This one is real. It is also more dangerous than the last few, for a specific reason: the agent is confident, fast, and wrong just often enough that you cannot tell by reading.

Wire up one agent with nothing around it and here is what you actually get. It writes a lot of code, fast. The code looks right. Some of it is wrong in ways you cannot see by reading it, and the agent tells you every time that the work is safe and complete. On a throwaway script that is fine. On a multi-tenant product that holds customer data, that is how you leak one tenant’s data into another tenant’s account, and you find out from the customer.

The speedup is real, but the bottleneck moved. It is no longer typing the code. It is verifying it. An agent removes the constraint that used to slow you down, which means the only thing between fast and dangerous is the environment you put the agent in.

So before I let an agent write a single feature a user could see, I spent about two weeks building that environment, and shipped nothing a user would notice in that time. That was the point. This post is the environment.

The one idea

An agent does what you build into its environment. It does very little of what you only ask for in a document. Ask it in prose to scope every query to one tenant, and it complies, right up until the once it does not, and it calls that change safe and complete. Put the same rule behind a check that fails the build, and there is nothing left to comply with. The wrong code does not merge. Gates, not guidelines.

An agent does what you build, not what you ask.

Start from what you are building, not from a tool list

I did not pick these tools because they are nice. I needed all of them, because of what I was building. The right order is to work out what you are building and what it demands, then let that dictate the gates.

I did that on purpose, before writing code. I ran the plan through a series of adversarial reviews, each a separate pass from one angle, each producing a written audit I then had to answer: an architecture audit, a data-and-AI audit, a delivery audit, and a multi-role review of the implementation plan itself. Every finding got folded back into the plan before the first line of code. You can run the same passes with an agent, which is the honest version of “let AI help.” Point it at your spec and ask it to threat-model the thing, list the ways it can hurt a customer, and name the ones a human will forget to check. The gates fall out of that list.

Here is the mapping for my product. Yours will differ, but the method is the same.

  • It is multi-tenant on one shared database, so I needed tenant isolation proven on every commit, not in a review someone can forget.
  • It holds sensitive data and customers can demand deletion, so I needed an immutable, append-only schema and a delete that redacts in place.
  • The privacy promise is the product, so customer data can never touch the coding subscription, and logs are scrubbed before they leave the process.
  • I am solo, and agents write all the code, so there is no teammate to catch a mistake in the hallway. The catching has to be mechanical.
  • Agents move fast, so the architecture has to be pinned, or the same logic sprawls into three slightly different copies within a week.

Every gate below is one of those lines made structural. If your product is a marketing site, you need almost none of this. If it touches money, identity, or other people’s data, you need most of it, and you should work out which parts before you let an agent loose.

The environment before the code

The method comes from superpowers, Jesse Vincent’s open skills framework for Claude Code. It encodes the loop I already believed in: test-driven development, a plan-first habit (brainstorming, then writing-plans), subagent-driven-development (a fresh agent per task, then a two-stage review), and verification-before-completion, which forces the agent to prove work is done instead of claiming it. Every task runs the same loop.

Five-step loop diagram: brainstorm (shape the idea), spec plus plan (small tasks), build (test-first), two reviews (spec, then code), and merge (gates green), with the loop repeating by pulling the next unblocked task from the graph.
The loop repeats: pull the next unblocked task from the graph.

The agent reads a short guide at the repo root that documents only what it gets wrong, plus one short guide per package. Restraint is the point. Every line is context the agent pays for on every turn. A slice of the database package’s guide:

# db: schema, migrations, tenant context
- withTenant (src/tenant-context.ts) is the ONLY place the tenant GUC is set.
  ast-grep enforces this. Tenant id traces to the session, never user input.
- Immutability and append-only are DB triggers, not convention. Do not "fix"
  a blocked update in app code. The trigger is the contract.

The agent’s only database access in development is a read-only Postgres MCP server in restricted mode, pointed at a local container, never production. That container runs on OrbStack, a fast Docker-compatible runtime, so the database the agent sees is disposable and identical to the one CI uses. The agent can read the schema. It cannot change anything, and it cannot reach real data.

One more split, for privacy. I build on a coding subscription. The product’s customer data runs through a separate, metered path I control, and the two never touch. That separation is the thing I sell, so I keep it clean, down to two spend-capped keys so a runaway process cannot drain the wrong budget.

The foundations features stand on

Platform choices are recorded with a one-line reason each: Railway to host, Supabase Postgres, Drizzle for migrations. Before any data existed, I ran a real backup restore. An untested restore is not a restore.

The scaffold is one database and three services. Each scrubs its own logs through Sentry before anything leaves the process, so customer text never egresses. I proved it by planting fake secrets and watching them come out redacted in every service.

GitHub Actions run summary for a 'fix(ci):' pull request, showing status Success in 1m 34s with five green checks: build-test (1m 31s), secrets (8s), sast (28s), invariants (29s), and workflows (16s).
Every pull request, five gates, green.

The data model is immutable and append-only, every row scoped to a tenant, with deletion that redacts in place so references never shift. And tenant isolation is checked on every commit: a test suite discovers any new table missing its isolation policy and fails. Every time, automatically. This is the one gate I would not build a multi-tenant product without.

Quality is a gate, not a hope

This is the part that decays first when you move fast, so it gets the hardest gates. Everything is built test-first with Vitest, and the output has to be clean. There were more than a hundred tests before a single feature existed. Every file is also checked the instant it is written by a post-edit hook that typechecks, lints, and runs the architecture scan, advisory only so it stays fast:

case "$file" in /*) ;; *) file="$PWD/$file" ;; esac
cd "$(git -C "$PWD" rev-parse --show-toplevel)"   # anchor to repo root so the scan finds its rules
pnpm typecheck                              || echo "typecheck failed after editing $file"
pnpm exec eslint --fix "$file"  2>/dev/null || true
pnpm exec ast-grep scan "$file"             || echo "architecture-invariant violation in $file"

That cd to the repo root is not cosmetic. A scan run from inside a subfolder finds no rules and passes falsely. A check that quietly passes is worse than no check.

> tsc -b
packages/db/src/_demo.ts:1:14 - error TS2322: Type 'string' is not assignable to type 'number'.

1 export const _demo: number = "not a number";
               ~~~~~~

Found 1 error.
⚠ typecheck failed after editing ~/code/app/packages/db/src/_demo.ts

Review is not one pass. It runs as three named subagents, each with its own brief and its own model: a test-writer, a code reviewer, and a security reviewer pinned to the strongest model, because a missed injection costs more than a missed typo. Simplicity is written into each. The test-writer’s instruction is focused tests, not ceremony, one clear failing case over ten. The code reviewer checks readability and needless database calls, not only correctness, and it does not grep around blindly: it queries CodeGraph, a local code knowledge graph indexed over the repo, to trace call paths and blast radius, which keeps the review both accurate and cheap on tokens. And ast-grep enforces one shared implementation of the things that matter. One rule, in full:

id: no-tenant-context-outside-db
language: TypeScript
severity: error
message: The tenant GUC may be set only by withTenant in packages/db.
rule:
  any:
    - { kind: string, regex: "app\\.tenant_id" }
files:  ["packages/**/*.ts"]
ignores: ["packages/db/**"]
Running 7 tests
PASS  no-tenant-context-outside-db-ts
PASS  no-tenant-context-outside-db-tsx
PASS  no-raw-pg-outside-db-ts
PASS  no-raw-pg-outside-db-tsx
PASS  no-anthropic-sdk-outside-shared-ts
PASS  no-anthropic-sdk-outside-shared-tsx
PASS  no-dangerous-html-in-web

test result: ok. 7 passed; 0 failed

Agents also love to write enormous files. Left alone, one will grow a single module to two thousand lines, because it never has to read the whole thing back. So the coding standard is a gate too, in two tiers. As you write, an advisory tier warns on the things that make code hard to change later: cognitive complexity, deep nesting, too many parameters. At the hard edge, the build fails on a god file or a god function, no file over 700 lines and no function over 200. Tests, seeds, and generated code are exempt, because length there is not the enemy.

// tier 1: advisory, surfaced as you write
'sonarjs/cognitive-complexity': ['warn', 15],
'max-depth':  ['warn', 4],
'max-params': ['warn', 4],
// tier 2: hard ceilings, fail the build
'max-lines':              ['error', { max: 700 }], // no god files
'max-lines-per-function': ['error', { max: 200 }], // no god functions

A god file cannot land, because the linter refuses it, not because someone remembered to keep files small.

Not everything is a hard gate, and that is deliberate. Throwaway scripts and spikes run ungated. The complexity limits and the edit-time checks are advisory by design, a nudge while I write, not a wall. I spend the hard, build-failing gates only on the handful of properties that would actually hurt a customer: tenant isolation, immutability, secrets, the architecture invariants. Gate everything and the gates stop meaning anything; the skill is choosing the few that must never give.

Five-step diagram titled 'edit to merge': an advisory edit-time hook (typecheck, lint, invariant scan), a commit guard that blocks rm -rf, force-push, and prod commands, CI gates that block the merge (build, test, secrets, sast, invariants), an advisory automated review that comments on the PR, and a human-gate step for human review and merge, where no agent commits to main.
The gates, edit to merge.

The hooks are wired in one place, so they run whether the agent likes it or not:

"hooks": {
  "PreToolUse":  [{ "matcher": "Bash",       "hooks": [{ "command": "python3 .claude/hooks/block_dangerous.py" }] }],
  "PostToolUse": [{ "matcher": "Write|Edit", "hooks": [{ "command": ".claude/hooks/post_edit.sh" }] }]
}

The pre-command guard fails closed. If it cannot even parse its input, it blocks:

BLOCKED = [
    r"\brm\s+-rf\b", r"git\s+push\b.*--force",
    r"\bDROP\s+TABLE\b", r"\bTRUNCATE\b",
    r"<prod-db-name>",                    # the real production database
    r"--environment[=\s]+prod",
    r"railway\b.*\bprod(uction)?\b",
]
# exit 2 blocks the command and feeds the reason back to the agent
$ echo '{"tool_input":{"command":"rm -rf /"}}' | python3 .claude/hooks/block_dangerous.py
Blocked: command matches guarded pattern /\brm\s+-rf\b/.
If this is intentional, run it yourself outside the agent.
exit: 2

In CI the gates are least privilege and version-pinned: gitleaks for secrets, Semgrep OSS for static analysis, ast-grep for the architecture invariants, zizmor for the workflow files themselves, and Dependabot on a cooldown. Two stories from this layer are the real point.

The first: the guardrails have their own tests, held to the same bar as the code. The hollow check from the opening, the one that looked green while scanning nothing, was an architecture rule that had silently stopped covering a whole file type. A test on the gate caught it. A glance never would have. A gate you do not test is just a more confident guess.

The second: the automated reviewer did not work at first. The off-the-shelf tool, claude-code-action, ran the model, produced a review, and never posted it. Silently. So I tore it out and owned the last mile: fetch the diff, ask the model to review it, post the result as a comment. If a review is produced, it gets posted. Full stop.

GitHub pull request comment from github-actions bot titled 'Claude review — claude-sonnet-5,' judging a CI-tooling-only PR as not applicable to tenant-isolation invariants, walking through the script logic and test script with two correct-behavior notes and two minor non-blocking nits, and concluding 'Overall: clean.'
The reviewer I built, commenting on a real pull request.
A rule the agent can argue with is not a rule. A gate is.

The human stays at the merge

Everything above is automation, and none of it merges code. The agent does not run with a free shell. It runs under an allowlist. Building, testing, linting, typechecking, reading the repo, staging and committing to a branch all run without asking. Every action with a consequence sits on a stop-and-ask list and waits for a human yes.

This file is committed, so the guardrails travel with the repo, not with my laptop; machine-specific overrides live in a separate local file, out of the shared rules. The agent cannot commit to main at all. No agent touches main, ever. Every pull request is reviewed and merged by a person, me. The automated reviewer is the third line of defense, behind the security stack and my own read of the diff, not a replacement for either, and that read is not optional: the root guide tells me to work through every finding it posts and either fix it or consciously defer it before I merge, never silently past it.

"permissions": {
  "allow": [                            // runs without asking
    "Bash(pnpm:*)", "Bash(tsc:*)", "Bash(vitest:*)", "Bash(eslint:*)",
    "Bash(git add:*)", "Bash(git commit:*)", "Bash(git diff:*)",
    "Read", "Edit", "Write", "Grep", "Glob"
  ],
  "ask": [                              // stops and waits for a human yes
    "Bash(git push:*)", "Bash(gh pr create:*)",
    "Bash(rm:*)", "Bash(git reset --hard:*)", "Bash(git clean:*)"
  ]
}
The gates make the agent’s work safe to look at. A human still decides what ships.

Let the plan be something the agent pulls from

The roadmap lives as a dependency-aware task graph in beads, Steve Yegge’s issue tracker built for agents. A session starts by asking the graph for the next unblocked task, not by re-reading a document. Only the genuine safety gates are hard edges, and the graph reflects what is actually done, so it always shows the real next thing.

$ bd ready
P0  do next
  - set up billing and spend caps
  - decide hosting and database
  - stand up the monorepo
P1  after that
  - first data connector
  - observability and metering
# 5 unblocked tasks, everything else is waiting on a dependency

It had its own friction, which I will not pretend away: it wrote its database into the wrong place inside a worktree, and I chose not to wire its automatic syncing because it staged changes into unrelated commits. I keep it anyway. The alternative is an agent re-reading a stale planning doc and guessing what to do next, and that guess is exactly what wastes a session. A rough edge on the task graph beats no task graph.

What this actually buys you

The speedup is real. I am one person shipping the foundation of a product that would normally need a small team. But the ten times number hides where the work went. It did not disappear. It moved from writing code to designing the gates that let an agent write code you can trust.

This is written for founders and engineering leaders who were handed the 10x number and a lot of enthusiasm. The honest trade is that the speedup is real, but the judgment does not disappear. It moves to the front, into the environment, spent once instead of forever.

It is also an open invitation to the engineers reading. I built these gates from twenty years of running teams, but I am building solo now, and I am sure some of them are wrong. Tell me which gate you would add, and which one of mine you would tear out.

I am writing a series on this, from the seat of a CTO now building with agents. Follow if you want the next one.

Summary diagram titled 'the whole setup, in five zones': accounts and privacy (coding subscription separated from product data on a metered path, two spend-capped keys); the build loop (brainstorm, spec and plan, build test-first, two reviews, merge); what the agent reads (root and per-package CLAUDE.md, settings.json hooks and permissions, three review subagents); the gates from edit to merge (edit-time hook, commit guard, CI checks, auto review, human merge); and foundations (OrbStack local Postgres, Supabase, Railway, Drizzle, Sentry scrubber, immutable schema plus RLS).
The whole setup, in five zones.
An agent does what you build, not what you ask.

Tools and references

Claude Code · superpowers · beads · CodeGraph · Postgres MCP · Model Context Protocol · ast-grep · gitleaks · Semgrep OSS · zizmor · Dependabot · OrbStack · Drizzle · Vitest · Railway · Supabase · Sentry

Also published on AI in Plain English.

More insights

Want this discipline on your own systems?

We turn business processes into maintainable, human-orchestrated AI systems, with the governance built in from day one.