Three Claude Code hooks worth running, and why
A hook is a shell command Claude Code runs automatically at a fixed point in its own lifecycle: before a tool call, after one, or when Claude stops responding. You wire hooks into a settings.json file under a hooks key, one array per event name, each entry naming a matcher (which tool it applies to) and a command to run. The command reads the event as JSON on stdin and can block the action by exiting with code 2.
How to write hooks in Claude Code?
Start from one event and one narrow matcher. Pick the event that fires where you want to act: PreToolUse to block or check before something runs, PostToolUse to react after, Stop to run something once a reply finishes. Then pick a matcher that only catches the tool you care about, so the hook doesn't fire on unrelated calls.
Write the command to read its input from stdin, not from arguments, since that's how Claude Code passes the event data. Keep the command small and give it a real timeout. A hook that hangs holds up the whole turn.
Test every hook the same way you'd test any script: trigger the exact condition it's supposed to catch, and confirm it fires. Then trigger a case that should NOT match, and confirm it stays quiet.
Hooks live in two places. ~/.claude/settings.json applies to every project on the machine, and .claude/settings.json inside a project applies only there.
If a hooks key already exists in the file, merge your new array into the existing event. Don't replace the whole key.
What are the best hooks in Claude Code?
Three that guard against mistakes we have actually made, not ones that sounded interesting to write. Each one below is the actual JSON from The Operator Kit for Claude Code, trimmed to just that hook.
Block a push to a remote
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "input=$(cat); cmd=$(printf '%s' \"$input\" | python3 -c \"import json,sys;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))\" 2>/dev/null); case \"$cmd\" in *\"git push\"*) if [ \"${ALLOW_PUSH:-0}\" != \"1\" ]; then echo \"Blocked: git push needs ALLOW_PUSH=1 set in the environment.\" >&2; exit 2; fi ;; esac; exit 0",
"timeout": 10
}
]
}
]
}
}
This runs before every Bash call. It reads the command Claude is about to run, and if it contains git push, it blocks the call unless ALLOW_PUSH=1 is set in the environment. The block exits with code 2, which Claude Code reads as deny, and shows the message back to Claude.
What it guards. A push to a shared remote that Claude decided to make on its own, mid-task, without anyone asking for it.
How to test it. With ALLOW_PUSH unset, ask Claude to run git push in a git repo. It should come back blocked with the exact message above. Set ALLOW_PUSH=1 in the shell before launching Claude, ask again, and it should run.
Print usage on stop
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PROJECT_DIR}/scripts/usage-watch.sh\" --now 2>/dev/null",
"timeout": 30
}
]
}
]
}
}
This runs every time Claude finishes replying. It calls a usage script and prints the result, which for us is the weekly plan usage percentage.
It doesn't block anything. It reports.
What it guards. Finding out your weekly limit is nearly gone only after a big fan out already burned through it. A number after every reply catches that early, instead of once a day if you remember to check.
How to test it. Finish any normal turn. You should see the usage line print right after Claude's reply, matching what the script prints when you run it by hand.
Warn on em dashes and en dashes
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "input=$(cat); file=$(printf '%s' \"$input\" | python3 -c \"import json,sys;print(json.load(sys.stdin).get('tool_input',{}).get('file_path',''))\" 2>/dev/null); if [ -n \"$file\" ] && [ -f \"$file\" ]; then python3 -c \"import sys; p=sys.argv[1]; t=open(p, encoding='utf-8', errors='ignore').read(); n=sum(1 for c in t if c in ('\\u2014','\\u2013')); print('Warning: '+p+' contains '+str(n)+' em dash or en dash character(s). Replace with a comma, period, or parentheses.') if n else None\" \"$file\"; fi; exit 0",
"timeout": 10
}
]
}
]
}
}
This runs after every Write or Edit. It reads the file that was just touched, counts em dash and en dash characters in it, and prints a warning naming the file and the count if it finds any.
It doesn't undo the edit. It just tells you.
What it guards. An em dash sliding into a file after you told Claude not to use one. It's a cheap, mechanical backstop for a rule that otherwise depends on Claude remembering it every single time.
How to test it. Ask Claude to write a file containing an em dash somewhere in the text. After the Write or Edit call finishes, a warning line should print naming the file and the count. Write a clean file with none, and nothing should print.
Get the kit
These three hooks ship in The Operator Kit for Claude Code as one settings.hooks.example.json file, ready to merge into your own settings.json, plus a README covering exactly where to paste it. See the six skills in the kit and subagents without the drift for the rest.
For the full hook event list and the JSON schema each command receives, see Anthropic's hooks reference.
Independent product. Not affiliated with or endorsed by Anthropic.