CLAUDE.md is a Markdown file that Claude Code reads at the start of every session and injects into the context as persistent project instructions. It is not enforced configuration. According to the memory docs, the content goes in as a user message after the system prompt, and "there's no guarantee of strict compliance, especially for vague or conflicting instructions". Writing a CLAUDE.md that works means writing short, concrete, checkable instructions, and taking out of it everything that has to be guaranteed.
This post is one of the hands-on guides in the Claude Code guide, which explains the role of CLAUDE.md inside the harness (it is a guide; the hook is a sensor). Here we write one line by line and build a script that reviews the file in CI.
Generate a draft with /init and delete half of it
cd your-project
claude
> /init/init analyzes the repository and generates a CLAUDE.md with the build commands, tests and conventions it found. If the file already exists, it suggests improvements instead of overwriting it. With CLAUDE_CODE_NEW_INIT=1 in the environment, the flow becomes interactive. It asks whether you want CLAUDE.md, skills and hooks and shows the proposal before writing.
Treat the draft as a starting point. Whatever /init finds by reading the code, Claude also finds by reading the code. The value of the file is in what it cannot infer. The question the best practices page tells you to ask for each line is: "Would removing this cause Claude to make mistakes?". If the answer is no, cut it.
Pick the right file
| File | What for | In git? |
|---|---|---|
./CLAUDE.md or ./.claude/CLAUDE.md | Project rules, for the whole team | Yes |
./CLAUDE.local.md | Your preferences in that project | No (add it to .gitignore) |
~/.claude/CLAUDE.md | Your preferences in every project | No |
.claude/rules/*.md | Rules for one part of the code | Yes |
Subdirectory CLAUDE.md | Rules for that folder, loaded on demand | Yes |
The files add up; they do not overwrite each other. Claude Code walks up from the current folder to the root and concatenates what it finds. Subdirectory files only come in when Claude reads something in that folder.
Choose between CLAUDE.md and AGENTS.md
AGENTS.md is the instructions file other coding agents read. Claude Code reads it too, starting with v2.1.277, with this precedence rule:
| What exists in the repository | What Claude Code reads |
|---|---|
Only AGENTS.md | AGENTS.md |
AGENTS.md and a CLAUDE.md or CLAUDE.local.md in the current folder or above | Only the CLAUDE.md files |
A CLAUDE.md that imports @AGENTS.md | Both, through the import |
So creating a personal CLAUDE.local.md in a project that runs on AGENTS.md makes Claude stop reading AGENTS.md for you. If the team uses several agents, the simplest setup is a short CLAUDE.md like this:
@AGENTS.md
## Claude Code only
- Migration rules live in .claude/rules/migrations.mdAGENTS.md stays the single source, and CLAUDE.md adds only what is specific to Claude Code. If you prefer not to use the import, set Project instructions to claude-md-and-agents-md, which loads both.
Write only what the agent cannot guess
This is the example CLAUDE.md from this post's repository, for a fictional FastAPI API:
# Project: orders-api (FastAPI + Postgres)
Overview and local setup: @README.md
## Commands
- Start dependencies: `make up` (Postgres in Docker, port 5433, not 5432)
- Tests for one file: `make test FILE=tests/test_orders.py`; the full suite takes ~4 min, run it only at the end
- Lint and types: `make check` (ruff + mypy). Run it before saying you are done
## Conventions that break the default
- Money values are `int` in cents. Never `float`, never `Decimal` at the API boundary
- Dates in the database are UTC; convert to America/Sao_Paulo only in the response layer
- Domain errors inherit from `app.errors.DomainError`; do not raise `HTTPException` outside `app/api/`
## Known traps
- `tests/fixtures/` is generated by `make fixtures`. Do not edit it by hand
- The payment gateway client has a sandbox mode via `PAYMENT_SANDBOX=1`; without it, integration tests hit the real API
## Workflow
- Branch: `feat/<ticket>-short-description`. Commits in English, imperative mood
- Changed the API contract? Update `docs/openapi-notes.md` in the same PR
<!-- Migration rules live in .claude/rules/migrations.md (loads only when Claude touches migrations/) -->What is in it and what was left out, following the include and exclude table in the docs:
- Commands Claude cannot guess. Port 5433, the
FILE=parameter, the 4 minutes the suite takes. Each one prevents a specific mistake. - Conventions that break the default. Cents as
intis a project decision, not a language convention. "Use type hints" was left out because Claude already does that. - Traps. A generated file that looks editable, an environment variable that changes how the tests behave.
- No tour of the code. Folder-by-folder descriptions, dependency lists and API docs stay out. Claude reads the code. Claude Code's
/doctorproposes cuts for exactly this kind of content.
The HTML comment at the end costs no tokens. Claude Code strips block HTML comments before the content enters the context. Use them to leave notes for humans.
Every instruction is checkable. "Run make check before saying you are done" can be verified in the transcript. "Write clean code" cannot, and the docs cite exactly that example as something not to include.
Move single-folder rules to .claude/rules
The target in the docs is under 200 lines per file. The way to grow without going over is to move out of the main file what only matters for part of the code:
---
paths:
- "migrations/**/*.py"
---
# Migrations
- Generate with `make migration MSG="description"`; do not write the header by hand
- Every migration needs a working `downgrade()`
- A versioned migration is immutable: create a new one (a PreToolUse hook blocks the edit)Saved as .claude/rules/migrations.md, this file only enters the context when Claude reads or edits a file that matches the glob. paths is the only frontmatter field Claude Code reads in a rule; any other field is ignored without an error. A rule without paths loads at startup, as if it were in CLAUDE.md.
Look at the last line. The rule explains the why, but the PreToolUse hook is what enforces it. The memory docs say it directly: to block an action regardless of what Claude decides, use a hook.
Use @ imports without falling into the traps
@README.md in the example pulls the whole README into the context. The rules, all from the docs:
- The path is relative to the importing file, not to the current folder.
- Imports can import other files, up to four levels deep.
- An
@inside a code span or code block does not import. To mention a path without loading it, put it in backticks. - The first time a project imports a file from outside itself, Claude Code asks for approval.
- Imports do not save context. The imported file loads at startup along with the rest. Imports organize;
.claude/ruleswithpathsis what saves context.
Check what was loaded, and when
Inside the session, /context lists the files under Memory files and /memory opens each one. To watch on-demand rules come in mid-session, register a hook on the InstructionsLoaded event:
#!/usr/bin/env python3
"""InstructionsLoaded: log which instruction files entered the context, and why."""
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
e = json.load(sys.stdin)
line = {
"when": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"file": e.get("file_path"),
"reason": e.get(
"load_reason"
), # session_start, nested_traversal, path_glob_match, include, compact
"trigger": e.get("trigger_file_path"),
}
log = Path(os.environ.get("CLAUDE_PROJECT_DIR", ".")) / ".claude" / "instructions.log"
with log.open("a", encoding="utf-8") as f:
f.write(json.dumps(line) + "\n"){
"hooks": {
"InstructionsLoaded": [
{
"hooks": [
{
"type": "command",
"command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/log_instructions.py"]
}
]
}
]
}
}With the migrations rule, a log line looks like this (an example in the format the hook writes; this line was not captured, because the rule only loads when Claude reads a file in migrations/, and that needs a logged-in session):
{"when": "2026-10-11T23:04:22+00:00", "file": "/p/.claude/rules/migrations.md", "reason": "path_glob_match", "trigger": "/p/migrations/0002.py"}If the rule you expected never shows up with path_glob_match, the glob is wrong.
The hook fires before any call to the model, so you can test it without an account. In the example folder, with Claude Code 2.1.296 and no login, claude -p "hi" ends with Not logged in · Please run /login, but InstructionsLoaded has already run twice (the CLAUDE.md imports the README with @). The captured run used the Portuguese version of the script; the field names below are translated:
{"when": "2026-10-12T17:38:15+00:00", "file": "/tmp/cm/proj/README.md", "reason": "include", "trigger": null}
{"when": "2026-10-12T17:38:15+00:00", "file": "/tmp/cm/proj/CLAUDE.md", "reason": "session_start", "trigger": null}And the debug log (--debug-file) confirms the exec form:
[DEBUG] InstructionsLoaded:include [python3 ${CLAUDE_PROJECT_DIR}/.claude/hooks/log_instrucoes.py] completed with status 0
[DEBUG] InstructionsLoaded:session_start [python3 ${CLAUDE_PROJECT_DIR}/.claude/hooks/log_instrucoes.py] completed with status 0One caution. Without --bare, claude -p runs the hooks in the project's .claude/settings.json even in a folder you never marked as trusted, according to the headless mode docs. Do not run claude -p in someone else's repository without reading its hooks first.
Review CLAUDE.md in CI
The file goes stale like any code. The test:unit script gets renamed, docs/architecture.md disappears, and CLAUDE.md keeps telling the agent to use both. This script catches the mechanical errors:
#!/usr/bin/env python3
"""Review a CLAUDE.md against the recommendations in the Claude Code docs.
Usage: python3 claude_md_lint.py [PATH_TO_CLAUDE.md]
Exits with 1 if there is an error (good for pre-commit or CI).
"""
import json
import re
import sys
from pathlib import Path
LINE_LIMIT = 200 # docs target: under 200 lines per file
VAGUE = [
"clean code",
"best practices",
"format properly",
"be careful",
"código limpo",
"boas práticas",
"seja cuidadoso",
"formate corretamente",
]
IMPORT = re.compile(r"(?<![\w`])@((?:[^\s`\\]|\\ )+)")
COMMAND = re.compile(r"`((?:npm run|pnpm|yarn|make) [\w:.-]+)`")
def without_code(text: str) -> str:
"""Strip fenced blocks and code spans: the docs say @imports there do not count."""
text = re.sub(r"```.*?```", "", text, flags=re.DOTALL)
return re.sub(r"`[^`\n]*`", "", text)
def project_scripts(root: Path) -> set[str]:
names = set()
pkg = root / "package.json"
if pkg.exists():
names |= set(json.loads(pkg.read_text()).get("scripts", {}))
mk = root / "Makefile"
if mk.exists():
names |= set(re.findall(r"^([\w.-]+):", mk.read_text(), flags=re.MULTILINE))
return names
def review(file: Path) -> list[tuple[str, str]]:
text = file.read_text(encoding="utf-8")
findings = []
n = text.count("\n") + 1
if n > LINE_LIMIT:
findings.append(
("warning", f"{n} lines; the docs recommend under {LINE_LIMIT}")
)
for m in IMPORT.finditer(without_code(text)):
raw = m.group(1).rstrip(".,;:)")
target = Path(raw.replace("\\ ", " ")).expanduser()
if not target.is_absolute():
target = file.parent / target
if not target.exists():
findings.append(("error", f"import @{raw} points to a missing file"))
scripts = project_scripts(file.parent)
for cmd in COMMAND.findall(text):
name = cmd.split()[-1]
if scripts and name not in scripts:
findings.append(
(
"error",
f"`{cmd}` is referenced, but '{name}' does not exist in package.json/Makefile",
)
)
lower = text.lower()
for phrase in VAGUE:
if phrase in lower:
findings.append(
("warning", f"vague instruction: '{phrase}'. Replace it with something checkable")
)
emphasis = len(re.findall(r"\b(IMPORTANT|IMPORTANTE|NEVER|ALWAYS|NUNCA|SEMPRE)\b", text))
if emphasis > 3:
findings.append(
(
"warning",
f"{emphasis} all-caps emphasis markers; if everything is important, nothing stands out",
)
)
return findings
if __name__ == "__main__":
target = Path(sys.argv[1] if len(sys.argv) > 1 else "CLAUDE.md")
findings = review(target)
for level, msg in findings:
print(f"{target}: {level}: {msg}")
if not findings:
print(f"{target}: ok")
sys.exit(1 if any(n == "error" for n, _ in findings) else 0)Running it against a deliberately bad CLAUDE.md (vague sentences, four all-caps lines, broken imports, a script that does not exist in package.json):
$ python3 claude_md_lint.py bad-example/CLAUDE.md
bad-example/CLAUDE.md: error: import @docs/architecture.md points to a missing file
bad-example/CLAUDE.md: error: import @CONTRIBUTING.md points to a missing file
bad-example/CLAUDE.md: error: `npm run test:unit` is referenced, but 'test:unit' does not exist in package.json/Makefile
bad-example/CLAUDE.md: warning: vague instruction: 'clean code'. Replace it with something checkable
bad-example/CLAUDE.md: warning: vague instruction: 'best practices'. Replace it with something checkable
bad-example/CLAUDE.md: warning: vague instruction: 'be careful'. Replace it with something checkable
bad-example/CLAUDE.md: warning: 4 all-caps emphasis markers; if everything is important, nothing stands outThe emphasis warning comes straight from the docs. If an instruction keeps being ignored, put "IMPORTANT" on that line and only that line. "If you emphasize many lines, none of them stands out." The script ignores @ inside backticks, as Claude Code does.
The linter is mechanical on purpose. For a semantic review (contradictory instructions, text written for an older model), Claude Code has /doctor prompt-audit, starting with v2.1.283. It produces a report and only changes the files if you ask.
How we use it at Capiva
In capivaOS, CLAUDE.md went from ~7.1k tokens (518 lines) to ~2.8k tokens (170 lines), 61% less, and the whole always-loaded layer dropped 36% (from ~26.4k to ~16.9k tokens). The numbers are estimates from characters/4 and are in ADR-0011. What made the cut possible was moving the critical rules into a PreToolUse hook. One sentence from the ADR sums up the criterion: "Prompt text that merely restates what a hook mechanically denies is redundancy, not enforcement."
Troubleshooting checklist
- [ ] Claude ignores a rule. The file is long and the rule got lost. Cut it, or move it to
.claude/rules. If the rule must always hold, turn it into a hook. - [ ] The file does not show up in
/context. Check the name (CLAUDE.md, uppercase) and the folder. A subdirectory file only loads when Claude reads something there. - [ ]
AGENTS.mdstopped being read. A CLAUDE.md or CLAUDE.local.md in the current folder or above is enough to cause it. Import it with@AGENTS.md. - [ ] A huge procedure in the file. Move it into a skill.
- [ ] A
.claude/rulesrule never loads. The glob inpathsdoes not match. Use theInstructionsLoadedlog. - [ ] An import does not work. It is inside backticks, the path is relative to the wrong folder, or it goes more than four levels deep.
- [ ] An instruction disappeared after
/compact. The project root CLAUDE.md is re-read from disk after compaction. An instruction given only in the conversation is lost. - [ ] Claude asks about things that are in the file. The sentence is ambiguous. Rewrite it as a checkable command.
Frequently asked questions
What is the ideal size for a CLAUDE.md?▼
The docs recommend under 200 lines per file. More than that uses up context and lowers adherence.
Does a long procedure, like "how to deploy", go in CLAUDE.md?▼
No. A procedure becomes a skill, which only loads when it is used. See How to create a Skill in Claude Code.
Can I put secrets or environment variables in CLAUDE.md?▼
No. It goes into git and into the model's context. Give only the variable name, like PAYMENT_SANDBOX=1 in the example.
Does CLAUDE.md have to be in English?▼
No. The Portuguese version of this post uses an example written entirely in Portuguese. What matters is being concrete.
Read next
- Claude Code is a harness: CLAUDE.md, hooks, MCP and subagents (the main guide)
- How to configure hooks in Claude Code
- How to create a Skill in Claude Code
- How to install Claude Code on Windows
- How to build an MCP server in Python and configure it in Claude Code
- How to create an llms.txt, the CLAUDE.md cousin for websites
- Harness engineering
Want a lean CLAUDE.md and the hooks that back it up, already set up? See capivaOS.