Claude Code

Hooks Deep Dive

A complete, production-focused guide for software engineers building with Claude Code's lifecycle hook system

29 lifecycle events 5 handler types stdin/stdout protocol exit code semantics
Reference date: 2026-05-27  ·  Claude Code ≥ 1.x
Sources: docs.anthropic.com/claude-code/hooks  ·  settings.json schema  ·  live testing
What Are Hooks

User-defined callbacks at lifecycle events

  • Fire synchronously before/after Claude actions
  • Shell scripts, HTTP endpoints, MCP tools, or prompts
  • Can approve, block, or modify tool calls in-flight
  • Configured in settings.json at project or global scope
  • Run as the logged-in user — full filesystem access
  • Receive context via stdin (JSON); respond via stdout
Hooks let you enforce team policies, audit operations, inject context, and integrate Claude into existing workflows — without modifying Claude itself.

5 Handler Types

command Shell string or script path — most common
http POST to a URL; response parsed as decision
mcp_tool Call an MCP tool registered in the session
prompt Inject text into Claude's context window
agent Spawn a Claude subagent to handle the event
Configuration Schema

Three-level nesting

// ~/.claude/settings.json
{
  "hooks": {
    "PreToolUse": [        // event name
      {
        "matcher": "Bash",    // glob (optional)
        "if": "Bash(rm *)",  // guard (optional)
        "hooks": [           // handler list
          {
            "type": "command",
            "command": "~/.claude/hooks/block-rm.sh"
          }
        ]
      }
    ]
  }
}

Level 1 — Event Name

Top-level key is the lifecycle event: PreToolUse, Stop, etc.

Level 2 — Matcher Group

  • matcher — glob against tool name (optional; omit = all)
  • if — boolean guard; skip spawn if false
  • hooks — array of handler objects

Level 3 — Handler

  • type — one of the 5 handler types
  • command / url / tool_name / text — per type
Matcher & Guard

Targeting specific tools

matcher — glob on tool_name

"matcher": "Bash"         // exact
"matcher": "*"           // all tools
"matcher": "mcp__*"      // all MCP tools
"matcher": "Read|Edit"   // alternation

Matcher determines which tool events activate the group. Without it, all tool calls activate it.

if — boolean guard expression

// Skip spawn entirely when false
"if": "Bash(rm *)"
"if": "Bash(* --force)"
"if": "Edit(*.prod.*)"
Performance: without if, your hook spawns a subprocess on every tool call. Use if to gate expensive scripts to only the cases they care about.
matcher vs if: matcher is evaluated by the harness to select the group. if is a permission-rule expression checked before spawning the handler. Use them together for layered filtering.
Event Catalog

The lifecycle events

SessionStart
SessionStop
CwdChanged
FileChanged
PreToolUse
PostToolUse
PostToolBatch
ToolInput
ToolOutput
ToolError
UserPromptSubmit
AssistantResponse
Notification
PermissionRequest
SubagentStart
SubagentStop
SubagentToolUse
SubagentToolResult
Stop
asyncRewake
Session
Tool
User/Notification
Agent
Stop
Exact event names evolve across versions — run /hooks in your session to see what's wired and available.
01
Section One
Session Events
Session Events

SessionStart — inject env vars

Fires once when a Claude Code session begins. Ideal for injecting session-scoped environment variables.

CLAUDE_ENV_FILE

The harness sets CLAUDE_ENV_FILE in the hook's environment. Write KEY=VALUE lines to that path — they become env vars for the entire session.

Use this to inject dynamic secrets, user context, project metadata, or feature flags without hardcoding them in settings.
#!/usr/bin/env bash
# SessionStart hook: inject project env

if [ -f ".env.local" ]; then
  cat .env.local >> "$CLAUDE_ENV_FILE"
fi

# Inject git branch
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
if [ -n "$BRANCH" ]; then
  echo "GIT_BRANCH=$BRANCH" >> "$CLAUDE_ENV_FILE"
fi

echo "SESSION_START=$(date -u +%s)" >> "$CLAUDE_ENV_FILE"
exit 0
Session Events

CwdChanged & FileChanged

CwdChanged

Fires when Claude's working directory changes. Re-inject project context or reload env files.

stdin fields
  • cwd — new working directory
  • session_id

FileChanged

Fires when a file is modified. CLAUDE_ENV_FILE is available here too — reload config after a .env file changes.

stdin fields
  • file_path — absolute path changed
  • session_id
