← Back to AI Hub

What Are Hooks?

A hook is a shell command (or script path) that Claude Code runs at specific points in its execution lifecycle. Hooks run in your local shell environment, have access to the same environment variables as your terminal session, and can read information about the tool call that triggered them via stdin.

Hooks are configured in ~/.claude/settings.json — your global Claude Code configuration file. They apply to every project you work in, unless you override them at the project level.

Hooks run as you, not as Claude Claude Code is the orchestrator — hooks are your code running in your environment. Claude cannot bypass a blocking hook. If your PreToolUse hook exits with code 1, the tool call does not happen, period.

Hook Events

There are four hook event types, each firing at a different point in Claude's execution:

PreToolUse

Fires before Claude executes any tool call. Can inspect and block the call. Use for safety checks, validation, and semgrep-style pre-flight analysis.

PostToolUse

Fires after a tool call completes. Cannot block (the action already happened), but can trigger follow-up automation: formatting, linting, logging.

UserPromptSubmit

Fires when you submit a prompt, before Claude processes it. Use to inject additional context — RAG results, environment state, session memory.

Stop

Fires when the session ends or Claude finishes responding. Use for session memory persistence, cleanup, final state saves.

Hook Configuration Anatomy

Hooks live in the hooks array inside ~/.claude/settings.json. Each hook object has these fields:

Field Type Purpose
hookEvent string Which event triggers this hook: PreToolUse, PostToolUse, UserPromptSubmit, Stop
matcher string (optional) Tool name filter. If set, hook only fires for matching tool calls (e.g., "Write", "Bash"). Omit to match all tools.
command string The shell command to run. Can be inline or a path to a script.
timeout number (optional) Milliseconds before the hook is killed. Default is 30000 (30s). Set lower for fast checks, higher for RAG queries.

Minimal settings.json structure

~/.claude/settings.json
{
  "hooks": [
    {
      "hookEvent": "PostToolUse",
      "matcher": "Write",
      "command": "~/.claude/hooks/auto-format.sh",
      "timeout": 10000
    },
    {
      "hookEvent": "PreToolUse",
      "matcher": "Bash",
      "command": "python3 ~/.claude/hooks/security_reminder_hook.py",
      "timeout": 5000
    }
  ]
}

Exit Codes: The Control Mechanism

A hook's exit code determines what Claude Code does next. This is how you enforce rules without building complex middleware:

Exit Code Meaning Effect
0 Allow / OK Proceed normally. For PreToolUse, the tool call executes.
1 Block / Error For PreToolUse: block the tool call entirely. Claude is informed and can try a different approach. For PostToolUse: logged as an error but cannot undo the action.
2 Non-blocking warn Proceed but surface a warning to Claude and the user. Useful for "this looks risky but I won't stop it" scenarios.
stdin/stdout protocol Claude Code passes the tool call parameters to the hook via stdin as JSON. Your hook can write to stdout to pass information back — for example, a PreToolUse hook can write a modified version of the command, and PostToolUse hooks can write notes that Claude will see. Read the JSON from stdin with import sys, json; data = json.load(sys.stdin).

Real Hook Examples

Auto-format Python files after write

Fires every time Claude writes a .py file, runs ruff then black automatically. No more "please format this" prompts.

PostToolUse : Write → .py files auto-format.sh
#!/usr/bin/env bash
# ~/.claude/hooks/auto-format.sh
# Reads the written file path from stdin JSON, formats if .py

