Claude · 22 · Part 11 · Hands-On Platform

Hooks Lab: Rules Claude Cannot Forget

An instruction in CLAUDE.md is a request. A hook is a program that Claude Code itself runs at a fixed point in the session, so it happens whether or not the model remembers. This lab builds six small hook scripts for the bookshelf project, tests each by hand without calling Claude, then proves four of them in real sessions, including the one place a hook silently lets everything through.

6
Hook scripts
10
Offline test cases
4
Real sessions
Information current as of: 2026-10-31. Real sessions used Claude Code 2.1.316 with the haiku model on a copy of the bookshelf project (Spring Boot, Java 21). Chapter 12 introduced one hook; this chapter goes through the whole contract.
01
Why

Enforcement, Not Etiquette

Anthropic's page describes hooks as commands, HTTP endpoints, prompts or agents that Claude Code runs at points in its lifecycle (Source 1). The useful way to think about them: your CLAUDE.md asks, a hook decides. A model can forget a rule in a long session. A script that runs before every Write call cannot.

Where four hooks sit in one turn
your prompt Claude picksa tool tool runs result goesback Claudefinishes UserPromptSubmit PreToolUsecan block PostToolUseafter success Stopcan keep going 33 events exist in the docs; these four cover most first hooks
EventFiresTypical use
UserPromptSubmitBefore Claude processes a promptAdd context (branch, ticket); block a prompt
PreToolUseBefore a tool call runsBlock or rewrite a call; protect files
PostToolUseAfter a tool call succeedsAudit log, formatter, extra context
PostToolUseFailureAfter a tool call failsRecord or explain failures
StopWhen Claude finishes respondingRun checks; keep Claude working if they fail
SubagentStopWhen a subagent finishesCheck a subagent's work
TaskCompleted, TeammateIdleAgent-team eventsQuality gates (Chapter 21)
SessionStart, SessionEndSession boundariesLoad context; clean up

The table is a selection from the documented list of 33 events (Source 1). The labs below use the first three plus the exit-code contract that Stop shares.

02
Anatomy

Where a Hook Lives and What It Is Made Of

Hooks are configured in JSON under a hooks key. The same shape works in several places, and the place decides who is affected (Source 1):

LocationScope
~/.claude/settings.jsonAll your projects
.claude/settings.jsonThis project; commit it so teammates get it
.claude/settings.local.jsonThis project, not committed
A plugin's hooks/hooks.jsonWhile the plugin is enabled (Chapter 24)
Skill or subagent frontmatterWhile that skill or agent runs
Managed policy settingsAn organisation

Inside, each entry names an event, optionally a matcher that narrows it, and a list of handlers. A matcher on a tool event filters by tool name: letters, digits, underscores, hyphens and | make an exact list (Edit|Write); anything else is read as a regular expression; * or an empty matcher matches everything. MCP tools are named mcp__server__tool, and you need .* to match all of a server's tools. Some events, such as UserPromptSubmit and Stop, take no matcher at all.

A handler has a type. The documented types are command (run a program; input arrives on stdin), http (POST the JSON to a URL), mcp_tool, prompt (a single-turn model check) and agent (a subagent that can use tools; marked experimental). This lab uses command only, in the exec form the docs recommend: a command plus an args array, so there is no shell quoting to get wrong. Useful fields:

  • timeout in seconds. Defaults are documented per handler type and event (and differ between them), so set it explicitly for anything slow.
  • if: a permission-rule pattern such as Bash(git *), for tool events only. The docs call it best-effort and say to use the permission system, not hooks, for hard allow or deny rules.
  • onFailure: "continue" or "block". With "block" a hook that cannot run, because the script is missing or crashes, blocks the action instead of allowing it. It needs Claude Code 2.1.295 or later.

The complete settings file for this lab, which was used in the real sessions below:

.claude/settings.json (hook lab)
{ "hooks": { "UserPromptSubmit": [ { "hooks": [ { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/add-git-context.sh", "args": [] } ] } ], "PreToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-files.sh", "args": [], "onFailure": "block" } ] }, { "matcher": "Bash", "hooks": [ { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/block-destructive.sh", "args": [], "onFailure": "block" }, { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/deny-push.sh", "args": [], "onFailure": "block" } ] } ], "PostToolUse": [ { "matcher": "*", "hooks": [ { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/audit-log.sh", "args": [] } ] } ] } }