#!/usr/bin/env bash
# FileChanged: reload .env when it changes

STDIN=$(cat)
FILE=$(echo "$STDIN" | python3 -c \
  "import json,sys; print(json.load(sys.stdin).get('file_path',''))")

# Only react to .env file changes
if [[ "$FILE" != *"/.env" ]]; then
  exit 0
fi

if [ -n "$CLAUDE_ENV_FILE" ] && [ -f "$FILE" ]; then
  grep -v '^#' "$FILE" | \
    grep '=' >> "$CLAUDE_ENV_FILE"
fi

exit 0
02
Section Two
Tool Events
Pre-Tool Use

PreToolUse — intercept before execution

Fires before any tool runs. The most powerful hook — you can approve, block, or modify the tool input.

stdin shape

{
  "tool_name": "Bash",
  "tool_input": {
    "command": "rm -rf /tmp/old"
  },
  "session_id": "abc123"
}

Decision responses

  • approve proceed as-is (or empty stdout)
  • block cancel; reason shown to Claude
  • modify replace via modifiedInput
#!/usr/bin/env bash
# PreToolUse: block dangerous rm commands

STDIN=$(cat)
CMD=$(echo "$STDIN" | python3 -c "
import json,sys
d = json.load(sys.stdin)
print(d.get('tool_input',{}).get('command',''))
")

if echo "$CMD" | grep -qE 'rm\s+-rf\s+/'; then
  echo '{"decision":"block","reason":"rm -rf on absolute paths is not allowed."}'
  exit 0
fi

exit 0 # implicit approve
Pre-Tool Use — Input Shapes

tool_input per tool type

ToolKey fields in tool_input
Bashcommand, timeout, description
Readfile_path, offset, limit
Editfile_path, old_string, new_string, replace_all
Writefile_path, content
Greppattern, path, include, exclude
WebFetchurl, prompt
WebSearchquery
mcp__*varies by MCP server; introspect by logging stdin
Agentprompt, subagent_type, description
Pro tip: log all PreToolUse stdin to a file for a few minutes to map the exact shape of tools you want to hook — cat >> /tmp/hook-debug.jsonl
Pre-Tool Use — Modify

modifiedInput — rewrite tool input

Return decision: "modify" with modifiedInput to replace the tool's input before execution. Claude sees the modified version.

Critical: the field is modifiedInput — NOT updatedInput. The wrong field name silently fails (treated as approve). updatedInput belongs to PermissionRequest.
Use cases: strip --force flags, sanitize paths, add --dry-run, rewrite destructive git commands to safer equivalents.
#!/usr/bin/env bash
# Strip --force from git push commands

STDIN=$(cat)
CMD=$(echo "$STDIN" | python3 -c "
import json,sys
print(json.load(sys.stdin)['tool_input'].get('command',''))
")

if ! echo "$CMD" | grep -q "git push.*--force"; then
  exit 0
fi

SAFE=$(echo "$CMD" | sed 's/--force//g;s/  / /g')
python3 -c "
import json,sys
ti = json.loads(sys.argv[1])['tool_input']
ti['command'] = sys.argv[2]
print(json.dumps({'decision':'modify','modifiedInput':ti}))
" "$STDIN" "$SAFE"
Post-Tool Use

PostToolUse — observe results

Fires after a tool completes. Receives input and output. Read-only — decisions are ignored.

stdin shape

{
  "tool_name": "Bash",
  "tool_input": { "command": "ls -la" },
  "tool_output": "total 48\ndrwxr-xr-x...",
  "tool_error": null,
  "session_id": "abc123"
}

Use cases

  • Audit logging — who ran what, when
  • Metrics — tool frequency, latency
  • Alerting — writes to sensitive paths
  • Side effects — notify Slack on deploys
#!/usr/bin/env bash
# PostToolUse: append to audit log

STDIN=$(cat)
LOG="${HOME}/.claude/audit.jsonl"

python3 -c "
import json,sys,datetime
d = json.loads(sys.argv[1])
entry = {
  'ts': datetime.datetime.utcnow().isoformat()+'Z',
  'tool': d.get('tool_name'),
  'input': d.get('tool_input'),
  'session': d.get('session_id'),
  'error': d.get('tool_error')
}
print(json.dumps(entry))
" "$STDIN" >> "$LOG"

exit 0
Post-Tool Batch

PostToolBatch — after parallel tool sets

Fires after Claude executes a batch of parallel tool calls. Receives an array of results.

PostToolBatch has NO matcher support. All filtering must be done inside your script. A matcher in the config is silently ignored.

stdin shape

{
  "tool_results": [
    {
      "tool_name": "Bash",
      "tool_input": {...},
      "tool_output": "..."
    }
  ],
  "session_id": "abc123"
}
#!/usr/bin/env bash
# PostToolBatch: run tests if Go files edited
# NOTE: no matcher — filter inside

STDIN=$(cat)

GO_EDIT=$(echo "$STDIN" | python3 -c "
import json,sys
d = json.load(sys.stdin)
for r in d.get('tool_results', []):
  if r.get('tool_name') == 'Edit':
    fp = r.get('tool_input',{}).get('file_path','')
    if fp.endswith('.go'):
      print('yes'); break
")

if [ "$GO_EDIT" != "yes" ]; then
  exit 0
fi

go test ./... -count=1 -short 2>&1 | \
  tail -20 >> /tmp/claude-test.log
exit 0
Permission Request

PermissionRequest — custom authorization

Fires when Claude requests permission for a tool that isn't pre-approved. Implement custom authorization logic.

stdin shape

{
  "tool_name": "Bash",
  "tool_input": { "command": "..." },
  "permission_type": "tool_use",
  "session_id": "..."
}

Decision response

{ "decision": "approve" }  // or "deny"

// with modification — uses updatedInput!
{
  "decision": "approve",
  "updatedInput": { ... }
}
Event-specific field name! PermissionRequest uses updatedInput to modify input. PreToolUse uses modifiedInput. Different fields on different events.

Use cases

  • Team policy without static allow lists
  • Time-based access (deny writes after 6pm)
  • Path-scoped approval (only under src/)
  • Audit trail before granting permission
03
Section Three
Stop & Rewake
Stop Hook

The Stop event

Fires when Claude is about to end its turn. You can block the stop to force Claude to continue work — the "rewake" pattern.

stdin shape

{
  "stop_reason": "end_turn",
  "session_id": "abc123"
}

The rewake pattern

Exit 0 + stdout JSON {"decision":"block","reason":"..."}

The reason is injected as a new instruction, causing Claude to continue.

#!/usr/bin/env bash
# Stop: rewake if transcript grew enough

THRESHOLD=25
STDIN=$(cat)
SID=$(echo "$STDIN" | python3 -c \
  "import json,sys; print(json.load(sys.stdin).get('session_id',''))")
[ -z "$SID" ] && exit 0

JSONL=$(find ~/.claude/projects \
  -name "${SID}.jsonl" -type f | head -1)
[ -z "$JSONL" ] && exit 0

CUR=$(wc -l < "$JSONL" | tr -d ' ')
SENT="/tmp/rewake-${SID}.lines"
LAST=$(cat "$SENT" 2>/dev/null || echo 0)
[ $(( CUR - LAST )) -lt "$THRESHOLD" ] && exit 0

echo "$CUR" > "$SENT"
echo '{"decision":"block","reason":"Run /my-summary"}'
exit 0 # NOT exit 2 !
Exit Code Semantics

Exit codes matter

ExitMeaningStdoutStderr
0Success — parse stdout for decision JSONDecision JSON (optional)Logged only
1Non-blocking warning — failed, don't stop ClaudeIgnoredLogged as warning
2Blocking — show stderr to user, haltIgnored entirelyShown to user
Exit 2 ignores stdout completely. If you write JSON to stdout and exit 2, the JSON is silently discarded. For decisions, use exit 0 + JSON on stdout.
Stop rewake: exit 0 + {"decision":"block","reason":"..."} on stdout
asyncRewake background hook: exit 2 + message on stderr — inverted pattern!
Empty stdout + exit 0 = implicit approve. You don't need to output anything if you only want to observe.
asyncRewake

asyncRewake — background-triggered rewake

asyncRewake hooks run asynchronously in the background. To rewake Claude they exit 2 and write to stderr.

Different from Stop rewake!
Stop: exit 0 + stdout JSON
asyncRewake: exit 2 + stderr message

Use cases

  • Watch for CI pipeline completion
  • Monitor filesystem events (fswatch)
  • Poll external API for task status
  • Trigger Claude when build succeeds
#!/usr/bin/env bash
# asyncRewake: wake Claude when CI passes
# Background; exit 2 + stderr = rewake

STDIN=$(cat)
PR=$(echo "$STDIN" | python3 -c \
  "import json,sys; print(json.load(sys.stdin).get('pr_number',''))")
[ -z "$PR" ] && exit 0

for i in $(seq 1 40); do
  ST=$(gh pr checks "$PR" --json state \
    -q '.[].state' 2>/dev/null | sort -u)
  if echo "$ST" | grep -q "SUCCESS"; then
    echo "CI passed for PR #$PR — review & merge" >&2
    exit 2  # rewake via stderr
  fi
  sleep 30
done
exit 0 # timed out, silently
04
Section Four
Handler Types & Protocol
Command Handler

command — shell execution

Shell form vs script path

// Inline shell string
{
  "type": "command",
  "command": "cat >> /tmp/audit.jsonl"
}

// Script file (recommended)
{
  "type": "command",
  "command": "~/.claude/hooks/my-hook.sh"
}

Path placeholders

  • ${CLAUDE_PROJECT_DIR} — project root
  • ${CLAUDE_PLUGIN_ROOT} — plugin install dir
  • ${CLAUDE_PLUGIN_DATA} — plugin data dir
  • $CLAUDE_ENV_FILE — env injection path

Execution model

  • Runs via sh -c — system sh, not bash
  • Working directory = project root
  • stdin = event JSON (piped automatically)
  • stdout = parsed for decision JSON (exit 0)
  • stderr = logged (exit 0) or shown (exit 2)
  • Default timeout applies — configurable per hook
Use #!/usr/bin/env bash in scripts to get bash instead of sh — needed for arrays, string ops, and [[ conditionals.
HTTP & MCP Handlers

http and mcp_tool handlers

HTTP handler

{
  "type": "http",
  "url": "https://hooks.slack.com/services/...",
  "method": "POST",
  "headers": {
    "Content-Type": "application/json"
  },
  "timeout": 5000
}
  • Body = full event JSON (same as command stdin)
  • Response body parsed as decision JSON
  • HTTP errors ~ exit 1 (non-blocking)
  • No process spawn — lower overhead

MCP tool handler

{
  "type": "mcp_tool",
  "tool_name": "my-server__my_tool",
  "input": {
    "event_data": "${EVENT_JSON}"
  }
}
  • Calls an MCP tool registered in the session
  • Tool must be available in current MCP config
  • Tool response parsed as decision JSON
  • Synchronous — blocks until tool returns
Ideal for integrating with existing MCP-based services without writing shell scripts.
Prompt & Agent Handlers

prompt and agent handlers

prompt — inject context

{
  "type": "prompt",
  "text": "When editing Go files, always run gofmt after writing."
}
  • Injects text into Claude's conversation context
  • Fires at event time, not session start
  • Good for event-specific instructions
  • Text can reference env vars via ${VAR}

agent — spawn subagent

{
  "type": "agent",
  "prompt": "Review the just-edited file for security issues.",
  "subagent_type": "claude"
}
  • Spawns a Claude subagent for the event
  • Subagent has full tool access
  • Can return decisions to parent
  • Higher latency — complex gating only
prompt = lightweight context injection. agent = a full Claude instance. Use agent sparingly — it adds model latency to every matched event.
Decision JSON

Output protocol

Standard decisions

// Approve (or empty stdout)
{ "decision": "approve" }

// Block with reason
{
  "decision": "block",
  "reason": "Force pushes are not permitted."
}

// Modify input (PreToolUse only)
{
  "decision": "modify",
  "modifiedInput": {
    "command": "git push origin HEAD"
  }
}

hookSpecificOutput

For events needing extra fields beyond a plain decision, wrap in hookSpecificOutput — hookEventName is required:

{
  "hookSpecificOutput": {
    "hookEventName": "Stop",
    "decision": "block",
    "reason": "Post summary first"
  }
}

// terminalSequence: OS notification
{
  "terminalSequence": "\x1b]9;Done\x07"
}
terminalSequence emits OSC escape codes for native desktop notifications.
05
Section Five
Working Examples
Example 1

Block destructive commands

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

STDIN=$(cat)
TOOL=$(echo "$STDIN" | python3 -c \
  "import json,sys; print(json.load(sys.stdin).get('tool_name',''))")
CMD=$(echo "$STDIN" | python3 -c \
  "import json,sys; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))")

case "$TOOL" in
  Bash)
    if echo "$CMD" | grep -qE 'rm\s+-rf\s+[/~]'; then
      echo '{"decision":"block","reason":"rm -rf on root/home is not allowed."}'
      exit 0
    fi
    if echo "$CMD" | grep -qE 'git push.*(-f|--force).*(main|master)'; then
      echo '{"decision":"block","reason":"Force-push to main is not allowed."}'
      exit 0
    fi ;;
esac
exit 0

settings.json config

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "if": "Bash(rm *)",
        "hooks": [{
          "type": "command",
          "command": "~/.claude/hooks/guard-bash.sh"
        }]
      }
    ]
  }
}
The if guard avoids spawning the process for every command — it only fires when the command contains rm.
Example 2

