# Stop asking your AI agent to follow rules. Enforce them.

> Source: <https://dev.to/toffy/stop-asking-your-ai-agent-to-follow-rules-enforce-them-4mlo>
> Published: 2026-08-25 15:25:37+00:00

You've written it a hundred times. In your `CLAUDE.md`

, in your system prompt, in ALL CAPS:

NEVER put`"use client"`

at the page level. NEVER commit`@ts-ignore`

without a reason.

And your agent does it anyway. Not always — that would almost be easier to deal with. It follows the rule for the first 50k tokens, then quietly stops. Or Sonnet follows it and Haiku doesn't. Or it follows nine rules and forgets the tenth.

Here's the thing I finally accepted: **a rule in a prompt is a request. The model can decline it.** So I stopped asking, and started enforcing.

`exit 2`

turn those rules into a deterministic reviewer that runs after Some background in three lines: I run Claude Code with orchestrated agent teams — a builder writes code, a reviewer verifies it, and both get a stack-specific "playbook" of rules distilled from the mistakes mid-tier models actually make. It works well. I wrote about the prompt-engineering side of it before.

But rereading my playbooks, I noticed the rules split cleanly into two categories.

**Rules that need judgment:**

Trace the Server/Client boundary by hand.

Don't write a fix until you can state the root cause.

These need a model. Prompts are the right place for them.

**Rules that are just string matching:**

`"use client"`

at the top of`app/**/page.tsx`

→ wrong.

`process.env.SECRET`

in a client file → wrong.

`@ts-ignore`

with no justification → wrong.

Why was I asking a *language model* to remember these? A regex doesn't get tired at 200k tokens. A regex doesn't perform worse on a smaller model. I was running deterministic checks on the most expensive, least reliable runtime available.

Claude Code has [hooks](https://docs.anthropic.com/en/docs/claude-code/hooks) — commands that run on lifecycle events. The one you want is **PostToolUse**: it fires every time the agent runs `Edit`

or `Write`

, and it receives a JSON payload on stdin telling you which file was touched.

The magic is in the exit-code contract:

No human in the loop. No approval dialog. The agent edits a file, and — from its point of view — the edit "responds" with a code review. It reads the feedback and fixes the problem *in the same turn*, before a human or a reviewer agent ever sees the mistake.

A minimal version is embarrassingly simple:

``` bash
#!/usr/bin/env node
// .claude/hooks/check.mjs — wired to PostToolUse in .claude/settings.json
const input = JSON.parse(await readStdin());
const file = input.tool_input?.file_path;
if (!file?.endsWith(".tsx")) process.exit(0);

const src = fs.readFileSync(file, "utf8");
if (/^\s*['"]use client['"]/.test(src) && /\/app\/.*page\.tsx$/.test(file)) {
  process.stderr.write(
    "route-level \"use client\": this makes the whole page render client-side. " +
    "Push it down to the smallest interactive leaf component."
  );
  process.exit(2); // ← this line is the entire trick
}
```

That's a code reviewer that never sleeps, never gets context-drunk, and works for free.

In [ccteams](https://github.com/toffyui/ccteams) v0.3.0, every stack-specific team now bundles a check script built from its playbook's known failure patterns:

| Team | Checks (excerpt) |
|---|---|
`next-ts` |
route-level `"use client"` , non-`NEXT_PUBLIC_` env in client files, `useEffect` +`fetch` for initial data, `fetch()` without explicit cache intent, `@ts-ignore` /`as any`
|
`go-api` |
`http.Error` not followed by `return` , wrapping errors with `%v` instead of `%w` , errors discarded with `_` , `context.Background()` mid-request |
`python-fastapi` |
bare `except:` , Pydantic v1 API, mutable default args, `time.sleep` /`requests.*` inside async code |
`rails` |
SQL interpolation in `where` , `update_column` /`save(validate: false)` , `default_scope` , params mass-assignment, `Time.now`
|
`django` |
naive `datetime.now()` , `fields = '__all__'` , injection-prone `.raw()` /`.extra()` , `post_save` signals |
`react-native` |
`.map` inside `ScrollView` , index as key, DOM APIs like `localStorage` , unconditional `behavior="padding"`
|
`frontend` |
`onClick` on a `<div>` , `<img>` without `alt` , `outline: none` with no `:focus-visible` , z-index escalation |

When an agent writes a violation, it gets this back instantly:

```
ccteams next-ts check — app/dashboard/page.tsx:
  - route-level "use client": this page and its entire import tree now render
    client-side. Push "use client" down to the smallest interactive leaf component.
  - client file reads process.env.API_SECRET: non-NEXT_PUBLIC_ env vars are
    undefined in the browser (or a secret leak if inlined).
Fix these now, or state in your report why each is intentional.
```

Note the escape hatch in the last line. These are nudges, not walls — the edit already happened, and sometimes `useEffect`

+ `fetch`

is legitimate. The agent can push back with a reason, and the reviewer checks that it did one or the other.

**1. It fires at 100%.** A prompt rule needs to be read, retained, and recalled at the right moment. A hook is a grep. Token 500k? Fires. Haiku wrote the code? Fires.

**2. You pay per violation, not per instruction.** Prompt rules cost tokens on every delegation *even when they're followed*. A hook costs zero tokens until something is actually wrong — then it costs three lines.

**3. Mistakes die before the review round-trip.** Builder writes bug → reviewer catches it → sends it back → builder fixes it: that loop is the expensive part of multi-agent setups. Hooks kill the mistake at write time, so your reviewer spends its (expensive) tokens on things that actually need judgment.

This is the part I didn't expect to matter so much.

ccteams agents ship in two tiers — builders on Sonnet, reviewers on Opus. v0.3.0 adds model profiles:

```
ccteams use next-ts --profile budget    # builder: haiku / reviewer: sonnet
ccteams use next-ts                     # builder: sonnet / reviewer: opus
ccteams use next-ts --profile max       # everyone: opus
```

Getting Haiku to *remember* a playbook through prompts alone is a losing game. But hooks fire on Haiku's code with exactly the same precision as on Opus's. With deterministic checks backing it from below, `--profile budget`

stops being "cheap and broken" and becomes a legitimate configuration for routine work.

**The more discipline you mechanize, the less intelligence you need to rent.**

Hooks only cover rules that are mechanically checkable without false positives. "Trace the data flow," "run the actual build and quote its output," "state the root cause before fixing" — those stay in prompts and reviewer gates, and I have no intention of moving them.

The rule of thumb I've landed on:

If a violation can be detected with grep, enforce it with a hook. If it needs judgment, put it in the prompt.

One design note if you build this yourself: make your hook scripts fail silent (`catch → exit 0`

). A buggy check that crashes loudly will poison every editing session. And namespace your hook entries (ours all live at `.claude/hooks/ccteams-*`

) so that installing/removing them never touches hooks the user wrote themselves.

```
npm install -g ccteams
ccteams use next-ts --profile budget   # or go-api, rails, django, python-fastapi...
# restart Claude Code — hooks and agents load at session start
```

One command gets you the agent team, the playbook, the hooks, and the cost profile. Switching teams swaps the hooks cleanly.

If this saves you a round-trip or two, a star on the [repo](https://github.com/toffyui/ccteams) genuinely helps. And I'd love to hear what checks you'd add for your stack — the whole point of this design is that a new rule is one regex away.
