Objective
What you're building
One real, working hook in your own repo — something that runs automatically at a specific point in Claude Code's workflow, not something you have to remember to ask for.
A CLAUDE.md instruction is a suggestion. A hook is guaranteed.
This is the actual distinction that decides which tool to reach for
Writing "always run tests after editing a file" in CLAUDE.md is a strong hint Claude Code will usually follow — but it's still a judgment call it makes each time, and it can reasonably decide a specific edit doesn't warrant a full test run. A hook configured on the right event runs every single time that event fires, no judgment call involved. Use CLAUDE.md for things that benefit from judgment ("usually run the tests, use your discretion for trivial changes"); use a hook for things that must always happen ("never let an edit to a protected file through," "always run the linter after any file write").
Configuring a real hook
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "echo 'File modified — remember to run tests' >&2"
}
]
}
]
}
}
.claude/settings.json's hooks key maps event names (PostToolUse here — after a tool call completes) to matchers (which tools trigger it) and the actual command to run. This example is intentionally simple (just a reminder echoed to stderr) so you can verify the mechanism works before building something with real consequences, like the guardrail below.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash -c 'input=$(cat); path=$(echo \"$input\" | jq -r \".tool_input.file_path // empty\"); if echo \"$path\" | grep -q \"legacy/\"; then echo \"Blocked: legacy/ is frozen\" >&2; exit 2; fi'"
}
]
}
]
}
}
This is a real guardrail: PreToolUse fires BEFORE the tool executes, and exit code 2 specifically tells Claude Code to block the action and show the stderr message — the actual mechanism that turns "please don't touch legacy/" (a CLAUDE.md hint) into "this is not possible" (a hook-enforced boundary). Exit codes are the hook's entire control-flow vocabulary: 0 means proceed normally, non-zero (especially 2) means block and report. Notice the hook reads its input from stdin as JSON (tool_name, tool_input, etc.), not from an environment variable -- cat captures the full JSON payload Claude Code pipes in, and jq pulls the specific field the guardrail cares about.
You configure a PreToolUse hook to block edits to legacy/, but Claude Code still successfully edits a file in that directory. What's the first thing to check?
A hook that doesn't fire as expected is almost always a configuration or logic bug, not the mechanism failing silently -- check the matcher pattern matches the actual tool name being used, and run the shell command by hand with a realistic input to confirm it really does exit with a blocking code under the condition you expect. This debugging instinct (test the command standalone before assuming the framework is broken) generalizes to any hook-like system, not just this one.
What you're building today
A working .claude/settings.json hook in your own repo — either a PostToolUse reminder or a real PreToolUse guardrail — tested by actually triggering the event and confirming the hook fires exactly when and how expected, including testing the failure case (the guardrail correctly blocking something).