In my previous post, I covered how CLAUDE.md teaches Claude your conventions. That works well for guidance: coding style, architecture decisions, workflow preferences. But guidance has a gap: Claude can choose to ignore it. If you need something to happen every single time, without exception, you need enforcement.
That's where hooks come in. Hooks are shell commands, prompts, or agents that fire automatically at specific points in Claude Code's lifecycle. Claude writes a file? Your formatter runs. Claude tries to delete your CloudFormation stack? Blocked before it executes. Claude finishes responding? An agent verifies the tests pass.
CLAUDE.md is the style guide. Hooks are the linter.
Why Hooks?
CLAUDE.md says "always run the type checker." Hooks actually run the type checker. Every time, automatically, with zero exceptions.
| CLAUDE.md | Hooks | |
|---|---|---|
| Type | Guidance | Enforcement |
| Guarantee | Claude usually follows it | Always executes |
| Failure mode | Claude forgets or deprioritizes | Script crashes (visible, debuggable) |
| Use case | "Prefer functional patterns" | "Block rm -rf commands" |
Use CLAUDE.md for things Claude should generally do. Use hooks for things that must happen.
The Three Hook Types
Hooks come in three flavors, each suited to different automation needs.
Command Hooks
Shell commands that receive JSON on stdin and signal decisions via exit codes. This is the most common type.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/validate-command.sh"
}
]
}
]
}
}
The script gets the full tool input as JSON, does whatever validation it needs, and exits with a code:
- Exit 0: Allow the action. Stdout is parsed for JSON output.
- Exit 2: Block the action. Stderr is shown to Claude as an error.
- Any other code: Non-blocking error. Stderr appears in verbose mode.
Prompt Hooks
Send the hook input to a Claude model for a single-turn yes/no evaluation. Good for judgment calls that don't need file access.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Evaluate if Claude should stop: $ARGUMENTS. Check if all tasks are complete.",
"timeout": 30
}
]
}
]
}
}
The model returns {"ok": true} to allow or {"ok": false, "reason": "..."} to block. The $ARGUMENTS placeholder gets replaced with the hook's input JSON.
Agent Hooks
Spawn a subagent that can use tools (Read, Grep, Glob) to investigate the codebase before making a decision. This is the most powerful type. It can actually check file contents, run searches, and verify state. Note that agent hooks consume tokens and add latency, since they spin up a sub-agent that may take multiple turns. Use them for high-value verification, not routine checks.
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "agent",
"prompt": "Verify that all unit tests pass. Run the test suite and check the results. $ARGUMENTS",
"timeout": 120
}
]
}
]
}
}
Same response format as prompt hooks, but the agent gets up to 50 tool-use turns to do its work.
When to use which:
- Command: Deterministic checks (regex matching, file existence, running linters)
- Prompt: Fuzzy judgment calls ("does this look like a breaking change?")
- Agent: Verification that requires inspecting code ("are all the tests passing?")
Where to Configure Hooks
Hooks live in your settings files, same places as other Claude Code configuration:
| Location | Scope | Shared? |
|---|---|---|
~/.claude/settings.json |
All projects | No (your machine) |
.claude/settings.json |
Single project | Yes (via git) |
.claude/settings.local.json |
Single project | No (gitignored) |
Project-level hooks in .claude/settings.json are the sweet spot for team conventions. Everyone on the team gets the same enforcement automatically.
The 14 Hook Events
Hooks fire at specific points in Claude Code's lifecycle. Here are the ones you'll actually use:
The Essentials
PreToolUse: Before a tool executes. Can block it. This is your security layer.
PostToolUse: After a tool succeeds. Can't undo it, but can run follow-up actions (formatting, linting, type-checking).
Stop: When Claude finishes responding. Can force Claude to continue if work isn't done.
SessionStart: When a session begins or resumes. Good for environment setup.
Also Useful
UserPromptSubmit: When you submit a prompt, before Claude processes it. Can block or add context.
PostToolUseFailure: After a tool fails. Good for providing additional context to help Claude recover.
Notification: When Claude sends a notification. Good for desktop alerts when Claude needs your input.
PermissionRequest: When a permission dialog appears. Can auto-allow or deny on behalf of the user. Note: doesn't fire in non-interactive mode (-p). Use PreToolUse for automated permission decisions.
PreCompact: Before context compaction. Good for saving state.
SessionEnd: When a session ends. Good for cleanup.
For Agent Teams
SubagentStart / SubagentStop: When subagents spawn and finish.
TeammateIdle: When an agent team member is about to go idle. Can force it to keep working. Only supports command hooks (not prompt or agent).
TaskCompleted: When a task is being marked complete. Can block completion if quality gates aren't met.
Each event has a matcher that filters when it fires. For tool events, the matcher is the tool name: "Bash", "Edit|Write", "mcp__.*". Use regex patterns to match multiple tools.
Practical Examples
These are ready to use. Create the settings entry and the script, make the script executable (chmod +x), and you're done.
Example 1: Block Destructive Commands
The most common hook. Prevent Claude from running commands that could cause damage.
.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-destructive.sh"
}
]
}
]
}
}
.claude/hooks/block-destructive.sh:
#!/bin/bash
COMMAND=$(jq -r '.tool_input.command')
# Block rm -rf, force push, database drops, and AWS resource deletion
if echo "$COMMAND" | grep -qE 'rm\s+-rf|git push.*--force|drop table|drop database|sam delete|cloudformation delete-stack|dynamodb delete-table'; then
echo "Blocked: destructive command not allowed" >&2
exit 2
fi
exit 0
Exit 2 blocks the tool call. The stderr message gets fed back to Claude so it knows why the command was rejected.
Example 2: Auto-Format on File Write
Run your formatter automatically every time Claude writes or edits a file.
.claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/auto-format.sh"
}
]
}
]
}
}
.claude/hooks/auto-format.sh:
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ -z "$FILE_PATH" ]]; then
exit 0
fi
case "$FILE_PATH" in
*.ts|*.tsx|*.js|*.jsx|*.json|*.css)
npx prettier --write "$FILE_PATH" 2>/dev/null
;;
*.py)
ruff format "$FILE_PATH" 2>/dev/null
;;
esac
exit 0
This fires after every Edit or Write. It checks the file extension and runs the appropriate formatter. Claude doesn't need to remember to format. It just happens.
Example 3: TypeScript Type-Check After Edits
Run the type checker every time Claude modifies a TypeScript file.
.claude/hooks/typecheck.sh:
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ "$FILE_PATH" != *.ts && "$FILE_PATH" != *.tsx ]]; then
exit 0
fi
RESULT=$(npx tsc --noEmit 2>&1)
EXIT_CODE=$?
if [ $EXIT_CODE -ne 0 ]; then
echo "{\"systemMessage\": \"Type errors found after editing $FILE_PATH: $RESULT\"}"
fi
exit 0
When type errors are found, the systemMessage gets shown to Claude, which then knows to fix them. This is the enforcement version of "always run npm run typecheck after making changes" from your CLAUDE.md.
Example 4: Run Tests in the Background
For longer-running test suites, use async hooks so Claude doesn't wait around.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-tests-async.sh",
"async": true,
"timeout": 300
}
]
}
]
}
}
.claude/hooks/run-tests-async.sh:
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Only run tests for source files
if [[ "$FILE_PATH" != *.ts && "$FILE_PATH" != *.js ]]; then
exit 0
fi
RESULT=$(npm test 2>&1)
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
echo "{\"systemMessage\": \"Tests passed after editing $FILE_PATH\"}"
else
echo "{\"systemMessage\": \"Tests failed after editing $FILE_PATH: $RESULT\"}"
fi
With "async": true, Claude continues working while the tests run in the background. Results appear on the next conversation turn.
Example 5: Environment Setup on Session Start
Set up environment variables at the start of every session.
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/setup-env.sh"
}
]
}
]
}
}
.claude/hooks/setup-env.sh:
#!/bin/bash
if [ -n "$CLAUDE_ENV_FILE" ]; then
echo 'export NODE_ENV=development' >> "$CLAUDE_ENV_FILE"
echo 'export AWS_REGION=us-east-1' >> "$CLAUDE_ENV_FILE"
echo 'export PATH="$PATH:./node_modules/.bin"' >> "$CLAUDE_ENV_FILE"
fi
exit 0
Variables written to $CLAUDE_ENV_FILE persist for all subsequent Bash commands in the session. Use append (>>) to avoid overwriting variables from other hooks.
Example 6: Desktop Notifications
Get notified when Claude needs your input instead of staring at the terminal.
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/notify.sh"
}
]
}
]
}
}
.claude/hooks/notify.sh:
#!/bin/bash
INPUT=$(cat)
MESSAGE=$(echo "$INPUT" | jq -r '.message // "Claude needs your attention"')
# macOS
osascript -e "display notification \"$MESSAGE\" with title \"Claude Code\"" 2>/dev/null
# Linux (uncomment if needed)
# notify-send "Claude Code" "$MESSAGE" 2>/dev/null
exit 0
Example 7: Suggest Better Commands
Instead of blocking commands outright, suggest better alternatives.
.claude/hooks/suggest-commands.py:
#!/usr/bin/env python3
import json, re, sys
SUGGESTIONS = [
(r"^grep\b(?!.*\|)", "Use 'rg' (ripgrep) instead of 'grep' for better performance"),
(r"^find\s+\S+\s+-name\b", "Use 'rg --files | rg pattern' instead of 'find -name'"),
]
input_data = json.load(sys.stdin)
if input_data.get("tool_name") != "Bash":
sys.exit(0)
command = input_data.get("tool_input", {}).get("command", "")
issues = []
for pattern, message in SUGGESTIONS:
if re.search(pattern, command):
issues.append(f"* {message}")
if issues:
for issue in issues:
print(issue, file=sys.stderr)
sys.exit(2)
sys.exit(0)
This is adapted from the official example. Claude gets the suggestion as an error message and retries with the recommended command.
Example 8: Quality Gate for Task Completion
Prevent tasks from being marked complete if tests aren't passing.
{
"hooks": {
"TaskCompleted": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/quality-gate.sh"
}
]
}
]
}
}
.claude/hooks/quality-gate.sh:
#!/bin/bash
INPUT=$(cat)
TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject')
if ! npm test 2>&1; then
echo "Tests not passing. Fix failing tests before completing: $TASK_SUBJECT" >&2
exit 2
fi
exit 0
Example 9: Block Edits to Protected Files
Prevent Claude from modifying files that shouldn't be touched.
.claude/hooks/protect-files.sh:
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
PROTECTED_PATTERNS=(
".env"
".env.local"
"package-lock.json"
"pnpm-lock.yaml"
".git/"
"samconfig.toml"
"template.yaml"
)
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
Use this on both PreToolUse with matcher Edit|Write to cover all file modification paths.
Example 10: Force Dev Servers Into tmux
If Claude starts a dev server in the foreground, it blocks the session. Claude waits for the command to exit, and a server never exits, so Claude hangs forever. Force long-running processes into tmux.
.claude/hooks/tmux-check.sh:
#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$COMMAND" | grep -qE 'npm run dev|pnpm dev|yarn dev'; then
if ! echo "$COMMAND" | grep -q 'tmux'; then
echo "Dev servers must run in tmux. Use: tmux new-session -d -s dev \"npm run dev\"" >&2
exit 2
fi
fi
exit 0
Setting Up Your First Hook
The fastest way to get started:
Use the interactive menu: Type
/hooksin Claude Code to open the hooks manager. You can add, edit, and toggle hooks without touching JSON files.Or edit settings directly: Add the hook configuration to
.claude/settings.json(project-level) or~/.claude/settings.json(global).Create your script: Write the hook script, save it to
.claude/hooks/, and make it executable:
mkdir -p .claude/hooks
chmod +x .claude/hooks/your-script.sh
-
Test it: Run Claude Code and trigger the hook. Use
claude --debugto see which hooks matched, their exit codes, and output.
Gotchas That Will Save You Time
Stop hook infinite loops: If your Stop hook always blocks Claude from stopping, it runs forever. Always check the stop_hook_active field:
#!/bin/bash
INPUT=$(cat)
ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active')
if [ "$ACTIVE" = "true" ]; then
exit 0 # Already continuing from a stop hook, don't block again
fi
# Your actual verification logic here
Shell profile interference: If your ~/.zshrc prints anything on startup (like a welcome message or conda activation output), it prepends to hook stdout and breaks JSON parsing. Wrap noisy profile lines in an interactive check:
if [[ $- == *i* ]]; then
echo "Welcome back!"
fi
Scripts must be executable: chmod +x is easy to forget. If your hook silently does nothing, this is usually why.
Exit 2 ignores stdout: When you exit with code 2, any JSON on stdout is discarded. Only stderr matters. Don't try to return JSON and exit 2 at the same time.
Hooks snapshot at startup: Editing settings files while Claude Code is running doesn't take effect immediately. Claude Code detects external changes and prompts you to review them in the /hooks menu, which applies the updates without a full restart.
Matchers are case-sensitive: "bash" won't match the Bash tool. Tool names are PascalCase: Bash, Edit, Write, Read, Glob, Grep.
PostToolUse can't undo: The tool already executed. You can provide feedback to Claude, but you can't reverse the action. If you need to prevent something, use PreToolUse.
Async hooks can't block: With "async": true, the action has already proceeded. Decision fields in the output have no effect. Async is for monitoring and follow-up, not gating.
Debugging Hooks
When a hook isn't working:
Run with debug mode:
claude --debugshows which hooks matched, exit codes, and output.Toggle verbose mode: Press
Ctrl+Oduring a session to see hook progress, stdout, and stderr in the transcript.Test scripts standalone: Pipe sample JSON into your script to verify it works:
echo '{"tool_name": "Bash", "tool_input": {"command": "rm -rf /"}}' | .claude/hooks/block-destructive.sh
echo $? # Should be 2
- Check stderr: Hook errors show up in stderr. If your hook exits with a non-zero, non-2 code, stderr appears in verbose mode but is otherwise silent.
Combining Hooks with CLAUDE.md
The best setups use both. CLAUDE.md handles the guidance layer: conventions, patterns, architectural decisions. Hooks handle the enforcement layer: things that must happen or must be prevented.
CLAUDE.md says:
- Use `pnpm`, NOT `npm` or `yarn`
- Always run `pnpm typecheck` after making changes
- Never commit directly to main
Hooks enforce:
- Auto-format after every file edit
- Type-check after every TypeScript change
- Block
git pushto main - Block
npm install(use pnpm instead)
The guidance tells Claude what to do. The hooks catch it when it forgets.
Conclusion
Hooks take Claude Code from "follows your conventions most of the time" to "follows them every time." Start with one or two. Block destructive commands and auto-format on write are the highest-value hooks for most projects. Add more as you find patterns where Claude consistently needs enforcement instead of guidance.
For more details on teaching Claude your conventions with CLAUDE.md, see the previous post. For IDE setup, see From Terminal to IDE. For the initial Bedrock setup, start with the first post in the series.
Additional Resources
- Claude Code Hooks Reference
- Claude Code Hooks Guide
- Official Bash Validator Example
- Previous: Teaching Claude Code How You Work
- Previous: Claude Code with Bedrock in VS Code and JetBrains
- Previous: Claude Code with Bedrock
What hooks are you using in your workflow? Let me know in the comments!
Top comments (1)
The tmux hook for dev servers is genius. I've lost count of how many times I've had Claude hang because it started a foreground process and just... waited forever.
The shell profile interference gotcha is one I wish I'd known earlier. Had a hook silently breaking for a week because my .zshrc was printing a conda env activation message that was prepending garbage to the JSON output. Took way too long to figure out.
For anyone reading — I'd also recommend adding a hook that blocks
npm installand suggestspnpm installif your team uses pnpm. We had Claude keep reverting to npm because it "felt" more natural to the model. A simple PreToolUse hook with a regex fixed that instantly.