For the tenth time today, I type "don't forget to run gofmt" to Claude Code. Then "run the tests before you stop." Then "don't touch the .env." The day I wired up three hooks, those three sentences vanished from my vocabulary — the machine applies them itself, every time, without me thinking about it.
Claude Code hooks are poorly documented when it comes to real examples: the docs list the events, not the configs you actually use day to day. Here are mine, ready to copy, with the traps I hit along the way — including the Stop hook infinite loop, the classic one.
A hook is a script wired to an event
A hook is a shell command Claude Code runs automatically at a specific point in its cycle. It receives JSON on stdin (the tool name, its arguments, the directory…) and can let it through, block it, or inject context. The configuration lives in settings.json (global, project, or local).
The most useful events:
| Event | Fires… | Typical use |
|---|---|---|
PreToolUse | before a tool call | block a command, validate |
PostToolUse | after a tool call | format, lint, test |
Stop | when Claude wants to stop | force a final check |
UserPromptSubmit | on every message you send | inject context |
Notification | when Claude is waiting | desktop notification |
PostToolUse: auto-format after every edit
The life-changing hook. As soon as Claude writes or edits a file, format it. Never again a diff polluted by indentation. The matcher targets the Edit and Write tools:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "f=$(jq -r '.tool_input.file_path'); [ \"${f##*.}\" = go ] && gofmt -w \"$f\"; true"
}
]
}
]
}
}
The hook receives the call's JSON on stdin; jq extracts the file path. We only format if it's Go. The trailing ; true guarantees a 0 exit code — otherwise a formatting error would block Claude for nothing.
PreToolUse: block what shouldn't go through
PreToolUse can refuse a tool call before it runs. Two ways: an exit code of 2 (blocks and returns stderr to the model), or a finer JSON decision. Example: forbid any edit to .env, no matter how much the model insists.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "f=$(jq -r '.tool_input.file_path'); case \"$f\" in *.env|*secrets*) echo 'Protected file' >&2; exit 2;; esac"
}
]
}
]
}
}
Exit code 2 = block, and the stderr message is returned to Claude so it understands why. It's more reliable than an instruction in the prompt: a hook never "forgets" between two turns of context. Same defensive logic as a middleware that filters before the handler.
Stop: force completion without an infinite loop
Stop fires when Claude thinks it's done. You can send it back to work — for example if some files are untested. But careful: if the hook blocks the stop and Claude re-triggers a Stop, and the hook re-blocks… infinite loop. The protection is the stop_hook_active field in the received JSON:
#!/usr/bin/env bash
# stop-guard.sh — refuse to stop if tests fail, ONCE
input=$(cat)
# if we're already in a Stop re-run, let it through (anti-loop)
if [ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
if ! go test ./... >/dev/null 2>&1; then
echo "Tests are failing — fix them before stopping." >&2
exit 2
fi
exit 0
The stop_hook_active guard is the detail that separates a useful hook from an agent stuck in a loop. Without it, a single red test turns your session into an endless loop. It's exactly the reflex of a cancellation context: plan the exit condition before you start the loop.
UserPromptSubmit: inject context automatically
This hook runs on every message you send, before Claude reads it. Whatever the hook writes to stdout is injected into the context. Handy to remind a project convention, the time, or the git state:
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": "echo \"Git branch: $(git branch --show-current 2>/dev/null)\"" }
]
}
]
}
}
Use sparingly: everything you inject costs tokens on every message. A short, useful reminder, not a dump.
The traps to know
A hook runs with your permissions. It's shell executed on your machine, no confirmation. Never write a hook that executes part of the received JSON without validating it — that's an injection door. Treat the model's output as untrusted input.
Exit code or JSON, not half of each. Exit 2 blocks, 0 lets through, the rest is non-blocking. For fine control (allow/deny with a structured reason), return JSON on stdout rather than juggling exit codes.
Performance matters. A slow PostToolUse hook runs after every edit. A gofmt is instant; running the whole test suite on every write is not. Keep the heavy stuff for Stop.
Conclusion
A hook isn't one more instruction in your prompt — it's a guarantee. The difference is huge: an instruction, the model can forget on the next turn as context fills up; a hook always runs, identically, because it's no longer the model deciding. Anything you catch yourself repeating to Claude is a candidate for a hook.
Start with the formatting PostToolUse — immediate win, zero risk. Add the Stop with its anti-loop guard when you want to lock in a final check. And keep in mind that you're wiring shell onto a model-driven loop: the rigor you put into your services, put it into your hooks.