You probably don't need hooks in Claude Code.
At least that's what I thought. I've been using Claude Code daily for months, and everything worked. I tell it what to do, it does it, I review, I move on.
But "works" and "works well" are different things. The difference, in my case, was discovering that the instructions I wrote in CLAUDE.md were not deterministic. I could write "run vue-tsc after every change" and Claude would do it... when it deemed relevant. Or when the context hadn't been compacted and lost that instruction. Or when it didn't decide "this time it's not necessary."
A hook doesn't negotiate. It runs every time. Without exception.
That distinction — probabilistic instruction vs deterministic execution — is what makes hooks one of Claude Code's most underrated features. They don't show up during onboarding, the official docs present them as a dense technical reference, and most users don't discover them until something breaks in production for the third time.
This article is not a reference dump. It's the guide I wish I'd had: which hooks to install, in what order, what types exist, what goes wrong, and when NOT to use them.
Minimal anatomy
A hook is a command that runs automatically at a specific point in Claude Code's lifecycle. The configuration has three levels:
- Event: when it fires (before a tool call, after, on stop, on compaction...)
- Matcher: regex that filters whether the hook applies (tool name, event type)
- Handler: the command or prompt that runs
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "your-script.sh",
"timeout": 30
}
]
}
]
}
}
Where to configure
| Location | Scope | Shareable |
|---|---|---|
~/.claude/settings.json |
All your projects | No |
.claude/settings.json |
Current project | Yes (goes to git) |
.claude/settings.local.json |
Current project | No (gitignored) |
You can also use /hooks in Claude Code to manage them visually without editing JSON.
How your script responds
- Exit 0: all good, proceed
- Exit 2: blocking error — stderr is sent to Claude as feedback
- Any other: non-blocking error, logged in verbose mode (
Ctrl+O)
That's enough. Let's get to the real stuff.
The 3 hook types (same use case, 3 approaches)
This is something no guide explains well. Claude Code has three types of handlers, not just bash scripts. Let's see all three with the same use case: "ensure tests pass before Claude stops working."
Command hook (bash script)
The most direct. A script that runs tests and blocks if they fail:
#!/bin/bash
# Block Stop if tests don't pass
INPUT=$(cat)
# Prevent infinite loop — critical
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
OUTPUT=$(npm test 2>&1)
if [ $? -ne 0 ]; then
echo "Tests failing. Fix before stopping: $OUTPUT" >&2
exit 2
fi
exit 0
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": ".claude/hooks/test-gate.sh",
"timeout": 120
}
]
}
]
}
}
Prompt hook (single-turn LLM evaluation)
Instead of a script, you ask a model to evaluate whether Claude should stop. Useful when the decision isn't binary:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Evaluate whether Claude should stop working. Context: $ARGUMENTS\n\nCheck:\n1. All requested tasks are complete\n2. No pending errors\n3. No follow-up work remaining\n\nRespond with JSON: {\"ok\": true} to allow stopping, or {\"ok\": false, \"reason\": \"explanation\"} to continue.",
"timeout": 30
}
]
}
]
}
}
Agent hook (multi-turn subagent with tools)
The most powerful. A subagent that can read files, run grep, and reason across multiple turns:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "agent",
"prompt": "Verify that all unit tests pass. Run the test suite, read the results, and confirm there are no failures. $ARGUMENTS",
"timeout": 120
}
]
}
]
}
}
When to use each type
| Aspect | Command | Prompt | Agent |
|---|---|---|---|
| Speed | Fast (ms-seconds) | Medium (1-5s) | Slow (5-30s) |
| Cost | Zero | LLM tokens | More LLM tokens |
| Flexibility | Rigid (exit codes) | Medium (simple judgment) | High (reasoning + tools) |
| Best for | Binary checks (pass/fail) | Subjective evaluations | Checks that require reading code |
My recommendation: start with command hooks always. Only upgrade to prompt or agent when you need judgment, not verification.
My 5 essential hooks
These are the hooks I install on every project. In this order, because each solves a more specific problem than the previous one.
1. Notification when Claude needs attention
The simplest and most useful hook. If you work with Claude in the background while doing something else, you need to know when it needs you:
{
"hooks": {
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude needs your attention\" with title \"Claude Code\"'"
}
]
}
]
}
}
On Linux, replace osascript with notify-send "Claude Code" "Claude needs your attention".
2. Block destructive commands
This should be enabled by default. It isn't:
#!/bin/bash
# Block rm -rf, DROP TABLE, force push, and similar
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
BLOCKED_PATTERNS=("rm -rf /" "DROP TABLE" "DROP DATABASE" "git push --force" "git push -f" "> /dev/sda")
for pattern in "${BLOCKED_PATTERNS[@]}"; do
if echo "$COMMAND" | grep -qi "$pattern"; then
echo "Blocked by security hook: contains '$pattern'" >&2
exit 2
fi
done
exit 0
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/block-destructive.sh"
}
]
}
]
}
}
Note it's PreToolUse, not PostToolUse. We want to block BEFORE execution, not after.
3. Auto-format after every edit
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write 2>/dev/null",
"timeout": 15
}
]
}
]
}
}
Always exit 0 — we don't want a formatting issue to block Claude. If Prettier can't resolve something, you'll see it in your IDE. We redirect stderr to /dev/null so Claude doesn't get distracted.
4. Type-check after every edit
#!/bin/bash
# Run tsc/vue-tsc after every edit
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Only TS/Vue files
if [[ "$FILE_PATH" != *.ts && "$FILE_PATH" != *.tsx && "$FILE_PATH" != *.vue ]]; then
exit 0
fi
OUTPUT=$(npx vue-tsc --noEmit 2>&1)
if [ $? -ne 0 ]; then
echo "$OUTPUT" >&2
exit 2
fi
exit 0
This one DOES block (exit 2). When vue-tsc fails, Claude gets the error and fixes it before moving on. It's the difference between a junior who hands you broken code and a teammate who builds before pushing.
Adapt vue-tsc to your stack: tsc for plain TypeScript, tsc --build for monorepos.
5. Re-inject context after compaction
The least known and most impactful hook in long sessions. When Claude compacts context, it can lose instructions from your CLAUDE.md. This hook re-injects them:
{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo 'Post-compaction reminder: use pnpm, not npm. Run pnpm test before committing. Follow patterns in /src/composables for new composables.'"
}
]
}
]
}
}
The text you print to stdout is added as context for Claude. Simple, but it saves sessions.
Advanced patterns
Async test runner
For test suites that take more than a few seconds, use async: true so Claude doesn't wait:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/run-tests.sh",
"async": true,
"timeout": 120
}
]
}
]
}
}
Claude keeps working. When tests finish, results arrive as context on the next turn. If they failed, Claude knows and acts.
Only type: "command" supports async. Prompt and agent hooks cannot run asynchronously.
File protection
Block edits to files that shouldn't be touched:
#!/bin/bash
# Block edits to protected files
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
PROTECTED=(".env" "package-lock.json" "pnpm-lock.yaml" ".git/")
for pattern in "${PROTECTED[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Blocked: $FILE_PATH is a protected file" >&2
exit 2
fi
done
exit 0
Hooks in skills
If you create skills for Claude Code, you can attach hooks to the YAML frontmatter:
---
name: deploy-check
hooks:
PreToolUse:
- matcher: "Bash"
hooks:
- type: command
command: "./scripts/verify-env.sh"
---
The hook only activates while the skill is in use. When it finishes, it disappears.
What goes wrong (and how to fix it)
The Stop hook infinite loop
The most common mistake. Your Stop hook blocks Claude (exit 2), Claude tries to stop again, the hook blocks it again, infinitely.
The fix: always check stop_hook_active:
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0 # Let Claude stop
fi
If stop_hook_active is true, Claude already tried to stop and was blocked. Let it go.
Your .zshrc pollutes JSON output
If your shell profile prints anything at startup (welcome messages, nvm updates, etc.), that text mixes with your hook's JSON output and Claude Code can't parse it.
Fix: wrap any echo in your .zshrc or .bashrc with:
if [[ $- == *i* ]]; then
echo "Welcome" # Only in interactive shells
fi
jq is not installed
Many hook scripts use jq to parse stdin JSON. If you don't have it: brew install jq (macOS) or apt install jq (Linux).
Timeout kills the hook silently
If your hook takes longer than the configured timeout, Claude Code kills it without warning. Default behavior is as if the hook didn't exist. Adjust timeout (in seconds) for your operation.
PermissionRequest doesn't fire in -p mode
If you use claude -p "do something" (non-interactive mode), PermissionRequest hooks don't fire. Use PreToolUse instead for automated permission decisions.
Decision framework
The question that always comes up: should I use a hook, a CLAUDE.md instruction, an MCP server, or a skill?
flowchart TD
Start[What do you need?] --> Q1{Must it run\nEVERY TIME, no exceptions?}
Q1 -->|Yes| Hook[Hook]
Q1 -->|No| Q2{Does it require judgment\nor interpretation?}
Q2 -->|Yes| CLAUDE[CLAUDE.md]
Q2 -->|No| Q3{Does it need to connect\nto an external service?}
Q3 -->|Yes| MCP[MCP Server]
Q3 -->|No| Q4{Is it a repeatable\nmulti-step flow?}
Q4 -->|Yes| Skill[Skill]
Q4 -->|No| CLAUDE
| Aspect | Hook | CLAUDE.md | MCP | Skill |
|---|---|---|---|---|
| Reliability | Deterministic | Probabilistic | Deterministic | Deterministic |
| Flexibility | Low | High | Medium | High |
| Context cost | Zero | Tokens | Tokens | Tokens |
| Team shareable | Yes (.claude/) | Yes | Yes | Yes |
| Requires code | Yes (bash/json) | No | Yes (server) | No (markdown) |
| Best for | Checks, formatting, security | Guides, patterns, style | External APIs, DBs, services | Workflows, generation, processes |
All 15 events at a glance
| Event | When it fires | Can block? | Typical use case |
|---|---|---|---|
SessionStart |
Session begins/resumes | No | Re-inject context, load env vars |
UserPromptSubmit |
Prompt submitted | Yes | Validate/transform input |
PreToolUse |
Before tool execution | Yes | Security, permissions, blocking |
PermissionRequest |
Permission dialog | Yes | Auto-approve/deny |
PostToolUse |
After successful tool | No* | Formatting, lint, type-check |
PostToolUseFailure |
After failed tool | No | Logging, additional context |
Notification |
On notification | No | Desktop alerts |
SubagentStart |
Subagent created | No | Logging, context injection |
SubagentStop |
Subagent finished | Yes | Validate result |
Stop |
Response finished | Yes | Quality gates, tests |
TeammateIdle |
Teammate going idle | Yes | Keep active |
TaskCompleted |
Task marked complete | Yes | Quality verification |
ConfigChange |
Config file changed | Yes | Auditing |
PreCompact |
Before compaction | No | Save state |
SessionEnd |
Session terminates | No | Cleanup |
* PostToolUse can't undo the action, but feedback (exit 2) reaches Claude.
Official docs: Hooks Reference · Hooks Guide
Closing
Hooks are the invisible infrastructure that turns Claude from "probably works" to "verified every time." They're not glamorous. They're not hard. And it's tempting to ignore them.
But after using them daily, I can't imagine working without them. I started with the notification. Added type-check after the third broken file nobody caught in time. The destructive command blocker came after a scare with an rm -rf that should never have happened. And the compaction hook... that one saved entire sessions.
Five hooks. Minimal setup. Maximum impact.
If you're interested in extending Claude Code with external tools, the MCP guide is the natural complement. For orchestrating complex tasks, the subagents guide. And if you want to build complete workflows with reusable instructions, the skills guide is the next step.