6. Hooks
"Hooks are commands Claude Code runs for you at fixed points in a session: before a tool call, after one, when a prompt is submitted, when Claude stops, and 29 other events. Each hook is an event, a matcher that picks which occurrences count, and a handler, usually a shell command that gets the event as JSON on stdin. The exit code is the answer: 2 blocks the action, 0 lets the normal flow decide, and anything else is just a warning. That's what makes hooks different from CLAUDE.md: they run every time, whatever the model decides. /hooks shows you every hook that's configured and where it came from."
The sentence that marks you as an architect: a hook is enforcement only if it blocks with exit 2 and is set to block when it breaks.
8 pictures, in order.
Use the arrow keys or the dots. Tap a picture to see it full size.
The facts someone can check.
Three levels of nesting
- Event: the moment, such as
PreToolUseorStop. - Matcher group: which occurrences count, such as
"matcher": "Bash". - Handler: what runs. Five types:
| Type | What it does | Default timeout |
|---|---|---|
command |
Runs a shell command. JSON in on stdin, answer out through the exit code and stdout | 600 seconds |
http |
POSTs the same JSON to a URL. The response body carries the answer | 600 seconds |
mcp_tool |
Calls a tool on a connected MCP server | 600 seconds |
prompt |
Asks a Claude model one question, reads a JSON decision back | 30 seconds |
agent |
Spawns a subagent that can read files before deciding. Experimental | 60 seconds |
When an event fires, all its matching hooks run in parallel. The same handler defined in two settings files runs once.
The 33 events
The events fall into three cadences: once per session (SessionStart, SessionEnd), once per turn (UserPromptSubmit, Stop, StopFailure), and on every tool call (PreToolUse, PostToolUse). Around those sit permission events, subagent and task events, compaction and model-switch events, file and directory events, and MCP elicitation events.
16 of the 33 can block their action with exit code 2. The rest either fire after the fact or only observe.
Exit codes
| Exit code | Meaning | What happens |
|---|---|---|
| 0 | Success | The action goes ahead unless JSON on stdout denies it. Plain stdout goes to the debug log, except on SessionStart, UserPromptSubmit, UserPromptExpansion, and PostModelSwitch, where Claude reads it as context |
| 2 | Blocking error | On events that can block, the action stops. stderr becomes the reason Claude reads |
| Anything else | Non-blocking error | The action goes ahead. You see a notice with the first line of stderr. Claude doesn't |
- Exit 1 doesn't block. It's the normal Unix failure code, and that's exactly why it trips people up.
- When the hook itself breaks, by crashing, a missing script, or a timeout, the action goes ahead.
"onFailure": "block"on a command or http hook (v2.1.295 or later) makes a failure block instead, the way exit 2 would. - JSON output gives finer control than exit codes: exit 0 and print one JSON object. Don't mix both: exit 2 blocks no matter what the JSON says.
- Output into context is capped at 10,000 characters. Past that, Claude gets a file path and a 2,000-character preview.
PreToolUse vs PostToolUse
| PreToolUse | PostToolUse | |
|---|---|---|
| Fires | Before the tool runs, ahead of the permission prompt | After the tool succeeds |
| Can it stop the action? | Yes: exit 2, or permissionDecision: "deny" |
No, the tool already ran |
| Decisions | allow, deny, ask, or defer |
decision: "block" adds a reason next to the result |
| Can it rewrite? | updatedInput replaces the tool's arguments |
updatedToolOutput replaces what Claude sees |
| Typical use | Block rm -rf, protect files, gate pushes |
Format, lint, run tests, log |
- When several PreToolUse hooks answer, the strictest wins: deny, then defer, then ask, then allow.
- A hook can tighten, never loosen. Deny and ask rules are checked whatever the hook returns, so a hook's allow can't skip them.
- A hook's deny holds in every mode, bypassPermissions and
--dangerously-skip-permissionsincluded. - PostToolUse runs only on success. Add the same hook to
PostToolUseFailureto see failed calls. - PermissionRequest fires when Claude Code is about to show you a permission prompt. It ignores exit 2; it answers with a decision object instead.
Matchers
| Matcher | Read as | Example |
|---|---|---|
"*", "", or none |
Everything | Every occurrence |
Only letters, digits, _, -, spaces, ,, | |
An exact name, or a list | Bash, or Edit|Write |
| Any other character | A JavaScript regex, unanchored | ^Notebook; Edit.* also matches NotebookEdit |
- The MCP trap:
mcp__memorycontains only exact-match characters, so it's compared as a whole tool name and matches nothing. Writemcp__memory__.*. - What the matcher filters depends on the event: the tool name for tool events,
startup,resume,clear,compact, orforkfor SessionStart,manualorautofor compaction. Ten events, includingUserPromptSubmitandStop, have no matcher support and always fire. - The
iffield narrows a tool hook further with one permission rule, such as"Bash(git *)"or"Edit(*.ts)". It's best-effort: when Claude Code can't tell what a Bash command runs, the hook runs anyway. - Matchers are case-sensitive.
Where hooks live, and who can switch them off
| Defined in | Scope | Shared |
|---|---|---|
~/.claude/settings.json |
All your projects | No |
.claude/settings.json |
One project | Yes, committed |
.claude/settings.local.json |
One project | No |
| Managed policy settings | The organization | Yes, admin-controlled |
A plugin's hooks/hooks.json |
While the plugin is on | Yes |
| Skill frontmatter | Rest of the session, once the skill runs | Yes |
| Subagent frontmatter | While that subagent runs | Yes |
- Hooks merge across levels. User, project, and local hooks add to managed ones, never replace them.
disableAllHooks: trueturns hooks off, but set outside managed settings it can't touch managed hooks. For one run, whatever the project says:--settings '{"disableAllHooks": true}'.allowManagedHooksOnlyin managed settings blocks user, project, local, and plugin hooks.
Security
- Command hooks run with your full user permissions, outside the sandbox. They can read, change, or delete anything your account can. Review a hook before you add it.
- Workspace trust: an interactive session holds every settings-file hook until you accept the trust dialog for the folder. A
claude -por SDK run never shows the dialog and treats the folder as trusted, so a cloned repo's hooks run. - The docs' checklist: validate inputs, quote shell variables, block
..in paths, use absolute paths, skip.envand keys.
Timeouts and limits
- A timed-out PreToolUse hook doesn't block the call. A stalled gate is an open gate, unless
onFailureis"block". SessionEndhooks share a 1.5-second budget, raised to match a longer per-hook timeout up to 60 seconds.- A Stop hook that keeps blocking is overridden after 8 blocks in a row. Check
stop_hook_activein the input to avoid the loop.
What /hooks actually does
Opens a read-only browser of every configured hook, labeled with where it comes from: user, project, or local settings, a plugin, or the session. Select one to see exactly what it runs. To change a hook, edit the settings file; Claude Code's file watcher picks up the change.
Hooks vs permission rules
Both are enforced by Claude Code, and they stack. A deny rule is declarative, easy to review, and can be locked in managed settings, but it only matches command text. A PreToolUse hook runs your own logic on the full tool call, so it can inspect a bash -c string or a file path the rule syntax can't express. Use rules for the common shape, a hook for the logic, and the sandbox for what must be impossible.
Answer out loud, then flip the card.
Mark each one honestly. Cards you miss stay flagged for review, in this browser.
A customer wants every Python file Claude edits to be auto-formatted. CLAUDE.md line or hook?
A PostToolUse hook with the matcher Edit|Write that runs the formatter on the edited file. It fires every time. A CLAUDE.md line asking Claude to run the formatter is guidance it may skip. Use CLAUDE.md for conventions and a hook for anything that has to happen.
Their policy hook prints an error and exits 1 when it sees a dangerous command, but the command still runs. Why?
Exit 1 is a non-blocking error: the action goes ahead and Claude never sees the message. Only exit 2 blocks, with stderr as the reason, or exit 0 with JSON permissionDecision: "deny". Also set "onFailure": "block" so a crash or a timeout blocks instead of quietly letting everything through.
To stop destructive commands, should they use a deny rule or a PreToolUse hook?
Both, plus the sandbox. A deny rule like Bash(rm -rf *) is simple, reviewable, and can live in managed settings, but it matches text, so bash -c slips past. A PreToolUse hook can parse the full command with real logic. And a hook's allow can never override a deny rule, so the two don't fight. For the files that must be untouchable, the OS sandbox is the only boundary that holds whatever the command does.
The security team asks: can a developer turn off our organization's hooks?
Not if they're in managed settings. Hooks merge across levels, and disableAllHooks set anywhere outside managed settings can't disable managed hooks. If they also want to stop developers adding their own, allowManagedHooksOnly blocks user, project, local, and plugin hooks.
A repo you clone has hooks in its .claude/settings.json. When do they run?
In an interactive session, not until you accept the workspace trust dialog for that folder. In claude -p or an SDK run, right away, because those never show the dialog and treat the folder as trusted. Before scripting -p over code you didn't write, read its .claude/ settings, start with --bare, or pass --settings '{"disableAllHooks": true}'.
Their Stop hook runs the tests and keeps Claude working until they pass, and it loops. What's wrong?
The hook doesn't check whether it already sent Claude back. The input has stop_hook_active; exit 0 when it's true. Claude Code also overrides a Stop hook after 8 blocks in a row with no tool call in between, and CLAUDE_CODE_STOP_HOOK_BLOCK_CAP raises that cap if a hook really needs more rounds.
How would you give Claude today's branch and open incidents at the start of every session?
A SessionStart hook that prints them. Plain stdout from SessionStart becomes context Claude reads, or return additionalContext in JSON. Keep it fast, since it runs on every session, and it runs again on resume, so the information stays fresh. Anything that never changes belongs in CLAUDE.md instead.
A hook with the matcher "mcp__github" never fires. Why?
That matcher contains only exact-match characters, so it's compared with whole tool names, and no tool is called just mcp__github. Use mcp__github__.* for every tool on the server. For a server bundled in a plugin, the name includes the plugin: mcp__plugin_<plugin>_<server>__.*.
Wrong answers that sound right.
Before you tap, say why each one is wrong.
Three moves in a fresh project.
Facts checked against code.claude.com/docs/en/hooks, hooks-guide, permissions, and commands on 2026-10-25. Claude Code ships fast: confirm anything that matters against the official docs.