Back to Blog
TutorialClaude CodeHooksBest Practices

Claude Hooks Best Practices: Write Hooks That Work

Security, reliability, and performance patterns for automation scripts that won't break your workflow

By PRPM TeamNovember 12, 202512 min read

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 validation

Good:

#!/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 chars

3. 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
done

Reliability

4. Handle Missing Tools Gracefully

Don't assume prettier, jq, or other tools are installed.

Bad:

prettier --write "$FILE"  # Crashes if prettier not installed

Good:

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 0

6. 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
fi

Don'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 test

Fast:

# Only format the changed file
prettier --write "$FILE"

Better:

# Format in background, don't block
(prettier --write "$FILE" &)
exit 0

8. 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"
    ;;
esac

10. 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
done

12. 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"
fi

Testing

13. Test Hooks Manually

Before registering, test hook scripts directly:

# Create test input
echo '{
  "input": {
    "file_path": "/path/to/test.ts"
  }
}' | ./my-hook.sh

Expected:

  • 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 prettier
  • jq - 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-coding

Write your own:

prpm init  # Choose format: claude, subtype: hook

Learn more: PRPM Now Supports Claude Code Hooks