DEV Community

Serenities AI
Serenities AI

Posted on • Originally published at serenitiesai.com

Claude Code Hooks Guide 2026: Automate Your AI Coding Workflow

What if Claude Code could automatically format your files after every edit, block dangerous shell commands before they execute, and run your test suite whenever code changes — all without you lifting a finger?

That's exactly what Claude Code hooks do. Hooks are lifecycle event listeners that let you attach custom logic to specific moments in Claude Code's execution pipeline. They intercept actions at precisely the right time — before a tool runs, after it succeeds, when a session starts, or when Claude finishes responding.

Think of hooks as middleware for your AI coding assistant. They give you programmatic control over Claude's behavior without modifying Claude itself.

This guide is the definitive resource on Claude Code hooks in 2026. We'll cover all 18 hook events, four hook types, configuration locations, and five production-ready recipes.

What You'll Learn

By the end of this guide, you'll understand the complete hook system. You'll know every lifecycle event, how to configure each hook type, and how data flows through the hook pipeline — from stdin JSON input to stdout decisions.

Prerequisites

  • Claude Code installed and working — Run claude from your terminal
  • jq installedsudo apt install jq or brew install jq
  • Basic terminal/shell knowledge
  • A project directorymkdir ~/hooks-playground && cd ~/hooks-playground

How Hooks Work: The stdin JSON Pattern

Command hooks receive event data as JSON on stdin. There is no $CLAUDE_TOOL_INPUT environment variable — that doesn't exist.

Here's what the JSON input looks like for a PreToolUse event:

{
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "ls -la /home/user/project"
  },
  "session_id": "abc123...",
  "transcript_path": "/path/to/transcript",
  "cwd": "/home/user/project",
  "permission_mode": "default"
}
Enter fullscreen mode Exit fullscreen mode

Reading it in bash:

#!/bin/bash
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
Enter fullscreen mode Exit fullscreen mode

Output: Exit Codes and stdout JSON

Exit Code Meaning Behavior
0 Allow Proceeds normally
2 Block For PreToolUse: blocks the call
Other Error Hook failed

For granular control, output JSON to stdout:

jq -n '{
  hookSpecificOutput: {
    hookEventName: "PreToolUse",
    permissionDecision: "deny",
    permissionDecisionReason: "Destructive command blocked"
  }
}'
Enter fullscreen mode Exit fullscreen mode

All 18 Hook Events

Event When It Fires Matchers?
SessionStart Session begins/resumes/clears Yes
InstructionsLoaded CLAUDE.md or rules loaded No
UserPromptSubmit Before Claude processes prompt No
PreToolUse Before tool executes Yes
PermissionRequest Permission dialog appears Yes
PostToolUse After tool succeeds Yes
PostToolUseFailure After tool fails Yes
Notification Claude sends notification Yes
SubagentStart Subagent spawned Yes
SubagentStop Subagent terminates Yes
Stop Claude finishes responding No
TeammateIdle Teammate going idle No
TaskCompleted Task marked complete No
ConfigChange Config file changes Yes
WorktreeCreate Git worktree created No
WorktreeRemove Git worktree removed No
PreCompact Before compaction Yes
SessionEnd Session terminates Yes

Hook Configuration Structure

Three-level JSON: event → matcher group → hook handler.

{
  "hooks": {
    "EVENT_NAME": [
      {
        "matcher": "REGEX_PATTERN",
        "hooks": [
          {
            "type": "command",
            "command": "your-script.sh"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Four Hook Types

Type Required Optional Timeout
command type, command timeout, async, statusMessage, once 600s
http type, url headers, allowedEnvVars, timeout 30s
prompt type, prompt model, timeout
agent type, prompt model, timeout 60s

Your First Hook: Blocking rm -rf /

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "COMMAND=$(cat | jq -r '.tool_input.command'); if echo \"$COMMAND\" | grep -qE 'rm\\s+-rf\\s+/'; then jq -n '{hookSpecificOutput: {hookEventName: \"PreToolUse\", permissionDecision: \"deny\", permissionDecisionReason: \"Blocked: rm -rf / detected\"}}'; else exit 0; fi"
          }
        ]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

5 Production-Ready Recipes

Recipe 1: Auto-Format on File Save

#!/bin/bash
# .claude/hooks/auto-format.sh
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty')
if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then exit 0; fi

EXT="${FILE##*.}"
case "$EXT" in
  js|jsx|ts|tsx|json|css|md) npx prettier --write "$FILE" 2>/dev/null ;;
  py) black --quiet "$FILE" 2>/dev/null ;;
  go) gofmt -w "$FILE" 2>/dev/null ;;
  rs) rustfmt "$FILE" 2>/dev/null ;;