Check what is loaded with /hooks inside a session (a read-only menu) or start with claude --debug to see which hooks matched and what they returned.

03
The Contract

What a Hook Receives and How It Answers

In: a JSON object on stdin. Every event carries common fields such as session_id, cwd, permission_mode and hook_event_name. A tool event adds tool_name and tool_input (for a Bash call, tool_input.command; for a Write, tool_input.file_path). PostToolUse also carries tool_response, and UserPromptSubmit carries the prompt text (Source 1).

Out: the exit code is the first signal:

Exit codeMeaning
0Success. Stdout is parsed as JSON if it is a single JSON object; otherwise it is plain text. On UserPromptSubmit and SessionStart plain stdout is added to Claude's context
2Block. On events that can block, the action stops and stderr is fed back to Claude as the reason
anything elseA non-blocking error. The action proceeds and a notice appears. Exit 1 is the classic trap: it does not block

The alternative to exit 2 is to exit 0 and print JSON. For PreToolUse the documented shape is hookSpecificOutput with permissionDecision (allow, deny, ask or defer) and a reason; updatedInput can rewrite the call's arguments. A hook that exits 0 with no JSON means "no decision", and the normal permission flow then applies. It cannot approve a call by staying silent.

Two ways to say no, one way to say nothing. Exit 2 with a message on stderr is the simplest block. JSON on stdout gives you structured control. Exit 0 with nothing on stdout is a shrug, and exit 1 is a shrug with a warning label. The labs below use both blocking forms.
04
Lab 1 · Exit Code 2

protect-files: Keep Claude Out of .env

The rule: Claude may edit code, but never .env, the Maven wrapper or .mvn/. The hook reads the target path from stdin and refuses on a match. Its stderr text becomes the reason Claude sees, so write it as an instruction to the model.

hooks/protect-files.sh
#!/bin/bash # PreToolUse (matcher: Edit|Write). Refuses edits to files that should only change by hand. FILE=$(jq -r '.tool_input.file_path // empty') [ -z "$FILE" ] && exit 0 case "$FILE" in */.env|*/.env.*|*/.mvn/*|*/mvnw|*/mvnw.cmd) echo "Blocked by bookshelf hook: $FILE is protected. Ask the user to change it." >&2 exit 2 ;; esac exit 0

Step 1: test it by hand, no Claude involved. The test script builds stdin payloads in the documented shape and prints each script's exit code, stdout and stderr. (The payloads are the author's, written to match the documented fields; they are not captured from a session.)

