#2: Hook Basics 🪝
Today we'll look at one of the best ways to make Claude Code behave the same way every time: hooks.
Hooks run our own code at key moments in a Claude Code session. Instead of hoping Claude remembers to format a file or skip a protected directory, we make it happen automatically, on every matching event.
Let's dive in.
Setting Them Up
Hooks live in our settings files. The user file (~/.claude/settings.json) applies to every project, .claude/settings.json is shared with the team, and .claude/settings.local.json stays on our machine. Plugins, skills, and subagents can bring their own hooks too.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs bunx prettier --write"
}
]
}
]
}
}This one runs Prettier every time Claude Code edits or writes a file. Every hook gets a JSON payload on stdin, so jq pulls the file path right out of it. Neat!
The Events
Claude Code has more than 30 hook events now. These are the ones we'll reach for most:
- PreToolUse fires before a tool call and can block it
- PostToolUse fires after a tool call succeeds
- UserPromptSubmit fires when we send a prompt, before Claude sees it
- SessionStart fires when a session begins or resumes
- Stop fires when Claude finishes responding, and can make it keep going
- Notification fires when Claude Code wants our attention
- PreCompact and PostCompact wrap context compaction
- SubagentStart and SubagentStop wrap subagent runs
There are also events for config changes, file changes on disk, worktrees, model switches, permission requests, and MCP elicitations.
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt|idle_prompt",
"hooks": [{ "type": "command", "command": "say 'HAL needs us'" }]
}
]
}
}That one uses macOS say so we hear it when Claude is waiting on us.
Five Handler Types
Each hook handler has a type:
- command runs a shell command (the most common)
- http POSTs the event JSON to a URL
- mcp_tool calls a tool on one of our MCP servers
- prompt asks a Claude model for a quick yes/no style decision
- agent spawns a subagent that can read files before deciding (still experimental)
Matchers And if
The matcher field filters what a hook fires on. For tool events it matches the tool name, so Bash, Edit|Write, and mcp__.* all work. For more precise filtering on tool events, the if field takes one permission rule:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git push *)",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/check-branch.sh"
}
]
}
]
}
}Note that if goes on the handler, next to type. It holds exactly one rule, so two conditions means two handlers.
Exit Codes
How our hook exits decides what happens next:
- Exit 0: success, and the action goes ahead
- Exit 2: a blocking error. On a
PreToolUsehook the tool call is stopped, and our stderr goes back to Claude as the reason - Anything else: a non-blocking error, and the action still goes ahead
#!/usr/bin/env python3
import json, sys
data = json.load(sys.stdin)
path = data.get("tool_input", {}).get("file_path", "")
if any(p in path for p in [".env", "bun.lock", ".git/"]):
print(f"Blocked: {path} is protected", file=sys.stderr)
sys.exit(2)Claude reads that message and tries another approach.
Watch Out
Exit code 1 does not block anything, even though it's the usual Unix failure code. If a hook is enforcing a policy, use exit 2.
The timeout field is in seconds, and command hooks default to 600. All matching hooks for an event run in parallel. Edits to hooks in our settings files are picked up automatically, and /hooks opens a read-only browser showing every hook and where it came from.
More info about hooks can be found at the Claude Code docs.