Skip to content

Custom Instructions for AI Code Review

Lesson 3 of 6Advanced13 min readGitHub Copilot & AI Engineering · AI Code ReviewVerified: GitHub Copilot custom instructions and code review documentation, September 2026

An untuned AI review finds generic problems. A tuned one finds your problems.

The difference is a text file. That sounds like an overstatement until you compare two reviews of the same diff — one against a repository with no instructions, one against a repository whose instructions say what this codebase cares about. The findings are not merely different in tone; they are about different things.

This is the highest-leverage work in the whole cluster, and it is also the part teams skip, because turning the feature on takes a minute and writing the instructions takes an afternoon.

Three files, three scopes:

FileScopeApplies to
.github/copilot-instructions.mdWhole repositoryEvery review in this repository
.github/instructions/NAME.instructions.mdPath-matchedFiles matching the front matter applyTo glob
Organisation-level instructionsCross-repositoryEvery repository in the organisation

All of them are read from the pull request’s head branch, which has both a convenience and a security consequence. The convenience: you can change instructions and see the effect in the same pull request. The security consequence is covered in secure AI code review and is not optional reading.

The single most useful distinction: checkable against a diff, or not.

A review reads a change. It can apply a criterion that is decidable from the code in front of it. It cannot apply one that requires knowledge it does not have.

Works:

Every exported function must have a JSDoc block with @param and @returns.
Database queries must use the query builder in `src/db/query.ts`, never raw SQL strings.
New public API endpoints must have a corresponding entry in `docs/api/`.
Errors crossing a module boundary must be wrapped in `AppError`, never re-thrown raw.
Time values are stored and compared in UTC. Local time appears only at render.

Each of those is a property of the diff. A reviewer reading the change can decide it.

Does not work:

Code should be clean and maintainable.
Follow best practices.
Ensure good performance.
Consider the user experience.

Not because they are wrong, but because they are not decidable. “Clean” produces either no findings or arbitrary ones, and arbitrary findings are the noise that trains people to skip the review section.

The test to apply to every line you write: could two competent engineers disagree about whether a given diff satisfies this? If yes, the instruction will produce inconsistent findings. Rewrite it until the answer is no, or delete it.

Underused, and often the fastest improvement available.

## Out of scope for review
- Formatting and import ordering — handled by Prettier in CI.
- Line length — enforced by the linter.
- Dependency versions in lockfiles — handled by Dependabot.
- Test naming style — no team convention; do not comment.

Every category you exclude raises the average value of what remains. A review that stopped mentioning formatting is a review whose remaining comments are more likely to be read.

The categories worth excluding are the ones your toolchain already covers. If a formatter runs in CI, a review comment about formatting is redundant by construction — the formatter will win the argument regardless of what anybody says in the thread.

The mechanism that makes review criteria fit the file rather than the repository.

.github/instructions/migrations.instructions.md:

---
applyTo: "db/migrations/**"
---
Review criteria for database migrations:
- Every migration must be reversible, or state in a comment why it cannot be.
- No migration may both alter a column type and backfill data in one step.
- Adding a NOT NULL column to an existing table requires a default or a
three-step migration; flag single-step attempts.
- Index creation on tables over ten million rows must be CONCURRENTLY.
- Migrations must not reference application code or ORM models.

Those criteria are correct for db/migrations/** and would be noise everywhere else. That is the entire argument for path scoping.

Useful splits in most codebases:

PathWhat its review should care about
db/migrations/**Reversibility, locking, data safety
.github/workflows/**Pinned actions, permissions, secret handling
infra/**State safety, blast radius, provider version pinning
src/api/**Contract changes, versioning, auth checks
**/*.test.tsAssertion quality, not implementation style

Writing them from your actual review history

Section titled “Writing them from your actual review history”

The best source for review instructions is not a style guide. It is the comments your team has already written.

  1. Read the last fifty review comments on merged pull requests in this repository. Not the ones about the specific change — the ones somebody has clearly written before.

  2. Group them. You will usually find five to ten recurring themes, and they are rarely the ones a style guide would predict.

  3. Write each recurring theme as a checkable criterion. If it cannot be made checkable, it belongs in a design document rather than a review instruction.

  4. Check which ones your toolchain already enforces. Those become exclusions, not criteria — the linter is more reliable than the review at things the linter can do.

  5. Sort the remainder by scope. Repository-wide if universal; path-specific if not.

  6. Write the file, open a pull request that violates one criterion, and check the review mentions it. An instruction you have not seen fire is an instruction you are guessing about.

This produces instructions grounded in what the team actually corrects, which is a much better predictor of what matters here than any general standard. It also has a side effect worth having: several of the themes will turn out to be things the team disagrees about, and that disagreement is more productive to resolve than to keep re-litigating one pull request at a time.

The file is read by a model, and the same things that make it clear to a human make it usable.

