Claude Code follows your CLAUDE.md instructions most of the time. But “most of the time” isn’t good enough when the instruction is “never delete .env” or “always run Prettier after editing a file.”
Claude Code hooks are event-driven shell commands, LLM prompts, or agentic verifiers that execute deterministically at specific points in Claude Code’s tool-call lifecycle. They block destructive actions, enforce code standards, and automate post-edit tasks without relying on the LLM to remember instructions. Unlike CLAUDE.md guidelines that Claude may occasionally skip, hooks fire every time, with no exceptions.
This tutorial is for developers and engineering leads who use Claude Code and want deterministic guardrails around agent behavior. It assumes you have Claude Code installed and have worked with it on at least one project. We focus exclusively on command hooks (not prompt or agent hooks) for the two events you’ll use most: PreToolUse and PostToolUse. For prompt and agent hook types, see the Beyond Commands section.
As a software engineer who builds agentic development workflows daily, I’ve been implementing PreToolUse and PostToolUse hooks across various repositories. This tutorial distills what I’ve learned into the patterns that matter.
By the end, you’ll have a working hook configuration that blocks dangerous commands, auto-formats code, and protects sensitive files.
Key Takeaways
- Hooks are deterministic enforcement. Unlike CLAUDE.md instructions, hooks fire every time, with no exceptions.
- PreToolUse intercepts tool calls before execution. Use it to block destructive commands, protect files, or modify parameters.
- PostToolUse reacts after a tool succeeds. Use it to auto-format, lint, or run tests on changed files.
- Exit code 2 is the magic number. It blocks the action and feeds the error back to Claude.
- Start with three hooks: destructive command blocking, file protection, and auto-formatting. Add more as patterns emerge.
What Are Claude Code Hooks?
Hooks are user-defined event handlers that execute shell commands, LLM prompts, or agentic verifiers at specific points in Claude Code’s workflow. They were introduced in June 2025 as part of Anthropic’s push toward agentic coding tools, and have grown from 6 event types to 17 as of early 2026. According to the Claude Code changelog, major hook capabilities (including updatedInput for parameter rewriting and prompt/agent handler types) shipped across four releases between June and October 2025.
The core idea: instead of telling Claude “please format code with Prettier” in your how to write a CLAUDE.md file guide and hoping it listens, you configure a PostToolUse hook that runs Prettier automatically every time Claude writes a file. The formatter runs whether Claude remembers to or not.
Three handler types exist:
| Type | What It Does | When to Use |
|---|---|---|
| command | Runs a shell command | Deterministic checks: formatting, linting, file protection |
| prompt | Sends a single prompt to an LLM | Judgment calls, e.g. “is this response complete?” |
| agent | Spawns a multi-turn agent with tool access | Complex verification: run tests and analyze failures |
In practice, command hooks dominate real-world configurations because most guardrails are deterministic: block a command, format a file, protect a path. Prompt and agent hooks fill the gap when you need LLM judgment. This tutorial focuses on command hooks because they’re the ones you should set up first.
The Hook Lifecycle
Here’s what happens when Claude Code processes a request:
User prompt
→ Claude generates tool call (e.g., Edit a file)
→ PreToolUse fires (can block, modify, or allow)
→ Tool executes
→ PostToolUse fires (can format, lint, test, log)
→ Claude continues with next action

