If you've added a CLAUDE.md or AGENTS.md file to your repo and felt like your coding agent still ignores half of it, you're not alone. Most of these files are just vague prose — "write clean code," "follow best practices," "use good error handling" — and vague prose doesn't change agent behavior any more than it changes a new hire's behavior on day one.
The fix isn't a longer file. It's a differently structured one. Below is a template you can copy straight into your repo today, plus the reasoning behind each section so you're not just cargo-culting it.
Why most AGENTS.md files fail
Here's the pattern almost everyone starts with:
## Code style
- Write clean, maintainable code
- Use good error handling
- Follow best practices
None of this is wrong, exactly. It's just useless to a model. "Good error handling" means nothing without a concrete shape to imitate. Compare that to this:
## Error handling
### Avoid
catch (e) {
console.log(e);
}
### Preferred
catch (e) {
logger.error('checkout.payment_failed', { orderId, cause: e });
throw new PaymentError(orderId, e);
}
The second version gives the model an actual pattern to pattern-match against — a logger call with a namespaced event, structured metadata, and a typed error thrown upward. That's the single biggest lever in this whole exercise: replace adjectives with code blocks.
The anatomy of a file that actually works
A good context file has six parts, in this order:
- Metadata header — so humans (and eventually you, in six months) know if the file is still trustworthy
- Project context — stack, architecture, and the non-obvious constraints a new engineer wouldn't guess
- Coding conventions — Preferred/Avoid blocks, not adjectives
- Testing requirements — what "done" actually means in your repo
- Do-not-touch list — the fastest way to prevent a confidently wrong 200-line diff
- Command reference — how to build, test, and lint, so the agent isn't guessing or hallucinating flags
Let's build the file section by section.
1. Metadata header
---
last_updated: 2026-09-14
owner: platform-team
scope: global
review_cadence: quarterly
---
This looks like overhead, but it's the difference between a file that rots silently and one that gets maintained. When a rule looks outdated, whoever finds it knows exactly who to ping. Without this, stale context files just accumulate contradictions nobody catches until an agent trips on one.
2. Project context
This section exists to state the things a competent engineer would otherwise have to reverse-engineer from the codebase — especially anything that goes against the obvious default.
## Project context
- Stack: Node.js 20, Express, PostgreSQL (raw SQL via `pg`, no ORM — see ADR-014)
- Monorepo managed with pnpm workspaces
- Auth: sessions via `iron-session`, not JWTs — do not introduce JWT-based auth
- All API responses follow the envelope in `src/lib/response.ts`; never return raw objects from route handlers
Notice the ORM line. Any model trained broadly will default to reaching for an ORM the moment it touches a database. Stating the constraint and pointing to the reasoning (an ADR link) heads that off before it happens.
3. Coding conventions (Preferred/Avoid blocks)
Pick your 3-5 highest-value conventions — the ones that actually recur in every PR — rather than trying to cover everything. More isn't better here; it's context rot.
API responses
Avoid:
res.json({ id: user.id, name: user.name });
Preferred:
res.json(successResponse({ id: user.id, name: user.name }));
Async error handling
Avoid:
app.get('/users/:id', async (req, res) => {
const user = await getUser(req.params.id);
res.json(user);
});
Preferred:
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await getUser(req.params.id);
res.json(successResponse(user));
}));
Naming
Avoid:
const d = new Date();
const u = await getUser(id);
function calc(x, y) { return x * y * 0.08; }
Preferred:
const requestTimestamp = new Date();
const user = await getUser(id);
function calculateSalesTax(subtotal, taxRate = 0.08) {
return subtotal * taxRate;
}
Dependency access
Avoid:
import { db } from '../../../lib/db';
export async function getOrders(userId) {
return db.query('SELECT * FROM orders WHERE user_id = $1', [userId]);
}
Preferred:
import { OrdersRepository } from './orders.repository';
export async function getOrders(userId, ordersRepo = new OrdersRepository()) {
return ordersRepo.findByUserId(userId);
}
(Repos are injectable so tests can pass a fake — see tests/api/orders.test.ts for the pattern.)
Each pair takes about 30 seconds to write and saves you from re-explaining the same thing in code review, repeatedly, forever.
4. Testing requirements
Be specific about what "done" means. "Write tests" is not a requirement an agent (or a junior engineer) can act on.
## Testing
- Every new route handler needs an integration test in `tests/api/`, following the pattern in `tests/api/users.test.ts`
- Run `pnpm test:unit` before considering any change complete
- Do not mock the database in integration tests — use the test containers setup in `tests/setup.ts`
- Minimum coverage for new files: 80% (checked in CI, not enforced locally, but treat it as a gate)
5. Do-not-touch list
This is the highest-leverage section for preventing damage, and the one most files skip entirely.
## Do not touch
- `migrations/` — migrations are hand-reviewed only; never generate or edit these
- `src/legacy/billing/` — frozen code pending a rewrite; bug fixes only, no refactors
- `.github/workflows/` — CI changes require a platform-team review; flag instead of editing directly
If you've ever had an agent "helpfully" refactor a file that was explicitly untouchable, this section is why it happened — nobody told it not to.
6. Command reference
## Commands
- Install: `pnpm install`
- Run dev server: `pnpm dev`
- Run all tests: `pnpm test`
- Run a single test file: `pnpm test -- tests/api/users.test.ts`
- Lint: `pnpm lint`
- Type check: `pnpm typecheck`
Trivial, but it removes an entire category of wasted agent turns spent guessing your package.json scripts.
The full template
Here's everything assembled — copy this, then replace every line with your own project's specifics.
---
last_updated: YYYY-MM-DD
owner: team-name
scope: global
review_cadence: quarterly
---
## Project context
- Stack: [languages, frameworks, database]
- Architecture: [monorepo/polyrepo, key services]
- Non-obvious constraints: [things that go against the default assumption]
## Conventions
### [Convention name]
Avoid:
[bad example]
Preferred:
[good example]
(repeat for 3-5 highest-value conventions)
## Testing
- [what every change requires]
- [how to run tests]
- [coverage or quality gates]
## Do not touch
- [path]: [reason]
- [path]: [reason]
## Commands
- Install: [command]
- Dev: [command]
- Test: [command]
- Lint: [command]
A real-world example: ASP.NET Core project
The template above is deliberately generic. Here's what it looks like filled in for an actual project — a mid-sized ASP.NET Core Web API with Entity Framework Core and a Clean Architecture-style layout. Use this as a second reference point alongside the generic template; between the two you should be able to adapt this to pretty much any stack.
---
last_updated: 2026-09-14
owner: payments-team
scope: global
review_cadence: quarterly
---
## Project context
- Stack: .NET 8, ASP.NET Core Web API, EF Core 8, SQL Server
- Architecture: Clean Architecture — `Api/`, `Application/`, `Domain/`, `Infrastructure/`
- CQRS via MediatR — every write is a `Command`, every read is a `Query`, handled in `Application/`
- Do not call EF Core directly from controllers — always go through a MediatR handler
- Validation via FluentValidation, wired in automatically through a MediatR pipeline behavior
- Dependency injection only — no `new SomeService()` inside business logic; register in `Program.cs`
Controllers stay thin
Avoid:
[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderDto dto)
{
var order = new Order { CustomerId = dto.CustomerId, Total = dto.Total };
_dbContext.Orders.Add(order);
await _dbContext.SaveChangesAsync();
return Ok(order);
}
Preferred:
[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderCommand command)
{
var result = await _mediator.Send(command);
return CreatedAtAction(nameof(GetOrder), new { id = result.OrderId }, result);
}
Controllers only translate HTTP in and out. All logic lives in the handler.
Nullable reference handling
Avoid:
public string GetCustomerName(int id)
{
var customer = _repository.Find(id);
return customer.Name; // throws NullReferenceException if not found
}
Preferred:
public async Task<Result<string>> GetCustomerNameAsync(int id)
{
var customer = await _repository.FindAsync(id);
return customer is null
? Result.Failure<string>($"Customer {id} not found")
: Result.Success(customer.Name);
}
We use the Result<T> pattern (see Domain/Common/Result.cs) instead of throwing for expected failure cases. Reserve exceptions for truly unexpected states.
Async naming and cancellation
Avoid:
public Task<List<Order>> GetOrders(int customerId)
{
return _dbContext.Orders.Where(o => o.CustomerId == customerId).ToListAsync();
}
Preferred:
public Task<List<Order>> GetOrdersAsync(int customerId, CancellationToken cancellationToken)
{
return _dbContext.Orders
.Where(o => o.CustomerId == customerId)
.ToListAsync(cancellationToken);
}
Every async method ends in Async and accepts a CancellationToken as the last parameter — this is enforced by an analyzer, so missing it will fail the build, not just review.
## Testing
- Every new `Command`/`Query` handler needs a test in `Application.Tests/`, using the in-memory EF Core provider pattern in `TestBase.cs`
- Controller-level tests use `WebApplicationFactory` — see `Api.Tests/OrdersControllerTests.cs`
- Run `dotnet test` before considering any change complete
- Do not mock `DbContext` directly; use the SQLite in-memory provider configured in `TestBase.cs`
## Do not touch
- `Migrations/` — EF Core migrations are hand-reviewed only; never hand-edit a generated migration
- `Infrastructure/Legacy/` — frozen pending the billing-system rewrite; bug fixes only
- `appsettings.Production.json` — never edit directly; changes go through the deployment pipeline config
## Commands
- Restore: `dotnet restore`
- Run API locally: `dotnet run --project Api`
- Run all tests: `dotnet test`
- Add a migration: `dotnet ef migrations add <Name> --project Infrastructure --startup-project Api`
- Format check: `dotnet format --verify-no-changes`
A few things worth noticing about this version versus the generic one:
- The CQRS/MediatR rule in Project Context is the single highest-leverage line in the whole file — without it, an agent trained on typical ASP.NET tutorials will default to injecting
DbContextstraight into a controller, which is exactly the pattern this team is trying to avoid. - The
Result<T>pattern example matters more than it looks — .NET code samples online overwhelmingly favor exceptions for control flow, so this file has to actively counteract that default with a concrete example, not just a rule stated in words. - The analyzer-enforced convention (async naming +
CancellationToken) is worth calling out explicitly in the file even though it's also enforced by tooling — it saves the agent a wasted turn discovering the build failure itself, and it explains why the rule exists rather than just that it exists.
How to verify your file is actually pulling weight
Don't just ship this and assume it's working. Run a five-minute experiment:
- Pick a real, moderately complex task from your backlog.
- Give it to your agent with the context file temporarily renamed/removed.
- Give it the exact same prompt with the file restored.
- Diff the two outputs.
If the two diffs look nearly identical, your file isn't doing anything — go back and turn more of your prose into Preferred/Avoid code blocks. If the second run correctly follows a convention the first one violated, you've got a working file.
Common mistakes to avoid
- Too long. A 500-line context file loaded on every single turn is context rot in disguise — the model's attention degrades the more irrelevant material it has to wade through. Keep it to your highest-value rules, not everything you can think of.
- Duplicating the README. If it's already documented elsewhere for humans, link to it instead of restating it.
- Never updated. A stale "do not touch" list that references a file that no longer exists is worse than no list at all — it teaches the team to ignore the file entirely. The metadata header exists specifically so someone notices when this happens.
Wrap-up
The difference between a context file that gets ignored and one that actually shapes agent behavior isn't length or thoroughness — it's specificity. Adjectives don't transfer; code blocks do. Start with the six sections above, keep it lean, and run the five-minute verification test before you assume it's working.