Claude Hooks Best Practices: Write Hooks That Work
Security, reliability, and performance patterns for automation scripts that won't break your workflow
Claude Code hooks run automatically during your AI coding sessions. Write them wrong and they'll fail silently, break your workflow, or expose sensitive data. This guide shows you how to write hooks that work reliably and safely.
Security First
Hooks execute with your user permissions. They can read, modify, or delete any file you can access. Treat hook code like you'd treat a shell script from the internet—because that's what it is.
1. Validate All Input
Hook input arrives via JSON on stdin. Never trust it.
Bad:
#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.input.file_path')
prettier --write $FILE # DANGEROUS: no validationGood:
#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.input.file_path // empty')
# Validate file exists and is in project
if [[ -z "$FILE" ]] || [[ ! -f "$FILE" ]]; then
echo "Invalid file path" >&2
exit 1
fi
# Validate file is in project directory
if [[ "$FILE" != "$CLAUDE_PROJECT_DIR"* ]]; then
echo "File outside project" >&2
exit 1
fi
prettier --write "$FILE"2. Quote Everything
Spaces, special characters, and Unicode in file paths will break unquoted variables.
Bad:
FILE=$(echo "$INPUT" | jq -r '.input.file_path')
cat $FILE # Breaks on "my file.txt"Good:
FILE=$(echo "$INPUT" | jq -r '.input.file_path')
cat "$FILE" # Handles spaces, special chars3. Block Sensitive Files
Never let hooks touch credentials, keys, or git internals.
# Block list - add to PreToolUse hook
BLOCKED_PATTERNS=(
".env"
".env.*"
"*.pem"
"*.key"
".git/*"
"credentials.json"
)
FILE=$(echo "$INPUT" | jq -r '.input.file_path // empty')
for pattern in "${BLOCKED_PATTERNS[@]}"; do
if [[ "$FILE" == $pattern ]]; then
echo "Blocked: $FILE matches sensitive pattern $pattern" >&2
exit 2 # Exit 2 blocks the operation
fi
doneReliability
4. Handle Missing Tools Gracefully
Don't assume prettier, jq, or other tools are installed.
Bad:
prettier --write "$FILE" # Crashes if prettier not installedGood:
if ! command -v prettier &> /dev/null; then
echo "prettier not installed, skipping format" >&2
exit 0 # Exit 0 = success, just skip
fi
prettier --write "$FILE"5. Set Reasonable Timeouts
Default timeout is 60 seconds. Long-running hooks block Claude.
{
"hooks": {
"PostToolUse": [{
"matcher": "Write",
"hooks": [{
"type": "command",
"command": "./slow-script.sh",
"timeout": 10000 // 10 seconds max
}]
}]
}
}For slow operations (linting large files, running tests), use background jobs:
# Run in background, don't block Claude
(eslint "$FILE" &)
exit 06. Log Failures
When hooks fail, you need to know why.
LOG_FILE=~/.claude-hooks/prettier.log
if ! prettier --write "$FILE" 2>> "$LOG_FILE"; then
echo "Format failed, check $LOG_FILE" >&2
exit 1
fiDon't log to stdout—Claude sees that as output. Use stderr or log files.
Performance
7. Keep Hooks Fast
Hooks block operations. A 5-second hook means Claude waits 5 seconds.
Slow:
# Runs tests on every file write (terrible)
npm testFast:
# Only format the changed file
prettier --write "$FILE"Better:
# Format in background, don't block
(prettier --write "$FILE" &)
exit 08. Use Specific Matchers
Match only tools you care about. matcher: "*" runs on everything.
Slow:
{
"matcher": "*", // Runs on EVERY tool call
"hooks": [...]
}Fast:
{
"matcher": "Edit|Write", // Only file modifications
"hooks": [...]
}Fastest:
{
"matcher": "Write", // Only writes
"hooks": [...]
}Common Patterns
9. Format On Save
#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.input.file_path // empty')
case "$FILE" in
*.ts|*.tsx|*.js|*.jsx)
prettier --write "$FILE"
;;
*.py)
black "$FILE"
;;
*.go)
gofmt -w "$FILE"
;;
esac10. Command Logger
#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.input.command')
LOG=~/claude-commands.log
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $COMMAND" >> "$LOG"11. File Protection
#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.input.file_path // empty')
PROTECTED_DIRS=(
".git"
"node_modules"
".env"
)
for dir in "${PROTECTED_DIRS[@]}"; do
if [[ "$FILE" == *"$dir"* ]]; then
echo "Blocked: $FILE is protected" >&2
exit 2
fi
done12. Desktop Notifications
#!/bin/bash
# Requires: libnotify (Linux) or terminal-notifier (macOS)
INPUT=$(cat)
MESSAGE=$(echo "$INPUT" | jq -r '.message // "Claude needs input"')
if command -v notify-send &> /dev/null; then
notify-send "Claude Code" "$MESSAGE"
elif command -v terminal-notifier &> /dev/null; then
terminal-notifier -title "Claude Code" -message "$MESSAGE"
fiTesting
13. Test Hooks Manually
Before registering, test hook scripts directly:
# Create test input
echo '{
"input": {
"file_path": "/path/to/test.ts"
}
}' | ./my-hook.shExpected:
- Exit code 0 = success
- Exit code 2 = blocked operation
- Other codes = error
14. Test With Edge Cases
Test hooks with:
- Files with spaces:
"my file.txt" - Unicode filenames:
"文件.txt" - Deep paths:
"src/components/deep/nested/file.tsx" - Missing files (simulate tool failures)
- Empty input (test error handling)
Publishing
15. Write Clear Descriptions
Users see your description in prpm search:
Bad:
{
"description": "A hook"
}Good:
{
"description": "Runs prettier on edited TypeScript/JavaScript files (PostToolUse)"
}16. Document Dependencies
If your hook needs tools, document them:
Prerequisites
This hook requires:
prettier- Install:npm install -g prettierjq- Install:brew install jq
Quick Checklist
Before publishing a hook:
- Validates all input from stdin
- Quotes all file paths and variables
- Uses absolute paths for scripts
- Blocks sensitive files (
.env,*.key,.git/*) - Handles missing tools gracefully
- Sets reasonable timeout (default 60s)
- Logs errors to stderr or log file
- Tests with edge cases (spaces, Unicode, missing files)
- Documented dependencies
- Tested manually with sample JSON input
- Tested in real Claude Code session
- README includes installation instructions
- Clear description and tags
Final Thoughts
Hooks are powerful automation tools. They save time, enforce standards, and extend Claude Code with custom logic. But power requires responsibility. Write hooks like you write production code: validate input, handle errors, test edge cases, document behavior. The best hooks are invisible—they just work, every time, without slowing you down.
Get Started
Install hook examples:
prpm install @prpm/prettier-on-save
prpm install @prpm/command-logger
prpm install @prpm/secure-codingWrite your own:
prpm init # Choose format: claude, subtype: hookLearn more: PRPM Now Supports Claude Code Hooks