esac
exit 0
Enter fullscreen mode Exit fullscreen mode

Config:

{
  "hooks": {
    "PostToolUse": [{
      "matcher": "Edit|Write|MultiEdit",
      "hooks": [{
        "type": "command",
        "command": ".claude/hooks/auto-format.sh",
        "statusMessage": "Formatting..."
      }]
    }]
  }
}
Enter fullscreen mode Exit fullscreen mode

Recipe 2: Block Dangerous Commands

#!/bin/bash
COMMAND=$(cat | jq -r '.tool_input.command // empty')
if [ -z "$COMMAND" ]; then exit 0; fi

PATTERNS=('rm\s+-rf\s+/' 'mkfs\.' 'dd\s+if=' 'chmod\s+-R\s+777\s+/' 'curl.*\|\s*bash' '>\s*/dev/sd[a-z]')

for p in "${PATTERNS[@]}"; do
  if echo "$COMMAND" | grep -qE "$p"; then
    jq -n --arg r "Blocked: $p detected" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}'
    exit 0
  fi
done
exit 0
Enter fullscreen mode Exit fullscreen mode

Recipe 3: Auto-Run Tests (Async)

#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty')
if [ -z "$FILE" ]; then exit 0; fi
if ! echo "$FILE" | grep -qE '\.(js|ts|py|go|rs)$'; then exit 0; fi

if [ -f "package.json" ]; then
  npx jest --findRelatedTests "$FILE" --no-coverage >> /tmp/claude-tests.txt 2>&1
elif [ -f "pyproject.toml" ]; then
  python -m pytest --tb=short >> /tmp/claude-tests.txt 2>&1
fi
exit 0
Enter fullscreen mode Exit fullscreen mode

Set "async": true so Claude continues working.

Recipe 4: Log All Tool Usage

#!/bin/bash
INPUT=$(cat)
mkdir -p "$HOME/.claude/logs"
TOOL=$(echo "$INPUT" | jq -r '.tool_name // "unknown"')
SESSION=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
TINPUT=$(echo "$INPUT" | jq -c '.tool_input // {}')

jq -n --arg ts "$(date -Iseconds)" --arg t "$TOOL" --arg s "$SESSION" --argjson i "$TINPUT" \
  '{timestamp:$ts,tool:$t,session:$s,input:$i}' >> "$HOME/.claude/logs/tool-usage.jsonl"
exit 0
Enter fullscreen mode Exit fullscreen mode

Recipe 5: Custom Notifications

#!/bin/bash
INPUT=$(cat)
EVENT=$(echo "$INPUT" | jq -r '.hook_event_name')
case "$EVENT" in
  Notification) MSG=$(echo "$INPUT" | jq -r '.message // "Notification"') ;;
  Stop) MSG="Claude finished responding." ;;
esac

# Desktop
command -v notify-send &>/dev/null && notify-send "Claude Code" "$MSG"

# Slack
[ -n "$SLACK_WEBHOOK_URL" ] && curl -s -X POST "$SLACK_WEBHOOK_URL" \
  -H 'Content-Type: application/json' \
  -d "$(jq -n --arg t "$MSG" '{text:$t}')" || true
exit 0
Enter fullscreen mode Exit fullscreen mode

Hook Locations

Location Path Scope
Global ~/.claude/settings.json All projects
Project shared .claude/settings.json Team-wide
Project local .claude/settings.local.json Your machine
Managed policy Org-wide Enterprise

Advanced Patterns

MCP Tool Matching

{
  "matcher": "mcp__database__execute_query",
  "hooks": [{
    "type": "command",
    "command": ".claude/hooks/check-sql-safety.sh"
  }]
}
Enter fullscreen mode Exit fullscreen mode

The "once" Flag

{
  "type": "command",
  "command": "npm ls --depth=0",
  "once": true,
  "statusMessage": "Verifying dependencies..."
}
Enter fullscreen mode Exit fullscreen mode

Security Best Practices

  1. Never store secrets in configs — use allowedEnvVars
  2. Use script files over inline commands
  3. Set appropriate timeouts
  4. Log hook actions for audit trails
  5. Test hooks locally first
  6. Use /hooks to verify hooks loaded

FAQ

Do hooks work with MCP? Yes. MCP tools follow mcp__<server>__<tool> naming.

Can hooks modify Claude's behavior? Yes. PreToolUse blocks actions, PostToolUse triggers automation.

Exit code 0 vs 2? 0 = allow, 2 = block (PreToolUse).

How to debug? Use /hooks command. Validate JSON with python3 -c "import json; json.load(open('.claude/settings.json'))".


Originally published at serenitiesai.com

Top comments (0)