Skip to content

Writing a path-scoped rule that agents actually follow

Rules fail for two reasons: they load when nobody needs them, or they are unfalsifiable. Scope by path, write checkable statements, and give every rule an escape hatch.

Next.js on Vercel4 min readships at docs/solutions/nextjs-vercel/writing-path-scoped-rules-agents-follow.md

Tags: rules · agents · conventions · claude-code · cursor

Every team that works with coding agents writes a conventions file. Most of them look like this after three months:

# Conventions

- Write clean, maintainable code
- Follow best practices
- Use meaningful variable names
- Handle errors appropriately
- Consider performance
- Prefer composition over inheritance
- Don't over-engineer
- Keep it simple

Nine hundred words of it, loaded into every request, and the agent still does the thing you told it not to do. Then someone concludes that rules do not work.

Rules do work. These ones cannot, for two specific reasons.

Why this file fails

It is unfalsifiable. Take "handle errors appropriately". You cannot look at a diff and determine whether it complies. Neither can a model. A statement that cannot be checked cannot be followed: it can only be agreed with, which is not the same thing.

It is always loaded. Nine hundred words of generic advice sits in every request, including the one where you are editing a CSS file. It costs context and it trains the reader (human or model) to skim, so when a rule that genuinely matters appears in the same file, it gets skimmed too.

Both problems have the same root: the file was written to be comprehensive rather than to change behaviour at a specific moment.

The fix: scope by path, state a check

A rule in this repo has two required parts. paths decides when it loads, and the body is a list of statements you could tick off against a diff.

---
title: Never construct Stripe objects in the browser
paths:
  - src/lib/billing/**
  - src/app/api/webhooks/stripe/**
---

- The `Stripe` server SDK is imported only in Route Handlers, Server Actions and
  files under `src/lib/billing/`. If a file has `"use client"`, importing it is
  a bug, not a style preference.
- Prices are read from the database, never hardcoded in a component. Changing a
  price must not require a deploy.
- Every webhook handler verifies the signature with
  `stripe.webhooks.constructEvent` before it reads the body, and returns 400 on
  failure.
- Webhook handlers are idempotent: look up the event id, return 200 if it has
  been processed, otherwise process and record it in the same transaction.

Four statements, all checkable, and the reader only ever sees them while touching billing code. That is a rule that changes behaviour.

The compiler turns this one file into the format each tool wants: Claude Code gets .claude/rules/<id>.md with paths: frontmatter (the only key it reads there, and a rule without it loads every session), Cursor gets .cursor/rules/<id>.mdc with globs:, Codex gets a nested AGENTS.md in the closest common directory. You author once.

Scoping, concretely

ScopepathsUse for
Repo-wide["**"]Things genuinely always true: no secrets in source, conventional commits
Feature area["src/lib/billing/**"]The bulk of your rules
File kind["src/**/*.test.ts"]Testing conventions
Single file["next.config.ts"]Config invariants

paths defaults to ["**"] when you leave it out, which is exactly the failure mode described above. Set it deliberately every time. If you cannot name the paths, the rule is probably advice rather than a rule, and advice belongs in a solution doc, where someone reads it once and understands the reasoning, rather than in a rule that fires on every request forever.

Write statements, not adjectives

The test: could a reviewer hold this line against a diff and get a yes or a no?

Instead ofWrite
Handle errors appropriatelyEvery Route Handler returns a typed error body { error: string } and a 4xx/5xx status. Never throw into the framework.
Keep components smallA component over 150 lines is split, or carries a comment saying why it is not.
Use good typesNo any in an exported signature. Use unknown and narrow.
Be careful with the databaseEvery query that reads a tenant resource filters on workspaceId from the session, in the query, not after it.

Notice the right-hand column is longer. That is fine. Ten specific rules beat sixty vague ones, and the specific ones are shorter to read than they look because you only load them when they apply.

Give every rule an escape hatch

A rule with no exception path gets broken silently, and you never find out. A rule with a documented exception gets broken loudly, in a way you can review:

- Route Handlers default to the Node runtime. `export const runtime = "edge"` is
  allowed only when the handler uses fetch-compatible APIs exclusively: add a
  comment saying which, so the next reader does not have to work it out.

Now the exception is visible in the diff instead of hidden in someone's head.

Where a rule comes from

Not from a brainstorm. The good ones have a specific origin: you corrected the same thing twice.

The first correction is a conversation. The second is evidence of a systemic gap, and that is when it becomes a rule: one sentence, in the file whose paths cover where the mistake happened. Ask the system-manager agent, whose entire trigger is repetition.

Rules that come from real corrections have a property invented rules never do: they are about things that actually go wrong in this codebase.

When a rule is not enough

Rules are read by a model that is also holding a task, a diff and a long conversation. Most of the time that is enough. When the cost of the mistake is unrecoverable (a deleted database, a leaked key, a force push) do not rely on reading. Write a hook. Hooks run in the harness and cannot be reasoned around, and docs/solutions/nextjs-vercel/guard-hooks-and-how-to-extend-them.md shows the shape.

The division is: rules for things that should be true, hooks for things that must never happen.

Keep them pruned

Delete rules that no longer apply. A rule referring to a directory that was deleted in March teaches the reader that this file is stale, and a stale rules file is ignored wholesale, including the rules that still matter. Pruning is maintenance, not admission of failure.