Ten production-ready hook recipes — one per real-world need, covering all seven lifecycle event families. Copy, paste, customize. Each recipe is <20 lines of bash.
What a hook is, in 30 seconds
A hook is a shell script Claude Code runs at a specific lifecycle event. It reads a JSON payload from stdin, decides via exit code (0 = allow, 2 on a Pre* event = BLOCK with stderr shown to the agent, anything else = log-only), and optionally writes additionalContext to stdout for the agent to consume.
Anatomy of a recipe
When — the real scenario where this recipe pays off.
Payload — sample JSON the hook receives on stdin.
Script — the actual bash. Short. Real.
Wire-up — the settings.json fragment.
Gotcha — the one thing that bit someone.
§1
Inject CHANGELOG + uncommitted state at session start
SessionStart
When — Claude opens a session and the user wants the latest release notes and current git state surfaced automatically, instead of asking "what's changed?" every time.
Gotcha — stdout from UserPromptSubmit hooks is injected as additionalContext right after the user message. Keep it bounded (head -c 4096) or one big PR floods the context window.
§4
Log which CLAUDE.md / rule loaded when (debug aid)
InstructionsLoaded
When — you've added a path-scoped rule (paths: frontmatter) and it doesn't seem to be firing. The hook writes a JSONL trace so you can confirm.
Gotcha — InstructionsLoaded fires inside the same turn as the load that triggered it. Hook is pure-observability; stdout/stderr are NOT injected into the conversation for this event.
§5
Block destructive shell shapes
PreToolUse(Bash)
When — you trust Claude to run Bash, but you never want to see rm -rf, git push --force, or a dependency install without a lockfile. Block these by shape, not by global denial.
Gotcha — matcher: "Bash" at the array level limits the hook to Bash calls. Without it, the hook fires on every tool call and your jq query fails on the wrong payload shape.
§6
Require a 5-field delegation handoff
PreToolUse(Agent)
When — you keep getting under-specified sub-agents that invent their own framing. Enforce a structured handoff: TASK / CONTEXT / CONSTRAINTS / DELIVERABLE / DONE_WHEN.
▸ Script — .claude/hooks/pre-agent.sh
#!/usr/bin/env bash
set -euo pipefail
prompt=$(jq -r '.tool_input.prompt')
required=(TASK CONTEXT CONSTRAINTS)
outcome=0
for f in "${required[@]}"; do
echo "$prompt" | grep -qiE "^[[:space:]]*$f:" || { missing+=" $f"; outcome=1; }
done
echo "$prompt" | grep -qiE "^[[:space:]]*(DELIVERABLE|DONE_WHEN):" || outcome=1
[[ $outcome -eq 0 ]] && exit 0
cat >&2 <<MSG
delegation-gate: missing fields$missing (and/or DELIVERABLE/DONE_WHEN).
Use 5-field handoff:
TASK: <one sentence>
CONTEXT: <files/paths>
CONSTRAINTS: <what NOT to do>
DELIVERABLE: <artifact> | DONE_WHEN: <verifier>
MSG
exit 2
Gotcha — case-insensitive matching (-i) is on purpose; agents write Task:, TASK:, task: indifferently. Anchoring to start of line (^) avoids matching "task list" inside prose.
§7
Run prettier check on every edited file
PostToolUse(Edit | Write | MultiEdit)
When — you want format consistency without manual prettier --write after every edit. The hook runs --check and surfaces violations to the agent on its next turn.
Gotcha — the matcher "Edit|Write|MultiEdit" uses regex alternation. Payload field names differ: Edit/MultiEdit use file_path; Write uses file_path; some legacy tools used path. The // empty fallback in jq covers all shapes.
§8
Suggest an alternative when the user denies a tool
PermissionDenied
When — the agent asks for Bash(curl …), you deny, but you'd rather it use a different verb. The hook injects a hint so the agent retries with the right tool.
▸ Script — .claude/hooks/perm-denied-hint.sh
#!/usr/bin/env bash
set -euo pipefail
denied=$(jq -r '.tool_name + "(" + (.tool_input | tostring) + ")"')
case "$denied" in
Bash*curl*) echo "hint: try the WebFetch tool instead of curl" ;;
Bash*rm*) echo "hint: this project blocks rm; if you need to remove, ask the user" ;;
Bash*gh*pr*) echo "hint: prefer the GitHub MCP server (mcp__github__*) for PR ops" ;;
esac
Gotcha — PermissionDenied fires after the user denies the prompt. Stdout becomes additionalContext on the agent's next turn. Don't moralize — give a concrete redirect or stay quiet.
§9
Verify the sub-agent's claimed deliverable actually exists
SubagentStop
When — sub-agents say "done, created file X" but X doesn't exist. The hook parses the brief's DELIVERABLE: field, checks the path, and flags discrepancies.
▸ Script — .claude/hooks/subagent-verify.sh
#!/usr/bin/env bash
set -euo pipefail
brief=$(jq -r '.tool_input.prompt // empty')
deliverable=$(echo "$brief" | grep -iE '^[[:space:]]*DELIVERABLE:' | head -1 | sed -E 's/^[^:]*:[[:space:]]*//')
# crude: pull any path-shaped token from the deliverable line
path=$(echo "$deliverable" | grep -oE '[a-zA-Z0-9_./-]+\.(ts|tsx|md|json|astro|sh|py|go|rs)' | head -1 || true)
[[ -z "$path" ]] && exit 0
[[ -f "$path" ]] && exit 0
echo "subagent-advisory: claimed deliverable '$path' not found in tree" >&2
exit 0 # advisory
Gotcha — extracting paths from prose is fragile; the script only catches deliverables that name a path with a recognized extension. For richer checks, pair DELIVERABLE with DONE_WHEN: and run the command instead.
§10
Snapshot last 12 user turns before summarization
PreCompact
When — long sessions get summarized and the summarizer drops verbatim wording, file paths, exact identifiers. The hook saves raw turns to disk so the next session-start can re-inject them.
▸ Script — .claude/hooks/pre-compact.sh
#!/usr/bin/env bash
set -euo pipefail
transcript=$(jq -r '.transcript_path // empty')
[[ -f "$transcript" ]] || exit 0
# extract last 12 user prompts + their assistant text
jq -c 'select(.role=="user" or .role=="assistant") | {role, content}' "$transcript" \
| tail -n 48 > .claude/COMPACT_NOTES.md
echo "wrote .claude/COMPACT_NOTES.md ($(wc -l < .claude/COMPACT_NOTES.md) lines)" >&2
▸ Companion SessionStart hook
# in .claude/hooks/session-start.sh
if [[ "$(jq -r '.source')" = "compact" && -f .claude/COMPACT_NOTES.md ]]; then
echo "=== PRE-COMPACT SNAPSHOT ==="
cat .claude/COMPACT_NOTES.md
fi
Gotcha — only PreCompact can write before the summarizer truncates context, and only SessionStart with source: "compact" can inject after. No other hook bridges that gap. Both halves are required.