Claude Code hooks are commands that Claude Code runs on its own at fixed points in a session: before a tool call, after a file edit, when a prompt is sent, when Claude stops or needs your attention. You define them under the hooks key in settings.json. A hook can add context, format a file, or block an action by exiting with code 2 or returning a JSON decision. Unlike instructions in CLAUDE.md, a hook always runs, because the harness runs it, not the model.
This guide covers the events that matter, the config format, matchers, exit codes and JSON output, three hooks you can copy today, and the security notes to read before you add any. The facts come from the official hooks reference and hooks guide. If you only want the short definition, see the Claude Code hooks glossary entry.
Why use hooks instead of instructions
You can write “always run Prettier after editing” in CLAUDE.md, and Claude will usually do it. Usually is the problem. Instructions are advice the model weighs against everything else in its context. Hooks are code that runs every time the event fires.
That makes hooks the right tool for three kinds of rules:
- Things that must always happen, like formatting, linting or logging.
- Things that must never happen, like editing
.envor runningrm -rfon the wrong folder. - Things you want to know about, like Claude waiting for a permission answer while you are in another window.
Keep style advice and project knowledge in CLAUDE.md and put hard rules in hooks.
Hook events you will actually use
The reference lists more than thirty events, and the list grows with new releases. Most real setups use a handful:
| Event | When it fires | Can it block? |
|---|---|---|
PreToolUse | Before a tool call runs | Yes, blocks the call |
PostToolUse | After a tool call succeeds | No, the tool already ran; stderr goes to Claude |
UserPromptSubmit | When you send a prompt | Yes, the prompt never reaches Claude |
Notification | When Claude Code sends a notification, such as a permission prompt or idle wait | No |
Stop | When Claude finishes its answer | Yes, Claude keeps working |
SubagentStart / SubagentStop | When a subagent starts or finishes | Only SubagentStop |
SessionStart | On startup, resume, /clear or compaction | No, but stdout becomes context |
PreCompact | Before the context is compacted | Yes, blocks compaction |
PermissionRequest | When a permission dialog is about to show | Through JSON only, not exit 2 |
SessionEnd | When the session ends | No |
PreToolUse and PostToolUse cover most needs. Notification and Stop are the ones for “tell me when it is done or stuck”.
How hooks are configured in settings.json
Hooks live in the same settings files as permissions:
| File | Scope | Shared through git? |
|---|---|---|
~/.claude/settings.json | All your projects | No |
.claude/settings.json | One project | Yes |
.claude/settings.local.json | One project, only you | No (gitignored) |
| Managed policy settings | Whole organization | Set by admins |
Plugins can ship hooks in hooks/hooks.json, and skills and subagents can define hooks in their frontmatter.
The structure has three levels: the event, a matcher group, and a list of handlers.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write",
"timeout": 30
}
]
}
]
}
}
A handler’s type can be command (a shell command), http (a POST to a URL), mcp_tool (a tool on an MCP server you have connected), prompt (a quick yes/no check by a model) or agent (an experimental subagent check). The default timeout is 600 seconds for command, HTTP and MCP tool handlers, 30 seconds for prompt hooks and 60 for agent hooks.
Every hook gets JSON on stdin. Common fields are session_id, transcript_path, cwd, permission_mode and hook_event_name. Tool events add tool_name, tool_input and tool_use_id. Your script reads that JSON, usually with jq, and decides what to do. The environment variable CLAUDE_PROJECT_DIR points to the project root, which is handy for referencing scripts stored in the repo.
You can type /hooks inside Claude Code to browse what is loaded and which file each hook came from. The browser is read only, so you change hooks by editing the JSON.
Matchers: which tool calls a hook sees
The matcher decides which calls trigger a hook. For tool events it matches the tool name:
| Matcher | Meaning |
|---|---|
"*", "" or no matcher | Every call |
"Bash" | Exactly the Bash tool |
"Edit|Write" | Edit or Write (a list) |
"mcp__memory__.*" | Every tool from the memory MCP server |
"mcp__.*__write.*" | Any MCP tool whose name starts with write |
The rule is simple. If the matcher has only letters, digits, _, -, spaces, commas or |, it is an exact name or a list of names. Any other character turns it into a JavaScript regular expression. MCP tools are named mcp__<server>__<tool>.
Some events ignore the matcher, including UserPromptSubmit and Stop, because there is nothing to match. Others match something other than a tool name. SessionStart matches how the session started (startup, resume, clear, compact), and Notification matches the notification type, such as permission_prompt or idle_prompt.
Tool events also accept an if field with permission rule syntax, so a Bash hook can run only for, say, "Bash(git *)". The docs describe this as a best-effort filter, so keep the real check in your script.
Exit codes and JSON output
A command hook talks back to Claude Code in two ways: the exit code, and optional JSON on stdout.
| Exit code | What happens |
|---|---|
0 | Success. stdout is parsed as JSON if it is a single JSON object. For UserPromptSubmit and SessionStart, plain text on stdout is added to Claude’s context. |
2 | Blocking error. stderr is the reason. On PreToolUse the call is cancelled, on UserPromptSubmit the prompt is rejected, on Stop Claude keeps going. |
Anything else, including 1 | Non-blocking error. Claude Code notes it and carries on. |
The trap here is exit code 1. Many scripts fail with 1 by default, and that does not block anything. If you mean “stop”, exit 2.
For finer control, exit 0 and print JSON. A PreToolUse hook can allow, deny or ask:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Use rg instead of grep for better performance"
}
}
permissionDecision takes allow, deny, ask or defer. A hook can also rewrite the call with updatedInput, or pass extra text to Claude with additionalContext. Fields that work on any event include continue: false (stop Claude entirely, with a stopReason) and systemMessage (a warning shown to you).
When several hooks match, they run in parallel, and the most restrictive answer wins: deny beats defer, defer beats ask, ask beats allow. An allow from a hook does not override a deny rule in your permissions.
Example 1: format files after every edit
This is the hook most people start with. It runs Prettier on any file Claude writes or edits:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
Swap in ruff format, gofmt -w or rustfmt for other languages. Since the formatter runs after the edit, Claude sees the formatted file next time it reads it, and you stop seeing style-only diffs in review.
Example 2: block dangerous commands and protected files
A PreToolUse hook is the place for hard limits. This script, adapted from the official guide, blocks edits to sensitive files. Save it as .claude/hooks/protect-files.sh:
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
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
Make it executable with chmod +x .claude/hooks/protect-files.sh, then register it:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
}
]
}
]
}
}
The same pattern works for shell commands. Match Bash, read .tool_input.command, and exit 2 when it contains something you never want, such as rm -rf /, git push --force or DROP TABLE. Keep in mind that a string check is easy to get around, so treat it as a seatbelt, not a lock.
Example 3: get a desktop notification
Long tasks stop and wait for you more often than you’d like. A Notification hook tells you right away. On macOS:
{
"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. Set the matcher to permission_prompt if you only care about permission requests, or add a Stop hook with the same command to hear when Claude finishes.
More useful patterns
- Re-inject context after compaction. A
SessionStarthook with matchercompactcanechoa short reminder of your rules. Its stdout is added to the fresh context. - Audit config changes. A
ConfigChangehook can append each settings change to a log file. - Quality gate on Stop. A
Stophook can run your tests and exit 2 with the failures, so Claude keeps fixing. Check thestop_hook_activefield in the input to avoid an endless loop. Claude Code also overrides a Stop hook after several blocks in a row. - Auto-approve one safe prompt. A
PermissionRequesthook can answer a specific dialog with{"behavior": "allow"}inside its decision object. Keep the matcher narrow.
Security notes before you add hooks
Hooks run as shell commands with your user’s permissions, without asking. That is the whole point, and also the risk.
- Read hooks before you trust a repo. A project’s
.claude/settings.jsoncan ship hooks. Review them the way you would review apostinstallscript. - Quote your variables. Hook input comes from the model, and the model reads files and web pages that may contain prompt injection. Use
"$VAR", never eval input, and preferjqover hand-rolled parsing. - Use absolute or project-relative paths with
$CLAUDE_PROJECT_DIR, so a hook does not depend on the current folder. - Keep hooks fast. A slow
PreToolUsehook slows every tool call. Set atimeout. - Hooks are not a sandbox. A deny hook blocks calls even under
--dangerously-skip-permissions, which is useful, but a determined or confused agent can reach the same result another way. Combine hooks with deny rules and isolation, as covered in AI coding agent security and skipping permissions safely. - Know the off switches.
"disableAllHooks": trueturns hooks off, and admins can setallowManagedHooksOnlyin managed settings to block user and project hooks.
When a hook misbehaves, start Claude Code with claude --debug, or write the log to a file with --debug-file /tmp/claude.log, to see each hook’s matcher, exit code and output.
How AgentVera uses hooks
AgentVera is a desktop app that runs Claude Code, Codex, Gemini CLI and other coding CLIs side by side. Hooks are a big part of how it works with Claude Code.
- Your hooks stay yours. AgentVera passes its own hooks to each Claude Code process with the
--settingsflag. Claude Code merges them with the hooks in your settings files, and yoursettings.jsonis not edited. - Agent status comes from hooks. The status each agent shows in the app is read from the CLI’s own hook events, not guessed from the terminal text.
- Smart tools are hooks. The smart tools feature adds four hooks to local Claude Code agents: it blocks repeated reads of the same file, runs only the tests an edit affects, checks for errors right after an edit, and shares results between agents. They are on by default and you can turn them off under Settings → “Token saving”.
- No notify script needed. Notifications and approvals gather every agent waiting for approval in one queue, send desktop notifications while the app is in the background, and can ping your phone through Telegram.
Qwen Code gets AgentVera’s hooks as well, because its hook system follows Claude Code’s events and JSON format.
Wrapping up
Start with one PostToolUse formatter and one PreToolUse guard, check them with /hooks and --debug, and add more only when a rule keeps getting broken. Exit 2 to block, print JSON when you need more control, and treat every hook as code that runs with your permissions.
Read more:
To try AgentVera, start at the home page.