A Working CLAUDE.md/AGENTS.md Template You Can Copy Today
A Working CLAUDE.md/AGENTS.md Template You Can Copy Today
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 DbContext straight 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.
Comments
No comments yet. Start the discussion.