Claude Code hooks let you run your own scripts at fixed points in a session, such as before a tool runs or when Claude finishes. They turn “please always do X” into a rule that runs every time.
This guide covers every hook event, the settings.json format, matchers, exit codes, JSON output and six copy-paste recipes. It also covers what changed in v2.1.317.
- Hooks live in a
hooksblock in~/.claude/settings.json,.claude/settings.jsonor.claude/settings.local.json. - The official reference lists 33 hook events. The ones most people use are
PreToolUse,PostToolUse,Notification,StopandSessionStart. - Exit 2 blocks the action on events that can block. Exit 1 does not block, which surprises many people.
- For finer control, exit 0 and print one JSON object, such as
permissionDecision: "deny". - v2.1.317 added
onFailure: "block"for Agent SDK hook callbacks and a prompt before a tool call sends your account email over the network.
What Claude Code Hooks Are
A hook is a handler that Claude Code runs when a certain event fires. Most hooks are shell commands. Claude Code sends the event details to your script as JSON on stdin.
Your script can then do three kinds of things:
- Side effects: format a file, write a log line or send a desktop notification.
- Decisions: block a tool call, deny a prompt or keep Claude working.
- Context: add text that Claude reads on its next step, such as the current git branch.
The big win is reliability. A line in CLAUDE.md is a request that Claude usually follows. A hook is code that runs every time, whatever the model decides.
What’s New in v2.1.317
Claude Code v2.1.317 shipped in early November 2026. The official changelog lists two changes that matter for hook users and security-minded teams.
Agent SDK hooks: onFailure “block”
Before this release, an Agent SDK callback hook that crashed let the action go ahead. Your app got a warning with the tag hook_callback_failed, but a broken guard still let the tool call through.
Now you can set onFailure: "block" in TypeScript or on_failure="block" in Python on the matcher. A callback that throws, returns invalid output or returns nothing then blocks the action. The docs say this needs TypeScript Agent SDK v0.3.317+ or Python Agent SDK v0.2.177+.
// TypeScript
const options: Options = {
hooks: {
PreToolUse: [{ matcher: "Bash", hooks: [checkPolicy], onFailure: "block" }]
}
};
# Python
options = ClaudeAgentOptions(
hooks={"PreToolUse": [HookMatcher(matcher="Bash", hooks=[check_policy], on_failure="block")]}
)
Note: settings.json command and HTTP hooks already had "onFailure": "block" since v2.1.295. Release 2.1.317 brings the same safety net to SDK callbacks.
Confirmation before your account email leaves the machine
The same release added a check that asks you before a web or shell tool call sends your account email address to the network. It is a guard against a tool call, or a prompt hidden in a web page, quietly sending your email to an outside server.
Other recent hook changes
- v2.1.319:
PostToolUsehooks now run, with up to 10 seconds, on tool calls that finish after a new message interrupts the turn. - v2.1.316: fixed PowerShell hooks not blocking on exit code 2 in some cases.
- v2.1.314: each
PreCompacthook’s output is cut at 10,000 characters, and Bash matchers now also run on Monitor tool commands. - v2.1.311: fixed
SubagentStophooks running about twice a minute instead of once.
Where Hooks Go in settings.json
Hooks are defined in JSON settings files. The place you put them decides who they apply to.
| Location | Scope | Shared with team? |
|---|---|---|
~/.claude/settings.json |
All your projects | No |
.claude/settings.json |
One project | Yes, commit it to the repo |
.claude/settings.local.json |
One project | No, gitignored |
| Managed policy settings | Whole organization | Yes, set by admins |
Plugin hooks/hooks.json |
When the plugin is on | Yes |
| Skill or subagent frontmatter | While the skill or subagent is active | Yes |
Every hook config has three levels. You pick an event, add a matcher group to filter it, then list one or more handlers to run.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "/path/to/lint-check.sh" }
]
}
]
}
}
Hooks from different files merge. They do not replace each other. If two files define the exact same handler, it runs once.
New to Claude Code? Start with the install guide and the commands cheat sheet.
Set Up Your First Hook (Step by Step)
This walkthrough follows the official guide. It adds a desktop notification when Claude is waiting for you.
- Open your user settings. Edit
~/.claude/settings.json. Create the file if it does not exist. - Add a hooks block. Paste the Notification hook from Recipe 3 below. If you already have a
hookskey, addNotificationnext to the other events instead of replacing the object. - Check the JSON. No trailing commas and no comments. Invalid JSON is the top reason hooks do not show up.
- Verify it loaded. Type
/hooksin Claude Code. This opens a read-only browser that lists every hook and where it came from. - Test it. Ask Claude to do something that needs your permission, then switch to another window. You should get a notification.
- Debug if needed. Start with
claude --debug-file /tmp/claude.logand watch that file in another terminal.
You can also ask Claude itself to write a hook. Describe what you want in plain words, then check the result with /hooks.
Every Hook Event Explained
The official reference lists 33 events. They fall into three rhythms: once per session, once per turn, and once per tool call.
| Event | When it fires | Can block? |
|---|---|---|
SessionStart |
A session starts or resumes | No (adds context) |
Setup |
With --init-only, or --init/--maintenance in -p mode |
No |
UserPromptSubmit |
You send a prompt, before Claude sees it | Yes |
UserPromptExpansion |
A typed command expands into a prompt | Yes |
PreToolUse |
Before a tool call runs | Yes |
PermissionRequest |
A tool call needs a permission decision | Via JSON only |
PermissionDenied |
Auto mode denies a tool call | No (can allow a retry) |
PostToolUse |
After a tool call succeeds | No (feedback to Claude) |
PostToolUseFailure |
After a tool call fails | No |
PostToolBatch |
After a batch of parallel tool calls | Yes |
Notification |
Claude Code sends a notification | No |
MessageDisplay |
Assistant text is shown on screen | No (display only) |
SubagentStart / SubagentStop |
A subagent starts or finishes | Stop only |
TaskCreated / TaskCompleted |
A task is created or marked done | Yes |
Stop |
Claude finishes responding | Yes (keeps Claude working) |
StopFailure |
The turn ends on an API error | No |
TeammateIdle |
An agent team teammate is about to go idle | Yes |
InstructionsLoaded |
A CLAUDE.md or rules file loads | No |
ConfigChange |
A settings file changes mid-session | Yes (not policy settings) |
CwdChanged / DirectoryAdded |
Working directory changes or one is added | No |
FileChanged |
A watched file changes on disk | No |
WorktreeCreate / WorktreeRemove |
A worktree is created or removed | Yes (non-zero exit fails it) |
PreCompact / PostCompact |
Before and after context compaction | Pre only |
PreModelSwitch / PostModelSwitch |
Before and after a model change | Pre only |
Elicitation / ElicitationResult |
An MCP server asks for user input | Yes |
SessionEnd |
The session ends | No |
Hooks from settings files also run inside subagents. The input then carries agent_id and agent_type, so your script can tell which agent called the tool.
Matchers and the if Field
The matcher field filters when a hook fires. For tool events it matches the tool name. For other events it matches something else, such as startup or compact for SessionStart.
"*",""or no matcher: matches everything.- Only letters, digits,
_,-, spaces, commas and|: exact names.Edit|Writematches either tool. - Any other character: treated as a JavaScript regex.
mcp__memory__.*matches every tool from the memory MCP server.
Matchers are case-sensitive. A regex is not anchored, so Edit.* also matches NotebookEdit. Use ^Edit$ for an exact match.
One common trap: mcp__memory on its own matches nothing. It has only exact-match characters, so Claude Code looks for a tool with that exact name. Add __.* at the end.
Narrow a hook with “if”
The if field uses permission rule syntax on tool events. "Bash(git *)" runs the hook only for git commands, and "Edit(*.ts)" only for TypeScript files.
The docs call if best-effort. When Claude Code cannot work out which commands a Bash line runs, it runs your hook anyway. For a hard allow or deny, use permission rules as well.
Exit Codes and Blocking
For command hooks, the exit code is the simplest way to talk back to Claude Code.
| Exit code | Meaning | What happens |
|---|---|---|
| 0 | Success | Any JSON you printed is applied. The action goes ahead unless the JSON blocks it. |
| 2 | Blocking error | On events that can block, the action stops. Your stderr becomes the reason Claude sees. |
| Any other | Non-blocking error | The action goes ahead and you see a “hook error” notice, unless onFailure is "block". |
Warning: exit 1 does not block. It is the normal Unix failure code, but Claude Code treats it as a non-blocking error. Policy hooks must use exit 2.
What exit 2 does depends on the event. On PreToolUse it blocks the tool call. On Stop it stops Claude from finishing and keeps the conversation going. On PostToolUse the tool already ran, so Claude only sees your stderr as feedback.
Block when a hook breaks
By default, a hook that crashes, times out or has a wrong path lets the action through. Add "onFailure": "block" to a command or HTTP hook so a failure acts like exit 2.
It has no effect on Stop, SubagentStop, TaskCompleted, TeammateIdle or async hooks.
JSON Output for Fine Control
Exit codes only say “block” or “carry on”. For more control, exit 0 and print one JSON object on stdout with nothing else around it.
Universal fields
continue: set tofalseto stop Claude completely after the hook.stopReason: the message shown whencontinueis false.systemMessage: a warning shown to the user.terminalSequence: an allowed escape sequence, such as a desktop notification or window title.
PreToolUse decisions
PreToolUse puts its decision inside hookSpecificOutput. The permissionDecision can be allow, deny, ask or defer. If several hooks disagree, deny wins, then defer, then ask, then allow.
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Database writes are not allowed"
}
}
A hook’s deny holds in every permission mode, even bypassPermissions. An allow cannot get past deny rules in your settings.
Other decision patterns
- Top-level decision:
UserPromptSubmit,PostToolUse,Stopand others use{"decision": "block", "reason": "..."}. - Add context:
hookSpecificOutput.additionalContextpasses a string into Claude’s context. Each string is capped at 10,000 characters. - Rewrite input:
updatedInputonPreToolUsereplaces a tool’s arguments before it runs.
6 Copy-Paste Hook Recipes
All recipes below use jq to read the JSON input. Install it with brew install jq on macOS or apt-get install jq on Debian and Ubuntu.
Recipe 1: Auto-format files after every edit
This runs Prettier on each file Claude writes or edits. Put it in .claude/settings.json.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
Swap Prettier for black, ruff format or gofmt -w if you work in another language.
Recipe 2: Block rm -rf
Save this script as .claude/hooks/block-rm-rf.sh and run chmod +x on it.
#!/bin/bash
# Blocks recursive force deletes before they run
CMD=$(jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -Eq 'rm[[:space:]]+-[a-zA-Z]*([rR][a-zA-Z]*f|f[a-zA-Z]*[rR])'; then
echo "Blocked: rm -rf is not allowed here. Delete specific files instead." >&2
exit 2
fi
exit 0
Then register it on PreToolUse. The if field means the script only starts for rm commands, and onFailure blocks the call if the script is missing.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(rm *)",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-rm-rf.sh",
"onFailure": "block"
}
]
}
]
}
}
For a second layer, add a deny rule too: "permissions": {"deny": ["Bash(rm -rf *)"]}.
Recipe 3: Notify when Claude finishes or needs you
A Stop hook fires when Claude finishes responding. A Notification hook fires when Claude waits for input or permission. This Linux version uses both.
{
"hooks": {
"Stop": [
{ "hooks": [ { "type": "command", "command": "notify-send 'Claude Code' 'Task finished'" } ] }
],
"Notification": [
{ "matcher": "", "hooks": [ { "type": "command", "command": "notify-send 'Claude Code' 'Claude needs your attention'" } ] }
]
}
}
- macOS: use
osascript -e 'display notification \"Task finished\" with title \"Claude Code\"'as the command. - Linux:
notify-sendneeds thelibnotify-binpackage and a desktop session. - Only idle alerts: set the Notification matcher to
idle_promptorpermission_prompt.
Recipe 4: Log every Bash command
This writes one JSON line per command to a log file in your home folder. It is handy for audits and for spotting slow or risky commands.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -c '{time: (now|todate), cwd: .cwd, cmd: .tool_input.command}' >> ~/.claude/bash-commands.log"
}
]
}
]
}
}
Because it runs on PreToolUse, it also logs commands that a later hook or rule blocks. Use PostToolUse instead if you only want commands that ran.
Recipe 5: Protect .env and other secret files
This is based on the official example. Save it as .claude/hooks/protect-files.sh and make it executable.
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
PROTECTED=(".env" "package-lock.json" ".git/")
for p in "${PROTECTED[@]}"; do
if [[ "$FILE_PATH" == *"$p"* ]]; then
echo "Blocked: $FILE_PATH matches protected pattern '$p'" >&2
exit 2
fi
done
exit 0
Register it with "matcher": "Read|Edit|Write" on PreToolUse, the same way as Recipe 2. Adding Read stops Claude from loading your secrets into context.
Tip: a hook only sees the tools it matches, so a cat .env run through Bash skips it. Add "Read(./.env)" to your deny list and copy Recipe 2 for Bash.
Recipe 6: Re-add key context after compaction
Compaction summarizes long chats and can drop details. A SessionStart hook with the compact matcher prints text that Claude reads again.
{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{ "type": "command", "command": "echo 'Reminder: use Bun, not npm. Run bun test before committing.'; git log --oneline -5" }
]
}
]
}
}
Command, HTTP, MCP, Prompt and Agent Hooks
Shell commands are the most common handler, but there are five types in total.
command: runs a shell command. Default timeout is 600 seconds.http: POSTs the event JSON to a URL and reads the JSON response.mcp_tool: calls a tool on a configured MCP server. See our best MCP servers list for ideas.prompt: asks a Claude model a yes or no question about the event. Default timeout is 30 seconds.agent: starts a subagent that can read files before it decides. It is marked experimental, with a 60 second default timeout.
Add "async": true to a command hook to run it in the background, for example a slow test suite. Its result arrives on the next turn. Hooks also work inside skills; see the Claude Skills guide.
Troubleshooting: Common Problems and Fixes
- Hook never fires: run
/hooksand check it is listed under the right event. Matchers are case-sensitive, sobashwill not matchBash. - Not listed in /hooks: your JSON is probably invalid, or the file is in the wrong place. Restart the session if the file watcher missed the edit.
- “Only hooks from managed settings run here”: your admin set
allowManagedHooksOnly, so personal hooks are off. - Hook runs but does not block: you used exit 1. Switch to
exit 2, or print a JSON deny and exit 0. - JSON has no effect: your shell profile may print text before the JSON. Also check that
permissionDecisionsits insidehookSpecificOutput. - “No such file or directory”: the script path is wrong. Use
"$CLAUDE_PROJECT_DIR"and runchmod +xon the script. - Stop hook loops: check
stop_hook_activein the input and exit 0 when it is true. Claude Code overrides a Stop hook after 8 blocks in a row. - Test a hook by hand: pipe sample JSON in, for example
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh, then checkecho $?.
Frequently Asked Questions
Where do I put Claude Code hooks?
Put them in a hooks block in ~/.claude/settings.json for all projects, or .claude/settings.json for one project. Use .claude/settings.local.json for personal project hooks you do not want to commit.
Which exit code blocks a tool call?
Exit code 2 blocks on events that can block, such as PreToolUse. Exit 1 and other codes are non-blocking errors unless you set onFailure to "block".
Can a hook block Claude even in bypass permissions mode?
Yes. A PreToolUse hook that returns permissionDecision: "deny" blocks the tool in every permission mode, including bypassPermissions.
What did Claude Code v2.1.317 change for hooks?
It added onFailure: "block" for Agent SDK hook callbacks, so a crashing callback blocks the action. It also added a prompt before a web or shell call sends your account email to the network.
How do I turn off all hooks for a while?
Set "disableAllHooks": true in a settings file. It cannot turn off hooks that your organization set in managed settings.
Do hooks run inside subagents?
Yes. Hooks from settings files, managed settings and plugins also fire when a subagent calls a tool. The input includes agent_id and agent_type.
Do hooks cost extra tokens?
Command and HTTP hooks do not call a model, though any text they add as context counts as input. Prompt and agent hooks call a Claude model, so they count toward usage; see Claude Code pricing.