Audit logger (PostToolUse)

#!/usr/bin/env bash
# ~/.claude/hooks/audit.sh — append every tool invocation to a daily JSONL log

python3 <<'PYEOF'
import json, sys, datetime, os

try:
    d = json.loads(sys.stdin.read())
except Exception:
    sys.exit(0)                       # never break Claude on bad input

entry = {
    "ts":        datetime.datetime.utcnow().isoformat() + "Z",
    "tool":      d.get("tool_name"),
    "session":   d.get("session_id"),
    "input":     d.get("tool_input"),
    "had_error": d.get("tool_error") is not None,
}

log_dir  = os.path.join(os.environ["HOME"], ".claude")
log_path = os.path.join(log_dir, f"audit-{datetime.date.today()}.jsonl")
with open(log_path, "a") as f:
    f.write(json.dumps(entry) + "\n")
PYEOF

exit 0
PostToolUse decisions are ignored — this hook is purely observational. Always exit 0 so a logging failure never blocks Claude.
Example 3

HTTP Slack webhook on Stop

#!/usr/bin/env bash
# Stop hook: notify Slack when session ends

STDIN=$(cat)
SID=$(echo "$STDIN" | python3 -c \
  "import json,sys; print(json.load(sys.stdin).get('session_id','?'))")

WEBHOOK="${SLACK_HOOK_URL}"
[ -z "$WEBHOOK" ] && exit 0

