How to Set Up Claude Code Properly: CLAUDE.md, Subagents, Hooks
Three layers, in order: context that is always loaded, delegation that keeps it small, and enforcement that does not depend on the model cooperating.
Most Claude Code setups consist of a CLAUDE.md that grew by accretion until nobody reads it, including the model. That is not a bad start — it is an incomplete one, because it uses one of the three mechanisms the tool gives you and leaves the other two alone.
The three are worth naming precisely, because they solve different problems:
- CLAUDE.md is context. It is loaded into every request, so it is expensive, and it is advisory — the model can ignore it.
- Subagents are delegation. They keep a job’s context out of your main conversation and give it a narrower toolset.
- Hooks are enforcement. They are shell commands your machine runs at fixed points, and they do not care what the model intended.
Set up in that order, each one covering what the previous cannot.
Details below reflect Claude Code as documented in August 2026. It moves fast —
run /hooks and /doctor against your own install rather than trusting any
article, including this one, on the exact set of available events.
Layer 1: CLAUDE.md
Where it lives, and what wins
Claude Code reads several CLAUDE.md files and merges them. In load order:
- A managed policy file, if your organisation deploys one.
~/.claude/CLAUDE.md— you, across all projects../CLAUDE.mdor./.claude/CLAUDE.md— the project, checked into git../CLAUDE.local.md— this project, this machine, not committed.- Subdirectory
CLAUDE.mdfiles, loaded on demand when work touches them.
Ancestor files load before descendant ones, and at any given level CLAUDE.md
loads before CLAUDE.local.md. The practical consequence is that a monorepo can
put shared conventions at the root and package-specific ones next to the package,
and the model gets the specific ones only when it goes there.
You can also compose files with an import: @docs/architecture.md on its own line
pulls that file in. Paths resolve relative to the file doing the importing, not
your working directory, and imports nest up to four hops deep. Wrapping the path in
backticks turns it back into ordinary text.
/init will generate a starting CLAUDE.md by looking at your project. Treat its
output as a first draft.
What belongs in it
The test for every line: would a competent new contributor get this wrong without being told? If yes, it belongs. If it is discoverable from the code in ten seconds, it does not.
Good candidates:
- The commands that actually work here — the real test command, the real build, the one that needs a flag nobody remembers.
- Conventions a reader cannot infer: “migrations are never edited after merge”, “this package must stay dependency-free”, “errors bubble; we do not log-and-swallow”.
- The layout, in five lines. Where things live and why.
- Hard boundaries: files not to touch, services not to call, the directory that is generated.
What does not belong: general programming advice, restatements of your linter config, anything already enforced by a hook or a permission rule, and aspirational process nobody follows. Every token in CLAUDE.md is paid for on every single request, and a long file dilutes the instructions that matter into the ones that don’t.
A good project CLAUDE.md is closer to 40 lines than 400. If yours is longer, the
fix is usually to move detail into @-imported files that get pulled in only when
relevant, and to convert every “always remember to…” line into a hook — because a
line the model has to follow is worth more than a line asking it to.
Layer 2: subagents
A subagent is a Markdown file with YAML frontmatter, in .claude/agents/ for the
project or ~/.claude/agents/ for you personally. Both directories are scanned
recursively, so you can group them in subfolders.
---
name: test-writer
description: Writes tests for new or changed code. Use when the user asks for
tests, or after implementing a feature that has no coverage.
tools: Read, Grep, Glob, Edit, Write, Bash
model: sonnet
---
You write tests that follow the conventions already in this repository.
Before writing anything, read two existing test files near the code under test
and match their structure, naming, and assertion style. Do not introduce a new
test framework, a new assertion library, or new helpers unless the repository
has none at all.
Test behaviour, not implementation. A test that breaks when the code is
refactored without changing behaviour is a liability.
Report which files you created and which behaviours are covered. If something
important cannot be tested without restructuring the code, say so rather than
writing a weak test.
Three things about this that people get wrong.
description is the routing key, not documentation. It is what Claude matches
against when deciding whether to delegate. “Test writer” routes badly. “Writes
tests for new or changed code; use when the user asks for tests or after
implementing an uncovered feature” routes well. Write it as when to use this,
not what this is.
Omitting tools inherits everything. Listing tools is an allowlist, and it is
the main reason to bother with subagents at all for read-only work — a reviewer
that cannot write cannot “helpfully” fix what it was asked to report on. There is
also a disallowedTools field if subtracting is easier than listing.
The system prompt is the whole product. A subagent whose body is “You are a code reviewer. Review code.” is worse than no subagent, because it adds a delegation hop and returns generic output. The value is in the constraints: what to read first, what to refuse, what to report, and — the one that changes results most — an instruction to only report findings it can point at a line for.
The second real benefit is context isolation. A subagent that reads forty files to answer one question returns the answer, not the forty files, so your main conversation stays small.
Layer 3: hooks
Hooks are shell commands run at defined points in the lifecycle, configured in settings. They are the only mechanism here that is deterministic.
Where settings live, and what wins
~/.claude/settings.json— you, everywhere..claude/settings.json— the project, committed..claude/settings.local.json— the project, this machine, gitignored.
Precedence, highest first: managed policy settings, then command-line arguments,
then .claude/settings.local.json, then .claude/settings.json, then
~/.claude/settings.json.
The shape
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": ".claude/hooks/format.sh", "timeout": 15 }
]
}
]
}
}
An event name maps to an array of groups; each group has a matcher and its own
array of hooks. matcher is a string: "*", "" or omitting it matches
everything; plain text matches exactly or as a |-separated list; anything with
regex characters is treated as an unanchored JavaScript regex. Some events —
UserPromptSubmit and Stop among them — take no matcher at all.
The events you will use first are PreToolUse, PostToolUse, UserPromptSubmit,
SessionStart, SessionEnd, Stop, SubagentStop, Notification and
PreCompact. There are considerably more than that, and the list grows — /hooks
lists what your install actually supports.
What a hook receives, and how it answers
The hook gets a JSON object on stdin. Common fields include session_id,
transcript_path, cwd, permission_mode and hook_event_name; tool events add
tool_name, tool_input and tool_use_id.
A formatter, then, is about six lines:
#!/usr/bin/env bash
# .claude/hooks/format.sh — runs after every Edit or Write.
file=$(jq -r '.tool_input.file_path // empty')
[ -n "$file" ] && [ -f "$file" ] && npx --no-install prettier --write "$file" >/dev/null 2>&1
exit 0
Exit codes carry meaning:
- 0 — no decision. Anything on stdout that parses as JSON is interpreted; otherwise stdout is fed back to Claude as context.
- 2 — blocking error on events that can be blocked. stderr is fed back to Claude as the reason.
- anything else — a non-blocking error, surfaced to you, execution continues.
For PreToolUse you can also answer structurally, with exit code 0 and this exact
shape on stdout:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Force-push is blocked here. Push a new commit."
}
}
permissionDecision takes allow, deny or ask. allow skips the interactive
prompt but does not override deny rules or managed policy.
Two warnings the docs are right about
A hook that times out does not block. Execution proceeds through the normal permission flow. A hook that hangs is not a gate; it is a gap.
Only exit code 2 reliably blocks. Exit code 1 is treated as non-blocking, so a
script that fails for an unrelated reason — jq not installed, say — silently
permits the thing it was written to prevent.
Hooks or permissions?
For hard allow and deny, use the permission system, not a hook. It is the mechanism designed for the job, and it is not best-effort:
{
"permissions": {
"deny": ["Read(./.env)", "Read(./.env.*)", "Edit(./.git/**)"],
"allow": ["Bash(npm run test:*)", "Bash(git status)", "Bash(git diff *)"],
"ask": ["WebFetch"]
}
}
Rules resolve deny, then ask, then allow, first match wins — specificity does
not promote a narrow allow above a broad deny. One syntax detail worth internalising:
the space before the wildcard enforces a word boundary, so Bash(ls *) matches
ls -la but not lsof.
Use hooks when you want something to happen — format the file, run the tests, log the change, inject the current branch into context at session start. Use permissions when you want something to be impossible.
A setup worth half an hour
- Run
/init, then delete two thirds of what it wrote. - Add the three or four project facts a new contributor would otherwise get wrong.
- Add deny rules for your secrets and your generated directories.
- Add one
PostToolUseformatter hook. It is the highest-value hook there is, and it removes an entire category of diff noise. - Add one subagent for the job you delegate most often, with a real system prompt and a deliberately narrow tool list.
- Run
/doctor, which will find the duplicate CLAUDE.md files and the settings file that no longer parses.
Then stop. The rest gets added when something goes wrong twice — which is the only reliable signal for what your setup is actually missing.
Get the next template
One short email when a new spreadsheet or template ships — plus the occasional tip for getting more out of the ones you already have. Nothing else.
We send the list and nothing more. Your address is never sold or shared, and every email has a one-click unsubscribe.