Claude Code Hooks: The Complete Guide to Custom Hooks and Practical Techniques

A practical walkthrough of how Claude Code Hooks work — event hooks, PreToolUse/PostToolUse, configuration — along with real, ready-to-use hook recipes that raise development productivity.
代表 / エンジニア
Many readers will already have built out an AI-driven development environment by absorbing the big picture from What Is Claude Code? The Complete Guide, defining project rules with CLAUDE.md, and templating repetitive tasks with Skills. But these mechanisms share a common limitation: they are all requests. The rules in CLAUDE.md might be ignored by Claude; the instructions in Skills are interpreted probabilistically. Hooks were designed to remove that uncertainty. They are a mechanism for guaranteeing that a shell script runs at specific points in the Claude Code workflow. If CLAUDE.md is "the rules we'd like to follow," then Hooks are "the rules that are guaranteed to run." In this article we'll cover how Hooks work, through to practical recipes — drawing on our experience operating them at aduce.
What Are Claude Code Hooks?
Claude Code Hooks are a mechanism for automatically running shell scripts at specific points in Claude's workflow. Things like "run the formatter right after editing a file," "block writes to specific directories," or "log every tool invocation" can be made to reliably run by intervening in Claude's operation.
The key word there is reliably. Instructions you write in CLAUDE.md or Skills are, at their core, prompts to Claude (an LLM), and their interpretation is probabilistic. Hooks, in contrast, are shell scripts that run independently of Claude's reasoning — if the condition is met, they execute 100% of the time. Grasping that distinction is the first step to using Hooks well.
The Essential Difference from CLAUDE.md
Suppose you write in CLAUDE.md: "Never run supabase db reset." Claude might obey that rule 99% of the time. But as context grows, as tasks get more complex, or simply due to the probabilistic nature of LLMs, the chance of that rule being broken is not zero.
Hooks solve that problem at the root. Register a PreToolUse hook that rejects any Bash tool call containing supabase db reset, and the command physically cannot execute, regardless of Claude's intent. This is the core value of Hooks.
How Hooks Actually Work
Hooks operate under a simple contract. When a specified event occurs, Claude Code runs the shell command registered as a hook. It checks the hook's exit code and standard output, then continues, aborts, or alters processing based on the result.
Concretely: exit code 0 means success, and emitting JSON-formatted control instructions on standard output lets you steer Claude's behavior with fine granularity. A non-zero exit code is treated as an error, and the content of standard error is reported to Claude Code.
Through this mechanism, Hooks are more than a notification system — they function as a gatekeeper that actively controls Claude Code's behavior.
Hook Types and Events
Hooks fall into five event types. Here's when each fires and what it's for.
PreToolUse
This event fires before a tool runs. It's the most frequently used — and the most powerful — of all the Hook events.
Claude: about to run the Bash tool
-> PreToolUse Hook fires
-> exit 0 + {"decision": "allow"} -> execute
-> exit 0 + {"decision": "deny", "reason": "..."} -> rejected
-> exit 0 + {"updatedInput": {...}} -> modify input and executeAn especially important property: PreToolUse fires before the permission check. In other words, even if the user is running with bypass mode (--dangerouslySkipPermissions), a Hook denial takes precedence. That property is extremely useful from a safety standpoint.
For example, when running Claude Code unattended in a CI/CD pipeline, you can grant it all permissions while still installing guardrails like "but never run the command that connects to the production DB."
You can specify a tool name as the matcher — "Bash", "Edit", "Write" — to react only to specific tools. Using "*" fires on every tool invocation.
PostToolUse
This event fires after a tool runs. It's a natural fit for post-processing based on a tool's result.
Claude: modified a file with the Edit tool
-> PostToolUse Hook fires
-> run the formatter to clean up the code
-> run the linter to detect errors
-> log the changeAs with PreToolUse, PostToolUse lets you specify a tool name via matcher. The classic use cases are auto-formatting and linting after file changes. Even if Claude's code doesn't match your project's formatting conventions, a PostToolUse hook running Prettier or ESLint --fix keeps the code canonical at all times.
Also, returning {"suppressOutput": true} as JSON on standard output stops the tool's output from being passed back to Claude. Useful when a tool produces voluminous logs that you'd rather keep out of Claude's context.
Notification
This event fires when Claude Code sends a notification to the user.
It fires, for instance, when Claude finishes a long-running task or is waiting for user input. You can use it to customize desktop notifications or forward them to Slack. There is no matcher concept — it fires for every notification.
Stop
This event fires just before Claude finishes generating a response and stops processing.
It's well suited to verifying Claude's final output or performing cleanup. Think: "confirm the code Claude generated actually builds," or "send a task-completion notification."
Returning {"decision": "block", "reason": "..."} on a Stop hook's standard output prevents the stop, conveys the reason to Claude, and lets processing continue. You can build workflows like "keep iterating until the build passes."
SubagentStop
This fires just before a subagent (a child process Claude has delegated a task to) completes its processing.
It allows the same kind of control as Stop, but the target is a subagent rather than the main Claude session. You can validate a subagent's output and, if it fails quality bars, instruct it to try again.
Event Summary
| Event | Timing | Matcher | Typical Use |
|---|---|---|---|
| PreToolUse | Before tool runs | Tool name | Execution control, input validation/modification |
| PostToolUse | After tool runs | Tool name | Post-processing, formatting, logging |
| Notification | When notifying | None | Customizing notifications |
| Stop | Before stopping | None | Output validation, cleanup |
| SubagentStop | Before subagent stops | None | Validating subagent output |
Configuring Hooks via settings.json
Hooks are defined in settings.json. There are two possible locations, depending on intent.
Where the Configuration File Lives
Project settings (shared with the team): .claude/settings.json
Place this in the .claude/ directory at the project root. Commit it to Git to share with the whole team. Project-specific formatters, protected directory definitions, and similar things belong here.
User settings (personal): ~/.claude/settings.json
Place this in .claude/ under your home directory. Good for personal hooks that apply horizontally across all projects — e.g., logging every tool invocation to your personal log file.
Basic Configuration Structure
Hooks configuration in settings.json takes this shape:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo 'The Bash tool is about to run'"
}
]
}
]
}
}Field meanings:
hooks: Top-level key holding the entire Hooks configuration"PreToolUse": Event type. Specify one of"PostToolUse","Notification","Stop", or"SubagentStop"matcher: Tool name to react to —"Bash","Edit","Write", or"*"(all tools)hooks: Array of hooks to run. Multiple commands can run sequentiallytype: Currently only"command"command: The shell command to execute
Environment Variables
Hook commands receive event-specific environment variables.
CLAUDE_TOOL_NAME: The tool being run (PreToolUse/PostToolUse)CLAUDE_TOOL_INPUT: Input to the tool (JSON string)CLAUDE_TOOL_OUTPUT: Tool output (PostToolUse only)CLAUDE_NOTIFICATION: Notification message (Notification only)CLAUDE_STOP_RESPONSE: Claude's final response (Stop/SubagentStop only)
These let you branch on the tool input or send the output to external services.
Control via JSON Output
Emitting JSON on a hook's stdout lets you control Claude Code's behavior. The main control options:
PreToolUse control:
{"decision": "allow"}Explicitly allow the tool to run. The permission prompt is skipped.
{"decision": "deny", "reason": "Editing production migration files is prohibited."}Reject the tool and communicate the reason to Claude.
{"updatedInput": {"command": "npm run lint -- --fix"}}Run the tool with a modified input. Useful for swapping the original command for a safer alternative.
PostToolUse control:
{"suppressOutput": true}Keep the tool's output out of Claude's context.
Stop/SubagentStop control:
{"decision": "block", "reason": "Build errors detected. Please fix them."}Prevent Claude from stopping and request additional work.
Example with Multiple Hooks
You can attach multiple matchers and hooks to a single event.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/path/to/validate-bash-command.sh"
}
]
},
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "/path/to/check-write-target.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": "npx prettier --write \"$CLAUDE_TOOL_INPUT\" 2>/dev/null || true"
}
]
}
]
}
}Hooks run in the order they're defined. If one hook returns deny, the subsequent hooks do not run and the tool invocation is rejected.
Practical Hook Recipes
What follows are concrete hook configurations you can actually use in projects. Based on what we run at aduce, I've picked the most broadly applicable patterns.
Recipe 1: Auto-Format After File Edits
The code Claude generates is mostly well-formatted, but won't always be perfectly compliant with your project's Prettier config or ESLint rules. A PostToolUse hook that runs the formatter resolves this permanently.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": "node -e \"const input = JSON.parse(process.env.CLAUDE_TOOL_INPUT || '{}'); const file = input.file_path; if (file && (file.endsWith('.ts') || file.endsWith('.tsx') || file.endsWith('.js') || file.endsWith('.jsx'))) { require('child_process').execSync('npx prettier --write ' + file + ' && npx eslint --fix ' + file, {stdio: 'inherit'}); }\""
}
]
},
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "node -e \"const input = JSON.parse(process.env.CLAUDE_TOOL_INPUT || '{}'); const file = input.file_path; if (file && (file.endsWith('.ts') || file.endsWith('.tsx') || file.endsWith('.js') || file.endsWith('.jsx'))) { require('child_process').execSync('npx prettier --write ' + file + ' && npx eslint --fix ' + file, {stdio: 'inherit'}); }\""
}
]
}
]
}
}The point of this recipe is that hooks are attached to both the Edit and Write tools. Claude distinguishes between modifying a file (Edit) and creating a new one (Write), so you need to cover both.
Recipe 2: Block Writes to Protected Directories
A hook that prevents Claude Code from modifying files that should be managed manually — migration files, config files, and so on.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": "node -e \"const input = JSON.parse(process.env.CLAUDE_TOOL_INPUT || '{}'); const file = input.file_path || ''; const protected_dirs = ['supabase/migrations', '.env', 'docker-compose.prod']; if (protected_dirs.some(d => file.includes(d))) { console.log(JSON.stringify({decision: 'deny', reason: file + ' is a protected file. Please modify it manually.'})); } else { console.log(JSON.stringify({decision: 'allow'})); }\""
}
]
},
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "node -e \"const input = JSON.parse(process.env.CLAUDE_TOOL_INPUT || '{}'); const file = input.file_path || ''; const protected_dirs = ['supabase/migrations', '.env', 'docker-compose.prod']; if (protected_dirs.some(d => file.includes(d))) { console.log(JSON.stringify({decision: 'deny', reason: file + ' is a protected file. Please modify it manually.'})); } else { console.log(JSON.stringify({decision: 'allow'})); }\""
}
]
}
]
}
}This hook uniformly rejects writes to files under supabase/migrations, to .env files, and to the production Docker Compose config. If "please don't modify migration files" in CLAUDE.md feels too loose, this is a firm defense.
Recipe 3: Block Dangerous Command Execution
Prevents execution of destructive commands like database resets, direct production deploys, and rm -rf.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node -e \"const input = JSON.parse(process.env.CLAUDE_TOOL_INPUT || '{}'); const cmd = input.command || ''; const dangerous = ['supabase db reset', 'rm -rf /', 'DROP DATABASE', 'git push --force origin main', 'git push --force origin prod']; const match = dangerous.find(d => cmd.includes(d)); if (match) { console.log(JSON.stringify({decision: 'deny', reason: 'Dangerous command detected: ' + match})); }\""
}
]
}
]
}
}In our projects we have "never run supabase db reset" in CLAUDE.md, but we also enforce the same rule with Hooks — a redundant defense: CLAUDE.md is "what we're asking Claude to do," Hooks is "system-level enforcement." Given the risk of data loss, that redundancy is easily justified.
Recipe 4: Log Every Tool Call
A hook that records every tool invocation to a log file — for debugging and shared team knowledge.
{
"hooks": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "echo \"$(date -u '+%Y-%m-%dT%H:%M:%SZ') [PRE] tool=$CLAUDE_TOOL_NAME input=$(echo $CLAUDE_TOOL_INPUT | head -c 200)\" >> /tmp/claude-hooks.log"
}
]
}
],
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "echo \"$(date -u '+%Y-%m-%dT%H:%M:%SZ') [POST] tool=$CLAUDE_TOOL_NAME\" >> /tmp/claude-hooks.log"
}
]
}
]
}
}The matcher "*" means every tool call gets captured. Periodically reviewing the log reveals what order Claude uses its tools and what it's actually doing — useful both for debugging unexpected behavior and for improving the workflow.
Recipe 5: Validate Commit Messages
A hook that auto-validates commit messages against your team's Conventional Commits convention.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node -e \"const input = JSON.parse(process.env.CLAUDE_TOOL_INPUT || '{}'); const cmd = input.command || ''; if (cmd.includes('git commit')) { const msgMatch = cmd.match(/(?:-m\\s+['\\\"])(.*?)(?:['\\\"])/); if (msgMatch) { const msg = msgMatch[1]; const valid = /^(feat|fix|docs|style|refactor|test|chore|ci|perf|build)/.test(msg); if (!valid) { console.log(JSON.stringify({decision: 'deny', reason: 'Commit message must follow Conventional Commits format (feat:, fix:, docs:, etc.)'})); } } }\""
}
]
}
]
}
}Git's own commit-msg hook can do similar validation, but Hooks differ in that they validate before Claude Code runs the command. If Claude generates an invalid commit message, it doesn't even execute — it's fed back immediately, avoiding wasted retries.
Recipe 6: Have Claude Write the Hook for You
Writing these recipes by hand might feel tedious. In fact, you can let Claude Code write them for you.
Just ask in the Claude Code chat:
"Set up a Hook in .claude/settings.json that automatically runs eslint after file edits"
Claude understands the structure of settings.json and will generate a hook with the appropriate event type, matcher, and command. Even for hooks with complex conditionals, describing the requirement in natural language produces a working shell script — meaning even those less comfortable with shell scripting can benefit from Hooks.
The /hooks command is also handy for inspecting existing hooks. It shows the currently configured hooks and their states, useful for debugging and inventorying your setup.
Hooks vs. Skills: When to Use Which
Hooks and Skills are both Claude Code extensions, but their design philosophies are fundamentally different. Understanding this distinction is the foundation for using them appropriately.
Different Design Philosophies
Skills are "on-demand knowledge and workflows." The user invokes them explicitly with a slash command like /review or /test-gen, giving Claude specific context and instructions. Timing is under the user's control, and effects manifest indirectly through Claude's response.
Hooks are "automatic, guaranteed actions." A specific event triggers them without any human intervention, executing directly as shell scripts. They don't pass through Claude's reasoning, so certainty is guaranteed.
Put differently: Skills are mechanisms for teaching Claude; Hooks are mechanisms for controlling it.
Decision Criteria
The table below lays out the heuristics.
| Aspect | Skills | Hooks |
|---|---|---|
| Timing | Explicit user invocation | Automatic on event |
| Reliability | Probabilistic (LLM interpretation) | Deterministic (shell script) |
| Flexibility | High (natural language) | Low (predefined rules) |
| Best for | Complex, judgment-laden tasks | Mechanical processing, guardrails |
| Config file | .claude/skills/*.md | .claude/settings.json |
A concrete example. "Perform a code review" is a good fit for Skills: the review criteria and depth change with context, and LLM judgment matters. "Run the linter after code review" is a good fit for Hooks: running the linter is mechanical and should always run.
Use Them Together
Hooks and Skills aren't mutually exclusive — their real value emerges when combined.
For example, use a test-generation Skill (/test-gen) to have Claude generate test code; use a PostToolUse Hook to automatically format the generated test file; use a Stop Hook to run npm test and confirm the tests pass. In this pattern, Skills dictate "what to do" while Hooks automate "ensuring quality."
At aduce we operate with a three-layer structure: rules in CLAUDE.md, workflows defined in Skills, and guardrails enforced by Hooks. CLAUDE.md is "policy," Skills is "procedure," Hooks is "enforcement." By playing to each layer's strengths, we maintain both reliability and efficiency in AI-driven development.
Pairing with Plan Mode
Claude Code has a "Plan Mode" execution mode. In normal mode, Claude interprets requirements and immediately changes code; in Plan Mode it first presents a change plan and waits for user approval before executing.
Hooks combined with Plan Mode can produce even more robust development workflows.
Validating Plan Mode's Change Plans with Hooks
For example, a workflow that automatically validates the plan proposed in Plan Mode against your criteria:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node -e \"const resp = process.env.CLAUDE_STOP_RESPONSE || ''; if (resp.includes('TODO') || resp.includes('handle later')) { console.log(JSON.stringify({decision: 'block', reason: 'Plan contains TODOs or deferred items. Replace them with concrete implementation plans.'})); }\""
}
]
}
]
}
}This hook blocks Claude from stopping on a plan that contains vague text like "TODO" or "handle later," pressing it to be concrete. It complements human review when evaluating the plan.
Controlling Staged Execution
For large-scale changes, embedding Hooks into a Plan Mode workflow that executes in stages is also effective. You can use Stop hooks to auto-run tests after each stage, and only advance when they pass.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "npm test 2>/dev/null && echo '{\"decision\": \"allow\"}' || echo '{\"decision\": \"block\", \"reason\": \"Tests are failing. Please fix them before completing.\"}'"
}
]
}
]
}
}With this hook in place, each time Claude tries to finish a task, tests run automatically; if they fail, Claude is forced to continue fixing. If Plan Mode laid out "change type definitions, then update components, then fix tests," you can enforce tests passing at each stage.
Operational Caveats
The combination of Hooks and Plan Mode is powerful, but a few caveats apply.
First, watch the execution cost of Stop hooks. Putting a heavy command like npm test in a Stop hook means it runs every time Claude tries to stop, increasing overall workflow time. For large test suites, consider running only the tests related to changed files.
Second, returning block too often from Stop hooks can lead to infinite loops. Constrain block conditions to ones realistically achievable, and set safety valves like a maximum retry count.
Summary
Claude Code Hooks provide a distinct value from CLAUDE.md and Skills: reliability. System-level control that doesn't rely on the probabilistic nature of LLMs dramatically raises the trustworthiness of AI-driven development.
A quick recap of the key points:
- Hooks are a mechanism for reliably running shell scripts on specific events — unlike CLAUDE.md (advisory), they behave deterministically
- PreToolUse fires before the permission check, making it the most powerful guardrail
- Defined as JSON in settings.json — use
.claude/settings.jsonfor team-shared hooks and~/.claude/settings.jsonfor personal ones - Practical recipes are ready to use now: auto-formatting, blocking writes to protected directories, preventing dangerous commands
- Not mutually exclusive with Skills — an effective combination is Skills for "what to do" and Hooks for "ensuring quality"
- Combining with Plan Mode enables automated plan validation and test-driven staged execution
Adopting Hooks, together with rule definition via CLAUDE.md and workflow automation via Skills, is an important building block for making Claude Code function as a genuine development partner. I recommend starting with simple hooks like "auto-format after file edits," then gradually adding hooks that fit your project.
If you'd like to discuss adopting or operating Claude Code, please get in touch.