curl -sf -X POST "$WEBHOOK" \
  -H "Content-Type: application/json" \
  -d "{\"text\": \"Claude session ${SID:0:8} ended\"}" \
  > /dev/null 2>&1 || true

exit 0

Alternative: native HTTP handler

{
  "hooks": {
    "Stop": [
      {
        "hooks": [{
          "type": "http",
          "url": "https://hooks.slack.com/...",
          "method": "POST",
          "timeout": 3000
        }]
      }
    ]
  }
}
The HTTP handler sends the raw event JSON. For custom message formatting, point it at a thin relay endpoint instead of Slack directly.
Example 4

Full team config

// ~/.claude/settings.json — composed hooks across the lifecycle
{
  "hooks": {
    "SessionStart": [
      { "hooks": [{ "type": "command", "command": "~/.claude/hooks/session-env.sh" }] }
    ],
    "PreToolUse": [
      {
        "matcher": "Bash", "if": "Bash(rm *)",
        "hooks": [{ "type": "command", "command": "~/.claude/hooks/guard-bash.sh" }]
      },
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": "~/.claude/hooks/write-guard.sh" }]
      }
    ],
    "PostToolUse": [
      { "hooks": [{ "type": "command", "command": "~/.claude/hooks/audit.sh" }] }
    ],
    "PostToolBatch": [
      { "hooks": [{ "type": "command", "command": "~/.claude/hooks/batch-test.sh" }] }
    ],
    "Stop": [
      { "hooks": [{ "type": "command", "command": "~/.claude/hooks/slack-rewake.sh" }] }
    ]
  }
}
06
Section Six
Advanced & Admin
Advanced

