Write Your First Claude Code Hook (2026)
Hooks let you run custom scripts before or after Claude Code executes tools. They automate quality checks, logging, notifications, and more. Here is how to create your first hook.
Prerequisites
- Claude Code installed
- A project directory
- Basic scripting knowledge (bash, Python, or Node.js)
Step 1: Understand Hook Types
Claude Code supports hooks at these lifecycle points:
- PreToolUse — Runs before Claude uses a tool (Read, Write, Bash, etc.)
- PostToolUse — Runs after a tool completes
- Notification — Runs when Claude sends a notification
- Stop — Runs when Claude finishes a response
Each hook receives context about the event and can modify behavior or block execution.
Step 2: Create the Hook Configuration
Edit your project’s .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash /path/to/your/project/.claude/hooks/pre-bash.sh"
}
]
}
]
}
}
This configures a hook that runs before every Bash tool execution.
Step 3: Write the Hook Script
Create the hook script directory:
mkdir -p .claude/hooks
Create .claude/hooks/pre-bash.sh:
#!/bin/bash
# Pre-bash hook: log all bash commands Claude runs
# Hook receives input as JSON on stdin
INPUT=$(cat)
# Extract the command
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['tool_input']['command'])" 2>/dev/null)
# Log the command
echo "[$(date)] Bash: $COMMAND" >> .claude/hooks/command-log.txt
# Exit 0 to allow execution, non-zero to block
exit 0
Make it executable:
chmod +x .claude/hooks/pre-bash.sh
Step 4: Create a Practical Hook — Pre-Commit Linting
A more useful hook: run a linter before Claude writes files.
Add to .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "bash /path/to/your/project/.claude/hooks/post-write-lint.sh"
}
]
}
]
}
}
Create .claude/hooks/post-write-lint.sh:
#!/bin/bash
# Post-write hook: lint modified files
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('file_path',''))" 2>/dev/null)
if [[ "$FILE_PATH" == *.js || "$FILE_PATH" == *.ts ]]; then
npx eslint "$FILE_PATH" --fix 2>/dev/null
elif [[ "$FILE_PATH" == *.py ]]; then
python3 -m ruff check "$FILE_PATH" --fix 2>/dev/null
fi
exit 0
chmod +x .claude/hooks/post-write-lint.sh
Step 5: Test Your Hook
Start Claude Code and trigger the hook:
claude
Ask Claude to write or edit a file. Check that your hook runs:
- For the logging hook: inspect
.claude/hooks/command-log.txt - For the linting hook: check that written files are linted
Verification
- Hook script is executable (
chmod +x) - Settings.json has valid JSON (no trailing commas)
- Matcher matches the correct tool name
- Hook script exits 0 for success
- Hook runs when the matched tool is used
Advanced: Conditional Hooks
Hooks can include logic to only run under specific conditions:
File-type specific hooks: Only run the linter for Python files, the type checker for TypeScript, etc. (as shown in the linting example above).
Branch-specific hooks: Only run intensive checks on main/production branches:
#!/bin/bash
BRANCH=$(git branch --show-current 2>/dev/null)
if [[ "$BRANCH" == "main" || "$BRANCH" == "production" ]]; then
# Run full test suite
npm test 2>/dev/null
fi
exit 0
Time-based hooks: Skip slow hooks during rapid iteration:
#!/bin/bash
LAST_RUN_FILE=".claude/hooks/.last-full-check"
NOW=$(date +%s)
LAST_RUN=$(cat "$LAST_RUN_FILE" 2>/dev/null || echo 0)
ELAPSED=$((NOW - LAST_RUN))
# Only run full checks every 5 minutes
if [ "$ELAPSED" -gt 300 ]; then
npm run lint && npm test
echo "$NOW" > "$LAST_RUN_FILE"
fi
exit 0
Hook Design Principles
Follow these guidelines to keep hooks maintainable:
-
Always exit 0 unless you intend to block — A non-zero exit blocks the tool execution. Only use this for security-critical checks.
-
Keep hooks fast — Hooks run synchronously. A slow hook makes every tool call feel sluggish. If a check takes more than 2 seconds, consider running it asynchronously or only periodically.
-
Log output to files, not stdout — Hook stdout may be captured or displayed unpredictably. Write logs to a dedicated file.
-
Handle errors gracefully — If your hook’s dependency is missing (linter not installed), fail silently rather than blocking Claude’s work.
-
Use absolute paths — Hooks may run from different working directories. Use absolute paths for all file references.
Troubleshooting
Hook does not run: Verify the matcher string matches the tool name exactly. Tool names are case-sensitive: Bash, Write, Edit, Read. Also verify the hook file is executable: chmod +x .claude/hooks/your-hook.sh.
Hook blocks execution unexpectedly: Your script is exiting with a non-zero code. Add exit 0 explicitly at the end. If you are using set -e in the script, any failing command will cause a non-zero exit.
Hook script errors: Test your script independently by piping sample JSON to it:
echo '{"tool_input":{"command":"ls"}}' | bash .claude/hooks/pre-bash.sh
Settings.json parse error: Validate with:
python3 -c "import json; json.load(open('.claude/settings.json'))"
Hook runs but output is not visible: Hook output goes to Claude’s tool response context. For debugging, write output to a log file instead of stdout.
Next Steps
- Explore Claude Code hooks for advanced patterns
- See best hooks for code quality for proven configurations
- Browse Claude Code Templates for pre-built hook templates
- Read Claude Code best practices for workflow integration
Find the right skill → Browse 155+ skills in our Skill Finder.
See Also
Try it: Paste your error into our Error Diagnostic for an instant fix.