A complete, production-focused guide for software engineers building with Claude Code's lifecycle hook system
// ~/.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 against 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 — per type"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.
// Skip spawn entirely when false "if": "Bash(rm *)" "if": "Bash(* --force)" "if": "Edit(*.prod.*)"
if, your hook spawns a subprocess on every tool call. Use if to 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 the handler. Use them together for layered filtering.
/hooks in your session to see what's wired and available.Fires once when a Claude Code 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 entire session.
#!/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
Fires when Claude's 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.
file_path — absolute path changedsession_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
Fires before any tool runs. The most powerful hook — you can approve, block, or modify the tool input.
{
"tool_name": "Bash",
"tool_input": {
"command": "rm -rf /tmp/old"
},
"session_id": "abc123"
}
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
| 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 |
| WebSearch | query |
| mcp__* | varies by MCP server; introspect by logging stdin |
| Agent | prompt, subagent_type, description |
cat >> /tmp/hook-debug.jsonl
Return decision: "modify" with modifiedInput to replace the tool's input before execution. Claude sees the modified version.
modifiedInput — NOT updatedInput. The wrong field name silently fails (treated as approve). updatedInput belongs to PermissionRequest.
--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"
Fires after a tool completes. Receives input and output. Read-only — decisions are ignored.
{
"tool_name": "Bash",
"tool_input": { "command": "ls -la" },
"tool_output": "total 48\ndrwxr-xr-x...",
"tool_error": null,
"session_id": "abc123"
}
#!/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
Fires after Claude executes a batch of parallel tool calls. Receives an array of results.
{
"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
Fires when Claude requests permission for a tool that isn't pre-approved. Implement custom authorization logic.
{
"tool_name": "Bash",
"tool_input": { "command": "..." },
"permission_type": "tool_use",
"session_id": "..."
}
{ "decision": "approve" } // or "deny"
// with modification — uses updatedInput!
{
"decision": "approve",
"updatedInput": { ... }
}
updatedInput to modify input. PreToolUse uses modifiedInput. Different fields on different events.
Fires when Claude is about to end its turn. You can block the stop to force Claude to continue work — the "rewake" pattern.
{
"stop_reason": "end_turn",
"session_id": "abc123"
}
{"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 | Meaning | Stdout | Stderr |
|---|---|---|---|
| 0 | Success — parse stdout for decision JSON | Decision JSON (optional) | Logged only |
| 1 | Non-blocking warning — failed, don't stop Claude | Ignored | Logged as warning |
| 2 | Blocking — show stderr to user, halt | Ignored entirely | Shown to user |
{"decision":"block","reason":"..."} on stdoutasyncRewake hooks run asynchronously in the background. To rewake Claude they exit 2 and write to stderr.
#!/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
// Inline shell string { "type": "command", "command": "cat >> /tmp/audit.jsonl" } // Script file (recommended) { "type": "command", "command": "~/.claude/hooks/my-hook.sh" }
${CLAUDE_PROJECT_DIR} — project root${CLAUDE_PLUGIN_ROOT} — plugin install dir${CLAUDE_PLUGIN_DATA} — plugin data dir$CLAUDE_ENV_FILE — env injection pathsh -c — system sh, not bash#!/usr/bin/env bash in scripts to get bash instead of sh — needed for arrays, string ops, and [[ conditionals.
{
"type": "http",
"url": "https://hooks.slack.com/services/...",
"method": "POST",
"headers": {
"Content-Type": "application/json"
},
"timeout": 5000
}
{
"type": "mcp_tool",
"tool_name": "my-server__my_tool",
"input": {
"event_data": "${EVENT_JSON}"
}
}
{
"type": "prompt",
"text": "When editing Go files, always run gofmt after writing."
}
${VAR}{
"type": "agent",
"prompt": "Review the just-edited file for security issues.",
"subagent_type": "claude"
}
// 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" } }
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.#!/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
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"if": "Bash(rm *)",
"hooks": [{
"type": "command",
"command": "~/.claude/hooks/guard-bash.sh"
}]
}
]
}
}
if guard avoids spawning the process for every command — it only fires 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.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
exit 0 so a logging failure never blocks Claude.#!/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
{
"hooks": {
"Stop": [
{
"hooks": [{
"type": "http",
"url": "https://hooks.slack.com/...",
"method": "POST",
"timeout": 3000
}]
}
]
}
}
// ~/.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" }] } ] } }
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.
--- name: my-skill hooks: PreToolUse: - matcher: Bash hooks: - type: command command: ./skill-guard.sh --- Skill instructions here...
~/.claude/skills/ (global) or .claude/skills/ (project). Invoke with /skill-name.
Run /hooks in any session to inspect active hooks:
| File | Scope |
|---|---|
| ~/.claude/settings.json | Global — all projects |
| .claude/settings.json | Project — this repo only |
| skill frontmatter | Skill execution only |
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.
eval/exec values from stdin"$VAR"## 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'
/hooks. Verify matcher glob and the if guard condition.updatedInput instead of modifiedInput. They differ per event.# 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 $?
echo '...' | ./hook.sh is the fastest iteration loop — no Claude session required.
if guards to avoid spawning a process 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 enforcement. Skills = user-triggered, named workflows. Use hooks for invariants that must always hold; skills for things you invoke explicitly.
stdout is ignored on exit 2 — your JSON silently disappears. Use exit 0 + stdout JSON.
if guard on expensive PreToolUseWithout a guard your script spawns for every tool call — even reads. Gate with if.
PostToolBatch ignores matchers. Move filtering into the script body.
PreToolUse uses modifiedInput. Wrong field = silent approve.
stdin carries content Claude processed. Never eval/exec it — injection risk.
Hooks block Claude. A slow call on every tool use makes Claude feel frozen. 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, handler types, config schema |
| docs.anthropic.com/claude-code/settings | settings.json full schema — hooks nest under the hooks key |
| docs.anthropic.com/claude-code/skills | Skill frontmatter, including hooks in skill definitions |
| ~/.claude/settings.json | Your local global config — active hooks live here |
| /hooks (slash command) | Live view of all active hooks in the current session |
| claude --debug | Debug mode — hook execution, exit codes, stderr logged |