# Writing an AGENTS.md for TypeScript

> Source: https://learn-typescript.org/ai/agents-md/
> Part of Learn TypeScript, free to read.

`AGENTS.md` is a Markdown file in your repository root that coding agents read before starting. Claude Code reads `CLAUDE.md`; most other tools read `AGENTS.md`. Write one, symlink the other:

```bash
ln -s AGENTS.md CLAUDE.md
```

TypeScript has an unusual property that should shape how you write this file: **a large share of what you would put in prose can be expressed as a compiler flag or a lint rule instead.** A rule in `tsconfig.json` is checked on every edit and cannot be deprioritised. A rule in `AGENTS.md` competes for attention with everything else in the window.

So the first question for every candidate line is: *can this be a config option?* If yes, it belongs there.

## Move these out of prose

| Instead of writing… | Set this |
|---|---|
| "Don't assume array elements exist" | `noUncheckedIndexedAccess: true` |
| "Don't use `any`" | `@typescript-eslint/no-explicit-any` |
| "Don't use non-null assertions" | `no-non-null-assertion` |
| "Always await promises" | `no-floating-promises` (needs type info) |
| "Use `@ts-expect-error`, not `@ts-ignore`" | `ban-ts-comment` |
| "Don't cast, parse" | `consistent-type-assertions: { assertionStyle: "never" }` |
| "Handle every switch case" | `noFallthroughCasesInSwitch` + `assertNever` |
| "Use import type for types" | `verbatimModuleSyntax: true` |

That is eight lines removed from the file and made mandatory instead. Full reasoning for each in [the TypeScript failure modes](/review/failure-modes/).

:::verdict The target
**Under 80 lines**, with the enforceable half pushed into `tsconfig.json` and `eslint.config.js`. If your file is 300 lines of TypeScript style advice, most of it is either already the model's default or should be a rule.
:::

## What actually belongs in prose

**Commands.** Unguessable, used every turn.

**Where the boundaries are.** Which directory is allowed to import which. A model cannot infer your layering from the code and no linter knows it unless you configure one.

**Domain types.** That money is `Cents`, that ids are branded, that dates are always `Temporal.Instant` in UTC. These are conventions the type system enforces *once you use them* — but the model has to know to reach for them.

**Which of the seven ways you use.** TypeScript has more than one reasonable answer to most questions: `interface` or `type`, enums or unions, classes or functions, Zod or Valibot, Vitest or Jest. Pick one of each and say so. The cost of not saying is a codebase that drifts into using all of them.

**Landmines.**

## The template

```markdown AGENTS.md
TypeScript 5.x strict, Node 22, pnpm, ESM only.

## Commands
- Check:    `pnpm check`  (tsc --noEmit && eslint . && vitest run). Must pass.
- Types:    `pnpm tsc --noEmit`
- One test: `pnpm vitest run src/thing.test.ts`
- Fix:      `pnpm eslint . --fix`

## Layout and boundaries
- `src/domain/`   pure logic. No I/O, no framework imports, no fetch.
- `src/adapters/` db, http clients, queues. The only place secrets are read.
- `src/api/`      route handlers. Parse, call domain, serialise. No logic.
- `src/schemas/`  zod schemas. One per external boundary.
- Tests live beside the file: `thing.ts` -> `thing.test.ts`.

domain/ must not import from adapters/ or api/. This is enforced by
eslint-plugin-import boundaries — if the rule fires, the design is wrong,
not the rule.

## Types are the specification
- External data is PARSED with a zod schema, never cast. `as` on a fetch,
  a JSON.parse or a database row is a bug.
- Model state as a discriminated union, not optional fields. Make illegal
  states unrepresentable.
- Ids and money are branded: UserId is not OrgId is not string; money is
  Cents (a branded integer), never a float.
- Exhaustive switches end with `assertNever(x)`.
- Prefer `type` over `interface` unless you need declaration merging.
- Unions of string literals, not enums — enums emit runtime code and are
  not erasable syntax.

## Pick-one decisions (do not introduce alternatives)
zod (not valibot) · vitest (not jest) · pnpm (not npm) · Result-style returns
in domain/, thrown errors at the api/ boundary.

## Landmines
- `src/adapters/legacy-sync.ts` is called by the ops repo over CLI. Its
  arguments and exit codes are a contract.
- `src/schemas/webhook.ts` mirrors a third-party payload. Do not "tidy"
  field names — they must match the wire format exactly.
```

Note how much of it is *decisions* rather than *advice*. That is the shape that works: the model already knows how to write TypeScript, it does not know which of six reasonable options your codebase chose.

:::tip The "pick-one" section is the one people skip
Every TypeScript codebase that has been worked on by more than one agent session ends up with two validation libraries, two test runners and three date helpers — not because anyone decided to, but because nobody wrote down which one. Four lines prevent it.
:::

## Config that carries the enforceable half

```json tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true,
    "target": "ES2023",
    "module": "nodenext",
    "moduleResolution": "nodenext"
  }
}
```

Then wire `tsc --noEmit` into a [post-edit hook](https://codelearningdojo.com/harness-hooks/) so type errors arrive within a second of the model writing them, rather than at the end of the turn. On a large repo use `--incremental` or project references to keep it fast enough to run every time.

## Monorepos

Root file for commands and shared decisions; per-package files for the specifics.

```text
AGENTS.md                    commands, pick-one decisions, global boundaries
packages/api/AGENTS.md       route conventions, auth rules
packages/ui/AGENTS.md        component patterns, styling decision
packages/shared/AGENTS.md    "this is imported by everything — changes are breaking"
```

That last one is a landmine worth stating explicitly in every monorepo.

## Common questions

### Should I put my whole style guide in here?

No. Anything a formatter or linter can enforce should be enforced, and everything else should be short enough that the model actually weights it. A style guide is documentation — put it in `docs/` and add one line: "read `docs/style.md` before changing anything under `src/ui/`."

### interface or type?

Pick one and write it down; the choice matters far less than the consistency. `type` is the more common default now because it handles unions and intersections uniformly; `interface` is worth it if you rely on declaration merging or are publishing types for others to extend.

### How does this interact with Cursor rules?

Cursor reads its own `.cursor/rules` files, and increasingly also `AGENTS.md`. Keep the substance in `AGENTS.md` and make the tool-specific files thin pointers to it, otherwise you maintain two documents that disagree within a month.