# Code review guidance
## Stack
TypeScript, Node 22, PostgreSQL via Kysely, Vitest.
## Always flag
- Raw SQL strings outside `src/db/`.
- `any` in exported type signatures.
- `process.env` read outside `src/config/`.
- Unawaited promises in request handlers.
- New dependencies without a note in the pull request description.
## Never flag
- Formatting (Prettier), import order (ESLint).
- Test file naming.
## Context
This service handles payment webhooks. Idempotency is not optional:
any handler that mutates state must be safe to run twice.

Concrete, scannable, and specific to a codebase. The “Context” section does disproportionate work — a single sentence about what this service is changes what the review considers relevant, in a way that a list of rules does not.

Length. Long instruction files dilute. Twenty sharp criteria beat a hundred vague ones, and a file nobody has pruned in a year is usually mostly vague ones. Prune it when you prune anything else.

The criteria above are all local — properties of a line or a function. The instructions that produce the findings teams value most are usually about structure, and structure needs a different kind of writing.

A local criterion says what a line must look like. A structural criterion says where things belong:

## Layering
- HTTP handlers in `src/api/` may call services. They may not call the database directly.
- Services in `src/services/` may call repositories. They may not import from `src/api/`.
- Repositories in `src/db/` may not import from `src/services/`.
Flag any import that crosses these boundaries in the wrong direction.

That is checkable — an import statement either crosses the boundary or it does not — and it catches the class of change that quietly erodes an architecture over eighteen months. No individual pull request looks like a problem; the aggregate does.

The same shape works for other structural facts a codebase holds:

Ownership of a concern. “Retry logic lives in src/http/client.ts. Flag retry loops written elsewhere.”

Single points of construction. “Database connections are created only in src/db/pool.ts.”

Direction of dependency. “The shared/ package may not import from any application package.”

These are the rules a senior engineer applies without thinking and a new joiner cannot know. Writing them down is worth doing regardless of whether a model reads them — the review instruction file has turned out to be a surprisingly good home for architectural decisions that previously lived only in people’s heads and in the memory of an argument from two years ago.

The caveat. A model reading a diff sees the changed files, not the whole import graph. It will catch a violating import that appears in the diff; it will not reliably notice that a boundary has already been crossed elsewhere. For enforcement, use a dependency-cruiser rule or an ESLint boundary plugin in CI. The instruction catches the ones that slip past, and the CI rule is what makes the boundary real.

Instructions and the rest of the toolchain

Section titled “Instructions and the rest of the toolchain”

Worth being explicit about the division, because the overlap is where effort gets wasted.

ConcernBelongs in
FormattingFormatter
Syntax-level rulesLinter
Type safetyType checker
Known-vulnerable dependenciesDependabot
Secret patternsSecret scanning
Common vulnerability patternsCodeQL
Team conventions a tool cannot expressReview instructions
Architectural boundariesBoth — a CI rule for enforcement, an instruction for early feedback

The last row is the only genuine overlap, and it is deliberate. Everything above it, a tool does better: deterministically, faster, and without a review comment that somebody has to read and agree with.

The row that matters is the second-to-last. Team conventions a tool cannot express is the reason review instructions exist. “Errors crossing a module boundary are wrapped in AppError” is not a lint rule anybody is going to write, and it is exactly the sort of thing a reviewer corrects for the fifth time this quarter.

If you find yourself writing an instruction that a linter could enforce, write the lint rule instead. The review is the layer for things that resist automation, and filling it with things that do not resist automation is how it becomes noise.

Available on plans that support them, and useful for exactly one category: criteria that are genuinely true everywhere.

Good candidates: a security requirement that applies to all code, a legal or compliance constraint, a convention the whole organisation has actually agreed on.

Bad candidates: anything stack-specific, anything about testing style, anything a single team feels strongly about. An organisation instruction wrong for one repository is wrong on every review in that repository, and there is no clean repository-level override.

The practical rule: if you cannot name a repository where the criterion would be unwelcome, it is a candidate. If you can, it is not.

Reviewing the instruction files themselves

Section titled “Reviewing the instruction files themselves”

An instruction file is repository content, which means it goes through pull requests, which means somebody should be reviewing it. Most teams do not, and it shows.

Treat a change to review criteria as a change to a shared standard. It affects every subsequent review in the repository. That deserves at least the scrutiny a change to the linter configuration gets — and most teams review linter configuration changes carefully, because a bad lint rule is loud. A bad review criterion is quiet, which is why it needs the deliberate attention rather than less.

Put the files under CODEOWNERS if the repository has owners. The people who own the code own the standards applied to it.

Watch for criteria added to win an argument. Somebody loses a disagreement in a pull request thread and encodes their position in the instruction file, where it now applies to everyone forever without having been agreed. This happens, it is hard to spot after the fact, and the fix is cultural rather than technical: instruction changes get discussed like the policy changes they are.

Date the significant decisions. A criterion with a one-line rationale — “added after the incident on the payments webhook; handlers must be idempotent” — survives the departure of the person who wrote it. An unexplained rule does not, and unexplained rules are the ones that get deleted the moment they become inconvenient.

The instruction file is the closest thing most repositories have to a written record of their engineering standards. That is more valuable than the review findings it produces, and it is worth maintaining on those grounds alone.

Instructions are code, and untested code is a guess.

