Claude Code · Fight Card

Hooks Deep Dive

A complete, production-focused guide for engineers building with Claude Code's lifecycle hook system. You write the rules. The harness enforces them.

29 lifecycle events 5 handler types stdin/stdout protocol exit code semantics
REFERENCE 2026-05-27 · CLAUDE CODE ≥ 1.X
SOURCES: docs.anthropic.com/claude-code/hooks · settings.json schema · live testing
What Are Hooks

User callbacks at lifecycle events

  • Fire synchronously before/after Claude actions
  • Shell scripts, HTTP endpoints, MCP tools, or prompts
  • Approve, block, or modify tool calls in-flight
  • Configured in settings.json — project or global scope
  • Run as the logged-in user — full filesystem access
  • Receive context via stdin (JSON); respond via stdout
Enforce team policy, audit operations, inject context, 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 vs 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
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 selects which tool events activate the group. Without it, all tool calls activate it.

if — boolean guard

// 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. 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. 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/Notify Agent Stop
Exact event names evolve across versions — run /hooks in your session to see what's wired and available.
ROUND 01
Session Events
Session Events

SessionStart — inject env

Fires once when a 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 whole session.

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

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

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

CwdChanged & FileChanged

CwdChanged

Fires when the working directory changes. Re-inject project context or reload env files.

stdin
  • 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.

#!/usr/bin/env bash
# FileChanged: reload .env on change

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

if [[ "$FILE" != *"/.env" ]]; then
  exit 0
fi

if [ -n "$CLAUDE_ENV_FILE" ]; then
  grep -v '^#' "$FILE" | \
    grep '=' >> "$CLAUDE_ENV_FILE"
fi
exit 0
ROUND 02
Tool Events
Pre-Tool Use

PreToolUse — intercept

Fires before any tool runs. The heavyweight — approve, block, or modify the tool input.

stdin

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

Decisions

approve block modify
#!/usr/bin/env bash
# PreToolUse: block dangerous rm

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 blocked."}'
  exit 0
fi
exit 0 # implicit approve
Pre-Tool Use · Input Shapes

tool_input per tool

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
mcp__*varies by MCP server; introspect by logging stdin
Agentprompt, subagent_type, description
Pro tip: log all PreToolUse stdin 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

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. Wrong name fails silently (treated as approve). updatedInput belongs to PermissionRequest.
Use cases: strip --force, sanitize paths, add --dry-run, rewrite destructive git commands.
#!/usr/bin/env bash
# Strip --force from git push

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')
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

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

stdin

{
  "tool_name": "Bash",
  "tool_input": { "command": "ls -la" },
  "tool_output": "total 48...",
  "tool_error": null
}

Use cases

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

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

python3 -c "
import json,sys,datetime
d=json.loads(sys.argv[1])
print(json.dumps({
  'ts': datetime.datetime.now(
    datetime.timezone.utc).isoformat(),
  'tool': d.get('tool_name'),
  'input': d.get('tool_input'),
  'error': d.get('tool_error')
}))
" "$STDIN" >> "$LOG"
exit 0
Post-Tool Batch

PostToolBatch — parallel

Fires after 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

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

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

[ "$HIT" != "yes" ] && exit 0
go test ./... -short >> /tmp/t.log
exit 0
Permission Request

PermissionRequest

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

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

// with modification — updatedInput!
{
  "decision": "approve",
  "updatedInput": { ... }
}
Event-specific field! PermissionRequest uses updatedInput. PreToolUse uses modifiedInput. Different fields, 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
ROUND 03
Stop & Rewake
Stop Hook

The Stop event

Fires when Claude is about to end its turn. Block the stop to force Claude to keep working — the "rewake".

The rewake pattern

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

The reason is injected as a new instruction, so Claude continues.

# Stop: rewake if transcript grew

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

J=$(find ~/.claude/projects -name "$SID.jsonl" | head -1)
CUR=$(wc -l < "$J")
S="/tmp/rewake-$SID"; LAST=$(cat "$S" 2>/dev/null||echo 0)
[ $((CUR-LAST)) -lt "$THRESHOLD" ] && exit 0

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

Exit codes matter

ExitMeaningStdoutStderr
0Success — parse stdout for decision JSONDecision JSONLogged
1Non-blocking warning — failed, don't stopIgnoredWarning
2Blocking — show stderr, haltIGNOREDShown
Exit 2 ignores stdout completely. Write JSON to stdout and exit 2 → the JSON is silently discarded. For decisions, use exit 0 + JSON on stdout.
Stop rewake: exit 0 + JSON on stdout · asyncRewake: exit 2 + stderr — inverted pattern.
asyncRewake

asyncRewake — background

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

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

Use cases

  • Watch for CI pipeline completion
  • Monitor filesystem events
  • Poll external API for status
  • Trigger Claude when build succeeds
# asyncRewake: wake on CI pass
# 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' | sort -u)
  if echo "$ST" | grep -q SUCCESS; then
    echo "CI passed PR #$PR" >&2
    exit 2
  fi
  sleep 30
done
ROUND 04
Handler Types
Command Handler

command — shell

Shell form vs script path

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

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

Path placeholders

  • ${CLAUDE_PROJECT_DIR} — project root
  • ${CLAUDE_PLUGIN_ROOT} — plugin dir
  • $CLAUDE_ENV_FILE — env injection

Execution model

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

