skip to content

Claude Code Hooks

Automate Claude Code lifecycle events with shell hooks β€” PreToolUse, PostToolUse, Notification, and Stop. Covers configuration, environment variables, exit codes, and practical blocking examples.

5 min read 14 snippets yesterday intermediate

Claude Code Hooks#

Hooks are shell commands that run automatically at specific points in the Claude Code lifecycle. They let you enforce policies, log activity, notify external systems, or block dangerous operations β€” without modifying Claude’s prompt.

Hook types#

HookWhen it firesCan block?
PreToolUseBefore Claude executes a toolYes β€” non-zero exit blocks the tool
PostToolUseAfter a tool completesNo β€” informational only
NotificationWhen Claude sends a notificationNo β€” informational only
StopWhen Claude finishes a turnNo β€” informational only

Configuration#

Hooks are defined in settings.json under a "hooks" key. Each hook is a list of matchers with shell commands.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "/path/to/bash-guard.sh"
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit",
        "hooks": [
          {
            "type": "command",
            "command": "python3 /path/to/log_edit.py"
          }
        ]
      }
    ],
    "Notification": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "python3 /path/to/notify.py"
          }
        ]
      }
    ],
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "python3 /path/to/on_stop.py"
          }
        ]
      }
    ]
  }
}

Matchers#

The matcher field is a string matched against the tool name (for PreToolUse/PostToolUse) or is empty for session-wide hooks (Notification, Stop).

"matcher": "Bash"          // matches the Bash tool
"matcher": "Edit"          // matches the Edit tool
"matcher": "Write"         // matches the Write tool
"matcher": "Bash|Edit"     // matches either (pipe-delimited)
"matcher": ""              // matches all events (use for Notification/Stop)

Environment variables in hooks#

Claude Code injects these environment variables into every hook command:

VariableAvailable inContents
CLAUDE_TOOL_NAMEPreToolUse, PostToolUseTool name (e.g. "Bash")
CLAUDE_TOOL_INPUTPreToolUse, PostToolUseJSON-encoded tool input
CLAUDE_TOOL_RESULTPostToolUseJSON-encoded tool output
CLAUDE_NOTIFICATION_MESSAGENotificationNotification text
CLAUDE_SESSION_IDAll hooksUnique session identifier

Exit codes#

For PreToolUse hooks:

Exit codeEffect
0Allow the tool call to proceed
1 (or any non-zero)Block the tool call; Claude sees the hook’s stderr as an error message
2Block and show hook’s stdout to the user as a warning (not as an error)

Example: block dangerous bash commands#

#!/bin/bash
# ~/.claude/hooks/bash-guard.sh

COMMAND=$(echo "$CLAUDE_TOOL_INPUT" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d.get('command',''))")

# Block rm -rf
if echo "$COMMAND" | grep -qE 'rm\s+-[a-zA-Z]*r[a-zA-Z]*f|rm\s+--recursive.*--force'; then
  echo "Blocked: recursive force delete is not allowed" >&2
  exit 1
fi

# Block git push --force
if echo "$COMMAND" | grep -qE 'git\s+push\s+.*--force|git\s+push\s+.*-f\b'; then
  echo "Blocked: force push is not allowed" >&2
  exit 1
fi

# Allow everything else
exit 0

Make it executable:

chmod +x ~/.claude/hooks/bash-guard.sh

Configure in settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{"type": "command", "command": "~/.claude/hooks/bash-guard.sh"}]
      }
    ]
  }
}

When Claude tries rm -rf /tmp/cache:

Output:

[Hook blocked: Blocked: recursive force delete is not allowed]
Claude will now try a different approach...

Example: log all file edits#

#!/usr/bin/env python3
# ~/.claude/hooks/log_edit.py
import json, os, datetime

tool_input = json.loads(os.environ.get("CLAUDE_TOOL_INPUT", "{}"))
session_id = os.environ.get("CLAUDE_SESSION_ID", "unknown")
timestamp = datetime.datetime.now().isoformat()

log_entry = {
    "ts": timestamp,
    "session": session_id,
    "file": tool_input.get("file_path", ""),
    "old_len": len(tool_input.get("old_string", "")),
    "new_len": len(tool_input.get("new_string", "")),
}

log_path = os.path.expanduser("~/.claude/edit_log.jsonl")
with open(log_path, "a") as f:
    f.write(json.dumps(log_entry) + "\n")
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit",
        "hooks": [{"type": "command", "command": "python3 ~/.claude/hooks/log_edit.py"}]
      }
    ]
  }
}

Example: desktop notification on task completion#

#!/usr/bin/env python3
# ~/.claude/hooks/notify.py
import os, subprocess, platform

msg = os.environ.get("CLAUDE_NOTIFICATION_MESSAGE", "Claude Code task complete")

if platform.system() == "Darwin":
    subprocess.run(["osascript", "-e", f'display notification "{msg}" with title "Claude Code"'])
elif platform.system() == "Linux":
    subprocess.run(["notify-send", "Claude Code", msg])
{
  "hooks": {
    "Notification": [
      {
        "matcher": "",
        "hooks": [{"type": "command", "command": "python3 ~/.claude/hooks/notify.py"}]
      }
    ]
  }
}

Example: require tests pass before file writes#

#!/bin/bash
# .claude/hooks/require-tests.sh
# PreToolUse hook that runs before any Write/Edit

# Only enforce during "safe" hours (optional policy example)
FILE=$(echo "$CLAUDE_TOOL_INPUT" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d.get('file_path',''))")

# Only enforce on non-test source files
if echo "$FILE" | grep -qE '\.(py|ts|js)$' && ! echo "$FILE" | grep -q 'test'; then
  if ! python3 -m pytest tests/ -q --no-header 2>&1 | grep -q "passed"; then
    echo "Tests must pass before writing source files. Run pytest to see failures." >&2
    exit 1
  fi
fi

exit 0

[!WARNING] Running a full test suite before every file write is slow. Apply this pattern selectively β€” for example, only on files in a critical module, or only for certain team members.

Example: session cost summary on stop#

#!/usr/bin/env python3
# ~/.claude/hooks/on_stop.py
import os, json, datetime

session_id = os.environ.get("CLAUDE_SESSION_ID", "unknown")
log_path = os.path.expanduser("~/.claude/sessions.jsonl")

entry = {
    "ts": datetime.datetime.now().isoformat(),
    "session": session_id,
}
with open(log_path, "a") as f:
    f.write(json.dumps(entry) + "\n")

print(f"Session {session_id[:12]}... ended and logged.")

Debugging hooks#

# Test a hook manually
CLAUDE_TOOL_NAME="Bash" \
CLAUDE_TOOL_INPUT='{"command":"rm -rf /tmp"}' \
CLAUDE_SESSION_ID="test-session" \
bash ~/.claude/hooks/bash-guard.sh
echo "Exit code: $?"

Output:

Blocked: recursive force delete is not allowed
Exit code: 1

[!TIP] Test hooks with echo $? to verify the exit code before enabling them in settings. A hook that always exits non-zero will block every tool call of that type.