Home / Guides / Claude Code hooks
Claude Code hooks: get an alert when a session stops or needs you
Quick answer: hooks are shell commands (or HTTP calls, MCP tools, prompts or agents) that Claude Code runs at fixed points in a session. Add a hooks block to ~/.claude/settings.json. Use the Notification event to hear when Claude needs permission or input, Stop to hear when it finishes, PreToolUse to block a command before it runs, and PostToolUse to run a formatter after an edit. Each hook gets the event as JSON on stdin; exit 2 blocks, exit 0 lets things carry on.
What are Claude Code hooks?
Hooks are handlers you define that Claude Code runs automatically at specific points in its lifecycle. The docs list five handler types: command (a shell command), http (a POST to a URL), mcp_tool (a tool on a connected MCP server), prompt (a single-turn check by a Claude model) and agent (a subagent that can read files before deciding; experimental). This guide uses command hooks, the simplest kind.
Claude Code fires the same events in the terminal, the IDE extensions, the Desktop app and cloud sessions. When several hooks match an event, they run in parallel.
Which hook events are there?
The reference lists more than thirty events. These are the ones most people start with, and the fields each receives on top of the common ones (session_id, transcript_path, cwd, hook_event_name and, on many events, permission_mode):
| Event | When it fires | Extra input | Matcher filters |
|---|---|---|---|
SessionStart | A session begins or resumes | source, sometimes model | startup, resume, clear, compact, fork |
UserPromptSubmit | You submit a prompt, before Claude processes it | prompt | No matcher |
PreToolUse | Before a tool call runs; can block it | tool_name, tool_input, tool_use_id | Tool name |
PermissionRequest | Claude Code is about to ask you for permission | tool_name, tool_input, permission_suggestions | Tool name |
PostToolUse | After a tool call succeeds | tool_input, tool_response, duration_ms | Tool name |
Notification | Claude Code sends a notification | message, title, notification_type | permission_prompt, idle_prompt and others |
Stop | Claude finishes responding (not on a user interrupt) | stop_hook_active, last_assistant_message, background_tasks, session_crons | No matcher |
StopFailure | The turn ends on an API error | error, error_details | Error type, such as rate_limit |
SubagentStop | A subagent finishes | agent_id, agent_type, last_assistant_message | Agent type |
SessionEnd | A session ends | reason | clear, resume, logout, prompt_input_exit, other |
The rest cover more specialised moments: Setup, UserPromptExpansion, PermissionDenied, PostToolUseFailure, PostToolBatch, MessageDisplay, SubagentStart, TaskCreated, TaskCompleted, TeammateIdle, InstructionsLoaded, ConfigChange, CwdChanged, DirectoryAdded, FileChanged, WorktreeCreate, WorktreeRemove, PreCompact, PostCompact, PreModelSwitch, PostModelSwitch, Elicitation and ElicitationResult.
A matcher made only of letters, digits, _, -, spaces, commas and | is an exact match or a list, so Edit|Write matches either tool. Anything else is treated as a JavaScript regular expression. Matchers are case-sensitive, and "*", "" or no matcher matches everything.
Where do you configure hooks?
Hooks live in JSON settings files, nested three levels deep: the event, a matcher group, then one or more handlers. Where you put them sets the scope:
~/.claude/settings.json: all your projects, local to your machine..claude/settings.json: one project, and can be committed to the repo..claude/settings.local.json: one project, not shared.- Managed policy settings: organisation-wide, set by an admin.
- Plugins, skills and subagents: a plugin's
hooks/hooks.json, or hooks in skill or subagent frontmatter.
Hooks from different levels add up rather than replace each other. Type /hooks in Claude Code to see every configured hook and which file it came from. The menu is read-only, so edit the JSON to change anything.
What does a hook receive, and how does it answer?
A command hook gets the event as one JSON object on stdin. A Notification hook, for example, receives:
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf.jsonl",
"cwd": "/Users/...",
"hook_event_name": "Notification",
"message": "Claude needs your permission",
"title": "Permission needed",
"notification_type": "permission_prompt"
}
It answers with its exit code, and optionally JSON on stdout:
- Exit 0: success. Claude Code parses stdout as JSON if it starts with
{and ends with}. - Exit 2: a blocking error. On events that can block, stderr becomes the reason.
PreToolUseblocks the tool call,UserPromptSubmitrejects the prompt, andStopkeeps Claude working. OnNotification, exit codes are ignored. - Any other code, including 1: a non-blocking error for most events. The action goes ahead and the transcript shows a hook error notice.
For finer control, exit 0 and print JSON. PreToolUse uses hookSpecificOutput.permissionDecision with allow, deny, ask or defer. PostToolUse and Stop use a top-level decision of "block" with a reason. Command hooks time out after 600 seconds unless you set timeout.
Example 1: a Mac notification when Claude needs you
The Notification event fires with permission_prompt when a permission prompt has waited about six seconds, and idle_prompt about 60 seconds after Claude finishes and you have not typed. This script shows the message Claude Code sent, with the project folder in the title. It needs jq (brew install jq).
~/.claude/hooks/notify.sh
#!/bin/bash
# Notification hook: show a macOS notification with the text Claude Code sent.
input=$(cat)
msg=$(jq -r '.message // "Claude Code needs you"' <<<"$input")
project=$(basename "$(jq -r '.cwd // empty' <<<"$input")")
# Pass text as arguments so quotes in the message cannot break the script.
osascript -e 'on run argv' \
-e 'display notification (item 1 of argv) with title (item 2 of argv)' \
-e 'end run' "$msg" "Claude Code ${project}" >/dev/null 2>&1
exit 0
~/.claude/settings.json
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt|idle_prompt",
"hooks": [
{ "type": "command", "command": "\"$HOME/.claude/hooks/notify.sh\"", "timeout": 10 }
]
}
]
}
}
Run chmod +x ~/.claude/hooks/notify.sh. If nothing appears, the docs explain that osascript notifications go through Script Editor: run osascript -e 'display notification "test"' once, then allow notifications for Script Editor in System Settings > Notifications.
Example 2: a sound when Claude stops
Stop fires every time Claude finishes responding, not only when a whole task is done, and never on an interrupt. A short sound is enough. "async": true runs it in the background so nothing waits on it.
{
"hooks": {
"Stop": [
{
"hooks": [
{ "type": "command", "command": "afplay /System/Library/Sounds/Glass.aiff", "async": true }
]
}
]
}
}
Want a banner instead? Point the same event at notify.sh; it falls back to "Claude Code needs you" because Stop input has no message. Do not exit 2 from a Stop hook unless you mean it: that tells Claude to keep going.
Example 3: block a risky command
A PreToolUse hook on Bash sees the command before it runs. Exit 2 blocks the call and Claude reads your stderr as the reason.
~/.claude/hooks/block-risky.sh
#!/bin/bash
# PreToolUse hook for Bash: refuse a few commands that are hard to undo.
input=$(cat)
cmd=$(jq -r '.tool_input.command // empty' <<<"$input")
if grep -Eq 'rm[[:space:]]+-[a-zA-Z]*[rR][a-zA-Z]*f?[[:space:]]+(/|~|\$HOME)([[:space:]]|$)|git[[:space:]]+push[[:space:]].*(--force|-f)([[:space:]]|$)|git[[:space:]]+reset[[:space:]]+--hard' <<<"$cmd"; then
echo "Blocked by PreToolUse hook: this command is on the risky list. Ask the user to run it." >&2
exit 2
fi
exit 0
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "\"$HOME/.claude/hooks/block-risky.sh\"", "timeout": 5 }
]
}
]
}
}
Treat this as a seatbelt, not a lock. A pattern list will miss creative spellings, and the docs note that a PreToolUse command hook that times out does not block. For hard rules, add deny rules to your permissions as well.
Example 4: run a formatter after edits
PostToolUse with an Edit|Write matcher runs after Claude changes a file. This script formats the file with Prettier if the project has it installed, and does nothing otherwise.
~/.claude/hooks/format.sh
#!/bin/bash
# PostToolUse hook for Edit|Write: format the file Claude just changed.
input=$(cat)
file=$(jq -r '.tool_input.file_path // empty' <<<"$input")
[ -f "$file" ] || exit 0
case "$file" in
*.js|*.jsx|*.ts|*.tsx|*.css|*.json|*.md)
npx --no-install prettier --write "$file" >/dev/null 2>&1 ;;
esac
exit 0
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "\"$HOME/.claude/hooks/format.sh\"", "timeout": 30 }
]
}
]
}
}
A PostToolUse hook cannot undo anything; the edit already happened. If you add more than one event, put them side by side inside the single hooks object.
How do you keep hooks safe and fast?
- They run as you. Command hooks execute with your full user permissions. Read any hook before you install it, including ones in a repo's
.claude/settings.json. - Never block by accident. Exit 0 unless you mean to stop something, and remember only exit 2 blocks. Keep stdout empty or pure JSON, because a shell profile that prints text can break parsing.
- Keep them fast. Hooks hold Claude up until they finish. Set a short
timeout, and use"async": truefor anything slow. - Quote variables. Write
"$VAR", not$VAR, use absolute paths, and pass untrusted text as arguments rather than pasting it into a command, asnotify.shdoes.
Vibepanions' own hook follows the same pattern. It reads stdin fully, exits at once if the app is not running, sends one line to a local socket in the background with a one-second limit, never writes to stdout and always exits 0. Its timeout is 5 seconds on most events and 1 on SessionEnd.
Hooks for every session at once
A notification per event works for one session, but with five terminals you still have to work out which one pinged you. Vibepanions registers its own hooks for the session events, shows you the exact diff to settings.json and backs the file up before installing, and can uninstall them. It then shows every Claude Code session as a desk in one window: green while working, amber and a waving character when any session needs you, and a click takes you to that terminal.
FAQ
Why does my Notification hook fire late?
By design. In a terminal session, permission_prompt fires once the prompt has waited about six seconds without you typing, and idle_prompt about 60 seconds after Claude finishes. For a signal the moment Claude asks for permission, use a PermissionRequest hook.
Why does my PreToolUse hook not block anything?
Check the exit code. Only exit 2, or a JSON permissionDecision of deny, blocks a tool call. Exit 1 is a non-blocking error and the call goes ahead. A hook that times out does not block either.
Do I need to restart Claude Code after editing hooks?
Usually not. The file watcher normally picks up direct edits to settings files. If a hook does not show in /hooks after a few seconds, restart the session.
How do I turn all hooks off?
Set "disableAllHooks": true in your settings file, or pass --settings '{"disableAllHooks": true}' for one run. Hooks from managed settings keep running unless it is also set there.
Can a hook see my prompts and code?
Yes. A hook receives the event's JSON, which can include your prompt, tool inputs such as file contents, and the path to the transcript. It also runs with your full user permissions, so only install hooks you have read.
Want every session's state on screen? Get Vibepanions. SGD 7.99 once, full refund within 14 days.
Sources
Fetched 23 Sep 2026.
- Claude Code docs, Hooks reference (events, input fields, matchers, exit codes, JSON output, timeouts, async hooks, security considerations)
- Claude Code docs, Automate actions with hooks (macOS notification, Prettier example, hook locations, troubleshooting)