Claude Code Hooks Cookbook: 18 Recipes, All 33 Events
In short: Build a tested, version-pinned hook suite for Claude Code: a destructive-Bash blocker, a secret-leak blocker on Edit/Write, auto-format on edit, git context at SessionStart, a Bash audit log, a PreCompact transcript backup, and a Stop gate that refuses 'done' while tests fail.
| Key fact | Detail |
|---|---|
| What it covers | agents, open source, marketing, LLM |
| Tools | Claude, Claude Code, Anthropic, Codex, humanizer |
| Posts covered | 5 posts (2 S-tier, 3 A-tier) · top: Someone finally put numbers to what developers… · primer.md + memory.sh — live-updating… · Humanizer skill for Claude Code — strip AI… |
| Creators | @jens.heitmann, @aiemerges, @keshavsuki |
| Sources dated | Mar 2026 – Jul 2026 |
| Prompt system | Run-it-yourself prompts |
| Last verified | 2026-10-22 |
Category: Claude Code automation / lifecycle Confidence: High for the reference tables (checked against the official docs); recipes are fixture-tested scripts, not run inside a live Claude Code session. Primary sources (verified 2026-10-22):
- Hooks reference: https://code.claude.com/docs/en/hooks
- Hooks guide: https://code.claude.com/docs/en/hooks-guide
- Reference examples: https://github.com/disler/claude-code-hooks-mastery
What it is
Hooks are commands that Claude Code runs automatically at fixed points in its lifecycle: before a tool runs, after it runs, when a prompt is submitted, when Claude tries to stop, before compaction, when a session starts or ends, and more. The harness runs them, not the model.
That is the whole point. A rule in CLAUDE.md ("never run rm -rf") is advice that Claude usually follows; a confused turn or a prompt injection can skip it. A PreToolUse hook that exits 2 blocks the call every time, whatever the model decided, and a hook's deny holds even in bypassPermissions mode. Any time you catch yourself writing "from now on, always X" into CLAUDE.md, X probably belongs in a hook.
Hooks live in settings JSON, receive the event as JSON on stdin, and answer with an exit code and, optionally, JSON on stdout. Besides shell commands they can be HTTP calls, MCP tool calls, or a Claude model judging a condition.
Reference — events, matchers, exit codes, JSON output
Verified against https://code.claude.com/docs/en/hooks and https://code.claude.com/docs/en/hooks-guide on 2026-10-22. The event list grows between releases; run claude --version and re-check the reference if an event below is missing in your install.
Events
| Event | When it fires | Matcher filters on | Can block? |
|---|---|---|---|
SessionStart |
Session starts or resumes | startup, resume, clear, compact, fork |
No (stdout becomes context) |
Setup |
--init-only, or --init/--maintenance with -p |
init, maintenance |
No |
UserPromptSubmit |
You submit a prompt, before Claude sees it | — | Yes: exit 2 or decision: "block" |
UserPromptExpansion |
A typed command expands into a prompt | command name | Yes |
PreToolUse |
Before a tool call | tool name | Yes: exit 2 or permissionDecision: "deny" |
PermissionRequest |
A tool call needs a permission decision | tool name | Via decision.behavior: "deny" (exit 2 not honored) |
PermissionDenied |
Auto mode denied a tool call | tool name | No (retry: true lets the model retry) |
PostToolUse |
After a tool call succeeds | tool name | Can't undo; decision: "block" feeds a reason back to Claude |
PostToolUseFailure |
After a tool call fails | tool name | No (stderr shown to Claude) |
PostToolBatch |
A parallel batch of tool calls resolved, before the next model call | — | Yes: exit 2 stops the loop |
Notification |
Claude Code sends a notification | notification type | No |
MessageDisplay |
Assistant text is displayed | — | No (can replace the displayed text only) |
SubagentStart |
A subagent is spawned | agent type | No |
SubagentStop |
A subagent finishes | agent type | Yes |
TaskCreated |
A task is being created | — | Yes |
TaskCompleted |
A task is being marked complete | — | Yes |
Stop |
Claude finishes responding | — | Yes: Claude keeps working |
StopFailure |
The turn ended on an API error | error type | No |
TeammateIdle |
An agent-team teammate is about to go idle | — | Yes |
InstructionsLoaded |
CLAUDE.md or .claude/rules/*.md loaded |
load reason | No |
ConfigChange |
A settings file or skills change mid-session | user_settings, project_settings, local_settings, policy_settings, skills |
Yes, except policy_settings |
CwdChanged |
Working directory changes | — | No |
DirectoryAdded |
A directory is added (/add-dir) |
slash_command, register_repo_root |
No |
FileChanged |
A watched file changes on disk | literal filenames | No |
WorktreeCreate |
A worktree is being created | — | Any non-zero exit fails creation |
WorktreeRemove |
A worktree is removed | — | Non-zero exit fails removal |
PreCompact |
Before compaction | manual, auto |
Yes |
PostCompact |
After compaction | manual, auto |
No |
PreModelSwitch |
Before a requested model switch | model name | Yes |
PostModelSwitch |
After the model changes | model name | No |
Elicitation |
An MCP server asks you for input | MCP server name | Yes (declines) |
ElicitationResult |
After you answer an elicitation | MCP server name | Yes |
SessionEnd |
Session terminates | clear, resume, logout, prompt_input_exit, other |
No |
Matchers
"*","", or nomatchermatches everything. A matcher on an event without matcher support is ignored.- A matcher made only of letters, digits,
_,-, spaces,,and|is an exact name or a list:Edit|Write,Bash. Matching is case-sensitive. - Any other character turns it into an unanchored JavaScript regex:
^Notebook,mcp__github__.*. - MCP tools are named
mcp__<server>__<tool>; plugin MCP servers aremcp__plugin_<plugin>_<server>__<tool>. Usemcp__memory__.*for a whole server; a baremcp__memorymatches nothing. - To filter on arguments, add an
iffield with one permission rule, such as"if": "Bash(git *)"or"if": "Edit(*.ts)"(tool events only), or check the arguments inside your script.
Hook types
type |
What runs | Default timeout |
|---|---|---|
command |
A shell command (or an executable with args: [], no shell). Supports async, asyncRewake |
600 s |
http |
POSTs the input JSON to url; headers can interpolate env vars listed in allowedEnvVars |
600 s |
mcp_tool |
Calls tool on a configured MCP server with input (supports ${tool_input.file_path}) |
600 s |
prompt |
A Claude model answers {"ok": true} or {"ok": false, "reason": "..."} about $ARGUMENTS |
30 s |
agent |
Like prompt, but a subagent that can use tools (experimental) |
60 s |
The 600 s default drops to 30 s on UserPromptSubmit, PreModelSwitch and PostModelSwitch, and 10 s on MessageDisplay. All SessionEnd hooks share a 1.5 s budget, raised to your longest configured timeout up to 60 s. Hooks for one event run in parallel; identical handlers from several settings files run once.
Exit codes
| Exit | Meaning | stdout | stderr |
|---|---|---|---|
0 |
Success | Parsed if it is one JSON object; otherwise plain text. Plain text becomes context only on SessionStart, UserPromptSubmit, UserPromptExpansion and PostModelSwitch (debug log elsewhere) |
Debug log only |
2 |
Block (on events that can block) | JSON is read but can't undo the block | Sent as the reason: to Claude for tool and stop events, to you for UserPromptSubmit |
| other | Non-blocking error; the action proceeds (unless the hook sets onFailure: "block") |
Ignored unless valid JSON | First line shown as a notice |
Exit 1 does not block. Use exit 2 to enforce policy.
JSON output
| Field | Where | Effect |
|---|---|---|
continue |
any | false stops Claude entirely; overrides event decisions |
stopReason |
any | Shown to you when continue is false |
systemMessage |
any | Warning shown to you |
suppressOutput |
any | Documented as having no effect |
terminalSequence |
any | Terminal escape to emit (OSC 0/1/2/9/99/777, BEL) |
decision: "block" + reason |
UserPromptSubmit, UserPromptExpansion, PostToolUse, PostToolUseFailure, PostToolBatch, Stop, SubagentStop, ConfigChange, PreCompact, TaskCreated, PreModelSwitch, Elicitation, ElicitationResult |
Blocks, or for Stop, makes Claude continue with reason as its instruction |
hookSpecificOutput.permissionDecision |
PreToolUse |
allow, deny, ask, defer (-p only). Precedence across hooks: deny > defer > ask > allow |
hookSpecificOutput.permissionDecisionReason |
PreToolUse, PreModelSwitch |
For deny, shown to Claude |
hookSpecificOutput.updatedInput |
PreToolUse |
Replaces the whole tool input; pair with allow or ask |
hookSpecificOutput.additionalContext |
PreToolUse, PostToolUse, SessionStart, SubagentStart, PostModelSwitch, Stop, SubagentStop, UserPromptSubmit |
Added to Claude's context as a system reminder (capped at 10,000 chars) |
hookSpecificOutput.updatedToolOutput |
PostToolUse |
Replaces what Claude sees; must match the tool's output shape |
hookSpecificOutput.decision.behavior |
PermissionRequest |
allow or deny |
Every hookSpecificOutput object needs hookEventName. Top-level decision: "approve"/"block" on PreToolUse is deprecated (it maps to allow/deny).
Common input fields on stdin: session_id, transcript_path, cwd, permission_mode, hook_event_name, plus per-event fields: tool_name, tool_input, tool_use_id (tool events), tool_response and duration_ms (PostToolUse), prompt (UserPromptSubmit), source (SessionStart), stop_hook_active and last_assistant_message (Stop), trigger and custom_instructions (PreCompact), reason (SessionEnd), message, title, notification_type (Notification), error (StopFailure), from_model/to_model (model switch events).
Environment: CLAUDE_PROJECT_DIR (project root; stays fixed in worktrees), CLAUDE_ENV_FILE (in SessionStart, Setup, CwdChanged, FileChanged: append export NAME=value lines to set env vars for later Bash commands), CLAUDE_PLUGIN_ROOT and CLAUDE_PLUGIN_DATA (plugin hooks), CLAUDE_CODE_REMOTE ("true" in remote web sessions), CLAUDE_EFFORT.
Where hooks live
| File | Scope |
|---|---|
~/.claude/settings.json |
You, every project |
.claude/settings.json |
This project, committed and shared |
.claude/settings.local.json |
This project, gitignored |
| Managed policy settings | Whole organization |
Plugin hooks/hooks.json |
While the plugin is enabled |
| Skill or subagent frontmatter | While that skill or subagent is active |
Hook entries from all levels merge: every matching hook runs; a later file does not replace an earlier one. "disableAllHooks": true turns hooks off (it can't disable managed hooks). Edits are normally picked up automatically; run /hooks to see what is loaded. The shape is always hooks → event → [ { matcher, hooks: [ handler, ... ] } ].
The cookbook — 18 recipes
Conventions: project scripts live in .claude/hooks/ and are referenced in exec form ("args": [], no shell, no quoting problems). Personal hooks live in ~/.claude/hooks/. Every script reads the event JSON from stdin, needs jq or python3, and must be chmod +x. "Tested" means fixture-tested: each script was fed sample stdin JSON shaped per the reference and its exit code and output were checked. None of them was run inside a live Claude Code session, so confirm each with /hooks and claude --debug after installing. Test any of them yourself the same way:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | .claude/hooks/block-destructive.sh; echo "exit $?"
Guardrails
1. Block destructive shell commands
Prevents: rm -rf /, ~, . or *, mkfs, dd onto a device, fork bombs, git push --force, git reset --hard, git clean -f. Claude gets the reason and tries something narrower.
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-destructive.sh", "args": [] }] }
]
}
}
.claude/hooks/block-destructive.sh:
#!/usr/bin/env bash
# PreToolUse, matcher "Bash". Exit 2 blocks the call; stderr becomes Claude's reason.
cmd=$(jq -r '.tool_input.command // empty')
patterns=(
'rm[[:space:]]+-[[:alnum:]-]*[rR][[:alnum:]-]*[[:space:]]+(/|~|\$HOME|\.|\*)/?([[:space:]]|$)' # rm -rf / ~ $HOME . *
'mkfs(\.[a-z0-9]+)?[[:space:]]' # format a filesystem
'dd[[:space:]].*of=/dev/' # raw write to a device
':\(\)[[:space:]]*\{' # fork bomb
'>[[:space:]]*/dev/(sd|nvme|disk)' # redirect onto a disk
'chmod[[:space:]]+-R[[:space:]]+777[[:space:]]+/' # world-writable root
'git[[:space:]]+push([[:space:]].*)?[[:space:]](--force|-f)([[:space:]]|$)' # force push (--force-with-lease allowed)
'git[[:space:]]+reset[[:space:]]+--hard' # discard work
'git[[:space:]]+clean[[:space:]]+-[[:alnum:]]*f' # delete untracked files
)
for p in "${patterns[@]}"; do
if [[ "$cmd" =~ $p ]]; then
echo "Blocked by block-destructive hook (pattern: $p). Use a narrower, reversible command or ask the user to run it." >&2
exit 2
fi
done
exit 0
Tested: yes — exit 2 on rm -rf /, rm -rf ~, sudo rm -fr /, rm -rf ., mkfs.ext4, dd ... of=/dev/sda, the fork bomb, git push -f, git push origin main --force, git reset --hard, git clean -fd, > /dev/sda; exit 0 on ls, git status, rm -rf ./build, rm -rf /tmp/x, git push --force-with-lease. Pattern matching is a speed bump, not a sandbox: bash -c "$(echo ...)" or a script file gets around it.
2. Turn deletes into a reviewable trash folder
Enables: Claude can never delete files; it is told to move them to .trash/ and you empty it after review. Uses a JSON deny with a reason instead of exit 2.
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/rm-to-trash.sh", "args": [] }] }
]
}
}
.claude/hooks/rm-to-trash.sh:
#!/usr/bin/env bash
# PreToolUse, matcher "Bash". Denies rm / find -delete and tells Claude to move files to .trash/ instead.
cmd=$(jq -r '.tool_input.command // empty')
rm_re='(^|[;&|(][[:space:]]*|sudo[[:space:]]+)rm[[:space:]]'
find_re='find[[:space:]].*-delete'
if [[ "$cmd" =~ $rm_re || "$cmd" =~ $find_re ]]; then
jq -n --arg cmd "$cmd" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: ("Deleting is disabled in this project. Instead of `" + $cmd + "`, move the files with: mkdir -p .trash && mv <paths> .trash/ (the user empties .trash/ after review).")
}
}'
fi
exit 0
Tested: yes — emits permissionDecision: "deny" for rm foo.txt, cd x && rm -r build, sudo rm a, find . -name "*.pyc" -delete; silent (allow) for git rm --cached a, npm run rm-cache, ls. Add .trash/ to .gitignore.
Inspired by: Claude Code hooks — 3 daily-driver hooks from @jens.heitmann (his anti-deletion hook moves files to a staging folder).
3. Protect secrets, git internals and lockfiles from edits
Prevents: Claude editing .env*, keys and certificates, anything under .git/, and lockfiles (which should change only through the package manager). .env.example stays editable.
{
"hooks": {
"PreToolUse": [
{ "matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-files.sh", "args": [] }] }
]
}
}
.claude/hooks/protect-files.sh:
#!/usr/bin/env bash
# PreToolUse, matcher "Edit|Write". Blocks edits to secrets, git internals and lockfiles.
path=$(jq -r '.tool_input.file_path // empty')
[[ -z "$path" ]] && exit 0
path=${path//\\//} # normalize Windows separators
case "$path" in
*/.env.example|*/.env.sample) exit 0 ;; # templates are fine
*/.env|*/.env.*|*.pem|*.key|*/id_rsa*|*/id_ed25519*|*/.git/*|\
*/package-lock.json|*/yarn.lock|*/pnpm-lock.yaml|*/poetry.lock|*/Cargo.lock|*/uv.lock)
echo "Protected file: $path. Lockfiles change via the package manager; secrets and .git/ are off-limits. Ask the user if this edit is really needed." >&2
exit 2 ;;
esac
exit 0
Tested: yes — exit 2 for .env, .env.local, server.pem, .git/config, package-lock.json and a Windows-style C:\p\.env; exit 0 for src/app.ts, README.md, .envrc, .env.example. It does not stop Bash from writing those files (echo x > .env); pair it with recipe 1 or a permission deny rule.
4. Block credentials from being written into files
Prevents: API keys, tokens and private keys landing in source files. Scans the new text of every Write and Edit and names the kind of secret found, never its value.
{
"hooks": {
"PreToolUse": [
{ "matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/secret-scan.py", "args": [] }] }
]
}
}
.claude/hooks/secret-scan.py:
#!/usr/bin/env python3
"""PreToolUse, matcher "Edit|Write". Blocks writes whose new text contains a credential.
Prints only the pattern name, never the matched value."""
import json, re, sys
PATTERNS = {
"AWS access key id": r"\bAKIA[0-9A-Z]{16}\b",
"private key block": r"-----BEGIN [A-Z ]*PRIVATE KEY-----",
"GitHub token": r"\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{40,}",
"Anthropic key": r"\bsk-ant-[A-Za-z0-9_\-]{20,}",
"OpenAI-style key": r"\bsk-(proj-)?[A-Za-z0-9_\-]{32,}",
"Slack token": r"\bxox[abprs]-[A-Za-z0-9-]{10,}",
"Stripe live key": r"\b[sr]k_live_[A-Za-z0-9]{20,}",
"Google API key": r"\bAIza[0-9A-Za-z_\-]{35}\b",
"hardcoded secret assignment": r"(?i)\b(private[_-]?key|secret|api[_-]?key|access[_-]?token|password)\b\s*[:=]\s*[\"'][A-Za-z0-9_\-/+=]{16,}[\"']",
}
data = json.load(sys.stdin)
ti = data.get("tool_input", {})
text = "\n".join(str(ti.get(k, "")) for k in ("content", "new_string"))
hits = [name for name, rx in PATTERNS.items() if re.search(rx, text)]
if hits:
print(f"Blocked: the new text for {ti.get('file_path', '?')} looks like it contains: "
f"{', '.join(hits)}. Load secrets from the environment or a gitignored .env at runtime "
"instead of writing them into files.", file=sys.stderr)
sys.exit(2)
sys.exit(0)
Tested: yes — exit 2 for AWS's documented example access key ID, a PEM private-key header, a quoted API_KEY = "..." literal (via Edit's new_string) and a GitHub ghp_ token; exit 0 for os.environ["API_KEY"], plain code and a 64-hex transaction hash. Regexes miss novel formats; run a real scanner such as gitleaks in CI as well.
Formatting and code quality
5. Format every file Claude edits
Enables: consistent formatting with no "please run prettier" turns. Uses whichever formatter is installed and never blocks.
{
"hooks": {
"PostToolUse": [
{ "matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/format-on-edit.sh", "args": [] }] }
]
}
}
.claude/hooks/format-on-edit.sh:
#!/usr/bin/env bash
# PostToolUse, matcher "Edit|Write". Formats the edited file with whatever formatter is installed.
# Never blocks: a missing or failing formatter just leaves the file as written.
f=$(jq -r '.tool_input.file_path // empty')
[[ -f "$f" ]] || exit 0
has() { command -v "$1" >/dev/null 2>&1; }
case "$f" in
*.ts|*.tsx|*.js|*.jsx|*.mjs|*.css|*.scss|*.json|*.md|*.html|*.yaml|*.yml)
if has prettier; then prettier --write --log-level warn "$f"
elif [[ -x node_modules/.bin/prettier ]]; then node_modules/.bin/prettier --write --log-level warn "$f"; fi ;;
*.py) has ruff && ruff format --quiet "$f" ;; # format only: lint --fix would strip imports Claude is about to use
*.rs) has rustfmt && rustfmt --edition 2021 "$f" ;;
*.go) has gofmt && gofmt -w "$f" ;;
*.sh) has shfmt && shfmt -w "$f" ;;
esac >/dev/null 2>&1
exit 0
Tested: yes — reformatted x = 1 to x = 1 in a .py file with ruff, exited 0 on a missing file. The prettier, rustfmt, gofmt and shfmt branches were not exercised (formatters not installed on the test machine). Keep it to formatting: an auto---fix linter here deletes an import Claude added one edit before using it.
6. Hand Python syntax errors straight back to Claude
Prevents: Claude moving on after an edit that broke the file. decision: "block" on PostToolUse can't undo the edit, but it puts the error in front of Claude immediately.
{
"hooks": {
"PostToolUse": [
{ "matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/py-check.sh", "args": [] }] }
]
}
}
.claude/hooks/py-check.sh:
#!/usr/bin/env bash
# PostToolUse, matcher "Edit|Write". If a Python file no longer parses, hand the error back to Claude.
f=$(jq -r '.tool_input.file_path // empty')
[[ "$f" == *.py && -f "$f" ]] || exit 0
err=$(python3 - "$f" <<'PY'
import ast, sys
try:
ast.parse(open(sys.argv[1], encoding="utf-8").read(), sys.argv[1])
except SyntaxError as e:
print(f"{e.filename}:{e.lineno}:{e.offset}: {e.msg}\n {(e.text or '').rstrip()}")
PY
)
if [[ -n "$err" ]]; then
jq -n --arg r "Syntax error after editing — fix it before moving on: $err" '{decision: "block", reason: $r}'
fi
exit 0
Tested: yes — for a file containing def f(: it returned decision: "block" with bad.py:1:7: invalid syntax; silent for a valid file and for non-Python paths. Uses ast.parse, so it writes no __pycache__. Swap in tsc --noEmit or cargo check for other languages.
7. Refuse "done" while tests fail
Prevents: Claude declaring victory with a red test suite. On Stop, exit 2 sends the failing output back and Claude keeps working.
{
"hooks": {
"Stop": [
{ "hooks": [{ "type": "command", "command": "TEST_CMD='pytest -q -x' \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/stop-tests-gate.sh", "timeout": 300 }] }
]
}
}
.claude/hooks/stop-tests-gate.sh:
#!/usr/bin/env bash
# Stop (no matcher). Refuses to let Claude finish while the test suite fails.
# Set TEST_CMD in the hook command (default: npm test --silent).
input=$(cat)
[[ $(jq -r '.stop_hook_active // false' <<<"$input") == "true" ]] && exit 0 # already continuing: don't loop
cd "${CLAUDE_PROJECT_DIR:-$(jq -r '.cwd' <<<"$input")}" || exit 0
# Skip when nothing changed since the last commit: no edits, no reason to re-run tests.
if git rev-parse --git-dir >/dev/null 2>&1 && [[ -z $(git status --porcelain) ]]; then exit 0; fi
if ! out=$(bash -c "${TEST_CMD:-npm test --silent}" 2>&1); then
{ echo "Tests are failing, so the task is not done. Fix them, then finish. Last output:"; tail -n 30 <<<"$out"; } >&2
exit 2
fi
exit 0
Tested: yes — in a scratch git repo: exit 0 with a clean tree (tests skipped), exit 2 with the test output on stderr when the tree is dirty and TEST_CMD fails, exit 0 when it passes, exit 0 whenever stop_hook_active is true. Stop fires after every response, not only at task end, so keep the test command fast. Claude Code also overrides a Stop hook after 8 consecutive blocks.
Related: Codex + Claude Code — 3 systems to audit CC output with Codex wires a second model's review into a hook; the same Stop slot works for that.
8. Catch lazy endings
Prevents: final messages that skip work ("for brevity", "I'll leave the rest to you", "should work now" with nothing run). Uses last_assistant_message, so it never has to parse the transcript.
{
"hooks": {
"Stop": [
{ "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/lazy-stop.py", "args": [] }] }
]
}
}
.claude/hooks/lazy-stop.py:
#!/usr/bin/env python3
"""Stop (no matcher). Sends Claude back to work when its final message hands work off
or admits to skipping it."""
import json, re, sys
LAZY = {
"for brevity": r"\bfor brevity\b",
"left as an exercise": r"\bleft as an exercise\b",
"hands work to the user": r"\byou (can|could|may want to) (implement|add|fill in)\b|\bI'?ll leave (that|this|the rest|the \w+)\b",
"elided code": r"\.\.\. ?(existing|rest of) code|\brest of the (code|implementation) (remains|is) (the same|unchanged)\b",
"placeholder": r"\bplaceholder (implementation|logic|code)\b|\bnot (yet )?implemented\b",
"unverified 'should work'": r"\bshould (now )?work\b(?![^.]*\b(tested|ran|passed|verified)\b)",
}
data = json.load(sys.stdin)
if data.get("stop_hook_active"):
sys.exit(0) # one nudge per stop; never loop
msg = data.get("last_assistant_message") or ""
hits = [name for name, rx in LAZY.items() if re.search(rx, msg, re.IGNORECASE)]
if hits:
print(json.dumps({
"decision": "block",
"reason": "Your last message defers or skips work (matched: " + "; ".join(hits[:3]) +
"). Finish the remaining work, or state exactly what is blocked and why.",
}))
sys.exit(0)
Tested: yes — blocks "For brevity, I'll leave the error handling to you" and "The fix should work now."; allows "Ran pytest: 42 passed. This should work now since tests passed." and "Added the endpoint and its tests; 12 passed."; allows anything when stop_hook_active is true. Expect occasional false positives; the one-nudge rule keeps them cheap.
Inspired by: Someone finally put numbers to what developers have been feeling for weeks. (a stop-hook script catching lazy behavior fired 173 times in the logs it describes).
9. Flag AI-writing tells in docs
Enables: a de-AI pass on every human-facing document. After Claude writes a .md, .txt, .rst or .html file, it is told which stock phrases to rewrite. Advisory only.
{
"hooks": {
"PostToolUse": [
{ "matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/ai-tells.py", "args": [] }] }
]
}
}
.claude/hooks/ai-tells.py:
#!/usr/bin/env python3
"""PostToolUse, matcher "Edit|Write". Flags common AI-writing tells in prose files and
tells Claude to revise them. Advisory: never blocks."""
import json, re, sys
TELLS = {
"delve": r"\bdelv(e|es|ing)\b", "tapestry": r"\btapestry\b", "testament to": r"\btestament to\b",
"it's important to note": r"\bit'?s (important|worth) (to note|noting)\b",
"fast-paced world": r"\bin today'?s (fast-paced|digital|ever-evolving)\b",
"not just X, but Y": r"\bnot (just|only)\b[^.]{1,60}\bbut( also)?\b",
"game-changer": r"\bgame[- ]changer\b", "seamless(ly)": r"\bseamless(ly)?\b",
"unlock / unleash": r"\b(unlock|unleash)(es|ing)? the (power|potential)\b",
"in conclusion": r"\bin conclusion\b", "navigate the landscape": r"\bnavigat\w+ the (complex )?landscape\b",
}
data = json.load(sys.stdin)
ti = data.get("tool_input", {})
path = ti.get("file_path", "")
if not re.search(r"\.(md|mdx|txt|rst|html)$", path):
sys.exit(0)
text = ti.get("content") or ti.get("new_string") or ""
found = [name for name, rx in TELLS.items() if re.search(rx, text, re.IGNORECASE)]
dashes = text.count("—")
if dashes > max(3, len(text) // 1500):
found.append(f"{dashes} em dashes")
if found:
print(json.dumps({"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": f"{path} contains AI-writing tells: {', '.join(found)}. "
"Rewrite those sentences in plain, specific language.",
}}))
sys.exit(0)
Tested: yes — "In today's fast-paced world, we delve into a rich tapestry. It's not just fast, but also seamless." in post.md returned additionalContext listing delve, tapestry, fast-paced world, not just X but Y, seamless; a plain technical sentence and any .py file produced nothing.
Inspired by: Humanizer skill for Claude Code — strip AI tells from your writing.
Logging and telemetry
10. Audit log of every shell command
Enables: answering "what did Claude run on Tuesday?" with jq. Logs successes and failures, with duration.
{
"hooks": {
"PostToolUse": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "\"$HOME\"/.claude/hooks/audit-bash.sh" }] }
],
"PostToolUseFailure": [
{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "\"$HOME\"/.claude/hooks/audit-bash.sh" }] }
]
}
}
~/.claude/hooks/audit-bash.sh:
#!/usr/bin/env bash
# PostToolUse + PostToolUseFailure, matcher "Bash". Appends one JSON line per command Claude ran.
log="${HOOK_AUDIT_LOG:-$HOME/.claude/logs/bash-audit.jsonl}"
mkdir -p "$(dirname "$log")"
jq -c '{
ts: (now | todate),
event: .hook_event_name,
session: .session_id,
cwd: .cwd,
command: .tool_input.command,
ms: .duration_ms
}' >> "$log"
exit 0
Tested: yes — appended one valid JSON line per event (PostToolUse with ms: 812, PostToolUseFailure with ms: null). Query it with jq -r 'select(.session=="<id>") | .command' ~/.claude/logs/bash-audit.jsonl. Commands can contain secrets typed inline; keep the log private.
11. Back up the transcript before compaction
Enables: a full copy of the conversation before it is summarized, for post-mortems. Keeps the newest 50.
{
"hooks": {
"PreCompact": [
{ "hooks": [{ "type": "command", "command": "\"$HOME\"/.claude/hooks/backup-transcript.sh" }] }
]
}
}
~/.claude/hooks/backup-transcript.sh:
#!/usr/bin/env bash
# PreCompact (matcher "manual|auto" or none). Copies the full transcript before it is summarized.
input=$(cat)
src=$(jq -r '.transcript_path // empty' <<<"$input")
[[ -f "$src" ]] || exit 0
sid=$(jq -r '.session_id // "unknown"' <<<"$input")
trigger=$(jq -r '.trigger // "unknown"' <<<"$input")
dest="${HOOK_BACKUP_DIR:-$HOME/.claude/transcript-backups}"
mkdir -p "$dest"
cp "$src" "$dest/$(date -u +%Y%m%dT%H%M%SZ)-${sid}-${trigger}.jsonl"
# keep the newest 50 backups
ls -1t "$dest"/*.jsonl 2>/dev/null | tail -n +51 | while IFS= read -r old; do rm -f -- "$old"; done
exit 0
Tested: yes — copied a fixture transcript to <timestamp>-<session>-auto.jsonl; with 55 older backups present, pruned to 50 and kept the new one. It runs synchronously on purpose: a copy takes milliseconds, and an async hook in -p mode can be cancelled at teardown before it finishes.
12. Session ledger and API-failure log
Enables: one line per session (end reason, assistant messages, tool-call counts) and one per API failure (rate_limit, overloaded, billing_error, ...), for weekly retros.
{
"hooks": {
"SessionEnd": [
{ "hooks": [{ "type": "command", "command": "\"$HOME\"/.claude/hooks/session-ledger.py", "timeout": 10 }] }
],
"StopFailure": [
{ "hooks": [{ "type": "command", "command": "\"$HOME\"/.claude/hooks/session-ledger.py" }] }
]
}
}
~/.claude/hooks/session-ledger.py:
#!/usr/bin/env python3
"""SessionEnd and StopFailure. Appends one JSON line per session end (with tool-call counts
from the transcript) or per API failure (rate_limit, overloaded, billing_error, ...)."""
import collections, datetime, json, os, sys
data = json.load(sys.stdin)
log = os.environ.get("HOOK_LEDGER", os.path.expanduser("~/.claude/logs/sessions.jsonl"))
os.makedirs(os.path.dirname(log), exist_ok=True)
row = {
"ts": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
"event": data.get("hook_event_name"),
"session": data.get("session_id"),
"cwd": data.get("cwd"),
}
if row["event"] == "StopFailure":
row["error"] = data.get("error")
else:
row["reason"] = data.get("reason")
tools, turns = collections.Counter(), 0
path = data.get("transcript_path") or ""
if os.path.isfile(path):
with open(path, encoding="utf-8", errors="replace") as fh:
for line in fh:
try:
entry = json.loads(line)
except ValueError:
continue
msg = entry.get("message") if isinstance(entry, dict) else None
if not isinstance(msg, dict) or msg.get("role") != "assistant":
continue
turns += 1
for block in msg.get("content") or []:
if isinstance(block, dict) and block.get("type") == "tool_use":
tools[block.get("name", "?")] += 1
row.update(assistant_messages=turns, tool_calls=sum(tools.values()), top_tools=dict(tools.most_common(5)))
with open(log, "a", encoding="utf-8") as fh:
fh.write(json.dumps(row) + "\n")
Tested: yes — against a fixture transcript (2 assistant messages, 3 tool calls, one corrupt line) it wrote tool_calls: 3, top_tools: {Bash: 2, Edit: 1}; logged error: rate_limit for StopFailure; handled a missing transcript. The transcript line format is not part of the documented hooks contract and may change; the counts degrade to zero rather than crash. timeout: 10 raises the 1.5 s SessionEnd budget for long transcripts.
Notifications
13. Desktop alert when Claude needs you
Enables: alt-tab away and get pinged when Claude is waiting on a permission prompt or idle. Linux notify-send, macOS osascript, terminal bell elsewhere.
{
"hooks": {
"Notification": [
{ "matcher": "permission_prompt|idle_prompt",
"hooks": [{ "type": "command", "command": "\"$HOME\"/.claude/hooks/notify.sh" }] }
]
}
}
~/.claude/hooks/notify.sh:
#!/usr/bin/env bash
# Notification (matcher "permission_prompt|idle_prompt", or none for all). Desktop alert with Claude's message.
input=$(cat)
title=$(jq -r '.title // "Claude Code"' <<<"$input")
msg=$(jq -r '.message // "Claude needs your attention"' <<<"$input")
project=$(basename "$(jq -r '.cwd // empty' <<<"$input")")
[[ -n "$project" ]] && title="$title · $project"
if command -v notify-send >/dev/null 2>&1; then # Linux
notify-send --app-name="Claude Code" "$title" "$msg"
elif command -v osascript >/dev/null 2>&1; then # macOS
osascript - "$title" "$msg" <<'OSA'
on run argv
display notification (item 2 of argv) with title (item 1 of argv) sound name "Glass"
end run
OSA
else # anywhere else: terminal bell via Claude Code
printf '{"terminalSequence":"\\u0007"}\n'
fi
exit 0
Tested: partly — with notify-send stubbed, it received title Claude Code · myapp and the message from stdin; with neither notifier on PATH it printed {"terminalSequence":"\u0007"}. The macOS branch is untested (needs macOS; Script Editor must have notification permission or osascript fails silently).
Inspired by: Claude Code hooks — 3 daily-driver hooks from @jens.heitmann (his chime-when-waiting hook).
14. Phone ping through a Discord webhook
Enables: the same alert on your phone. Runs async so a slow network never holds up Claude.
{
"hooks": {
"Notification": [
{ "matcher": "permission_prompt|idle_prompt",
"hooks": [{ "type": "command", "command": "\"$HOME\"/.claude/hooks/discord-ping.sh", "async": true }] }
]
}
}
~/.claude/hooks/discord-ping.sh:
#!/usr/bin/env bash
# Notification, matcher "permission_prompt|idle_prompt". Posts to a Discord webhook so you can
# see from your phone that a session is waiting. Needs DISCORD_WEBHOOK_URL in the environment.
input=$(cat)
payload=$(jq -c '{
username: "Claude Code",
content: ("**" + (.notification_type // "notification") + "** in `" + ((.cwd // "?") | split("/") | last) + "`: " + (.message // ""))
}' <<<"$input")
if [[ -n "$HOOK_DRY_RUN" ]]; then echo "$payload"; exit 0; fi
[[ -n "$DISCORD_WEBHOOK_URL" ]] || exit 0
curl -fsS -m 10 -H "Content-Type: application/json" -d "$payload" "$DISCORD_WEBHOOK_URL" >/dev/null || exit 1
exit 0
Tested: partly — with HOOK_DRY_RUN=1 it built the Discord JSON body (username plus a content line naming the notification type, project folder and message); with no DISCORD_WEBHOOK_URL it exits 0 silently. Delivery is untested (needs a real webhook). Set the URL in your shell environment, never in a committed settings file. For Slack, change the body to {text: ...}.
Context injection
15. Start every session knowing the git state and the plan
Enables: Claude opens each session (and re-opens after compaction) with the branch, dirty files, last five commits, primer.md (a state file Claude rewrites at the end of each session) and an optional .claude/after-compact.md of rules worth re-reading.
{
"hooks": {
"SessionStart": [
{ "matcher": "startup|resume|compact",
"hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/session-context.sh", "args": [] }] }
]
}
}
.claude/hooks/session-context.sh:
#!/usr/bin/env bash
# SessionStart, matcher "startup|resume|compact". Plain stdout from SessionStart is added to Claude's context.
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
echo "## Session context ($(date '+%Y-%m-%d %H:%M %Z'))"
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "Branch: $(git branch --show-current 2>/dev/null)"
echo "### Uncommitted changes"; git status --short | head -15
echo "### Last 5 commits"; git log --oneline -5 2>/dev/null
fi
for f in primer.md .claude/after-compact.md; do # living state file + rules worth re-reading
[[ -f "$f" ]] && { echo "### $f"; head -c 4000 "$f"; echo; }
done
exit 0
Tested: yes — in a scratch repo it printed branch, untracked files, the one commit and the primer.md text, exit 0. Plain stdout from SessionStart is added to Claude's context; output is capped at 4 KB per file to protect the context window.
Inspired by: primer.md + memory.sh — live-updating CLAUDE.md companion.
16. Give every subagent the project's hard rules
Enables: subagents start with your non-negotiables ("use pnpm", "never commit") from .claude/subagent-rules.md, via SubagentStart's additionalContext.
{
"hooks": {
"SubagentStart": [
{ "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/subagent-rules.sh", "args": [] }] }
]
}
}
.claude/hooks/subagent-rules.sh:
#!/usr/bin/env bash
# SubagentStart (matcher: agent type, or none for all). Gives every subagent the project's hard rules,
# which it would not otherwise see in full.
rules="${CLAUDE_PROJECT_DIR:-.}/.claude/subagent-rules.md"
[[ -f "$rules" ]] || exit 0
jq -n --rawfile r "$rules" '{hookSpecificOutput: {hookEventName: "SubagentStart", additionalContext: $r}}'
exit 0
Tested: yes — with the rules file present it emitted valid hookSpecificOutput JSON containing the file; without it, nothing and exit 0. Add a matcher such as "Explore|Plan" to target specific agent types.
Cost and token control
17. Keep vendored, generated and huge files out of context
Prevents: Read/Grep/Glob calls into node_modules, dist, .next, .venv, lockfiles, minified bundles, and whole-file reads of anything over 256 KB without limit. Claude is told what to do instead.
{
"hooks": {
"PreToolUse": [
{ "matcher": "Read|Grep|Glob",
"hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/read-guard.py", "args": [] }] }
]
}
}
.claude/hooks/read-guard.py:
#!/usr/bin/env python3
"""PreToolUse, matcher "Read|Grep|Glob". Keeps generated and vendored files out of context and
makes Claude page through very large files instead of reading them whole."""
import json, os, re, sys
NOISE = re.compile(r"(^|/)(node_modules|dist|build|\.next|\.venv|venv|__pycache__|\.git|coverage|vendor)(/|$)"
r"|(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|\.min\.(js|css)|\.map)$")
MAX_BYTES = int(os.environ.get("READ_GUARD_MAX_BYTES", 256_000))
def deny(reason):
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse",
"permissionDecision": "deny", "permissionDecisionReason": reason}}))
sys.exit(0)
data = json.load(sys.stdin)
tool, ti = data.get("tool_name"), data.get("tool_input", {})
target = (ti.get("file_path") or ti.get("path") or "").replace("\\", "/")
if target and NOISE.search(target):
deny(f"{target} is generated or vendored. Read the source that produces it, or grep for one symbol.")
if tool == "Read" and target and not ti.get("limit"):
try:
size = os.path.getsize(target)
except OSError:
sys.exit(0)
if size > MAX_BYTES:
deny(f"{target} is {size // 1024} KB. Grep for what you need, or Read it with offset/limit in chunks.")
sys.exit(0)
Tested: yes — denied node_modules/x/index.js, Grep on dist and a 292 KB Read without limit; allowed the same file with limit: 200, a small file, src/build_tools.py and Glob on src.
18. Ask before switching to the most expensive model
Prevents: a session drifting onto an Opus-class model without a decision. PreModelSwitch with permissionDecision: "ask" puts the choice to you.
{
"hooks": {
"PreModelSwitch": [
{ "hooks": [{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/model-switch-guard.sh", "args": [] }] }
]
}
}
.claude/hooks/model-switch-guard.sh:
#!/usr/bin/env bash
# PreModelSwitch (no matcher). Asks before switching a session onto an Opus-class model.
input=$(cat)
to=$(jq -r '.to_model // empty' <<<"$input")
if [[ "${to,,}" == *opus* ]]; then
jq -n --arg to "$to" '{hookSpecificOutput: {
hookEventName: "PreModelSwitch",
permissionDecision: "ask",
permissionDecisionReason: ("Switching to " + $to + " costs more per token. Confirm this task needs it.")
}}'
fi
exit 0
Tested: yes — to_model: claude-opus-4-6 returned permissionDecision: "ask" with a reason; claude-sonnet-4-6 returned nothing. Model names in the input are the canonical IDs your install reports; check one with claude --debug before relying on the substring.
Gotchas and limits
- Hooks run with your full user permissions. A hook can delete anything you can. Read every script before installing it, and never paste one you don't understand.
- Workspace trust. Interactive sessions hold back all hooks until you accept the trust dialog for the folder.
claude -pand SDK sessions skip the dialog, so a cloned repo's.claude/settings.jsonhooks run immediately. Review.claude/before scripting-pover code you didn't write, or pass--settings '{"disableAllHooks": true}'. - Exit 1 is not a block. Only exit 2 blocks. Any other non-zero code is a non-blocking error and the action proceeds.
- stdout must start with
{to count as JSON. A shell profile that prints a banner ahead of your JSON makes Claude Code treat it as plain text and silently ignore the decision. Wrap profileechos inif [[ $- == *i* ]]. - Fields at the wrong level are ignored silently.
permissionDecisionandadditionalContextbelong insidehookSpecificOutputwithhookEventName.claude --debuglogsHook JSON output had unrecognized keys. updatedInputreplaces the whole input. Include the unchanged fields. If two hooks rewrite the same tool's input, the last to finish wins, and hooks run in parallel, so the order is random.UserPromptSubmitcan't rewrite your prompt. It can block it or addadditionalContextalongside it.allowdoesn't beat your deny rules. A hook'sallowskips the prompt but can't overridedenyrules in settings; a hook'sdenyholds in every permission mode, includingbypassPermissions.Stopfires after every response, not only at task completion, and not on user interrupts (API errors fireStopFailureinstead). Checkstop_hook_active; Claude Code overrides aStophook after 8 consecutive blocks (raise withCLAUDE_CODE_STOP_HOOK_BLOCK_CAP).- Matchers filter on names, not arguments. Use the
iffield ("Bash(git *)") or check arguments inside the script. Matchers are case-sensitive, and a letters-only matcher is an exact match, not a substring. - Synchronous hooks add latency to every matching call. Keep
PreToolUsehooks fast; useasync: truefor logging and network calls. Async hooks can't block, and theiradditionalContext/systemMessagearrive on the next turn. - Timeouts: 600 s for command hooks by default (30 s on
UserPromptSubmit), and a shared 1.5 s budget forSessionEndunless you settimeout. On aUserPromptSubmittimeout the hook's output is discarded and the prompt goes through. - Never put secrets in committed hook config.
.claude/settings.jsonis shared through git. Read webhook URLs and tokens from the environment at run time. - Debugging: run
/hooksto see what loaded, thenclaude --debugand read~/.claude/debug/<session-id>.txt; each hook run logs aHooks:line with its exit status and duration. Test scripts by piping fixture JSON before trusting them live.
Bottom line
Hooks are where Claude Code stops being "a smart assistant that mostly follows rules" and becomes a runtime with hard constraints. The documented surface is now over thirty events, but most value still comes from six: PreToolUse for guardrails, PostToolUse for formatting and fast feedback, Stop for "not done yet", SessionStart for context, Notification for getting your attention, and PreCompact/SessionEnd for records. Start with recipes 1, 5, 13 and 15, pipe fixture JSON through each script before trusting it, and add a hook every time you catch yourself saying "Claude should always X" for the third time. That sentence is always a hook.
Prompt system — run this yourself
Everything below is pasteable into Claude Code, Claude, or any agent. Use the one-shot when you want the whole result in one run; use the step prompts when you want to steer. Every step carries its own audit, and the gap audit at the end makes the agent find and fill what it missed.
Mission. Build a tested, version-pinned hook suite for Claude Code: a destructive-Bash blocker, a secret-leak blocker on Edit/Write, auto-format on edit, git context at SessionStart, a Bash audit log, a PreCompact transcript backup, and a Stop gate that refuses 'done' while tests fail. Done means each hook is proven to fire (or block) in a live headless Claude Code session, not just in theory.
1 · Deeper, not bigger
Before running anything, make the task deeper rather than larger. Ask: what would a top professional do here that I am not asking for; what is the rawest material; which methods and tools; what, other than the model or me, proves it right. Then ask for exactly that.
- What a top professional would do (that you didn't ask for): A platform engineer would treat hooks as production code that runs with your full user permissions on every tool call, so they would version the scripts in git, unit-test each one against recorded event payloads, and measure their latency because a slow PreToolUse hook taxes every single Bash call. They would read the installed version's hook reference instead of trusting a blog table, because event names and the structured-output schema have changed between releases (the guide's
modified_tool_inputand top-leveldecision: approveare older shapes; current docs usehookSpecificOutput.permissionDecisionandupdatedInput). They would guard Stop hooks against infinite loops using thestop_hook_activeinput field. - Rawest material to start from: Start from the source, not summaries: (1) the official hooks reference at code.claude.com/docs/en/hooks and the hooks guide at code.claude.com/docs/en/hooks-guide, read for the exact version you run (
claude --version); (2) the Agent SDK hooks page at platform.claude.com/docs/en/agent-sdk/hooks; (3) disler/claude-code-hooks-mastery on GitHub, which logs the real stdin payload of every event to JSON files; (4) your own captured payloads, produced by a catch-all logging hook that dumps stdin for every event to~/.claude/hook-payloads/<event>-<ts>.json; (5) your existing~/.claude/settings.json,.claude/settings.json, and.claude/settings.local.json, plus any hookify rules (/hookify:list). - Methods and tools: jq for parsing stdin; bash with
set -euo pipefailor Python 3 stdlib for anything nontrivial; bats-core or pytest for the harness (pipe fixture JSON into the script, assert exit code 0/2 and stdout/stderr); shellcheck on every .sh;hyperfineto time each hook (budget under 50 ms for PreToolUse);claude --debugand the/hooksslash command to confirm registration; headlessclaude -pruns with--output-format stream-jsonto prove a hook fired end to end; gitleaks or trufflehog regex sets as the source for the secret-pattern blocker. - What proves it right (other than you or the model): The oracles are outside the model: (a) a bats/pytest suite where recorded payloads from your own Claude Code version produce the expected exit codes; (b) a headless
claude -psession told to runrm -rf /tmp/hooktest-canary(a directory holding a .protected sentinel) that shows the block reason in the stream-json transcript and leaves the canary directory intact; (c) a Stop-gate run where a deliberately failing pytest keeps the session working and the session ends only after the test passes.
Rewritten prompt — ask for exactly that:
You are a platform engineer hardening my Claude Code setup with hooks. First run `claude --version` and read the hooks reference for that version (code.claude.com/docs/en/hooks); record the exact event names, stdin fields, exit-code semantics, and the structured JSON output schema (permissionDecision, updatedInput, additionalContext, decision/reason for Stop). Install a temporary catch-all hook that dumps stdin for every event into ~/.claude/hook-payloads/ and run one short session to capture real payloads; these become test fixtures. Then build, in a git-tracked ~/.claude/hooks/ directory: block-destructive.sh (PreToolUse, Bash), block-secrets.py (PreToolUse, Edit|Write|MultiEdit, gitleaks-derived patterns), format-on-edit.sh (PostToolUse), session-git-context.sh (SessionStart), bash-audit.sh (PostToolUse, Bash, TSV), precompact-backup.py (PreCompact, async), stop-tests-gate.sh (Stop, honours stop_hook_active). For each: a bats or pytest test feeding fixtures and asserting exit code and output, shellcheck clean, hyperfine timing under 50 ms for PreToolUse. Register them in ~/.claude/settings.json without clobbering existing hooks. Prove each fires in a headless `claude -p --output-format stream-json` session using canary files under /tmp. Return the file tree, the test output, the timing table, and a list of guide claims you found wrong for this version.
2 · One-shot prompt
ROLE AND STANCE
You are a platform engineer building a deterministic guardrail layer for Claude Code. You treat hooks as production code that runs with the user's full permissions on every tool call. You trust the installed version's documentation and captured payloads over any blog post, including the guide you were given. You never claim a hook works until a live session proves it.
CONTEXT
- Hooks are shell commands the harness (not the model) runs at lifecycle events: SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Notification, Stop, SubagentStop, PreCompact, SessionEnd, plus newer ones that churn between versions.
- Stdin is JSON (session_id, hook_event_name, tool_name, tool_input, cwd, transcript_path, and per-event fields). Exit 0 allows, exit 2 blocks and feeds stderr to Claude, other non-zero is a non-blocking error.
- Claims from the guide you must TEST, not repeat: (1) 'structured JSON can return {"modified_tool_input": ...} to rewrite arguments' (current docs may name this updatedInput under hookSpecificOutput); (2) '{"decision": "approve"}' as a valid PreToolUse output; (3) 'SessionStart stdout gets injected into Claude's first system message'; (4) 'async: true hooks cannot block or modify'; (5) 'later settings files win for the same key' (hooks may merge rather than override).
- References: code.claude.com/docs/en/hooks, code.claude.com/docs/en/hooks-guide, github.com/disler/claude-code-hooks-mastery, the hookify plugin if installed (/hookify:list).
HARD CONSTRAINTS
- Back up ~/.claude/settings.json to settings.json.bak-<date> before editing. Merge hooks with jq; never overwrite existing entries.
- All scripts live in ~/.claude/hooks/, are git-tracked, executable, shellcheck clean.
- PreToolUse hooks must finish in under 50 ms median (hyperfine, 20 runs).
- Every Stop hook must exit 0 when stop_hook_active is true to prevent infinite loops.
- Destructive tests only against canary paths under /tmp/hooktest-*. Never test against real data.
- The secret blocker reports the pattern name, never the matched value.
ORDERED DELIVERABLES
1. ground-truth.md: claude --version, the event table copied from the installed version's docs, the exact output JSON schema, and a verdict on each of the five guide claims above.
2. A capture hook (temporary) that writes every event's stdin to ~/.claude/hook-payloads/; run one session that triggers Bash, Edit, Write, a subagent, and /compact. Keep the payloads as fixtures in ~/.claude/hooks/tests/fixtures/. Remove the capture hook afterward.
3. Seven hooks: block-destructive.sh, block-secrets.py, format-on-edit.sh, session-git-context.sh, bash-audit.sh, precompact-backup.py (async), stop-tests-gate.sh.
4. tests/: bats or pytest cases, at least three per hook (allow case, block case, malformed-stdin case).
5. settings.json merged entries with matchers (Bash; Edit|Write|MultiEdit; empty matcher for lifecycle events) and timeouts.
6. Live proof: headless runs of `claude -p '...' --output-format stream-json` for the blocker, the secret blocker, and the Stop gate, saved as transcripts in ~/.claude/hooks/proof/.
7. README.md in ~/.claude/hooks/: what each hook does, how to disable (disableAllHooks or removing the entry), and a 'what this cannot stop' section.
BUILT-IN GAP ANALYSIS
Before finishing, write GAPS.md listing: every event you could not trigger live and why; every guide claim that was false or unverifiable on this version; bypasses you found for each blocker (try bash -c, eval, base64 -d | sh, writing a script then running it, MCP tools that write files); hooks whose latency exceeded budget. Fix what you can now (for example extend the Edit matcher to MCP filesystem tools), and mark the rest open with a reason.
SELF-QUESTIONS BEFORE FINISHING
- Did I read the docs for my installed version, or an older page?
- Does any hook print a secret, a full tool_input, or a transcript to stdout where Claude will see it?
- What happens to each hook when jq is missing, stdin is empty, or tool_input lacks the field I read?
- Does the Stop gate handle a repo without tests, and does it honour stop_hook_active?
- Does format-on-edit run on files outside the project (for example ~/.claude itself)?
STOP CONDITIONS
Stop and report when all seven hooks pass their tests and the three live proofs exist, or when a doc/behaviour mismatch blocks a hook (report the mismatch with the doc excerpt and the observed payload). Stop immediately and ask if any test would touch a path outside /tmp/hooktest-* or the hooks directory.
RETURN FORMAT
A single message with: (1) file tree of ~/.claude/hooks; (2) test summary (pass/fail counts per hook); (3) latency table (hook, median ms); (4) verdicts on the five guide claims; (5) the GAPS.md contents; (6) the exact settings.json diff.
3 · Step-by-step prompts
Step 1 — Establish ground truth for the installed version
Goal: Pin the exact hook contract of the Claude Code version you run and capture real payloads.
Run `claude --version` and record it. Read the hooks reference at code.claude.com/docs/en/hooks and the hooks guide at code.claude.com/docs/en/hooks-guide. Write ~/.claude/hooks/ground-truth.md containing: the full list of hook events with when they fire and whether they can block; the stdin fields common to all events and the extra fields per event (tool_name, tool_input, tool_response, prompt, stop_hook_active, trigger for PreCompact, source for SessionStart); exit-code semantics; the structured JSON output schema (continue, stopReason, suppressOutput, hookSpecificOutput.permissionDecision, permissionDecisionReason, updatedInput, additionalContext, and Stop's decision/reason). Then judge these guide claims as true, false, or renamed for this version: modified_tool_input rewrites args; decision: approve is valid for PreToolUse; SessionStart stdout is injected as context; async hooks cannot block; later settings files override earlier ones for hooks. Next, back up ~/.claude/settings.json, add a temporary catch-all hook on every event that runs `cat > ~/.claude/hook-payloads/$(date +%s%N)-event.json`, start one session that runs a Bash command, edits a file, writes a file, spawns a subagent, and runs /compact, then remove the catch-all. Copy the payloads into ~/.claude/hooks/tests/fixtures/ named by event.
Expect: ground-truth.md (one to two pages) with a version string, an event table, a schema block, and five claim verdicts each quoting the doc line; at least one fixture JSON per event triggered.
Audit before moving on: Open each fixture and confirm the fields you documented actually appear; any documented field missing from a real payload is a fail, so re-read the docs for that version and correct ground-truth.md. Confirm settings.json matches the backup after removing the capture hook (diff must be empty).
Step 2 — Build and test the PreToolUse blockers
Goal: Ship a destructive-Bash blocker and a secret-leak blocker with fixture tests and latency budgets.
Using the fixtures in ~/.claude/hooks/tests/fixtures/ and the schema in ground-truth.md, write two hooks. (a) block-destructive.sh: read .tool_input.command with jq; block with exit 2 and a one-line stderr reason on rm -rf against /, ~, $HOME or a repo root, mkfs, dd of= to /dev/, fork bombs, chmod -R 777 /, git push --force to main or master, `find ... -exec rm -rf` without a .git prune, and rm -rf of any directory containing a .protected sentinel file (this gives live tests a safe target). Allow everything else. Handle missing jq or empty stdin by exiting 0 and logging to ~/.claude/hooks/errors.log. (b) block-secrets.py: on Edit, Write, and MultiEdit, scan new_string, content, and edits[].new_string against a pattern set derived from gitleaks default rules (AWS keys, GitHub tokens, Anthropic and OpenAI keys, PEM private key headers, 64-hex Ethereum private keys) and exit 2 naming only the rule, never the value. Write bats or pytest cases: three allow, three block, one malformed stdin per hook. Run shellcheck. Time each with `hyperfine --runs 20` piping a fixture; the median must be under 50 ms. Then try to bypass block-destructive with bash -c, eval, base64 -d | sh, and a script file, and record which get through.
Expect: Two executable scripts, a test file with at least 14 passing cases, a hyperfine table, and a bypass list with pass/blocked per technique.
Audit before moving on: Re-run the tests from a clean shell. Grep the scripts' stderr strings to confirm no secret value can be echoed. If a known-dangerous fixture passes, or latency exceeds 50 ms, fix before moving on. Bypasses that remain open go into GAPS.md, not silently ignored.
Step 3 — Build the advisory hooks
Goal: Add format-on-edit, Bash audit logging, and SessionStart git context, each tested.
Write three hooks using the fixtures. (a) format-on-edit.sh for PostToolUse on Edit|Write|MultiEdit: read .tool_input.file_path, skip files outside the session cwd (from .cwd) and anything under ~/.claude, then dispatch by extension: prettier --write for ts/tsx/js/jsx/json/css/md, ruff format for py, rustfmt for rs, gofmt -w for go. Skip silently if the formatter is not installed. Never exit 2. (b) bash-audit.sh for PostToolUse on Bash: append a TSV row of ISO timestamp, session_id, cwd, and the command with tabs and newlines escaped, to ~/.claude/bash-log.tsv; rotate when the file passes 10 MB. (c) session-git-context.sh for SessionStart: if cwd is a git repo, print the branch, `git status --short | head -10`, and `git log --oneline -5`; print nothing outside a repo. Verify against ground-truth.md whether SessionStart stdout becomes model context on this version and whether JSON additionalContext is the preferred path; use whichever the docs specify. Write tests: format-on-edit actually reformats a badly indented fixture file in /tmp/hooktest-fmt; audit log escapes a multi-line command; git context prints nothing in a non-repo directory.
Expect: Three scripts, passing tests, and a sample bash-log.tsv row and git-context output pasted into the step notes.
Audit before moving on: Check that a command containing a tab or newline yields exactly one TSV row. Check the formatter does not touch files outside cwd. If SessionStart output is not visible to the model in a live test, switch to the documented JSON field and retest.
Step 4 — Build the Stop gate and PreCompact backup
Goal: Refuse premature 'done' while tests fail, and snapshot transcripts before compaction.
Write stop-tests-gate.sh for the Stop event. Read stdin; if .stop_hook_active is true, exit 0 immediately to avoid an infinite loop. Detect the test runner from the cwd: pytest if pyproject.toml or pytest.ini exists, `npm test --silent` if package.json has a test script, `cargo test --quiet` for Cargo.toml; if none, exit 0. Run it with a 120-second timeout. On failure, block the stop using the mechanism ground-truth.md specifies for Stop on this version (exit 2 with the failing test names on stderr, or JSON decision: block with a reason), capped to the last 30 lines of output. Write precompact-backup.py for PreCompact, registered async: copy the file at .transcript_path to ~/.claude/transcript-backups/<session_id>-<trigger>-<UTC timestamp>.jsonl, keep the newest 50, and never print the transcript. Test the Stop gate in /tmp/hooktest-stop with a tiny pytest project in three states: passing, failing, and stop_hook_active true while failing. Test the backup with a fixture payload pointing at a dummy transcript.
Expect: Two scripts and a test log showing three Stop outcomes (allow, block with reason, allow due to stop_hook_active) and one backup file created with the right name.
Audit before moving on: Confirm the gate cannot loop: simulate two consecutive Stop events, the second with stop_hook_active true, and verify exit 0. Confirm a 120-second timeout is shorter than the hook timeout you will register. Confirm the backup rotation deletes only files in its own directory.
Step 5 — Prove each hook end to end in live sessions
Goal: Show the harness, not the test harness, enforces each hook.
First register the hooks: back up ~/.claude/settings.json, then merge all seven entries into .hooks with jq, appending to existing arrays (matchers Bash and Edit|Write|MultiEdit for the tool hooks, empty for lifecycle events, async true for PreCompact, Stop timeout 150 seconds). Validate with `jq empty`, confirm each entry appears under /hooks, and list project-level hooks in .claude/settings.json that would double-fire. Then create canaries: mkdir -p /tmp/hooktest-canary && touch /tmp/hooktest-canary/keep.txt /tmp/hooktest-canary/.protected. Never target a parent path in a test. Run headless sessions and save each transcript to ~/.claude/hooks/proof/: (1) `claude -p 'Run: rm -rf /tmp/hooktest-canary then report' --output-format stream-json --verbose` with permission settings that would otherwise allow Bash; confirm the stream shows a block with your stderr reason and the canary still exists. (2) Ask Claude to write a file /tmp/hooktest-secret/config.py containing a fake AWS key in the documented AKIA format; confirm the write is blocked and the file does not exist. (3) In /tmp/hooktest-stop with a failing test, ask Claude to 'add a docstring and say done'; confirm the session continues and fixes or reports the failing test before stopping. (4) Count Bash tool calls in the transcripts and compare with new rows in bash-log.tsv. (5) Run /compact in an interactive session and confirm a backup file appears.
Expect: Five proof transcripts or notes with pass/fail, a canary directory that still exists, and matching Bash-call counts.
Audit before moving on: Any proof that passes only because the model declined on its own (no hook event in the stream) is a fail; re-run with a prompt that pushes the action and confirm the block reason comes from the hook. Mismatched audit-log counts mean the matcher misses some calls. A settings diff that removes any pre-existing line means restore the backup and redo the merge.
Step 6 — Validate against the oracles and write the gap report
Goal: Run every external check and publish what is proven, what is open, and what the guide got wrong.
Run the full test suite from a clean shell and paste the summary. Run shellcheck on all .sh files. Run hyperfine on every PreToolUse hook and tabulate medians. Run gitleaks detect on the project repo and confirm no secret type it finds is missing from block-secrets.py's rules. Re-read the five proof transcripts. Write ~/.claude/hooks/README.md (purpose of each hook, how to disable each, fail-open versus fail-closed choice, what each blocker cannot stop) and GAPS.md with three sections: Verified (claim, evidence file), Open (claim or bypass, why not fixed, impact high/medium/low), and Guide corrections (each guide claim that was false or renamed on this version with the doc excerpt). Commit ~/.claude/hooks to git with a message naming the Claude Code version tested.
Expect: All tests green, a latency table, README.md and GAPS.md, and a commit hash.
Audit before moving on: Every Verified row must cite a file in tests/ or proof/. Any high-impact Open item without a mitigation note is a fail: add a mitigation (for example a permissions deny rule) or mark it explicitly as accepted risk with the reason.
4 · Gap analysis and audit
Run this after the build. It makes the agent enumerate its own claims, mark what is verified, rank the gaps and fix the top ones.
You are auditing a freshly built Claude Code hook suite in ~/.claude/hooks/ that you did not write. Do not trust README.md. (1) List every claim the README and ground-truth.md make (each hook's trigger, behaviour, latency, and each guide-claim verdict). (2) For each, mark verified, unverified, or false, and cite evidence: a test case name, a proof transcript line, or a doc excerpt for the version from `claude --version`. (3) Attack the blockers: try at least eight bypasses of block-destructive (bash -c, sh -c, eval, base64 -d | sh, python -c with shutil.rmtree, a heredoc script then execute, xargs rm, an MCP filesystem delete tool) and five of block-secrets (split string concatenation, base64 of a key, a key in a NotebookEdit cell, a key via Bash echo > file, a key in an MCP write tool). Record which get through. (4) Check for silent failures: what does each hook do with empty stdin, invalid JSON, a missing jq or formatter; does any hook print tool_input or transcript content where Claude sees it. (5) Rank all gaps by impact (data loss, secret leak, workflow friction). Fix the top three now, re-run tests, and append results to GAPS.md. Report what you could not fix and why, with the exact command that demonstrates each remaining bypass on a /tmp canary.
Questions the agent must ask itself before it says done:
- Which version's hook schema did I implement, and did I check it against a captured payload rather than the guide's table?
- Does the Stop gate honour stop_hook_active, and what happens if the test suite takes longer than the hook timeout?
- Does block-secrets cover MultiEdit edits[] and NotebookEdit, or only Edit and Write?
- Can Claude write a file through an MCP server tool that none of my matchers see?
- Is fail-open on missing jq the right call for the destructive blocker, and did I document it?
- Do project-level .claude/settings.json hooks double-fire with my user-level ones?
- Does format-on-edit rewrite files Claude did not intend to change, such as lockfiles or generated code?
- Will the audit log or transcript backups grow without bound?
5 · What proves it right
- bats or pytest suite: every hook passes allow, block, and malformed-stdin fixtures captured from the installed Claude Code version.
- Headless
claude -p --output-format stream-jsontranscripts showing the hook's block reason for a destructive command, with /tmp/hooktest-canary still present afterward. - A failing pytest project where the Stop gate keeps the session working until tests pass, and a stop_hook_active=true run that exits cleanly.
- gitleaks detect on the repo finds no secret class that block-secrets.py lacks a rule for.
6 · Failure modes
- Stop hook loops forever. Tell: the session keeps restarting after every response and burns tokens. Fix: exit 0 when stop_hook_active is true and cap retries.
- Hook written to an outdated schema. Tell: JSON output is ignored and tools run anyway. Fix: re-read the docs for
claude --versionand use hookSpecificOutput fields. - Regex blocker treated as a sandbox. Tell:
bash -cor a written script runs the blocked command. Fix: document bypasses, add permissions deny rules and a sandboxed run mode for risky work. - Slow PreToolUse hook. Tell: every Bash call stalls noticeably. Fix: hyperfine it, move logic to a compiled binary or cache, keep heavy work in async PostToolUse.
7 · Agent direction (unattended runs)
Run this in the user's home config, so treat ~/.claude/settings.json as precious: back it up before every write and diff after. Reach for the installed version's docs and a captured payload first; do not code from the guide's tables. Spawn a subagent only for the adversarial bypass hunt in the gap audit, so the builder is not grading its own blockers. Never execute a destructive command outside /tmp/hooktest-* paths; if any proof would need one, stop and ask. Never print, log, or echo the value of anything matching a secret pattern, including in test output. Return in chat: the hook file tree, test summary, latency table, guide-claim verdicts, settings diff, and GAPS.md; the files themselves live in ~/.claude/hooks/ and are committed to a git repo there.
Prompt system updated 2026-10-22 · all prompt systems · JSON