Hooks & Automation
You can write "always run Prettier" in CLAUDE.md and Claude will usually do it. Hooks Hooks (Claude Code) Handlers that run automatically at specific moments in a Claude Code session, such as before a tool runs (PreToolUse), after it finishes (PostToolUse), when you submit a prompt, or when Claude stops. A hook can be a shell command, an HTTP call, an MCP tool, or a prompt, and can block risky actions. Configure them under the `hooks` key in settings.json or ship them in a plugin. "Like motion-sensor lights. When something happens (motion), an action triggers automatically (lights on)."
1Instructions vs. guarantees
CLAUDE.md CLAUDE.md A Markdown file Claude Code reads at the start of every session: project context, commands, conventions, and rules. Put it at `./CLAUDE.md` (shared with the team), `~/.claude/CLAUDE.md` (personal, all projects), or `CLAUDE.local.md` (personal, gitignored). Run `/init` to generate a starter; AGENTS.md is read too. "Like a welcome packet for a new team member. It tells Claude everything it needs to know about your project."PreToolUse hook. Think of CLAUDE.md as the employee handbook and hooks as the badge reader on the server room door.
CLAUDE.md says
"Never edit .env files."
Followed... until a long session buries it.
A hook enforces
PreToolUse on Edit|Write → path matches .env → exit 2
Blocked every time, and Claude is told why.
2The events you'll actually use
Claude Code exposes 30-plus hook events (including ones for subagents, tasks, worktrees, compaction, config changes, and model switches). These ten cover almost every real use:
| Event | Fires when | Good for |
|---|---|---|
| SessionStart | A session starts or resumes | Print branch status or open issues so Claude starts with fresh facts |
| UserPromptSubmit | You hit Enter, before Claude sees the prompt | Add context, or block prompts that contain secrets |
| PreToolUse | Before any tool call runs | Block dangerous commands and protected files |
| PermissionRequest | A permission dialog is about to appear | Auto-decide routine approvals |
| PostToolUse | After a tool call succeeds | Format, lint, or type-check the file that was just edited |
| Notification | Claude Code sends a notification (e.g. needs permission, idle) | Desktop or phone alerts |
| SubagentStop | A subagent finishes | Check a subagent's work before it reports back |
| Stop | Claude finishes responding | Run tests, refuse to stop if they fail; ping you |
| PreCompact | Before the conversation is compacted | Save a transcript or handoff notes |
| SessionEnd | The session ends | Quick cleanup or logging (tiny time budget) |
Full list and input schemas: Hooks reference
3How a hook is wired up
Hooks live under a hooks key in a settings file: ~/.claude/settings.json (you, everywhere), .claude/settings.json (the project, committed), or .claude/settings.local.json (you, this project). Plugins ship them in hooks/hooks.json. The shape is: event → list of matchers → list of handlers.
.claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-dangerous.sh" }
]
}
]
}
}matcher
For tool events it filters the tool name. "Bash", "Edit|Write", or a regex like "mcp__github__.*". Events like Stop don't need one.
type
command (a shell command, the common case), plus http, mcp_tool, prompt, and an experimental agent type.
Input: JSON on stdin
Your script receives the event as JSON: tool_name, tool_input (e.g. tool_input.command for Bash, tool_input.file_path for Edit/Write), cwd, session_id, and more. jq is your friend.
${CLAUDE_PROJECT_DIR}
Points at the project root where the session started, so your script paths work no matter which folder Claude has cd'd into.
Run /hooks in a session to browse every configured hook, its matcher, and which settings file it came from. Or skip hand-editing: ask Claude "add a hook that..." and review the diff.
4Exit codes: how hooks say no
Exit 0: success, carry on. On most events stdout only goes to the debug log. (On SessionStart and UserPromptSubmit, plain stdout is added as context Claude can see.)
Exit 2: blocking error. On events that can block, the action is stopped and your stderr text is shown to Claude as the reason. Even a JSON "allow" can't override it.
Any other code: a non-blocking error. The action proceeds. This is the classic bug: you wrote exit 1 thinking it would block. It won't.
| Event | What exit 2 does |
|---|---|
| PreToolUse | Blocks the tool call |
| UserPromptSubmit | Blocks the prompt |
| Stop | Prevents Claude from stopping, so it keeps working |
| PostToolUse | Can't block (the tool already ran), but stderr still reaches Claude |
| PermissionRequest | Doesn't block; use the JSON decision object instead |
For finer control, hooks can print JSON to stdout instead (for example a PreToolUse permissionDecision of "deny"). Exit codes plus stderr are enough for everything on this page.
5Four recipes worth stealing
Auto-format after every edit
Pulls the edited file path out of the JSON and runs Prettier on it. Claude never ships unformatted code again.
.claude/settings.json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}Block rm -rf and writes to secrets
Two small scripts. Remember to chmod +x them.
.claude/hooks/block-dangerous.sh
#!/usr/bin/env bash
cmd=$(jq -r '.tool_input.command // ""')
if echo "$cmd" | grep -Eq 'rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*f|rm[[:space:]]+-[a-zA-Z]*f[a-zA-Z]*r'; then
echo "Blocked: recursive force delete. Delete specific files instead, or ask the user to run it." >&2
exit 2
fi
if echo "$cmd" | grep -Eq 'git[[:space:]]+push.*(--force|-f)'; then
echo "Blocked: force push. Ask the user." >&2
exit 2
fi
exit 0.claude/hooks/protect-secrets.sh
#!/usr/bin/env bash
path=$(jq -r '.tool_input.file_path // ""')
case "$path" in
*.env|*.env.*|*/secrets/*|*.pem|*.key)
echo "Blocked: $path holds secrets. Tell the user what to change instead." >&2
exit 2 ;;
esac
exit 0.claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-dangerous.sh" }]
},
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-secrets.sh" }]
}
]
}
}Pattern-matching shell commands is a seatbelt, not a vault. A determined (or confused) agent can phrase things differently. Pair it with permission deny rules and don't run agents with credentials they don't need. More in Prompt Injection & Security.
Desktop notification when Claude is done
Go make coffee. Your laptop will tell you when it's your turn. Put this in your user settings so it works everywhere.
~/.claude/settings.json (macOS)
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "osascript -e 'display notification \"Claude finished\" with title \"Claude Code\"'" }
]
}
],
"Notification": [
{
"matcher": "permission_prompt|idle_prompt",
"hooks": [
{ "type": "command", "command": "osascript -e 'display notification \"Claude needs you\" with title \"Claude Code\"'" }
]
}
]
}
}On Linux, swap the command for notify-send "Claude Code" "Claude finished".
Don't let Claude stop with failing tests
A Stop hook that exits 2 sends Claude back to work with your stderr as instructions. The stop_hook_active check is what keeps this from looping forever.
.claude/hooks/tests-must-pass.sh
#!/usr/bin/env bash
input=$(cat)
# Already continuing because of this hook? Let it stop this time.
if [ "$(echo "$input" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
if ! output=$(npm test --silent 2>&1); then
echo "Tests are failing. Fix them before finishing:" >&2
echo "$output" | tail -n 40 >&2
exit 2
fi
exit 0.claude/settings.json
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/tests-must-pass.sh", "timeout": 300 }
]
}
]
}
}timeout is in seconds. Only send the tail of the output back: 2,000 lines of test logs is a context bomb.
6Common traps
Slow hooks
Matching hooks run on every matching tool call. A 20-second type-check after every single edit makes a session crawl. Scope the matcher tightly, check only the changed file, and save the full test run for Stop. Command hooks default to a 600-second timeout, so a hung script can stall you for ten minutes.
Infinite loops
A Stop hook that always exits 2 means Claude can never stop. Check stop_hook_active. Also watch for PostToolUse hooks that edit files and trigger more edits.
exit 1 instead of exit 2
Only exit 2 blocks. exit 1 logs a non-blocking error and the action goes through.
Silent failures
Missing jq, a script without execute permission, a relative path that breaks after Claude cds. Use ${CLAUDE_PROJECT_DIR} and test hooks by triggering them on purpose.
Copying hooks you didn't read
Hooks run with your full user permissions, outside Claude's sandbox. That includes hooks shipped in plugins. Read them first.
7Hook, instruction, or permission rule?
CLAUDE.md
Judgment calls and style: "prefer Server Components," "keep functions small." Things where context matters.
Permission rules
Simple allow/deny by tool and pattern. See permission modes Permission Modes Settings that control how much Claude Code can do without asking: `default` (shown as Manual, asks before edits and commands), `acceptEdits` (auto-approves file edits), `plan` (read-only planning), `auto` (Claude judges what's safe; the starting mode on Pro, Max, and Team plans), `dontAsk`, and `bypassPermissions` (no prompts at all; for sandboxes only). Shift+Tab cycles through the common ones. "Like parental controls for AI. You choose how much freedom to give based on the task."
Hooks
Logic that must run every time: format, verify, notify, block based on file contents or custom conditions.
Next steps
Once your hooks are solid, package them in a plugin so every project gets them.