A complete, production-focused guide for engineers building with Claude Code's lifecycle hook system. You write the rules. The harness enforces them.
// ~/.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" } ] } ] } }
Top-level key is the lifecycle event: PreToolUse, Stop, etc.
matcher — glob vs tool name (optional; omit = all)if — boolean guard; skip spawn if falsehooks — array of handler objectstype — one of the 5 handler typescommand / url / tool_name / text"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.
// Skip spawn entirely when false "if": "Bash(rm *)" "if": "Bash(* --force)" "if": "Edit(*.prod.*)"
if, your hook spawns a subprocess on every tool call. Gate expensive scripts to only the cases they care about.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./hooks in your session to see what's wired and available.Fires once when a session begins. Ideal for injecting session-scoped environment variables.
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.
#!/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
Fires when the working directory changes. Re-inject project context or reload env files.
cwd — new working directorysession_idFires 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
Fires before any tool runs. The heavyweight — approve, block, or modify the tool input.
{
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /tmp/old"
},
"session_id": "abc123"
}
#!/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
| Tool | Key fields in tool_input |
|---|---|
| Bash | command, timeout, description |
| Read | file_path, offset, limit |
| Edit | file_path, old_string, new_string, replace_all |
| Write | file_path, content |
| Grep | pattern, path, include, exclude |
| WebFetch | url, prompt |
| mcp__* | varies by MCP server; introspect by logging stdin |
| Agent | prompt, subagent_type, description |
cat >> /tmp/hook-debug.jsonlReturn decision: "modify" with modifiedInput to replace the tool's input before execution. Claude sees the modified version.
modifiedInput — NOT updatedInput. Wrong name fails silently (treated as approve). updatedInput belongs to PermissionRequest.--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"
Fires after a tool completes. Receives input + output. Read-only — decisions are ignored.
{
"tool_name": "Bash",
"tool_input": { "command": "ls -la" },
"tool_output": "total 48...",
"tool_error": null
}
#!/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
Fires after a batch of parallel tool calls. Receives an array of results.
{
"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
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": { ... }
}
updatedInput. PreToolUse uses modifiedInput. Different fields, different events.Fires when Claude is about to end its turn. Block the stop to force Claude to keep working — the "rewake".
{"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 | Meaning | Stdout | Stderr |
|---|---|---|---|
| 0 | Success — parse stdout for decision JSON | Decision JSON | Logged |
| 1 | Non-blocking warning — failed, don't stop | Ignored | Warning |
| 2 | Blocking — show stderr, halt | IGNORED | Shown |
asyncRewake hooks run asynchronously. To rewake Claude they exit 2 and write to stderr.
# 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
// Inline shell string { "type": "command", "command": "cat >> /tmp/audit.jsonl" } // Script file (recommended) { "type": "command", "command": "~/.claude/hooks/h.sh" }
${CLAUDE_PROJECT_DIR} — project root${CLAUDE_PLUGIN_ROOT} — plugin dir$CLAUDE_ENV_FILE — env injectionsh -c — system sh, not bash#!/usr/bin/env bash in scripts to get bash — needed for arrays, [[ conditionals, string ops.{ "type": "http",
"url": "https://hooks.slack.com/...",
"method": "POST",
"timeout": 5000 }
{ "type": "mcp_tool",
"tool_name": "my-server__tool",
"input": { "data": "${EVENT_JSON}" } }
{ "type": "prompt",
"text": "When editing Go, run gofmt after writing." }
${VAR}{ "type": "agent",
"prompt": "Review the edited file for security.",
"subagent_type": "claude" }
// Approve (or empty stdout) { "decision": "approve" } // Block with reason { "decision": "block", "reason": "Force pushes not permitted." } // Modify (PreToolUse only) { "decision": "modify", "modifiedInput": { "command": "..." } }
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" }
#!/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
{ "hooks": {
"PreToolUse": [{
"matcher": "Bash",
"if": "Bash(rm *)",
"hooks": [{
"type": "command",
"command": "~/.claude/hooks/guard-bash.sh"
}]
}]
}}
if guard means the process only spawns when the command contains rm.#!/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
exit 0 so a logging failure never blocks Claude.#!/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
{ "hooks": {
"Stop": [{
"hooks": [{
"type": "http",
"url": "https://hooks.slack.com/...",
"method": "POST",
"timeout": 3000
}]
}]
}}
// ~/.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" }] } ] } }
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 ---
| File | Scope |
|---|---|
| ~/.claude/settings.json | Global |
| .claude/settings.json | Project |
| skill frontmatter | Skill only |
# .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.eval/exec stdin values"$VAR"## 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'
/hooks. Verify matcher glob + if guard.updatedInput vs modifiedInput.# 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 $?
echo '...' | ./hook.sh is the fastest iteration loop — no Claude session needed.if guards to avoid spawning on every tool callHooks 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 = automatic, reactive, policy. Skills = user-triggered workflows. Hooks for invariants that must always hold.
stdout ignored on exit 2 — JSON disappears. Use exit 0.
if guard on costly hooksSpawns on every tool call — even reads. Gate with if.
Ignored. Move filtering into the script body.
PreToolUse uses modifiedInput. Wrong field = silent approve.
stdin carries content Claude processed. Injection risk.
Hooks block Claude. Set timeouts; go async.
Claude Code hooks are actively developed. Verify against current docs — event names, field names, and exit-code semantics may change across versions.
| Resource | Content |
|---|---|
| docs.anthropic.com/claude-code/hooks | Primary reference — events, handlers, schema |
| docs.anthropic.com/claude-code/settings | settings.json full schema |
| /hooks (slash command) | Live view of active hooks |
| claude --debug | Hook execution, exit codes, stderr logged |