Hooks in Full
Enforce your rules with machinery rather than by asking. All 33 events, the JSON contract a hook receives and answers with, what each exit code means, and examples you can paste in as they are.
Chapter 7 | Hooks in Full Written for: people already comfortable with Claude Code who want something to happen reliably rather than hoping the model chooses to do it
Why hooks exist when you can already write rules in CLAUDE.md
The difference is this: a rule in CLAUDE.md is a request; a hook is enforcement.
CLAUDE.md can say “never edit .env”, and most of the time the model obeys. But “most of the time” is not “always” — especially as context grows long, or when a situation appears that seems to justify an exception. A hook is a shell command Claude Code runs itself at points in the lifecycle. It never passes through the model’s judgement at all. If you write it to block, it blocks.
The official documentation puts it plainly: hooks provide “deterministic control: certain actions always happen rather than relying on the LLM to choose to run them”.
The shape of a hook definition
Hooks live in a hooks block in a settings file. The shape is always this:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "the command to run" }
]
}
]
}
}
Note the two nested levels of hooks. The outer key is the event; the inner list holds the commands that run when the matcher fits.
If your settings file already has a
hookskey, add the new event name as a sibling of the existing ones. Do not overwrite the whole block — every event name lives inside the samehooksobject.
Where to put them, and what each scope covers
| Location | Scope | Shareable with a team? |
|---|---|---|
~/.claude/settings.json | All your projects | No — your machine only |
.claude/settings.json | One project | Yes — commit it to the repo |
.claude/settings.local.json | One project | No — gitignored when Claude Code writes to it |
| Managed policy settings | Whole organisation | Yes — the administrator controls it |
Plugin hooks/hooks.json | Wherever that plugin is enabled | Yes — it travels with the plugin |
| Skill frontmatter | The rest of the session, once that skill is invoked | Yes — it lives in the skill file |
| Subagent frontmatter | While that subagent is working | Yes — it lives in the subagent file |
Type /hooks in Claude Code to see every hook you have, grouped by event. That menu is read-only. To add, change or remove one, edit the JSON yourself or ask Claude to do it.
To switch every hook off, set "disableAllHooks": true in a settings file.
All 33 events
This is the part the previous edition of this guide left out entirely. We knew hooks existed; we never knew what there was to hang one on.
Starting and ending a session
| Event | Fires when |
|---|---|
SessionStart | A new session starts, or an old one resumes |
Setup | Starting with --init-only, or using --init / --maintenance in -p mode |
SessionEnd | The session ends |
Taking instructions from you
| Event | Fires when |
|---|---|
UserPromptSubmit | You submit a prompt, before Claude processes it |
UserPromptExpansion | A command you typed is expanded into a prompt before it reaches the model. Can block the expansion |
Tool calls — the group you will use most
| Event | Fires when |
|---|---|
PreToolUse | Before a tool call. Can block |
PermissionRequest | A tool call needs a permission decision |
PermissionDenied | Auto mode denies a tool call |
PostToolUse | After a tool call succeeds |
PostToolUseFailure | After a tool call fails |
PostToolBatch | After a batch of parallel tool calls all finish, before the next model call |
Responding and displaying
| Event | Fires when |
|---|---|
MessageDisplay | While an assistant message is being displayed |
Notification | Claude Code sends a notification |
Stop | Claude finishes responding |
StopFailure | The turn ends because of an API error |
Subagents and tasks
| Event | Fires when |
|---|---|
SubagentStart | A subagent is created |
SubagentStop | A subagent finishes |
TaskCreated | A task is being created via TaskCreate |
TaskCompleted | A task is being marked complete |
TeammateIdle | A teammate in an agent team is about to go idle |
Context and files
| Event | Fires when |
|---|---|
InstructionsLoaded | A CLAUDE.md or .claude/rules/*.md file is loaded into context |
ConfigChange | A settings file changes mid-session |
CwdChanged | The working directory changes |
DirectoryAdded | A directory is added mid-session via /add-dir or the SDK’s register_repo_root |
FileChanged | A watched file changes on disk |
WorktreeCreate | A worktree is being created |
WorktreeRemove | A worktree is being removed |
PreCompact | Before context is compacted |
PostCompact | After compaction completes |
Model switching and MCP
| Event | Fires when |
|---|---|
PreModelSwitch | Before Claude Code switches model. Can block the switch |
PostModelSwitch | After the session’s model has changed |
Elicitation | An MCP server asks the user for input during a tool call |
ElicitationResult | After the user answers an MCP elicitation |
The JSON a hook receives
A command hook is handed JSON on stdin. Here is a PreToolUse hook intercepting a Bash call:
{
"session_id": "abc123",
"prompt_id": "550e8400-e29b-41d4-a716-446655440000",
"transcript_path": "/home/user/.claude/projects/.../transcript.jsonl",
"cwd": "/home/user/my-project",
"permission_mode": "default",
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "npm test",
"description": "Run test suite",
"timeout": 120000,
"run_in_background": false
},
"tool_use_id": "toolu_01ABC123..."
}
tool_name and tool_input are the fields you will reach for constantly. permission_mode earns its keep when you want a hook to be stricter in some modes than others.
Exit codes — where people go wrong most often
| Exit code | Meaning |
|---|---|
0 | Success. Claude Code reads JSON fields from stdout. For most events stdout goes to the debug log — except UserPromptSubmit, UserPromptExpansion, SessionStart and PostModelSwitch, where plain text on stdout is added to Claude’s context |
2 | Block. For blockable events, exit 2 blocks whether or not you print JSON. It overrides even a permissionDecision of "allow". The blocking message comes from the reason in your JSON, or from stderr |
| anything else | A non-blocking error. Execution continues. If you printed schema-valid JSON, it still governs the outcome for events using the standard decision model |
The short version: 2 blocks, everything else does not. And anything you want the agent to read so it can correct itself belongs on stderr.
Examples you can use straight away
1. Get told when Claude is waiting on you
So you are not staring at a terminal. In ~/.claude/settings.json:
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
}
]
}
]
}
}
On Linux, use notify-send 'Claude Code' 'Claude Code needs your attention' instead.
If nothing appears on macOS,
osascriptposts notifications through the Script Editor app. If that app has not been granted notification permission the command fails silently, and macOS does not prompt you either. Runosascript -e 'display notification "test"'in a terminal once — nothing will show — then open System Settings → Notifications, find Script Editor, turn on Allow Notifications, and run it again.
2. Format automatically after every edit
In the project’s .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
The documented examples use jq to parse the JSON. Install it with brew install jq on macOS, or apt-get install jq on Debian and Ubuntu.
If you want a particular file reformatted every time it changes — including when a Bash command is what rewrote it — use a FileChanged hook instead.
3. Block edits to files that must not be touched
A separate script reads more clearly. Save this at .claude/hooks/protect-files.sh:
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Normalise Windows backslash separators so the patterns below match
FILE_PATH="${FILE_PATH//\\//}"
PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")
for pattern in "${PROTECTED_PATTERNS[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
exit 2
fi
done
exit 0
On macOS and Linux the script must be executable, or Claude Code cannot run it:
chmod +x .claude/hooks/protect-files.sh
Then register it in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
}
]
}
]
}
}
$CLAUDE_PROJECT_DIR is set for you by Claude Code, so paths need no hardcoding.
Test it by asking Claude to add a comment to your .env file. The edit is blocked before it runs, and the Blocked: message is passed back to Claude as feedback.
4. Put context back after compaction
When the context window fills, Claude Code compacts the conversation to free space, which can lose important detail. A SessionStart hook with a compact matcher puts it back after every compaction — plain text your command writes to stdout is added to Claude’s context.
5. Approve certain permissions automatically
{
"hooks": {
"PermissionRequest": [
{
"matcher": "ExitPlanMode",
"hooks": [
{
"type": "command",
"command": "echo '{\"hookSpecificOutput\": {\"hookEventName\": \"PermissionRequest\", \"decision\": {\"behavior\": \"allow\"}}}'"
}
]
}
]
}
}
Hooks that are not shell commands
The official docs support three further kinds, for cases a fixed rule cannot cover:
- Prompt-based hooks use a model to judge a condition that needs judgement
- Agent-based hooks hand the judgement to an agent
- HTTP hooks call an endpoint instead of running a command locally
Things worth knowing before you start
A Stop hook that runs your full gate will trap the agent in a loop and burn a lot of tokens. Run something fast in the hook — lint and unit tests for the files that changed — and leave the full suite to CI.
When a hook does not fire, check three things: whether the script is executable, whether /hooks can see it at all, and whether the matcher actually matches the real tool name.
Never put a secret in a hook command. Commands are stored in settings files that usually get committed. Reference an environment variable instead.
Glossary for this chapter
| Term | Meaning |
|---|---|
| Hook | A shell command Claude Code runs itself at a point in the lifecycle |
| Event | The moment a hook attaches to, such as PreToolUse |
| Matcher | The condition deciding which tools or cases this hook applies to |
| Exit 2 | The code meaning “block”, usable on blockable events |
$CLAUDE_PROJECT_DIR | Variable pointing at the project root, for referencing hook scripts |
disableAllHooks | The setting that turns off every hook in that scope |