Test each new criterion once. Open a pull request that violates it. If the review does not mention it, the criterion is either not being read or not decidable. Both are worth knowing before you rely on it.

Watch the acted-on proportion. Of recent findings, how many led to a change or a considered dismissal? A falling proportion means the instructions have drifted from what the team cares about.

Watch what you still say by hand. The comments you keep writing manually are the criteria your instructions are missing. This is the same technique as the initial authoring pass, run continuously.

Watch for stale criteria. A rule about a framework you migrated away from produces findings about conventions nobody holds any more, and it is easy for that to sit unnoticed for a year because nothing fails.

A payments service, three months after enabling review, with instructions derived from its own review history rather than from a template.

The team’s recurring manual comments, grouped:

  1. “This handler is not idempotent” — five occurrences.
  2. “Use the money type, not a float” — four.
  3. “This logs the full request body, which contains card data” — three.
  4. “Missing timeout on the outbound call” — three.
  5. “Error swallowed here” — three.

What those became:

# Code review guidance
## Context
Payment webhook service. Every state-mutating handler may be delivered more
than once by the provider. Idempotency is a correctness requirement, not a
nicety.
## Always flag
- A handler in `src/webhooks/` that mutates state without an idempotency key
check against `processed_events`.
- Monetary values typed as `number`. Use `Money` from `src/money.ts`.
- Logging of a request body, or of any field named `card`, `pan`, `cvv`,
or `token`, at any log level.
- An outbound HTTP call without an explicit timeout.
- A `catch` block that neither re-throws, wraps in `AppError`, nor records
the error. An empty catch with a comment is still a finding.
## Never flag
- Formatting, import order, line length — Prettier and ESLint own these.
- Test naming.
- Comment density.
## Layering
`src/webhooks/` may call `src/services/`. It may not import from `src/db/`.

Five criteria, three exclusions, one boundary. It fits on a screen. Every line came from a comment somebody had already written more than twice, and each is decidable from a diff.

What changed after two weeks: findings in categories 1, 3 and 4 started arriving before a human looked at the pull request. Category 2 mostly stopped appearing at all, because the criterion being written down changed what people wrote in the first place — an effect worth noting, since it is invisible in any measurement of the review itself. Category 5 produced some false positives on deliberate swallows, which the team resolved by adding an explicit convention: a swallowed error carries a // swallowed: reason comment, and the criterion exempts it.

That last iteration is the shape of the work. The first version of an instruction is rarely the final one, and the fix for a false positive is usually a convention that makes the intent explicit rather than a softer rule.

Vague criteria. “Write clean code” produces nothing useful. If two engineers could disagree about whether a diff satisfies it, it is not an instruction.

Duplicating the linter. Redundant findings, and they crowd out the ones only a reviewer can make.

Never excluding anything. The “never flag” section is as valuable as the “always flag” one, and much shorter to write.

One giant repository-wide file. Migration criteria applied to React components. Path-specific files exist for this.

Writing them from a style guide instead of from review history. The style guide describes what somebody once decided. The review comments describe what the team actually corrects.

Treating instructions as enforcement. They shape the review. Rulesets and CI enforce.

Never pruning. Criteria outlive the conventions that produced them, and a stale rule is worse than no rule because it produces confident findings about a standard nobody holds.

Assuming organisation instructions can be overridden locally. They largely cannot, which is why the bar for putting something there is “true everywhere”.

Review instructions are the encoding of what this team already knows, written down so it applies consistently rather than depending on who reviewed the pull request that day.

That framing decides most authoring questions. It explains why review history beats a style guide as a source; why criteria must be checkable; why exclusions matter; and why the file needs pruning, because what the team knows changes.

It also sets the limit. Instructions encode knowledge — they do not create obligation. The distinction between shaping and enforcing is the one to carry out of this lesson.

  • Three scopes: repository-wide, path-specific via applyTo, and organisation-level
  • Instructions are read from the pull request head branch, with a security consequence covered separately
  • A criterion must be decidable from the diff; “two engineers could disagree” means rewrite it
  • Exclusions raise the value of everything that remains
  • Your own review history is the best source of criteria
  • Test each criterion by violating it once; an untested instruction is a guess
  • Instructions shape a review; rulesets and CI enforce

Use a disposable repository with Copilot code review available.

  1. Open a pull request with a defect. Request a review with no instructions present. Save the comments.

  2. Add .github/copilot-instructions.md with three specific, checkable criteria drawn from your own codebase. Open an equivalent pull request. Compare.

  3. Write a deliberately vague criterion — “code should be maintainable”. Open a pull request. Predict: does it produce a finding, and is the finding useful?

  4. Add a ## Never flag section excluding formatting. Open a pull request with inconsistent formatting. Predict: is formatting mentioned?

  5. Add .github/instructions/migrations.instructions.md with applyTo: "db/migrations/**". Open one pull request inside that path and one outside. Compare the criteria applied.

  6. Modify the instructions inside a pull request and re-request review. Predict: does the review use the modified version?

  7. Delete the repository.

AI-assisted engineering learning pathEleven lessons on getting value from Copilot and agents without giving up review discipline.