← claude-core
Resources
▮ CLAUDE-CORE · ACADEMY CLAUDE CODE v2.1.143 10 RECIPES · 7 FAMILIES ▮

HOOK COOKBOOK

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

§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.

▸ Payload (stdin)

{
  "session_id": "abc-123",
  "hook_event_name": "SessionStart",
  "source": "startup",                  // startup | resume | clear | compact
  "transcript_path": "/.../transcript.jsonl",
  "cwd": "/home/user/project"
}

▸ Script — .claude/hooks/session-start.sh

#!/usr/bin/env bash
set -euo pipefail
{
  echo "=== CHANGELOG (latest) ==="
  head -n 40 CHANGELOG.md 2>/dev/null || echo "(no CHANGELOG.md)"
  echo
  echo "=== UNCOMMITTED ==="
  git status --short 2>/dev/null || echo "(not a git repo)"
}

▸ Wire-up

// .claude/settings.json
{ "hooks": {
    "SessionStart": [{ "hooks": [{
      "type": "command",
      "command": "bash .claude/hooks/session-start.sh"
    }] }]
} }

GotchaSessionStart fires on every source including compact. If you want startup-only, branch on source via jq -r '.source' first.

§2

Block exit if SESSION.md is stale with uncommitted WIP

Stop

When — you keep ending sessions with WIP that the next session forgets. The hook nags once: "either commit, or update SESSION.md, then end normally."

▸ Script — .claude/hooks/session-stop.sh

#!/usr/bin/env bash
set -euo pipefail
dirty=$(git status --porcelain 2>/dev/null || true)
[[ -z "$dirty" ]] && exit 0
session_mtime=$(stat -c %Y .claude/SESSION.md 2>/dev/null || echo 0)
started=$(stat -c %Y .claude/.session-state/started-at 2>/dev/null || echo 0)
[[ "$session_mtime" -gt "$started" ]] && exit 0
[[ -f .claude/.session-state/nagged ]] && exit 0
touch .claude/.session-state/nagged
echo "Before ending: uncommitted WIP exists but SESSION.md was not updated this session. Update it (or commit), then end normally." >&2
exit 2

▸ Wire-up

{ "hooks": {
    "Stop": [{ "hooks": [{
      "type": "command",
      "command": "bash .claude/hooks/session-stop.sh"
    }] }]
} }

Gotchanagged marker is per-session; without it the hook would block forever (infinite loop). Always include a "block at most once" guard.

§3

Auto-expand @PR/123 in prompts to the fetched PR diff

UserPromptSubmit

When — you reference PRs constantly. Instead of pasting the diff each time, type @PR/123 and the hook fetches and inlines the diff.

▸ Payload

{
  "hook_event_name": "UserPromptSubmit",
  "user_message": "review @PR/123 for security issues"
}

▸ Script — .claude/hooks/expand-pr.sh

#!/usr/bin/env bash
set -euo pipefail
msg=$(jq -r '.user_message')
prs=$(echo "$msg" | grep -oE '@PR/[0-9]+' | sed 's|@PR/||' | sort -u || true)
[[ -z "$prs" ]] && exit 0
echo "=== referenced PR diffs ==="
for n in $prs; do
  echo "--- PR #$n ---"
  gh pr diff "$n" 2>/dev/null | head -c 4096 || echo "(failed to fetch)"
done

▸ Wire-up

{ "hooks": {
    "UserPromptSubmit": [{ "hooks": [{
      "type": "command",
      "command": "bash .claude/hooks/expand-pr.sh"
    }] }]
} }

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.

▸ Script — .claude/hooks/rule-load-debug.sh

#!/usr/bin/env bash
set -euo pipefail
[[ "$&#123;CLAUDE_RULE_LOAD_DEBUG:-0&#125;" = "1" ]] || exit 0
mkdir -p .claude
exec 9>>.claude/.rule-load-debug.jsonl
flock 9
jq -c '{ts:now|todate, file, memory_type, load_reason, globs, trigger_file, session_id}' >&9

▸ Inspect

tail -f .claude/.rule-load-debug.jsonl | jq .

GotchaInstructionsLoaded 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.

▸ Script — .claude/hooks/pre-bash.sh

#!/usr/bin/env bash
set -euo pipefail
cmd=$(jq -r '.tool_input.command')
block() { echo "blocked: $1" >&2; exit 2; }
[[ "$cmd" =~ rm[[:space:]]+-[rRf]+ ]] && block "rm -rf is denied"
[[ "$cmd" =~ git[[:space:]]+push.*--force ]] && block "force push is denied"
[[ "$cmd" =~ (npm|pnpm|bun)[[:space:]]+(i|install) ]] && {
  [[ -f package-lock.json || -f pnpm-lock.yaml || -f bun.lock ]] ||     block "install without lockfile in tree"
}
exit 0

▸ Wire-up

{ "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{
        "type": "command",
        "command": "bash .claude/hooks/pre-bash.sh"
      }]
    }]
} }

Gotchamatcher: "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 "$&#123;required[@]&#125;"; 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.

▸ Script — .claude/hooks/post-edit-prettier.sh

#!/usr/bin/env bash
set -euo pipefail
file=$(jq -r '.tool_input.file_path // .tool_input.path // empty')
[[ -z "$file" ]] && exit 0
[[ "$file" =~ \.(ts|tsx|js|jsx|json|md|css)$ ]] || exit 0
out=$(npx -y prettier --check "$file" 2>&1) && exit 0
echo "prettier-advisory: $file is not formatted" >&2
echo "$out" >&2
exit 0  # advisory: log, do not block

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

GotchaPermissionDenied 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.