I kept telling Claude "run the linter before committing" and it kept forgetting. Not sometimes — like every third commit. So I stopped asking and made it automatic. One JSON block in my settings, and now Claude literally cannot commit without linting first.
That's what hooks are. Not a suggestion to Claude. A rule it can't break.
What You'll Learn
- What hooks are and why they're better than CLAUDE.md rules
- The four hook types and when each one fires
- Three hooks I use daily that save me from real mistakes
- Prompt-based hooks that use AI to evaluate conditions
Why Not Just Write It in CLAUDE.md?
You can tell Claude "always run tests before committing" in your CLAUDE.md. Claude will try. But it's an LLM — it interprets, it forgets, it sometimes decides the tests aren't necessary for "this small change."
Hooks are deterministic. They're shell commands that execute at specific points in Claude's workflow. Claude doesn't choose whether to run them. They just run. CLAUDE.md is advice. Hooks are enforcement.
The Four Hook Types
PreToolUse — Runs before Claude uses a tool. You can block the action or let it through. Perfect for catching dangerous commands.
PostToolUse — Runs after a tool finishes. Use for formatting, validation, or notifications.
Notification — Fires when Claude needs your attention. Great for desktop alerts so you don't watch the terminal.
PermissionDenied — New in auto mode. Fires when the classifier blocks an action. Return retry: true to let Claude try a different approach.
Your First Hook: Never Commit Without Linting
Add this to ~/.claude/settings.json:
{ "hooks": { "PreToolUse": [{ "if": "Bash(git commit *)", "command": "npm run lint", "failAction": "block" }] } }
Every time Claude tries to run git commit, the linter runs first. If it fails, the commit is blocked. The if field uses permission rule syntax so this only fires on commits, not every bash command.
✅ You'll know it's working when you see lint output in the transcript before each commit.
Three Hooks I Use Every Day
Desktop notification when Claude finishes:
{"hooks": {"Notification": [{"command": "osascript -e 'display notification \"$EVENT_TITLE\" with title \"Claude\"'"}]}}
On Linux, use notify-send instead of osascript.
Block force-push entirely:
{"hooks": {"PreToolUse": [{"if": "Bash(git push *--force*)", "command": "echo 'Blocked' && exit 1", "failAction": "block"}]}}
No exceptions. If I really need to force-push, I do it myself outside Claude.
Auto-format after file edits:
{"hooks": {"PostToolUse": [{"if": "Edit(*) Write(*)", "command": "prettier --write $TOOL_INPUT_FILE_PATH"}]}}
What You Probably Didn't Know: Prompt-Based Hooks
Hooks aren't limited to shell commands. You can use a Claude model to evaluate conditions:
{"hooks": {"PreToolUse": [{"if": "Bash(git commit *)", "type": "prompt", "prompt": "Review this commit for hardcoded secrets or API keys. Block if found."}]}}
This runs a separate Claude instance to evaluate the commit. Like a security reviewer that never takes a day off.
⚠️ Prompt-based hooks add 2-5 seconds per invocation. Use them for high-stakes checks (security, compliance), not every file edit. 🔬 Tested.
What Hooks Can't Do
❌ Hooks can't modify Claude's thinking or reasoning. They run alongside actions, not inside the model's head.
❌ Hooks don't persist state between runs. Each invocation is a fresh shell.
Setting Up
Hooks go in your settings file: ~/.claude/settings.json for personal, .claude/settings.json for project-wide, or .claude/settings.local.json for project + just you. Use /settings in Claude Code to open it directly.
The best automation is the kind you set up once and never think about again. Hooks won't make Claude smarter, but they'll make sure it follows the rules even when it's feeling creative.