Skill frontmatter hooks

Hooks can be defined in a skill's .md frontmatter. They are scoped to the skill's execution — active while the skill runs, then deactivated.

Frontmatter format

---
name: my-skill
hooks:
  PreToolUse:
    - matcher: Bash
      hooks:
        - type: command
          command: ./skill-guard.sh
---

Skill instructions here...

Use cases

  • Enforce dry-run mode during a review skill
  • Block writes during a read-only audit skill
  • Inject skill-specific environment context
  • Log all tool calls during a debug skill
Skill hooks compose with session-level hooks — both fire for matching events.
Skills live in ~/.claude/skills/ (global) or .claude/skills/ (project). Invoke with /skill-name.
Admin

/hooks viewer & managed settings

/hooks slash command

Run /hooks in any session to inspect active hooks:

  • Lists all events with registered hooks
  • Shows matcher, if guard, and handler type
  • Indicates which config file each came from
  • The fast path for debugging unexpected behavior

Config file precedence

FileScope
~/.claude/settings.jsonGlobal — all projects
.claude/settings.jsonProject — this repo only
skill frontmatterSkill execution only

Managed distribution

Teams distribute hook configs via committed .claude/settings.json:

# .claude/settings.json (committed)
{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{
        "type": "command",
        "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.sh"
      }]
    }]
  }
}
${CLAUDE_PROJECT_DIR} resolves at runtime — safe to commit; works on any machine.
Security