FILE=$(python3 -c "
import sys, json
data = json.load(sys.stdin)
print(data.get('file_path', ''))
")

if [[ "$FILE" == *.py ]]; then
    ruff check --fix "$FILE" 2>/dev/null
    black --quiet "$FILE" 2>/dev/null
fi

exit 0  # always allow — this is post-write, nothing to block

Settings entry:

{
  "hookEvent": "PostToolUse",
  "matcher": "Write",
  "command": "~/.claude/hooks/auto-format.sh",
  "timeout": 15000
}

Security reminder on Bash execution

Fires before every Bash tool call. Checks for patterns that should trigger a pause — destructive commands, production targets, force pushes.

PreToolUse : Bash → security check security_reminder_hook.py
#!/usr/bin/env python3
# ~/.claude/hooks/security_reminder_hook.py

import sys, json, re

data = json.load(sys.stdin)
command = data.get("command", "")

DANGER_PATTERNS = [
    (r"rm\s+-rf\s+/", "rm -rf from root is extremely dangerous"),
    (r"git push.*--force.*main", "force push to main is blocked"),
    (r"git push.*--force.*master", "force push to master is blocked"),
    (r"drop\s+table", "SQL DROP TABLE — confirm this is intentional"),
    (r":\s*>\s*/etc/", "overwriting system files"),
]

for pattern, message in DANGER_PATTERNS:
    if re.search(pattern, command, re.IGNORECASE):
        print(f"BLOCKED: {message}", flush=True)
        sys.exit(1)  # block the command

# Warn (but allow) on staging/prod keywords
WARN_PATTERNS = ["production", "prod-", "staging", "--force"]
for p in WARN_PATTERNS:
    if p in command.lower():
        print(f"WARNING: command targets a sensitive environment: {p}")
        sys.exit(2)  # warn but allow

sys.exit(0)

RAG context injection on prompt submit

Before Claude sees your prompt, this hook queries the local RAG system and prepends relevant knowledge chunks. Claude gets grounded context without you having to paste it manually.

UserPromptSubmit → RAG injection rag-inject.sh
#!/usr/bin/env bash
# ~/.claude/hooks/rag-inject.sh
# Queries RAG and prepends results to the prompt via stdout

PROMPT=$(python3 -c "
import sys, json
data = json.load(sys.stdin)
print(data.get('prompt', ''))
")

# Query the local RAG system (Kernel agent — code-focused, 0.2 temp)
RAG_RESULT=$(curl -s -X POST http://your-rag-server:9990/query \
  -H "Content-Type: application/json" \
  -d "{\"query\": \"$PROMPT\", \"agent\": \"kernel\", \"k\": 3}" \
  --max-time 5 2>/dev/null)

if [ -n "$RAG_RESULT" ]; then
    CONTEXT=$(echo "$RAG_RESULT" | python3 -c "
import sys, json
data = json.load(sys.stdin)
chunks = data.get('chunks', [])
print('\n'.join(f'- {c[\"text\"]}' for c in chunks[:3]))
")
    # Write augmented prompt to stdout — Claude Code reads this
    echo "=== RAG Context (auto-injected) ===
$CONTEXT
=== End RAG Context ===

$PROMPT"
fi

exit 0
UserPromptSubmit stdout becomes the prompt When a UserPromptSubmit hook writes to stdout and exits 0, Claude Code uses that stdout as the actual prompt content. This is how context injection works — you're literally rewriting the user's message before Claude sees it.

Save session on Stop

When the session ends, write a timestamped summary of what happened to a session log file. Useful for building long-term memory outside the context window.

Stop → session persistence save-session.sh
#!/usr/bin/env bash
# ~/.claude/hooks/save-session.sh

TIMESTAMP=$(date +%Y-%m-%d_%H-%M)
LOG_DIR=~/.claude/session-logs
mkdir -p "$LOG_DIR"

# The Stop event passes session summary data via stdin
python3 -c "
import sys, json
data = json.load(sys.stdin)
summary = data.get('summary', 'No summary available')
print(summary)
" >> "$LOG_DIR/session-$TIMESTAMP.md"

exit 0

Semgrep static analysis before file writes

Run semgrep rules against code before Claude writes it to disk. Catches security patterns at the point of creation, not after.

PreToolUse : Write → semgrep semgrep-check.sh
#!/usr/bin/env bash
# ~/.claude/hooks/semgrep-check.sh
# Runs semgrep on content before it's written

FILE=$(python3 -c "
import sys, json
data = json.load(sys.stdin)
print(data.get('file_path', ''))
")

CONTENT=$(python3 -c "
import sys, json
data = json.load(sys.stdin)
print(data.get('content', ''))
")

# Only check source files, skip lockfiles and generated code
if [[ "$FILE" =~ \.(js|ts|py|rs|go)$ ]]; then
    TMPFILE=$(mktemp --suffix="${FILE##*.}")
    echo "$CONTENT" > "$TMPFILE"

    semgrep --config=auto "$TMPFILE" --quiet 2>/dev/null
    EXIT=$?

    rm -f "$TMPFILE"

    if [ $EXIT -ne 0 ]; then
        echo "Semgrep found issues in $FILE — review before writing"
        exit 2  # warn but allow (change to 1 to block)
    fi
fi

exit 0

Full settings.json with All Hooks

~/.claude/settings.json — complete hooks section
{
  "hooks": [
    {
      "hookEvent": "PostToolUse",
      "matcher": "Write",
      "command": "~/.claude/hooks/auto-format.sh",
      "timeout": 15000
    },
    {
      "hookEvent": "PreToolUse",
      "matcher": "Bash",
      "command": "python3 ~/.claude/hooks/security_reminder_hook.py",
      "timeout": 5000
    },
    {
      "hookEvent": "UserPromptSubmit",
      "command": "~/.claude/hooks/rag-inject.sh",
      "timeout": 8000
    },
    {
      "hookEvent": "Stop",
      "command": "~/.claude/hooks/save-session.sh",
      "timeout": 10000
    },
    {
      "hookEvent": "PreToolUse",
      "matcher": "Write",
      "command": "~/.claude/hooks/semgrep-check.sh",
      "timeout": 20000
    }
  ]
}

Safety Patterns

Blocking dangerous commands

Use PreToolUse on Bash to intercept destructive operations before they execute:

Pattern to block Why Exit code
rm -rf /, rm -rf ~ Data destruction 1 (hard block)
git push --force main/master Rewrites shared history 1 (hard block)
DROP TABLE without WHERE Database destruction 1 (hard block)
curl ... | bash Arbitrary remote code execution 2 (warn and allow with audit)
Commands containing production Live environment — extra caution 2 (warn)

Preventing secret leaks

A PreToolUse:Write hook can scan file content before writing for patterns that look like API keys, tokens, or credentials:

#!/usr/bin/env python3
import sys, json, re

data = json.load(sys.stdin)
content = data.get("content", "")
file_path = data.get("file_path", "")

SECRET_PATTERNS = [
    r"sk-[a-zA-Z0-9]{48}",              # OpenAI key
    r"ghp_[a-zA-Z0-9]{36}",             # GitHub PAT
    r"AKIA[0-9A-Z]{16}",                # AWS access key
    r"-----BEGIN (RSA|EC) PRIVATE KEY", # Private key
]

for pattern in SECRET_PATTERNS:
    if re.search(pattern, content):
        print(f"BLOCKED: Possible secret detected in {file_path}")
        sys.exit(1)

sys.exit(0)

Automation Patterns

Auto-commit on session end

If you're doing a focused work session, a Stop hook can automatically create a WIP commit so nothing gets lost:

#!/usr/bin/env bash
# Only auto-commit if there are staged or modified files
if ! git diff --quiet || ! git diff --staged --quiet; then
    git add -p  # interactive staging is better, but for automation:
    git add $(git diff --name-only)
    git commit -m "wip: auto-commit at session end $(date +%Y-%m-%d_%H:%M)"
fi
exit 0

Notify on task completion

A Stop hook can send a desktop notification or message when Claude finishes a long-running task:

#!/usr/bin/env bash
# Send desktop notification
notify-send "Claude Code" "Session complete" --icon=claude 2>/dev/null

# Or send to Telegram
# curl -s "https://api.telegram.org/bot$TELEGRAM_TOKEN/sendMessage" \
#      -d "chat_id=$CHAT_ID&text=Claude Code session complete"
exit 0

Gotchas and Pitfalls

Hooks run in a subshell — shell state doesn't persist Each hook invocation is a fresh process. Shell functions, aliases, and variables set in one hook are not available in the next. Source your profile explicitly if you need environment setup: source ~/.bashrc or source ~/.zshrc.
PATH may differ from your interactive shell Claude Code may not have your full PATH. Use absolute paths for commands in hooks (/usr/bin/python3, /home/youruser/.local/bin/ruff) or explicitly set PATH at the top of your hook script.
Timeout kills silently If your hook exceeds its timeout, it's killed and Claude proceeds as if the hook exited 0 (for PostToolUse) or 1 (for PreToolUse blocking hooks). Set your timeout generously for anything involving network calls, and test it before relying on it for safety.
Test hooks in isolation first Before wiring a hook into settings.json, test the script directly: pipe in sample JSON from stdin and check the exit code and output. This is far faster than iterating through Claude Code sessions.

Hook Development Workflow

  1. Write and test the script standalone: echo '{"command": "rm -rf /"}' | python3 ~/.claude/hooks/security_reminder_hook.py; echo "Exit: $?"
  2. Add to settings.json with a generous timeout
  3. Test in a safe Claude Code session — trigger the hook deliberately
  4. Tighten the timeout once you know the typical runtime
  5. For blocking hooks: test the block path explicitly before trusting it in production

Summary Table

Use case Event Matcher Exit for block
Auto-format code PostToolUse Write N/A (post-write)
Block dangerous shell commands PreToolUse Bash 1
Inject RAG context UserPromptSubmit (none) N/A
Save session memory Stop (none) N/A
Semgrep pre-flight PreToolUse Write 1 or 2
Prevent secret commits PreToolUse Write 1
Warn on prod operations PreToolUse Bash 2

See also: CLAUDE.md Guide · Writing Skills · Knowledge Graphs

← Back to AI Hub