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:
// 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.
2. Project context
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
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 heads that off before it happens.
3. Coding conventions (Preferred/Avoid blocks)
Pick your 3-5 highest-value conventions 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
## 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%
5. Do-not-touch list
## 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`
- Lint: `pnpm lint`
- Type check: `pnpm typecheck`
The full template
---
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]
## Commands
- Install: [command]
- Dev: [command]
- Test: [command]
- Lint: [command]
A real-world example: ASP.NET Core project
Here's the same template filled in for a mid-sized ASP.NET Core Web API with EF Core and Clean Architecture:
---
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
- Do not call EF Core directly from controllers — always go through a MediatR handler
- Dependency injection only — no `new SomeService()` inside business logic
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);
}
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);
}
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 — enforced by an analyzer, so missing it fails the build, not just review.
A few things worth noticing about this version versus the generic one:
- The CQRS/MediatR rule is the single highest-leverage line — without it, an agent trained on typical ASP.NET tutorials will default to injecting
DbContextstraight into a controller. - The
Result<T>pattern matters more than it looks — .NET samples online overwhelmingly favor exceptions for control flow, so the file has to actively counteract that default. - The analyzer-enforced convention is worth stating explicitly even though tooling enforces it — it saves the agent a wasted turn discovering the build failure itself.
How to verify your file is actually pulling weight
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. 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 turn is context rot in disguise.
- Duplicating the README. If it's already documented for humans, link to it instead.
- Never updated. A stale "do not touch" list teaches the team to ignore the file entirely.
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.
Originally published at Dhrutika's Blog — I write about .NET, Angular migrations, and AI-assisted development.