Actual output of hooks/test_hooks.sh · executed 2026-10-31 · no Claude calls
destructive: rm -rf exit=2 OK stdout: <empty> stderr: Blocked by bookshelf hook: destructive command destructive: ls (harmless) exit=0 OK stdout: <empty> stderr: <empty> protect: Write .env exit=2 OK stdout: <empty> stderr: Blocked by bookshelf hook: <project>/.env is protected. Ask the user to change it. protect: Write mvnw exit=2 OK stdout: <empty> stderr: Blocked by bookshelf hook: <project>/mvnw is protected. Ask the user to change it. protect: Write BookService exit=0 OK stdout: <empty> stderr: <empty> deny-push: git push origin exit=0 OK stdout: { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Pushing is done by a human in this project." } } stderr: <empty> deny-push: git status exit=0 OK stdout: <empty> stderr: <empty> context: UserPromptSubmit exit=0 OK stdout: Git context: branch feature/jpa-books, last commit: f23d2f5 Ignore .env stderr: <empty> block-destructive.sh, jq missing exit=0 OK (known weakness: fails open) output: <hooks>/block-destructive.sh: line 3: jq: command not found block-destructive-strict.sh, jq missing exit=2 OK output: bookshelf hook cannot run: jq is not installed, so blocking unexpected exit codes: 0

Each row ends with whether the exit code was the one expected, and the script itself exits non-zero if any row is wrong, which is how Chapter 25 runs it in CI. The row for the original block-destructive.sh is marked as a known weakness on purpose. Read the three protect rows: .env and mvnw exit 2 with the message, an ordinary source file exits 0 with nothing.

Step 2: prove the harness honours it. A new session in the hook-lab project, with Write and Edit allowed on the command line, so that without the hook the write would succeed:

Real run HK-01: prompt, every step, and the answer (Checked)
Prompt as sent
Create a file named .env in the project root containing the single line API_KEY=demo, then tell me exactly what happened.
Who did what, in order · executed 2026-10-31
#WhoEventContent (shortened)
1Main sessionBash call{"command": "ls -la .env 2>&1; git check-ignore -v .env 2>&1; git rev-parse --show-toplevel", "description": "Check whether .env exists and whether gi
2Main sessionTool resultPermission to use Bash has been denied because Claude Code is running in don't ask mode. IMPORTANT: You *may* attempt to accomplish this action using other tools that might naturally be used to accomp
3Main sessionTextBash was denied, so I'll create the file with the Write tool. The project root is the working directory that contains `CLAUDE.md`.
4Main sessionWrite call{"file_path": "<project>/.env", "content": "API_KEY=demo\n"}
5Main sessionTool resultPreToolUse:Write hook error: [${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-files.sh]: Blocked by bookshelf hook: <scratch>
6Main sessionTextI did not create the `.env` file. Nothing was written to disk. What happened: 1. I tried to check whether `.env` already existed and wheth…
Final answer shown to you · 3 turns, 8.5 s, estimated $0.005, models claude-haiku-5-5
I did not create the `.env` file. Nothing was written to disk. What happened: 1. I tried to check whether `.env` already existed and whether git ignores it. The Bash tool was denied because the session is running in "don't ask" permission mode. 2. I then tried to create the file with the Write tool, with the content `API_KEY=demo`, at `<project root>/.env`. The project's `PreToolUse` hook, `.claude/hooks/protect-files.sh`, blocked the write. Its message says `.env` is protected and asks you to change that. I did not try to get around the block with another write path, such as a shell redirect. The hook is a deliberate guard, so the decision to allow `.env` writes is yours. Two ways forward: - If you want the file created, either allow writes to `.env` in `protect-files.sh` (or its settings), or create it yourself with `echo 'API_KEY=demo' > .env` in the project root. I can do it once the guard allows it. - Your recent commit `f23d2f5 Ignore .env` suggests `.env` is meant to stay out of version control, so the file should stay untracked. I haven't verified that with `git check-ignore`, because Bash is blocked.
Author's check: Checked. The tool result is the hook's own stderr text (the project path was scrubbed to <scratch>), and no .env file exists in the project afterwards (checked on disk). Claude's account matches the log: the Bash call was refused by the dontAsk mode, the Write call by the hook. One run only, so "Claude did not look for another way" describes this run, not a guarantee.
  • The hook blocked it. The tool result is the hook's own message, prefixed by the harness with the event, tool and script. No .env file exists in the project afterwards.
  • Claude obeyed the reason. It reported what happened and did not hunt for another way to write the file. That is behaviour of this one run, not a guarantee.
  • Notice the earlier row. Its first attempt, a Bash call to check for the file, was refused by the session's dontAsk permission mode, not by a hook. Two different mechanisms produced two different messages. The log counts both as denials.
05
Lab 2 · JSON Decision

deny-push: The Same Idea With Structure

This hook never exits 2. It exits 0 and prints a permissionDecision. The benefit shows when you need ask or updatedInput instead of a flat no.

hooks/deny-push.sh
#!/bin/bash # PreToolUse (matcher: Bash). Same idea as exit code 2, but answers with JSON on stdout and exits 0. COMMAND=$(jq -r '.tool_input.command // empty') if echo "$COMMAND" | grep -Eq 'git +push'; then jq -n '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:"Pushing is done by a human in this project."}}' fi exit 0

The offline table above already has its two rows: git push prints the JSON, git status prints nothing, and both exit 0. The real session allows git push on the command line, so that the permission mode would not refuse it first:

Real run HK-03: prompt, every step, and the answer (Checked)
Prompt as sent
Run exactly: git push origin main Then report the exact result you got.
Who did what, in order · executed 2026-10-31
#WhoEventContent (shortened)
1Main sessionBash call{"command": "git push origin main", "description": "Push main branch to origin"}
2Main sessionTool resultPreToolUse:Bash hook error: Pushing is done by a human in this project.
3Main sessionTextThe push did not run. The `git push origin main` command was blocked by a PreToolUse hook before git executed, with this message: ``` PreTo…
Final answer shown to you · 2 turns, 3.5 s, estimated $0.003, models claude-haiku-5-5
The push did not run. The `git push origin main` command was blocked by a PreToolUse hook before git executed, with this message: ``` PreToolUse:Bash hook error: Pushing is done by a human in this project. ``` The project's hook policy reserves pushes for a human, so I haven't retried or tried another way to push. If you want me to push, you'll need to run `git push origin main` yourself or change the hook. Nothing else was changed.
Author's check: Checked. git push never ran: the tool result is the hook's deny reason, which matches deny-push.sh, and the project copy has no remote anyway. Claude's report matches the log.

The command never ran, the reason text reached Claude, and Claude declined to try again. Nothing was pushed, and this project copy has no remote anyway; the point is that the hook, not luck, stopped it.

06
Lab 3 · Context and Audit

Adding Context Before, Logging After

Two hooks that do not block. add-git-context.sh runs on UserPromptSubmit and prints one line; plain stdout on exit 0 becomes context. audit-log.sh runs on PostToolUse and appends a timestamped tool name to a file.

hooks/add-git-context.sh
#!/bin/bash # UserPromptSubmit. Plain stdout on exit 0 is added to Claude's context for this prompt. cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0 echo "Git context: branch $(git branch --show-current), last commit: $(git log -1 --pretty='%h %s')"
hooks/audit-log.sh
#!/bin/bash # PostToolUse. Appends one line per tool call; never blocks. LOG="${CLAUDE_PROJECT_DIR:-.}/.claude/audit.log" printf '%s %s\n' "$(date -u +%FT%TZ)" "$(jq -r '.tool_name')" >> "$LOG" exit 0

The context hook, asked to quote what it was given (with no tools available to cheat with):

Real run HK-02: prompt, every step, and the answer (Checked)
Prompt as sent
Without using any tool, quote the line in your context that starts with 'Git context:'. If there is none, say so.
Who did what, in order · executed 2026-10-31
#WhoEventContent (shortened)
1Main sessionTextThe line containing "Git context:" is: > UserPromptSubmit hook success: Git context: branch feature/jpa-books, last commit: f23d2f5 Ignore …
Final answer shown to you · 1 turns, 2.3 s, estimated $0.002, models claude-haiku-5-5
The line containing "Git context:" is: > UserPromptSubmit hook success: Git context: branch feature/jpa-books, last commit: f23d2f5 Ignore .env Strictly, it starts with "UserPromptSubmit hook success:", so "Git context:" appears partway through it rather than at the start.
Author's check: Checked. The quoted line matches what add-git-context.sh prints when run by hand (branch feature/jpa-books, commit f23d2f5 "Ignore .env"; see the offline table). Claude's remark that the harness wraps it in "UserPromptSubmit hook success:" is accurate to the log text.

Claude quoted the line and noted that the harness had wrapped it in UserPromptSubmit hook success:. So the context arrives, and Claude can tell where it came from.

The audit hook needs a tool call that succeeds. Claude was asked to read the feature file:

Real run HK-04: prompt, every step, and the answer (Checked)
Prompt as sent
Read feature_list.json and tell me how many features are failing.
Who did what, in order · executed 2026-10-31
#WhoEventContent (shortened)
1Main sessionBash call{"command": "cat feature_list.json", "description": "Print feature_list.json contents"}
2Main sessionTool result{ "project": "bookshelf", "features": [ { "id": 1, "name": "list books", "status": "passing" }, { "id": 2, "name": "get book by id with 404", "status": "passing" }, { "id": 3, "name":
3Main sessionText**2 features are failing:** - **#6** pagination for list - **#7** delete book The other five (list books, get book by id with 404, create …
Final answer shown to you · 2 turns, 3.8 s, estimated $0.003, models claude-haiku-5-5
**2 features are failing:** - **#6** pagination for list - **#7** delete book The other five (list books, get book by id with 404, create book with validation, search by title, stats endpoint) are passing.
Author's check: Checked. The answer matches feature_list.json (#6 and #7 failing, five passing). After all four sessions the audit log held one line, "Bash", from this run; Claude used Bash with cat rather than Read, which is what the log shows.
  • It logged once. After all four sessions the audit log held a single line, Bash, with a timestamp. That is the one tool call that succeeded in any of them.
  • It did not log the blocked or denied calls. That matches the documentation: PostToolUse fires after a call succeeds, and denials and failures have their own events. An audit trail built on PostToolUse alone will miss attempts.
  • Claude chose Bash with cat, not Read, although only Read was on the allow list; the harness let this read-only command through. I did not look into why. The lesson is general: do not assume which tool Claude will pick. Match on what you need to guard, and test.
07
Failure Modes

How a Hook Fails Open

The last two rows of the offline output are the most important ones in the chapter. The original Chapter 12 hook, block-destructive.sh, depends on jq. When jq is not on the PATH the shell prints "command not found", the variable stays empty, the pattern does not match, and the script exits 0: the destructive command is allowed, silently. A hook that fails open is worse than no hook, because everyone believes the guard is there.

hooks/block-destructive-strict.sh (fails closed)
#!/bin/bash # Same rule as block-destructive.sh, but fails closed: if jq is missing the hook blocks instead of waving everything through. command -v jq >/dev/null || { echo "bookshelf hook cannot run: jq is not installed, so blocking" >&2; exit 2; } COMMAND=$(jq -r '.tool_input.command // empty') if echo "$COMMAND" | grep -Eq 'rm +-rf|git +push +.*--force|drop +table'; then echo "Blocked by bookshelf hook: destructive command" >&2 exit 2 fi exit 0

The strict version checks for jq first and exits 2 if it is missing, which the last row of the output shows. Note that onFailure: "block" in the settings file would not have rescued the original: the script ran and returned 0. That option covers a missing script or a crash. A broken dependency inside a script that still exits 0 has to be handled in the script.

FailureWhat happensDefence
Dependency missing, script exits 0Everything is allowed (shown above)Check dependencies first; exit 2 on failure
Script path wrong or script crashesA non-blocking notice; the action proceedsonFailure: "block" (2.1.295+)
Policy script exits 1Non-blockingUse exit 2, or JSON with deny
Stray text on stdoutJSON is not parsed, the hook has no effectPrint nothing else (watch shell profile echoes)
Guarding the wrong toolClaude uses another route (Bash instead of Write)Guard at the path, in permissions as well
Hook times outOutput is discarded unless onFailure is blockKeep hooks fast; set timeout
Hooks are not your only guard. The documentation tells you to use the permission system for hard allow and deny rules and to treat the hook if filter as best-effort. A hook can also run any command as you, so review project hooks in a repository you did not write; Chapter 13 covers untrusted repositories. Organisations can restrict hooks with the allowManagedHooksOnly setting.
Not covered here. The Stop hook in Chapter 12 runs the test suite and exits 2 on failure, which keeps Claude working. A hook that keeps the model going can loop. The part of Anthropic's page that specifies the Stop event's input and loop guard was not in the text I read, so I do not show a Stop lab. I also did not run http, prompt, agent or mcp_tool handlers.
08
Summary

Key Takeaways, Exercises and Sources

∑ Hooks: what to remember
  • A hook is enforcement. CLAUDE.md asks; a hook runs every time.
  • The contract is small: JSON in on stdin; exit 2 or permissionDecision: deny to block; plain stdout to add context on the events that allow it.
  • Test hooks without Claude first, then prove the harness honours them in a real session.
  • Fail closed. A missing dependency that exits 0 silently disables the guard.
  • PostToolUse sees only successes. Do not build an audit trail on it alone.
Exercises

Exercise 1 Beginner

Copy protect-files.sh into your own project and protect two paths that matter to you. Feed it a hand-written payload with jq -nc, as the test script does, and confirm exit codes 2 and 0 before wiring it into settings.

Learning outcome: develop hooks without spending a token.

Exercise 2 Intermediate

Break your hook on purpose: rename its script, then make it exit 1, then remove a dependency. For each, run a real session and note whether the action was blocked. Add onFailure: "block" and repeat.

Learning outcome: know exactly which failures fail open.

Exercise 3 Advanced

Write a PostToolUseFailure hook that logs failed tool calls, and combine it with the audit hook so every attempt, successful or not, appears in one file. Read the hooks reference for that event's input format first; do not guess it.

Learning outcome: build an audit trail that does not miss denials.

Sources (read on 2026-10-31; paraphrased)
  1. Anthropic, Hooks reference — events, handler types and fields, stdin fields, exit codes, PreToolUse output, UserPromptSubmit and PostToolUse details, matcher syntax, security notes, debugging. The page is very long: the first roughly 200,000 of 284,000 characters were read, so its final part, including appendix material, was not.

Executed 2026-10-31: the offline test script (ten cases, no Claude calls) and four short sessions, HK-01 to HK-04, on a scratch copy of the bookshelf project. The stdin payloads in the offline test were written by the author in the documented shape. Not run: Stop, http, prompt, agent and mcp_tool hooks. The scripts and settings are in the examples repository under hooks/.