Claude Code hooks are shell commands, HTTP endpoints, MCP tool calls or model prompts that run automatically at fixed points in a session, such as before a tool call (PreToolUse) or when Claude finishes responding (Stop). You define them under a hooks key in a settings file such as .claude/settings.json.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx prettier --write"
}
]
}
]
}
}
Applies to: Claude Code 2.1.287 (changelog), docs as of October 2, 2026. Last checked: October 2, 2026.
The example above, from Anthropic’s hooks guide, runs Prettier on every file Claude edits or writes.
On this page
Why use hooks instead of instructions?
Anthropic’s guide describes hooks as deterministic control: an action always happens instead of depending on the model choosing to run it. Typical jobs from the docs are formatting files after edits, blocking commands before they run, sending a notification when Claude needs input, and injecting context at session start. For static project conventions that never change, the reference recommends CLAUDE.md instead, since it loads without running a script.
Where do hooks go?
Where you define a hook sets its scope (hook locations):
| Location | Scope | Shareable |
|---|---|---|
~/.claude/settings.json | All your projects | No, local to your machine |
.claude/settings.json | Single project | Yes, can be committed to the repo |
.claude/settings.local.json | Single project | No, gitignored when Claude Code saves a setting to it |
| Managed policy settings | Organization-wide | Yes, admin-controlled |
Plugin hooks/hooks.json | When the plugin is enabled | Yes, bundled with the plugin |
| Skill frontmatter | Rest of the session once the skill is invoked | Yes, defined in the skill file |
| Subagent frontmatter | While that subagent runs | Yes, defined in the subagent file |
Hook entries merge across levels instead of replacing each other. Type /hooks in a session to open a read-only browser that shows every configured hook and the file it came from. To change hooks, edit the JSON or ask Claude to do it; direct edits are normally picked up by the file watcher (/hooks menu).
How is a hook configured?
The configuration has three levels (configuration):
- A hook event, such as
PreToolUseorStop. - A matcher group that filters when it fires, such as “only for the Bash tool”.
- One or more hook handlers that run when the matcher matches.
There are five handler types:
| Type | What runs | Default timeout |
|---|---|---|
command | A shell command; gets JSON on stdin, answers with exit codes and stdout | 600 s |
http | A POST of the JSON to a URL; answers with the response body | 600 s |
mcp_tool | A tool on an already-connected MCP server | 600 s |
prompt | A single-turn evaluation by a Claude model | 30 s |
agent | A subagent that can use tools such as Read and Grep (experimental) | 60 s |
UserPromptSubmit lowers the command, HTTP and MCP-tool default to 30 seconds, and SessionEnd hooks share a 1.5-second budget unless you set a longer timeout. All matching hooks run in parallel; an identical handler defined in more than one settings file runs once (handler fields).
Which hook events exist?
The hooks reference lists these events. The ones most setups use:
| Event | When it fires | Can block? |
|---|---|---|
SessionStart | A session begins or resumes | No |
UserPromptSubmit | You submit a prompt, before Claude processes it | Yes |
PreToolUse | Before a tool call executes | Yes |
PermissionRequest | A tool call needs a permission decision | Through JSON only |
PostToolUse | After a tool call succeeds | No (tool already ran) |
PostToolUseFailure | After a tool call fails | No |
Notification | Claude Code sends a notification | No |
SubagentStart / SubagentStop | A subagent is spawned / finishes | Stop only |
Stop | Claude finishes responding | Yes (keeps Claude working) |
PreCompact / PostCompact | Before / after context compaction | PreCompact only |
SessionEnd | A session terminates | No |
The full list also includes Setup, UserPromptExpansion, PermissionDenied, PostToolBatch, MessageDisplay, TaskCreated, TaskCompleted, StopFailure, TeammateIdle, InstructionsLoaded, ConfigChange, CwdChanged, DirectoryAdded, FileChanged, WorktreeCreate, WorktreeRemove, Elicitation and ElicitationResult. Each has its own section in the reference with its input schema.
How does matcher syntax work?
The matcher value is read in one of three ways, depending on its characters (matcher patterns):
| Matcher value | Evaluated as | Example |
|---|---|---|
"*", "" or omitted | Match all | Fires on every occurrence of the event |
Only letters, digits, _, -, spaces, , and | | Exact string, or a list split by | or , | Bash; Edit|Write; Edit, Write |
| Any other character | Unanchored JavaScript regular expression | ^Notebook; mcp__memory__.* |
Because the regex path is unanchored, Edit.* also matches NotebookEdit; write ^Edit$ for a whole-string match. Matchers are case-sensitive.
What the matcher is compared against depends on the event: the tool name for PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest and PermissionDenied; startup, resume, clear, compact or fork for SessionStart; the notification type for Notification; manual or auto for compaction events. UserPromptSubmit, Stop and several others have no matcher support, and a matcher there is silently ignored.
MCP tools
MCP tools are named mcp__<server>__<tool>. To match every tool from one server, append .*: a bare mcp__memory is treated as an exact string and matches nothing (match MCP tools).
{
"hooks": {
"PreToolUse": [
{
"matcher": "mcp__memory__.*",
"hooks": [
{
"type": "command",
"command": "echo 'Memory operation initiated' >> ~/mcp-operations.log"
}
]
},
{
"matcher": "mcp__.*__write.*",
"hooks": [
{
"type": "command",
"command": "/home/user/scripts/validate-mcp-write.py"
}
]
}
]
}
}
The memory server in that example is the reference Memory MCP Server. Server setup is covered in our Claude Code MCP guide.
The if field
On tool events, a handler can also take an if field in permission-rule syntax, such as "Bash(git *)" or "Edit(*.ts)", so the script only spawns when the tool call matches. It holds exactly one rule. The reference notes that the filter is best-effort and fails open when a Bash command can’t be parsed, so it says to use the permission system rather than a hook to enforce a hard allow or deny.
What JSON does a hook receive?
Command hooks get JSON on stdin; HTTP hooks get the same JSON as the POST body. Every event includes session_id, transcript_path, cwd and hook_event_name, and most include permission_mode. A PreToolUse hook for a Bash call receives (common input fields):
{
"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..."
}
Inside a subagent the input also carries agent_id and agent_type.
What do exit codes mean?
| Exit code | Meaning (exit code output) |
|---|---|
0 | Success. Stdout is parsed as JSON if it starts with {. For UserPromptSubmit, UserPromptExpansion and SessionStart, plain-text stdout is added to Claude’s context |
2 | Blocking error. On events that can block, it blocks even if JSON says "allow". Stderr becomes the reason Claude sees |
| Anything else | Non-blocking error for most events: the action proceeds and a hook error notice shows the first line of stderr |
The docs single out exit code 1: without valid JSON, Claude Code treats it as non-blocking and continues, even though 1 is the usual Unix failure code. “If your hook is meant to enforce a policy, use exit 2.” The one exception is WorktreeCreate, where any non-zero exit aborts worktree creation. A timed-out command hook renders no decision and doesn’t block the tool call.
This script from the reference blocks rm commands by exiting 2:
#!/bin/bash
# Reads JSON input from stdin, checks the command
input=$(cat)
command=$(jq -r '.tool_input.command' <<<"$input")
if [[ "$command" == rm* ]]; then
echo "Blocked: rm commands are not allowed" >&2
exit 2 # Blocking error: tool call is prevented
fi
exit 0 # No decision: the normal permission flow applies
What JSON can a hook return?
For finer control, exit 0 and print one JSON object on stdout. Universal fields include continue (set false to stop Claude entirely), stopReason, systemMessage (a warning shown to the user) and terminalSequence (an allowlisted escape sequence for desktop notifications). Output strings are capped at 10,000 characters (JSON output).
Decision fields differ by event (decision control):
| Event | Decision fields |
|---|---|
PreToolUse | hookSpecificOutput.permissionDecision: allow, deny, ask or defer, plus permissionDecisionReason; updatedInput rewrites the tool’s arguments |
PermissionRequest | hookSpecificOutput.decision.behavior: allow or deny |
UserPromptSubmit, PostToolUse, Stop, SubagentStop, PreCompact and others | Top-level decision: "block" with reason |
SessionStart | hookSpecificOutput.additionalContext, plus sessionTitle, watchPaths and others; no blocking |
Notification, SessionEnd, PostCompact and others | None; side effects only |
A PreToolUse deny:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Database writes are not allowed"
}
}
Context for Claude goes in additionalContext:
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "This file is generated. Edit src/schema.ts and run `bun generate` instead."
}
}
The reference advises writing that text as factual statements (“This repo uses bun test”) rather than imperative system instructions, which can trigger Claude’s prompt-injection defenses.
If a shell profile prints text on startup, it gets prepended to your JSON and Claude Code reads the whole output as plain text. The troubleshooting section shows how to wrap profile echo lines so they run only in interactive shells.
Examples from Anthropic’s docs
Desktop notification when Claude needs input
Add to ~/.claude/settings.json (macOS shown; the guide has Linux notify-send and Windows PowerShell versions):
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude Code needs your attention\" with title \"Claude Code\"'"
}
]
}
]
}
}
Block edits to protected files
-
Save this as
.claude/hooks/protect-files.sh:#!/bin/bash # protect-files.sh INPUT=$(cat) FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') # Normalize 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 -
Make it executable:
chmod +x .claude/hooks/protect-files.sh -
Register it in
.claude/settings.json:{ "hooks": { "PreToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" } ] } ] } }
Per the guide, Claude Code blocks the edit and passes the Blocked: message to Claude as feedback. The Bash examples need jq on your PATH.
Re-inject context after compaction
A SessionStart hook with the compact matcher adds its stdout to Claude’s context after every compaction (guide):
{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "echo 'Reminder: use Bun, not npm. Run bun test before committing. Current sprint: auth refactor.'"
}
]
}
]
}
}
Ask a model whether work is finished
A prompt hook sends the input to a Claude model, Haiku by default, which answers {"ok": true} or {"ok": false, "reason": "..."}. On Stop, a false answer feeds the reason back so Claude keeps working (prompt-based hooks):
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Check if all tasks are complete. If not, respond with {\"ok\": false, \"reason\": \"what remains to be done\"}."
}
]
}
]
}
}
Claude Code overrides a Stop hook after eight consecutive blocks. For command hooks, the guide recommends reading stop_hook_active from the input and exiting 0 when it is true.
Security considerations
Warning
Anthropic’s reference states that “command hooks execute shell commands with your full user permissions. They can modify, delete, or access any files your user account can access. Review and test all hook commands before adding them to your configuration.”
Points from the security section and limitations:
- Workspace trust. In an interactive session, Claude Code holds back hooks from every settings file until you accept the workspace trust dialog. In a
-por SDK session there is no dialog and the folder is treated as trusted, so hooks committed in a repository’s.claude/settings.jsonrun. Before scriptingclaude -pover a repository you didn’t write, the docs say to review its.claude/settings, start with--bare, or pass--settings '{"disableAllHooks": true}'. - Hooks tighten, they don’t loosen. A
PreToolUsedeny blocks the tool in every permission mode, includingbypassPermissionsand--dangerously-skip-permissions. A hook returningallowdoesn’t override deny rules from settings. - Write defensive scripts. Validate inputs, quote shell variables (
"$VAR"), block..path traversal, use absolute paths or${CLAUDE_PROJECT_DIR}, and skip sensitive files such as.env,.git/and keys. - Watch the first run. A missing or non-executable script exits with a code like 127, which is non-blocking, so a mistyped path in
settings.jsonleaves a policy hook silently disabled. - Organization control. Admins can set
allowManagedHooksOnlyin managed settings to block user, project, local and plugin hooks.disableAllHooksoutside managed settings can’t disable managed hooks.allowedHttpHookUrlslimits where HTTP hooks may send data.
To turn all hooks off temporarily, set "disableAllHooks": true. There is no switch to disable a single hook while keeping it in the file (disable or remove hooks).
How do I debug a hook?
-
Run
/hooksand confirm the hook appears under the right event. -
Pipe sample JSON into the script and check the exit code:
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | ./my-hook.sh echo $? # Check the exit code -
Start Claude Code with
claude --debug-file /tmp/claude.logandtail -fthat file to see which hooks matched, their exit codes, stdout and stderr. SetCLAUDE_CODE_DEBUG_LOG_LEVEL=verbosefor matcher details (debug hooks).
More configuration guides are in Skills & config and the Claude Code section.