http & mcp_tool

HTTP handler

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

MCP tool handler

{ "type": "mcp_tool",
  "tool_name": "my-server__tool",
  "input": { "data": "${EVENT_JSON}" } }
  • Calls a registered MCP tool
  • Must be in current MCP config
  • Tool response parsed as decision
  • Synchronous — blocks until return
Ideal for integrating existing MCP services without shell scripts.
Prompt & Agent Handlers

prompt & agent

prompt — inject context

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

agent — spawn subagent

{ "type": "agent",
  "prompt": "Review the edited file for security.",
  "subagent_type": "claude" }
  • Spawns a Claude subagent
  • Subagent has full tool access
  • Can return decisions to parent
  • Higher latency — gate carefully
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 not permitted." }

// Modify (PreToolUse only)
{ "decision": "modify",
  "modifiedInput": { "command": "..." } }

hookSpecificOutput

Events needing extra fields wrap in hookSpecificOutput — hookEventName required:

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

// terminalSequence: OS notify
{ "terminalSequence": "\x1b]9;Done\x07" }
ROUND 05
Working Examples
Example 1

Block destructive cmds

#!/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 blocked."}'; exit 0
    fi
    if echo "$CMD" | grep -qE 'push.*-f.*(main|master)'; then
      echo '{"decision":"block","reason":"Force-push to main blocked."}'; exit 0
    fi ;;
esac
exit 0

settings.json

{ "hooks": {
  "PreToolUse": [{
    "matcher": "Bash",
    "if": "Bash(rm *)",
    "hooks": [{
      "type": "command",
      "command": "~/.claude/hooks/guard-bash.sh"
    }]
  }]
}}
The if guard means the process only spawns when the command contains rm.
Example 2

Audit logger

#!/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.now(datetime.timezone.utc).isoformat(),
    "tool":      d.get("tool_name"),
    "session":   d.get("session_id"),
    "input":     d.get("tool_input"),
    "had_error": d.get("tool_error") is not None,
}
path = os.path.join(os.environ["HOME"], ".claude",
    f"audit-{datetime.date.today()}.jsonl")
with open(path, "a") as f:
    f.write(json.dumps(entry) + "\n")
PYEOF
exit 0
PostToolUse decisions are ignored — this hook is observational. Always exit 0 so a logging failure never blocks Claude.
Example 3

Slack webhook on Stop

#!/usr/bin/env bash
# Stop: notify Slack on session end

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

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

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

Or: native HTTP handler

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

Full team config

// ~/.claude/settings.json — composed 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" }] }
    ],
    "Stop": [
      { "hooks": [{ "type": "command", "command": "~/.claude/hooks/slack-rewake.sh" }] }
    ]
  }
}
ROUND 06
Advanced & Admin
Advanced

Skill frontmatter hooks

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

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

Use cases

  • Enforce dry-run during a review skill
  • Block writes during a read-only audit
  • Inject skill-specific context
  • Log all tool calls during a debug skill
Skill hooks compose with session-level hooks — both fire for matching events.
Admin

/hooks & managed

/hooks slash command

  • Lists all events with registered hooks
  • Shows matcher, if guard, handler type
  • Indicates the source config file
  • Fast path for debugging

Precedence

FileScope
~/.claude/settings.jsonGlobal
.claude/settings.jsonProject
skill frontmatterSkill only

Managed distribution

# .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 posture

Hooks run as you

  • Full filesystem + network access
  • Can read secrets, modify files, call APIs
  • No sandboxing — first-class code
  • Review committed hooks like prod code

Input validation

  • Parse stdin JSON safely (try/except)
  • Never eval/exec stdin values
  • Quote all bash variables: "$VAR"
  • Validate paths before writing
## UNSAFE — injection
CMD=$(echo "$STDIN" | jq -r .command)
eval "$CMD"          # NEVER

## SAFE — 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. Parse with python/jq; use values as inert data.
Troubleshooting

Debugging hooks

01
Hook not firing
Check /hooks. Verify matcher glob + if guard.
02
Decision ignored
Non-zero exit, or exit 2 with JSON on stdout.
03
Stop rewake fails
Using exit 2 instead of exit 0 + stdout JSON.
04
modifiedInput ignored
Wrong field — updatedInput vs modifiedInput.

Debug techniques

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

# 2. Debug mode
claude --debug

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

# 4. Check exit code
echo $?
Manual echo '...' | ./hook.sh is the fastest iteration loop — no Claude session needed.
ROUND 07
The Verdict
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. Skills = user-triggered workflows. Hooks for invariants that must always hold.

Anti-Patterns

What not to do

Avoid

exit 2 + stdout JSON for Stop

stdout ignored on exit 2 — JSON disappears. Use exit 0.

Avoid

No if guard on costly hooks

Spawns on every tool call — even reads. Gate with if.

Avoid

matcher on PostToolBatch

Ignored. 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. Injection risk.

Avoid

Slow hooks, no timeout

Hooks block Claude. Set timeouts; go async.

References

The corner

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, handlers, schema
docs.anthropic.com/claude-code/settingssettings.json full schema
/hooks (slash command)Live view of active hooks
claude --debugHook execution, exit codes, stderr logged
FIGHT-POSTER EDITION · TATAME0 LIGHT THEME · ALL EXAMPLES WRITTEN AGAINST THE LIVE HOOKS SYSTEM