Security considerations

Hooks run as you

  • Full filesystem and network access
  • Can read secrets, modify files, call APIs
  • No sandboxing — treat as first-class code
  • Review committed hook scripts like production code

Input validation

  • Parse stdin JSON safely (try/except)
  • Never eval/exec values from stdin
  • Quote all bash variables: "$VAR"
  • Validate paths before writing
  • Use absolute paths for commands

Injection prevention

## UNSAFE — command injection
CMD=$(echo "$STDIN" | jq -r .command)
eval "$CMD"          # NEVER

## SAFE — extract as data, never code
CMD=$(echo "$STDIN" | python3 -c "
import json,sys
print(json.load(sys.stdin)['tool_input']['command'])
")
echo "$CMD" | grep -qE 'pattern'
Never construct shell commands by interpolating stdin values. Parse with python/jq and use values as inert data.
Troubleshooting

Debugging hooks

Common problems

01
Hook not firing
Check /hooks. Verify matcher glob and the if guard condition.
02
Decision JSON ignored
Script exited non-zero, or exit 2 with JSON on stdout (stdout ignored on exit 2).
03
Stop rewake not working
Using exit 2 instead of exit 0. Write JSON to stdout and exit 0.
04
modifiedInput not applied
Wrong field — updatedInput instead of modifiedInput. They differ per event.

Debug techniques

# 1. Log all stdin
cat >> /tmp/hook-stdin.jsonl

# 2. Run Claude in debug mode
claude --debug

# 3. Test the hook manually
echo '{"tool_name":"Bash",
  "tool_input":{"command":"ls"},
  "session_id":"test"}' \
  | ~/.claude/hooks/my-hook.sh

# 4. Inspect the exit code
echo $?
Testing manually with echo '...' | ./hook.sh is the fastest iteration loop — no Claude session required.
07
Section Seven
Wrap-up
Key Takeaways

What to remember

The mental model

Hooks are your code running inside Claude's execution loop — synchronous callbacks with full system access. Treat them as production code: input-validate, avoid injection, log errors, fail open.

Hooks vs skills

Hooks = automatic, reactive, policy enforcement. Skills = user-triggered, named workflows. Use hooks for invariants that must always hold; skills for things you invoke explicitly.

Anti-Patterns

What not to do

Avoid

exit 2 + stdout JSON for Stop rewake

stdout is ignored on exit 2 — your JSON silently disappears. Use exit 0 + stdout JSON.

Avoid

No if guard on expensive PreToolUse

Without a guard your script spawns for every tool call — even reads. Gate with if.

Avoid

matcher on PostToolBatch

PostToolBatch ignores matchers. Move filtering into the script body.

Avoid

updatedInput in PreToolUse

PreToolUse uses modifiedInput. Wrong field = silent approve.

Avoid

eval on stdin values

stdin carries content Claude processed. Never eval/exec it — injection risk.

Avoid

Slow hooks without timeout

Hooks block Claude. A slow call on every tool use makes Claude feel frozen. Set timeouts; go async.

References

Documentation sources

Reference timestamp: 2026-05-27

Claude Code hooks are actively developed. Verify against current docs — event names, field names, and exit-code semantics may change across versions.

ResourceContent
docs.anthropic.com/claude-code/hooksPrimary reference — events, handler types, config schema
docs.anthropic.com/claude-code/settingssettings.json full schema — hooks nest under the hooks key
docs.anthropic.com/claude-code/skillsSkill frontmatter, including hooks in skill definitions
~/.claude/settings.jsonYour local global config — active hooks live here
/hooks (slash command)Live view of all active hooks in the current session
claude --debugDebug mode — hook execution, exit codes, stderr logged
Presentation generated 2026-05-27  ·  all examples written against the live hooks system