Claude Code Hooks: Every Event, the Config Format and Five Working Examples
A line in CLAUDE.md that says "always run the formatter after editing" works most of the time. Most of the time is the problem. The model reads the instruction, weighs it against everything else in the context, and sometimes decides this edit is too small to bother.
Hooks fix that by taking the decision away from the model. This post covers what Claude Code hooks are, every event the docs list, how to configure them, how exit codes and JSON output control Claude, and five examples you can paste into a settings file. I checked the details against the official hooks reference and hooks guide on October 23, 2026. Hooks change often, so treat the docs as the source of truth if anything here has drifted.
What Are Claude Code Hooks?
Claude Code hooks are user-defined handlers that Claude Code runs automatically at specific points in its lifecycle, such as before a tool call, after a file edit, or when Claude finishes responding. Most hooks are shell commands. Because the harness runs them, not the model, they fire every time, which makes them deterministic.
That last part is the whole point. A skill or a CLAUDE.md rule is a suggestion the model may follow. A hook is code that runs whether the model remembers it or not. If you want a rule enforced, you write a hook. If you want a rule suggested, you write a skill.
If you are still sorting out how hooks sit next to skills, plugins and MCP servers, my plugin and skills marketplace guide has the short version, and this post is the long one.
Claude Code Hook Events and When They Fire
The hooks reference lists 33 events. Each one is a key you put inside the hooks object in a settings file. These are the events as the docs list them, in the order they appear there.
| Event | When it fires |
|---|---|
SessionStart | A session begins or resumes |
Setup | Started with --init-only, or with --init or --maintenance in -p mode |
UserPromptSubmit | A prompt is submitted, before Claude processes it |
UserPromptExpansion | A command you typed expands into a prompt, before it reaches Claude |
PreToolUse | Before a tool call runs. 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 full batch of parallel tool calls resolves, before the next model call |
Notification | Claude Code sends a notification |
MessageDisplay | While assistant message text is displayed |
SubagentStart | A subagent is spawned |
SubagentStop | A subagent finishes |
TaskCreated | A task is being created with TaskCreate |
TaskCompleted | A task is being marked completed |
Stop | Claude finishes responding |
StopFailure | The turn ends because of an API error |
TeammateIdle | An agent team teammate is about to go idle |
InstructionsLoaded | A CLAUDE.md or .claude/rules/*.md file is loaded |
ConfigChange | A configuration file changes during a session |
CwdChanged | The working directory changes |
DirectoryAdded | A working directory is added mid-session with /add-dir |
FileChanged | A watched file changes on disk |
WorktreeCreate | A worktree is being created |
WorktreeRemove | A worktree created by a WorktreeCreate hook is removed |
PreCompact | Before context compaction |
PostCompact | After context compaction completes |
PreModelSwitch | Before a requested model switch is applied. Can block |
PostModelSwitch | After the session's model changes |
Elicitation | An MCP server requests user input during a tool call |
ElicitationResult | After a user responds to an MCP elicitation |
SessionEnd | A session terminates |
Most people need PreToolUse, PostToolUse, Stop, SessionStart and Notification. The rest cover narrower jobs like auditing config changes or reacting to compaction.
A few details from the docs that save you debugging time. PreToolUse and PostToolUse fire on every tool call in the agentic loop. Stop fires whenever Claude finishes responding, not only when the task is truly done, and it does not fire on user interrupts. And a PostToolUse hook cannot undo anything, because the tool has already run.
Where Claude Code Hooks Are Configured
Hooks live in JSON. You can put them in several places, and the entries merge across levels instead of replacing each other.
| Location | Scope | Shareable |
|---|---|---|
~/.claude/settings.json | All your projects | No |
.claude/settings.json | Single project | Yes, committed to the repo |
.claude/settings.local.json | Single project | No, gitignored |
| Managed policy settings | Organization-wide | Yes, admin-controlled |
Plugin hooks/hooks.json | While the plugin is enabled | Yes |
| Skill frontmatter | The rest of the session once the skill is invoked | Yes |
| Subagent frontmatter | While that subagent runs | Yes |
I put personal conveniences like notifications in ~/.claude/settings.json, and team rules like a formatter in the committed .claude/settings.json.
You can browse what is active by typing /hooks at the Claude Code prompt. It opens a read-only browser of your configured hooks. If a new hook does not appear after a few seconds, restart the session.
The JSON shape
Three levels: an event, a matcher group, and one or more handlers.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-bash.sh"
}
]
}
]
}
}If your settings file already has a hooks key, add the new event as a sibling of the existing ones. Each event name is a key inside the single hooks object. Overwriting the whole object is the most common way to delete a hook you forgot you had.
Hook types
The type field decides what the handler does.
commandruns a shell command and passes the event JSON on stdin.httpPOSTs the event JSON to a URL.mcp_toolcalls a tool on a configured MCP server.promptsends your prompt and the event data to a Claude model for a single-turn yes or no decision.agentspawns a subagent that can use tools to verify a condition. The docs mark this one experimental.
Shared fields include if, timeout, statusMessage and once. Command hooks add args (exec form, no shell), async, shell and onFailure. Timeouts default to 600 seconds for command, http and mcp_tool, 30 for prompt and 60 for agent, so set timeout on anything slow.
Matchers: Choosing Which Calls a Hook Sees
The matcher field filters when a group fires. How it is evaluated depends on what it contains.
| Matcher value | Evaluated as |
|---|---|
"*", "", or omitted | Match everything |
Only letters, digits, _, -, spaces, commas and pipes | Exact string, or a list separated by pipes or commas |
| Any other character | JavaScript regular expression, unanchored |
What the matcher is compared against depends on the event. For PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest and PermissionDenied, it is the tool name, like Bash, Edit or mcp__memory__.*. MCP tools are named mcp__<server>__<tool>, and matching every tool from one server needs the .*.
For SessionStart it is how the session started: startup, resume, clear, compact or fork. For Notification it is the notification type, such as permission_prompt or idle_prompt. UserPromptSubmit, Stop and several others take no matcher and always fire.
On tool events there is also an if field that uses permission-rule syntax, like "Bash(git *)" or "Edit(*.ts)". It holds one rule and no boolean operators. The docs call it best-effort and say to use the permission system when you need a hard allow or deny.
Exit Codes: How a Hook Blocks Claude
A command hook talks to Claude Code through its exit code, stdout and stderr.
- Exit 0 means success. If stdout is a single JSON object, Claude Code reads its fields. Plain stdout is added to Claude's context only for
UserPromptSubmit,UserPromptExpansion,SessionStartandPostModelSwitch. Claude never sees stderr on exit 0. - Exit 2 is a blocking error. On events that can block, the action stops and stderr becomes the reason. Exit 2 wins even if JSON on stdout says "allow".
- Any other code is a non-blocking error. The action proceeds, and the first line of stderr shows in the notice.
Which events can actually be blocked matters. By the docs, exit 2 blocks on PreToolUse, UserPromptSubmit, UserPromptExpansion, Stop, SubagentStop, TeammateIdle, TaskCreated, TaskCompleted, ConfigChange (except policy settings), PostToolBatch, PreCompact, PreModelSwitch, Elicitation, ElicitationResult, WorktreeCreate and WorktreeRemove.
It cannot block PostToolUse, PostToolUseFailure, PermissionRequest (use a decision instead), Notification, SessionStart, SubagentStart, SessionEnd, PostCompact and the other observational events. On those, exit 2 shows stderr to you and execution continues.
One gotcha from the guide: exit 0 from a PreToolUse hook does not approve the call. The tool call still goes through the normal permission flow.
JSON output for finer control
Exit codes are blunt. If you need more, exit 0 and print one JSON object to stdout. Print only the JSON. Anything else on stdout, like output from a shell profile, breaks parsing. The docs say to pick one approach per hook: exit 2 with stderr, or exit 0 with JSON.
Universal fields are continue (set false to stop Claude entirely), stopReason, systemMessage, suppressOutput and terminalSequence. Event-specific decisions go in different places. PreToolUse uses hookSpecificOutput with permissionDecision set to allow, deny, ask or defer, plus permissionDecisionReason. PostToolUse and Stop use a top-level decision: "block" with a reason.
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Database writes are not allowed"
}
}A field at the wrong level is a common reason a hook silently does nothing: permissionDecision belongs inside hookSpecificOutput, not at the top.
What a hook receives and which variables it can use
Command hooks get JSON on stdin. Common fields include session_id, transcript_path, cwd, permission_mode and hook_event_name. Tool events add tool_name, tool_input and tool_use_id, which is how a script finds the file path or command Claude is about to use. SessionStart hooks also get a source, and UserPromptSubmit hooks get the prompt text.
Environment variables the docs list for hooks:
CLAUDE_PROJECT_DIR: the project root where the session startedCLAUDE_PLUGIN_ROOTandCLAUDE_PLUGIN_DATA: a plugin's install and data directoriesCLAUDE_PLUGIN_OPTION_<KEY>: a plugin's user-config valuesCLAUDE_ENV_FILE: a file aSessionStartorCwdChangedhook can write environment variables into, which Claude Code runs as a preamble before each Bash command
Use ${CLAUDE_PROJECT_DIR} in script paths so they work from any directory.
Five Claude Code Hook Examples
These are examples, adapted from the official hooks guide. Test each in a throwaway project before you commit it to a repo your team uses.
Example 1: Get a desktop notification when Claude needs input
The Notification event fires when Claude is waiting for input or permission. This is the guide's macOS version. Put it in ~/.claude/settings.json.
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
}
]
}
]
}
}The empty matcher fires on every notification type. To narrow it, set the matcher to a type like permission_prompt or idle_prompt. The guide gives notify-send for Linux and a PowerShell variant for Windows. On macOS, if nothing appears, osascript routes through Script Editor, and the docs say to enable notifications for Script Editor in System Settings.
Example 2: Format every file Claude edits
This PostToolUse hook runs Prettier on the file Claude just wrote. It goes in .claude/settings.json in the project root.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}jq pulls the path out of the stdin JSON and Prettier does the rest. It needs jq installed. One limit the guide calls out: Claude can also change files through shell commands, which an Edit|Write matcher never sees. If your hook must see every change, add a Stop hook that checks the working tree once per turn.
Example 3: Block dangerous Bash commands
This PreToolUse hook exits 2 when a command matches a pattern you never want Claude to run. The guide's own script blocks edits to protected files with the same mechanism. I adapted it to read tool_input.command for the Bash tool.
Save this as .claude/hooks/block-dangerous.sh:
#!/bin/bash
# block-dangerous.sh (example adapted from the hooks guide's protect-files.sh)
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
BLOCKED_PATTERNS=("rm -rf" "git push --force" "DROP TABLE")
for pattern in "${BLOCKED_PATTERNS[@]}"; do
if [[ "$COMMAND" == *"$pattern"* ]]; then
echo "Blocked: command matches '$pattern'. Ask the user before running it." >&2
exit 2
fi
done
exit 0Make it executable with chmod +x .claude/hooks/block-dangerous.sh, then register it:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-dangerous.sh"
}
]
}
]
}
}Claude Code blocks the call before it runs and passes your stderr message to Claude, so it can pick another approach. Substring matching is crude. A command like rm -rf with two spaces slips past it. Treat this as a tripwire for honest mistakes, not as a security boundary, and read the next section before you rely on it.
The docs add one useful fact: a PreToolUse hook that returns a deny blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions. That is why hooks pair well with the permission setup in my least-privilege guide.
Example 4: Inject context at SessionStart
Compaction summarizes a long conversation and can lose details. A SessionStart hook with the compact matcher puts critical context back. This is the guide's version, in .claude/settings.json.
{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo 'Reminder: use Bun, not npm. Run bun test before committing. Current sprint: auth refactor.'"
}
]
}
]
}
}Plain text a SessionStart command writes to stdout goes into Claude's context. Swap the echo for something dynamic, like git log --oneline -5. The docs point out that if you want the same context on every session start, CLAUDE.md is the better home. Use the hook when the context is dynamic or only needed at a specific moment. Change the matcher to startup to inject on a fresh session instead.
Example 5: Run tests before Claude stops
A Stop hook can refuse to let Claude finish. This one runs the test suite and, if it fails, exits 2 with the failure output so Claude keeps working. It is an example I built from two pieces of the docs: the exit-2 behavior for Stop, and the guide's stop_hook_active check.
Save this as .claude/hooks/tests-on-stop.sh:
#!/bin/bash
# tests-on-stop.sh (example)
INPUT=$(cat)
# Stop hooks can re-trigger themselves. Bail out if we already blocked once.
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
cd "$CLAUDE_PROJECT_DIR" || exit 0
if ! OUTPUT=$(bun test 2>&1); then
echo "Tests are failing. Fix them before finishing:" >&2
echo "$OUTPUT" | tail -n 30 >&2
exit 2
fi
exit 0Swap bun test for your own test command. Then register it:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tests-on-stop.sh",
"timeout": 300
}
]
}
]
}
}The stop_hook_active guard matters. The docs say Claude Code overrides a Stop hook after it blocks eight times in a row with no tool call in between, and the cap can be raised with CLAUDE_CODE_STOP_HOOK_BLOCK_CAP. Because Stop fires after every response, a slow test suite will run more often than you expect. For a lighter touch, the guide shows a prompt hook on Stop that asks a model whether all requested tasks are done.
Security: Hooks Run With Your Permissions
A command hook is a shell command that runs as your user, with your environment and access to your files. There is no sandbox around it unless you build one. That has a few consequences.
Read every hook you did not write. A hook in a shared .claude/settings.json or a plugin's hooks/hooks.json runs on your machine once you trust the project or enable the plugin. Cloning a repo and accepting its settings means running its commands. The docs say project hooks in settings files and frontmatter are subject to workspace trust, and frontmatter hooks in project subagents run only after you accept the trust dialog. Treat that dialog the way you treat running an install script.
Quote your variables. The hook input contains file paths and commands the model chose. A path with a space or shell metacharacter in it will break an unquoted script or, worse, run something you did not intend. The docs recommend exec form with args for paths, and they note shell form needs careful quoting.
Do not mistake if or substring matching for enforcement. The if field is best-effort by the docs' own words. For hard rules, use permission rules and managed settings, and use hooks as one more layer.
For teams, managed settings add controls. allowManagedHooksOnly blocks user, project, local and plugin hooks, and allowedHttpHookUrls limits which URLs HTTP hooks can reach. I would also read the plugin security page in the docs before enabling any third-party marketplace, since plugin hooks run with the same authority as the ones you write yourself.
Hooks vs Skills vs Permissions vs CLAUDE.md
These four get confused because they all "tell Claude what to do". They differ in who enforces the rule.
| Hooks | Skills | Permissions | CLAUDE.md | |
|---|---|---|---|---|
| What it is | Handlers that run at lifecycle events | Folders with a SKILL.md of instructions | Allow, ask and deny rules for tool calls | A markdown file of standing instructions |
| Who enforces it | The harness, every time | The model, when the description matches | The harness | The model, as context |
| Can it block an action | Yes, on events that can block | No | Yes | No |
| Runs code | Yes | Only through tools Claude already has | No | No |
| Best for | Formatting, guards, notifications, checks | Teaching a repeatable workflow | Limiting what Claude may touch | Project conventions and commands |
The practical split: use CLAUDE.md for conventions, skills for workflows, permissions for the allowed surface, and hooks for anything that must happen or must not happen regardless of the model. If I find myself writing "always" or "never" in a CLAUDE.md rule and it keeps getting ignored, that rule probably wants to be a hook. I wrote about building skills in the skill developer guide.
A Notification hook is a cousin of the status line, which shows state continuously instead of alerting you once. And SubagentStart and SubagentStop let you hook into delegated work, which is covered in the subagents guide.
If a hook does nothing, run /hooks to confirm it is listed, validate the JSON (no trailing commas), check the script is executable, and make sure nothing else prints to stdout before your JSON. Pipe sample JSON into a script to test it outside a session, for example echo '{"tool_input":{"command":"rm -rf /tmp/x"}}' | .claude/hooks/block-dangerous.sh, then check echo $?.
Frequently Asked
What are Claude Code hooks? They are user-defined handlers that Claude Code runs at specific lifecycle events, such as before a tool call, after an edit, or when Claude finishes responding. Most are shell commands, and because the harness runs them, they fire every time instead of depending on the model.
Where do I put Claude Code hooks? In a settings file: ~/.claude/settings.json for all projects, .claude/settings.json for one project (committed), or .claude/settings.local.json for one project (gitignored). Plugins can ship them in hooks/hooks.json, and skills and subagents can define them in frontmatter.
How do I block a tool call with a hook? Use a PreToolUse hook. Exit with code 2 and write the reason to stderr, or exit 0 and print JSON with permissionDecision set to deny inside hookSpecificOutput. Either way, Claude Code stops the call before it runs.
Can a hook run when Claude finishes a task? Yes, with the Stop event. It fires whenever Claude finishes responding, not only at task completion, so check stop_hook_active in the input to avoid loops.
Do hooks work with --dangerously-skip-permissions? A PreToolUse hook that denies a call still blocks it in bypassPermissions mode or with that flag, according to the hooks guide. That makes hooks useful for policy people cannot switch off by changing permission mode.
Sources: Claude Code hooks reference and Automate actions with hooks (hooks guide), checked October 23, 2026.