PreToolUse fires after Claude decides what tool to call and with what parameters, but before the tool actually runs. You can block the call, modify the parameters, or let it through.
PostToolUse fires after the tool succeeds. The action already happened, so you can’t undo it. But you can react: format the written file, run a linter, trigger a test, log the action.
Claude Code supports 17 event types total. Here are the ones worth knowing about:
| Event | When It Fires | Can Block? |
|---|---|---|
| PreToolUse | Before a tool call executes | Yes |
| PostToolUse | After a tool call succeeds | No |
| Stop | When Claude finishes responding | Yes |
| SessionStart | When a session begins or resumes | No |
| UserPromptSubmit | When you submit a prompt | Yes |
| Notification | When Claude sends a notification | No |
| SubagentStop | When a sub-agent finishes | Yes |
The full list includes PostToolUseFailure, PermissionRequest, SubagentStart, TeammateIdle, TaskCompleted, ConfigChange, WorktreeCreate, WorktreeRemove, PreCompact, and SessionEnd. You don’t need all of them. PreToolUse and PostToolUse cover the vast majority of practical use cases in my experience.
Your First Hook: Configuration Walkthrough
Hooks live in JSON settings files. You have three options:
| File | Scope | Shareable |
|---|---|---|
.claude/settings.json | This project | Yes (commit to git) |
.claude/settings.local.json | This project | No (gitignored) |
~/.claude/settings.json | All your projects | No (local to your machine) |
For team-shared hooks (formatting, protected files), use .claude/settings.json. For personal preferences (notification style, debug logging), use ~/.claude/settings.json.
Here’s the structure:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/validate-bash.sh",
"timeout": 10
}
]
}
]
}
}
The key pieces:
- Event name (
PreToolUse): when this hook fires - matcher: a regex that filters which tool triggers this hook.
"Bash"matches the Bash tool."Edit|Write"matches either."mcp__.*"matches all MCP tools. Omit it or use""to match everything. - hooks array: one or more handlers to run. All matching hooks run in parallel.
- type:
"command","prompt", or"agent" - command: the shell command to execute
- timeout: seconds before the hook is killed (default: 600)
Your hook script receives JSON on stdin with context about the event: the tool name, its parameters, the session ID, and the current working directory.
To debug hooks, run Claude Code with claude --debug or toggle verbose mode with Ctrl+O during a session. Without this, hooks fail silently.
PreToolUse: Intercept Before Execution
PreToolUse matters more than any other hook event for production safety. It fires after Claude decides on a tool call but before that call executes, giving you a synchronous checkpoint to enforce constraints before any side effects occur. You can:
- Block the call (exit code 2, or return
permissionDecision: "deny") - Allow it, bypassing the permission dialog (
permissionDecision: "allow") - Modify the tool parameters before execution (
updatedInput, available since v2.0.10) - Add context that Claude sees before the tool runs (
additionalContext)
Example 1: Block Destructive Shell Commands
This hook prevents Claude from running rm -rf, git push --force, or DROP TABLE:
#!/bin/bash
# .claude/hooks/block-destructive.sh
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if [ -z "$COMMAND" ]; then
exit 0
fi
# Block dangerous patterns
DANGEROUS_PATTERNS=(
'rm -rf'
'rm -r /'
'git push --force'
'git push -f'
'DROP TABLE'
'DROP DATABASE'
'git reset --hard'
)
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
if echo "$COMMAND" | grep -qi "$pattern"; then
echo "Blocked: command matches dangerous pattern '$pattern'" >&2
exit 2
fi
done
exit 0
Hook configuration:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/block-destructive.sh"
}
]
}
]
}
}
Exit code 2 is critical here. It tells Claude Code this is a blocking error: the tool call is prevented and the stderr message is fed back to Claude as an error. Any other non-zero exit code is treated as a non-blocking warning (logged but ignored). This follows the convention documented in the Anthropic Claude Code hooks reference: exit 0 = success, exit 2 = blocking error, any other non-zero = non-blocking warning.
Here’s what this looks like in practice. Without the hook, Claude silently executes the command:
$ claude "clean up the temp directory"
> Bash: rm -rf /tmp/project-cache
✓ Done. Removed /tmp/project-cache and all contents.
With the hook active, the destructive command is caught and Claude adjusts:
$ claude "clean up the temp directory"
> Bash: rm -rf /tmp/project-cache
✗ Hook blocked: command matches dangerous pattern 'rm -rf'
> Bash: find /tmp/project-cache -type f -delete
✓ Done. Removed files in /tmp/project-cache while preserving the directory.
Claude receives the hook’s error, understands the constraint, and finds a safer alternative. No manual intervention needed.
Example 2: Protect Files From Edits
Some files should never be modified by Claude: .env, lock files, CI config.
#!/bin/bash
# .claude/hooks/protect-files.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [ -z "$FILE_PATH" ]; then
exit 0
fi
PROTECTED_PATTERNS=(
".env"
"package-lock.json"
"pnpm-lock.yaml"
"yarn.lock"
".git/"
".github/workflows/"
)
for pattern in "${PROTECTED_PATTERNS[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Blocked: '$FILE_PATH' is a protected file" >&2
exit 2
fi
done
exit 0
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/protect-files.sh"
}
]
}
]
}
}
Note the matcher: "Edit|Write|Bash" catches all three tools that could modify files. The Bash tool is included because Claude might use sed or echo > to write files.
Example 3: Modify Tool Input
Since v2.0.10, PreToolUse hooks can rewrite tool parameters before execution. This is a significant upgrade over the block-and-retry pattern.
Convert a destructive rm -rf into an interactive rm -i:
#!/bin/bash
# .claude/hooks/safer-rm.sh
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$COMMAND" | grep -q '^rm -rf'; then
SAFER=$(echo "$COMMAND" | sed 's/^rm -rf/rm -ri/')
jq -n --arg cmd "$SAFER" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
updatedInput: {
command: $cmd
}
}
}'
exit 0
fi
exit 0
The updatedInput field replaces the tool’s parameters before execution. Combined with permissionDecision: "allow", the modified command runs without a permission prompt.
PostToolUse: React After Execution
PostToolUse is the quality enforcement layer. It runs after every successful tool call, making it the ideal trigger for formatting, linting, and post-edit testing. The action is done. You can’t undo a file write or a shell command. But you can:
- Format the file that was just written
- Lint the code that was just changed
- Run tests related to the changed file
- Log what happened for audit purposes
Example 1: Auto-Format With Prettier
Every file Claude writes gets formatted automatically:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write 2>/dev/null || true",
"statusMessage": "Formatting with Prettier..."
}
]
}
]
}
}
This is inline, no separate script file needed. It pipes the file path from stdin JSON through jq, then passes it to Prettier. The 2>/dev/null || true ensures non-Prettier-supported files don’t cause errors.
The statusMessage field shows a custom spinner while the hook runs, replacing the default “Running hook…” text.
Example 2: Run Linter After Code Changes
#!/bin/bash
# .claude/hooks/lint-on-change.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [ -z "$FILE_PATH" ]; then
exit 0
fi
# Only lint TypeScript/JavaScript files
case "$FILE_PATH" in
*.ts|*.tsx|*.js|*.jsx)
RESULT=$(npx eslint --fix "$FILE_PATH" 2>&1)
if [ $? -ne 0 ]; then
echo "$RESULT" >&2
exit 2
fi
;;
esac
exit 0
When exit code 2 is returned from a PostToolUse hook, the error message is fed back to Claude. Claude sees the ESLint errors and can fix them in its next turn, creating an automatic fix loop.
Example 3: Trigger Related Tests
#!/bin/bash
# .claude/hooks/auto-test.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [ -z "$FILE_PATH" ]; then
exit 0
fi
# Find and run the matching test file
case "$FILE_PATH" in
*.test.ts|*.test.tsx|*.spec.ts|*.spec.tsx)
npx vitest run "$FILE_PATH" --reporter=verbose 2>&1
;;
*.ts|*.tsx)
TEST_FILE="${FILE_PATH%.ts}.test.ts"
if [ -f "$TEST_FILE" ]; then
npx vitest run "$TEST_FILE" --reporter=verbose 2>&1
fi
;;
esac
exit 0
This runs the corresponding Vitest test file whenever Claude edits a source file. No other hook pays back its setup time faster. Claude gets immediate feedback on whether its changes broke something.
Beyond Commands: Prompt and Agent Hooks
Command hooks handle deterministic checks. But sometimes you need judgment.
Prompt hooks send a single question to an LLM:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Review the conversation context: $ARGUMENTS\n\nAre all requested tasks complete? Respond with JSON: {\"ok\": true} to stop, or {\"ok\": false, \"reason\": \"what's missing\"} to continue.",
"timeout": 30
}
]
}
]
}
}
This Stop hook uses a fast model (Haiku by default) to evaluate whether Claude’s work is actually done before stopping. If the evaluator finds incomplete work, Claude continues.
Agent hooks spawn a multi-turn agent with Read, Grep, and Glob access:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "agent",
"prompt": "Run the test suite and verify all tests pass. If any fail, explain what's wrong. $ARGUMENTS",
"timeout": 120
}
]
}
]
}
}
Use command hooks for formatting, linting, and file protection. Use prompt hooks for quick judgment calls. Use agent hooks for complex verification that requires reading files and running multiple commands.
Hooks vs CLAUDE.md vs Skills
Three mechanisms configure Claude Code’s behavior. Each serves a different purpose:
| CLAUDE.md | Hooks | Skills | |
|---|---|---|---|
| Nature | Suggestion | Enforcement | Contextual expertise |
| Deterministic? | No | Yes | No |
| When it applies | Every session | On matching events | When context matches |
| Best for | Guidelines, preferences | Rules, automation | Reusable workflows |
| Example | “Prefer Bun over npm” | “Block rm -rf” | “Deploy to staging” |
The simplest rule: if violating the instruction would cause damage, use a hook. If violating it is merely suboptimal, use CLAUDE.md. The decision is straightforward:
- Preference? → CLAUDE.md. “Use named exports.” Claude usually follows it.
- Must never be violated? → Hook. “Never edit .env.” The hook blocks it every time.
- Reusable expertise? → Skill. “Deploy with these steps.” Activates when relevant.
For a deeper dive on CLAUDE.md configuration, see How to Write the Perfect CLAUDE.md File.
Five Common Mistakes
1. Wrong exit code. Exit code 2 blocks. Exit code 1 is a non-blocking warning. If your hook isn’t preventing anything, check your exit code. I spent two hours wondering why my file-protection hook was “broken” because Claude kept editing .env no matter what. I added echo statements, rewrote the pattern matching twice, and finally ran claude --debug only to see hook exited with code 1 (non-blocking) in the output. Changed exit 1 to exit 2 and it worked instantly.
2. Unquoted shell variables. $VAR breaks on spaces. Always use "$VAR". This is especially important when handling file paths from jq output.
3. Overly broad matchers. A PreToolUse hook matching all tools fires on every Read, Glob, and Grep call, slowing everything down. I measured this in one project: an empty-matcher hook added ~80ms per tool call, and Claude makes 15-30 tool calls per prompt. That’s 1-2 seconds of overhead per interaction, compounding across a session. Match only the tools you need: "Bash", "Edit|Write", or specific MCP tool patterns like "mcp__github__.*".
4. Missing stop_hook_active check. If your Stop hook tells Claude to keep working, and Claude’s next response triggers the Stop event again, you get an infinite loop. I hit this on my first prompt-based Stop hook. Claude generated 11 responses before I killed the session. Always check the stop_hook_active field in Stop hook input and exit 0 if it’s true.
5. Not testing with --debug. Hooks fail silently by default. Run claude --debug to see full hook execution output, or toggle verbose mode with Ctrl+O during a session. This is the first thing to try when a hook isn’t working.
What Changes With Hooks
Guardrails for AI coding agents are not optional. The 2025 Stack Overflow Developer Survey found that 84% of developers use or plan to use AI tools, yet only 33% trust the accuracy of AI-generated output. Two-thirds reported frustration with “AI solutions that are almost right, but not quite.” Hooks bridge that gap by enforcing review constraints programmatically. After six months of running the three core hooks across four TypeScript and Python projects, here’s what I’ve measured:
| Metric | Before Hooks | After Hooks |
|---|---|---|
| Accidental destructive commands per month | 2-3 | 0 |
| Manual Prettier/Black runs per session | 5-10 | 0 |
.env or lock file edits caught in review | ~1/week | 0 (blocked at source) |
| Average hook overhead per tool call | N/A | ~60ms |
| Time to set up hooks for a new project | N/A | 10 minutes |
Quick-Start Configuration
Here’s a complete .claude/settings.json with three practical hooks. Copy it, adjust the paths, and you’re running:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/block-destructive.sh",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/protect-files.sh",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write 2>/dev/null || true",
"statusMessage": "Formatting..."
}
]
}
],
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "notify-send 'Claude Code' 'Needs your attention'"
}
]
}
]
}
}
Create the hook scripts referenced above (block-destructive.sh, protect-files.sh), make them executable with chmod +x, and restart Claude Code. The hooks take effect on the next session.
Type /hooks inside Claude Code to see all active hooks and their sources.
FAQ
Do hooks work with sub-agents?
Yes. Hooks fire for tool calls made by sub-agents, not just the main session. A PreToolUse hook blocking rm -rf applies to sub-agent Bash calls too.
Can I modify tool input with hooks?
Yes, since v2.0.10 (October 2025). Return updatedInput in your PreToolUse hook’s JSON output to change tool parameters before execution.
What happens if a hook times out?
The hook is killed and treated as a non-blocking failure. Claude continues as if the hook didn’t exist. Set appropriate timeouts (5-10 seconds for simple checks, 30+ seconds for LLM-based hooks).
Where should I put hook scripts?
.claude/hooks/ in your project root. This keeps them version-controlled alongside your settings. Use "$CLAUDE_PROJECT_DIR" in hook commands for reliable path resolution.
Can hooks run asynchronously?
Yes. Set "async": true on a command hook to run it in the background. Claude continues immediately. Async hooks can’t block or modify tool calls. They’re for logging, notifications, and background tasks.
How do I debug hooks that aren’t firing?
- Run
claude --debugto see hook execution logs - Check your matcher regex (it’s case-sensitive)
- Verify the hook script is executable (
chmod +x) - Check the JSON structure in your settings file. A missing comma breaks everything.
- Type
/hooksto verify Claude Code loaded your hooks
What is a Claude Code hook?
A Claude Code hook is a user-defined event handler (a shell command, LLM prompt, or agentic verifier) that fires automatically at a specific point in Claude Code’s tool-call lifecycle. Hooks enforce rules deterministically, unlike CLAUDE.md instructions which the LLM may occasionally skip. Claude Code supports 17 hook events as of early 2026, with PreToolUse and PostToolUse being the most widely used.
How do I use Stop hooks without creating an infinite loop?
Check the stop_hook_active field in your Stop hook’s stdin JSON. If it’s true, exit 0 immediately. That means your hook already triggered a continuation and Claude is stopping again. Without this check, your Stop hook tells Claude to keep working, which triggers another Stop event, creating an infinite loop. Here’s the guard pattern:
INPUT=$(cat)
ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')
if [ "$ACTIVE" = "true" ]; then
exit 0
fi
# Your actual stop hook logic here
Start Using Claude Code Hooks Today
Claude Code hooks, especially PreToolUse and PostToolUse, are the enforcement layer that turns CLAUDE.md guidelines into guarantees. Start with the three-hook configuration above: blocking destructive commands, protecting sensitive files, and auto-formatting on every write. Those three alone will prevent the most common agent mistakes.
Once you’re comfortable, explore prompt and agent hooks for judgment-based verification. Add more hooks as you discover patterns in your workflow. Every time Claude makes a mistake that a deterministic check could have caught, that’s a new hook waiting to be written.
For the foundation that hooks build on, see how to write a CLAUDE.md file. CLAUDE.md provides the guidelines; hooks provide the guardrails.
