Claude Code hooks, explained (with examples)
We make AI Bridge, a Mac app that runs these command-line agents side by side. We do not sell any of them, are not affiliated with their makers, and none of them paid for or reviewed this guide. We use them every day.
What Claude Code hooks are
A hook is something Claude Code runs itself when an event happens, such as a tool call or the end of a turn. Unlike an instruction in CLAUDE.md, which Claude may or may not follow, a hook always runs.
A hook handler can be one of five types:
command: a shell command or script, which gets the event as JSON on stdin. Most hooks are this.http: the same JSON sent as a POST to a URL.mcp_tool: a call to a tool on an MCP server you have configured.prompt: a single question to a Claude model, which answers with a decision.agent: a subagent that can read files before deciding. Anthropic marks this one experimental.
People use them for desktop notifications, formatting files after every edit, blocking commands, loading context at the start of a session and keeping logs. One thing to keep in mind from the start: command hooks run with your full user permissions, outside Claude Code’s sandbox, so review a hook as you would any script you run.
The hook events
The hooks reference lists 33 events as of 12 October 2026. A dozen of them do almost all the everyday work.
| Event | Fires | Can it block? |
|---|---|---|
SessionStart | When a session starts, resumes, clears or compacts | No; can add context |
UserPromptSubmit | When you submit a prompt, before Claude sees it | Yes: rejects the prompt |
PreToolUse | Before any tool call runs | Yes: blocks the call |
PermissionRequest | When Claude Code is about to show you a permission prompt | Allows or denies through JSON, not exit codes |
PostToolUse | After a tool call succeeds | No; can send feedback to Claude |
PostToolUseFailure | After a tool call fails | No |
Notification | When Claude Code sends a notification, such as a waiting permission prompt | No |
Stop | When Claude finishes responding | Yes: keeps Claude working |
StopFailure | Instead of Stop, when a turn ends on an API error | No |
SubagentStart, SubagentStop | When a subagent starts or finishes | SubagentStop can keep it working |
PreCompact, PostCompact | Around context compaction | PreCompact can block it |
SessionEnd | When the session ends | No |
The rest are more specialised: Setup, InstructionsLoaded, UserPromptExpansion, MessageDisplay, PostToolBatch, PermissionDenied, TaskCreated, TaskCompleted, TeammateIdle, ConfigChange, CwdChanged, DirectoryAdded, FileChanged, WorktreeCreate, WorktreeRemove, PreModelSwitch, PostModelSwitch, Elicitation and ElicitationResult. The list grows often, so check the reference before relying on an older tutorial.
Where to configure hooks
Hooks go under a hooks key in a settings file, nested three deep: the event, then a matcher group, then one or more handlers. Use ~/.claude/settings.json for yourself, .claude/settings.json to share with a project, or --settings to add them for a single session.
This one runs Prettier on every file Claude writes or edits:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
]
}
]
}
}| Location | Applies to |
|---|---|
~/.claude/settings.json | All your projects on this machine |
.claude/settings.json | One project; commit it to share with the team |
.claude/settings.local.json | One project, just you |
| Managed settings | Your whole organisation, set by an admin |
A plugin’s hooks/hooks.json | While the plugin is enabled |
| Skill or subagent frontmatter | While that skill or subagent is active |
Hook entries merge across these levels rather than replacing each other, and an identical handler defined in two files runs once. That makes --settings, which takes a file path or inline JSON, a clean way to add hooks for one launch without editing any file:
claude --settings ./review-hooks.json
claude --settings '{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"afplay /System/Library/Sounds/Glass.aiff","async":true}]}]}}'Type /hooks in a session to browse what is configured and where each hook comes from. To switch every hook off without deleting them, set "disableAllHooks": true; there is no switch for a single hook. Edits to hooks in settings files are normally picked up while the session runs.
Hooks and trusted folders
In an interactive session, Claude Code holds back hooks from settings files until you accept the workspace trust dialog for the folder. A claude -p run never shows that dialog, so it runs the hooks committed in a repository’s .claude/settings.json even in a folder you never trusted. Before scripting -p over a repository you didn’t write, read its .claude/ files, or pass --settings '{"disableAllHooks": true}'.
What a hook receives on stdin
Every command hook gets one JSON object on stdin: the session ID, the transcript path, the working directory, the permission mode and the event name, plus the fields for that event.
A PreToolUse hook for a shell command receives something like this:
{
"session_id": "abc123",
"transcript_path": "/Users/you/.claude/projects/.../abc123.jsonl",
"cwd": "/Users/you/my-project",
"permission_mode": "auto",
"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..."
}- Tool events add
tool_name,tool_inputandtool_use_id; the shape oftool_inputdepends on the tool (commandfor Bash,file_pathfor Edit and Write). Notificationaddsmessage, an optionaltitleandnotification_type.SessionEndadds areason;StopFailureadds theerrortype, such asrate_limit.- Inside a subagent, events also carry
agent_idandagent_type.
The transcript file is written asynchronously and can lag the current turn, so a Stop hook that wants Claude’s last reply should read last_assistant_message rather than the transcript. The mode labelled Manual arrives as "default". Scripts can find the project root in $CLAUDE_PROJECT_DIR.
Matchers and the if field
A matcher decides which occurrences of an event run your hook. On tool events it is compared with the tool name; on others, with a type, such as the notification type or how the session started.
"*",""or no matcher: every occurrence.- Only letters, digits,
_,-, spaces, commas and|: exact names, soEdit|Writematches those two tools and nothing else. - Anything else: a JavaScript regular expression, unanchored.
Edit.*also matchesNotebookEdit; write^Edit$for an exact match. - MCP tools need the regular-expression form:
mcp__memory__.*. A baremcp__memoryis an exact name and matches no tool.
| Event | Matcher values |
|---|---|
| PreToolUse, PostToolUse, PermissionRequest | Tool name: Bash, Edit|Write, mcp__.* |
| SessionStart | startup, resume, clear, compact, fork |
| Notification | permission_prompt, idle_prompt, elicitation_dialog, auth_success and more |
| StopFailure | Error type: rate_limit, overloaded, authentication_failed, server_error and more |
| SessionEnd | clear, resume, logout, prompt_input_exit, other |
| UserPromptSubmit, Stop | No matcher: always fires |
For tool events you can narrow further with the if field on a handler, written in permission-rule syntax: "if": "Bash(git *)" runs the hook only when some part of the command matches git *. It saves starting a process for every command, but it is best-effort: when Claude Code can’t tell what a command runs, the hook runs anyway. Matchers are case-sensitive.
Exit codes, JSON output and timeouts
Exit 0 lets the action go ahead; exit 2 blocks it, on events that can be blocked, and shows your stderr to Claude; any other exit code is a non-blocking error. For finer control, exit 0 and print a JSON object.
| Exit code | Effect |
|---|---|
| 0 | Success. JSON on stdout is applied; plain text is logged (or added as context on SessionStart and UserPromptSubmit) |
| 2 | Blocking error. On PreToolUse the call is blocked and Claude reads your stderr as the reason |
| Anything else | Non-blocking error. A “hook error” notice appears and the action goes ahead |
The trap is exit 1. It is the usual Unix failure code, but to Claude Code it means “something went wrong, carry on”. A hook meant to enforce a rule must exit 2, or return a JSON decision.
JSON output gives each event its own controls: continue: false stops Claude entirely, systemMessage shows you a warning, and hookSpecificOutput carries event-specific decisions such as PreToolUse’s permissionDecision. Print only the JSON: a line your shell profile echoes first is enough to stop it parsing.
- Timeouts. Command, HTTP and MCP-tool hooks default to 600 seconds, prompt hooks to 30 and agent hooks to 60.
UserPromptSubmithooks default to 30 seconds, and allSessionEndhooks share a 1.5-second budget unless you give them longer. Set"timeout"in seconds on any handler. - A timed-out or crashing hook doesn’t block. On PreToolUse the call carries on through the normal permission flow. Since version 2.1.295 (8 October 2026),
"onFailure": "block"makes a failure count as a block instead. - Hooks run in parallel. All matching handlers start together; set
"async": trueto run one in the background without holding up Claude.
Example: a macOS notification when Claude Code needs you
Hook the Notification event, filter it to the types that mean Claude is waiting or done, and call osascript.
Save this as ~/.claude/hooks/notify.sh and make it executable with chmod +x. It needs jq (brew install jq):
#!/bin/bash
# A macOS notification when Claude Code is waiting on you or has finished
input=$(cat)
type=$(jq -r '.notification_type // empty' <<<"$input")
message=$(jq -r '.message // "Claude Code needs you"' <<<"$input")
project=$(basename "$(jq -r '.cwd' <<<"$input")")
case "$type" in
permission_prompt|elicitation_dialog|elicitation_url_dialog) title="Waiting on you: $project" ;;
idle_prompt) title="Finished: $project" ;;
*) exit 0 ;;
esac
# Pass the text as arguments, so quotes in a message can't break the AppleScript
osascript -e 'on run argv' \
-e 'display notification (item 1 of argv) with title (item 2 of argv)' \
-e 'end run' "$message" "$title"Then register it in ~/.claude/settings.json:
{
"hooks": {
"Notification": [
{
"matcher": "permission_prompt|idle_prompt|elicitation_dialog|elicitation_url_dialog",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/notify.sh", "timeout": 10 }
]
}
]
}
}- No notification appears?
osascriptnotifications come from Script Editor, and macOS drops them silently until it is allowed. Runosascript -e 'display notification "test"'once, then turn on Script Editor in System Settings, Notifications. - It isn’t instant, by design.
permission_promptfires once a prompt has waited about six seconds without you typing, andidle_promptabout 60 seconds after Claude finishes. For a signal the moment Claude asks, hookPermissionRequestinstead. - Prefer a terminal notification? Hooks have no terminal of their own, so print
{"terminalSequence": "…"}and Claude Code emits the escape sequence for you; it accepts the notification codes that iTerm2, Ghostty, Kitty and WezTerm understand.
Example: block a dangerous command with PreToolUse
A PreToolUse hook blocks a tool call by exiting with code 2, with the reason on stderr, or by exiting 0 with a JSON permissionDecision of "deny". Either way the block holds even under --dangerously-skip-permissions.
This one refuses force pushes and leaves every other command alone. Save it as .claude/hooks/no-force-push.sh in the project and chmod +x it:
#!/bin/bash
# Refuse git force pushes; everything else goes through the normal permission flow
cmd=$(jq -r '.tool_input.command // empty')
[[ "$cmd" == *push* ]] || exit 0
if grep -Eq -e '--force( |$)|(^| )-f( |$)' <<<"$cmd"; then
echo "Force pushes are blocked by a project hook. Push without --force, or ask the user." >&2
exit 2
fi
exit 0Register it in the project’s .claude/settings.json. The if field means the script only starts for git push commands, "args": [] runs it directly rather than through a shell, and "onFailure": "block" blocks the push if the script itself fails:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git push *)",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/no-force-push.sh",
"args": [],
"onFailure": "block"
}
]
}
]
}
}The JSON form does the same and lets you choose the outcome. Replace the two lines inside the if with:
jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Force pushes are blocked by a project hook."}}'
exit 0Return "ask" instead of "deny" to put the decision to you: a hook’s ask forces a prompt even in auto mode.
Hooks tighten, they don’t loosen
A hook’s "allow" skips the prompt but can’t override a deny or ask rule in your settings. And a hook is only as good as its pattern: git -C . push -f slips past a rule written for git push *. For a hard line, pair the hook with a deny rule and the sandbox, as described in Claude Code permissions, explained.
What hooks don’t tell you
Building AI Bridge meant turning these hooks into a live “working, waiting, done” status for every agent. Five behaviours are worth knowing before you build anything similar.
Classify notifications by type, never by their text
In early September 2026 we saw a background task’s completion notice, “<task title> failed”, arrive as a Notification after the turn had ended. Code that read the message text took it for a permission dialog, and an idle agent showed as blocked until something else changed its state. Read notification_type instead:
permission_prompt,elicitation_dialogandelicitation_url_dialogmean a dialog is open and typing would answer it: waiting on you.idle_promptmeans Claude finished and is sitting at its prompt: done, not blocked.agent_needs_inputandagent_completedare about background sessions, not the one the hook fired in; treat any type you don’t recognise as no change.
Ctrl-C fires nothing
Stop doesn’t run when you interrupt a turn, and in our testing no other event reported the interrupt either. Anything that tracks state from hooks keeps showing the agent as working until the next prompt or tool call. If that matters, watch for the keystroke yourself.
A failed turn skips Stop
A turn that ends on an API error, such as a rate limit, doesn’t fire Stop, so a status built only on Stop keeps showing a rate-limited agent as busy. Claude Code reports these turns through a separate event, StopFailure (added in version 2.1.78). Listen for both.
A stale resume ends without starting
Resume a session ID that no longer exists and Claude Code exits, firing SessionEnd with no SessionStart before it. Don’t assume the two arrive in pairs, and treat a SessionEnd with no start as “the resume failed”.
Give every hook a short timeout, and quote its path
The default timeout for a command hook is ten minutes, and blocking events wait for it. We give each of ours 8 seconds: long enough for a few retries of a local delivery, short enough that a stalled receiver never holds up a turn. And because Claude Code runs a command without args through a shell, a script under a path with a space, such as ~/Library/Application Support, splits at the space and never runs unless you quote it or use the args form.
How Codex, Grok Build and OpenCode hooks differ
Codex and Grok Build follow Claude Code’s hook format closely; OpenCode uses JavaScript plugins instead. What differs is where hooks can live, whether they need a review before running, and which moments they report.
| Claude Code | Codex CLI | Grok Build | OpenCode | |
|---|---|---|---|---|
| Where hooks live | Settings files, plugins, skills | hooks.json or [hooks] in config.toml | ~/.grok/hooks/*.json and .grok/hooks/*.json | JS/TS plugins in .opencode/plugins/ or ~/.config/opencode/plugins/ |
| For one launch | --settings | -c config overrides | Files only, in our testing | OPENCODE_CONFIG_CONTENT, in our testing |
| Review before running | Workspace trust, in interactive sessions | Every non-managed hook, re-reviewed when it changes | Project hooks need folder trust | No review step in the plugin docs |
| Waiting on you | PermissionRequest, Notification | PermissionRequest (tool approvals only) | Notification (permission_prompt) | permission.asked event |
| Turn failed | StopFailure | No Stop fired, in our testing | StopFailure | session.error event |
Codex
- Codex makes you review and trust each hook before it runs, recording the trust against the hook’s exact definition, so an edited hook is skipped until you review it again in
/hooks. - We pass Codex hooks for a single launch as
-coverrides, which leaves~/.codexuntouched. Two things we hit: a hook whose path contains a space registered but never fired, andPreToolUseandPermissionRequestfired at the same moment in no fixed order. - Its
PermissionRequestcovers tool approvals only: in our testing the folder-trust, hook-review, sign-in and update dialogs fired nothing. Background hooks ("async": true) can’t block anything and may finish out of order. - Codex’s docs now list an
Interruptevent for Ctrl-C, with a one-second default timeout. A turn that failed with an error fired noStopin our testing.
Grok Build
- Grok Build reads every
*.jsonfile in~/.grok/hooks/, plus the project’s.grok/hooks/once you trust the folder. We found no flag or environment variable to pass hooks for one launch. - It also reads Claude Code’s
.claude/settings.jsonhooks, so a Claude hook in your project can fire in Grok sessions too. Write scripts that cope with both payloads, or keep them apart. - It reports what Claude Code doesn’t: in our testing an interrupted turn (Ctrl-C) produced its own stop event, and a failed turn fires
StopFailure. It also sent a second stop with the reasonshutdownat the end of a session, so check the reason before treating a stop as a finished turn. - Its docs give hooks a five-second default timeout, and anything other than an explicit deny fails open: a crashed or slow hook lets the tool run.
OpenCode
- OpenCode hooks are JavaScript or TypeScript plugins that subscribe to events on its bus:
permission.askedandpermission.replied,session.idleandsession.error,tool.execute.beforeandtool.execute.after, among others. A plugin blocks a tool by throwing an error intool.execute.before. - In our testing, the tool hooks are awaited, so a slow plugin delays every tool call; nothing fired for the first 8 to 10 seconds after start; and we never saw a
file.editedevent. - A plugin can be injected for one process through
OPENCODE_CONFIG_CONTENT, which OpenCode merges with your own config.
For how the CLIs compare beyond hooks, see Codex vs Claude Code and OpenCode vs Claude Code.
Questions
What are Claude Code hooks?
Commands, HTTP calls, MCP tool calls or model prompts that Claude Code runs automatically at fixed points in a session, such as before a tool runs or when a turn ends. Unlike instructions in CLAUDE.md, a hook always runs.
Where do I put Claude Code hooks?
Under a hooks key in ~/.claude/settings.json for all your projects, .claude/settings.json to share with a project, or .claude/settings.local.json for yourself in one project. Pass --settings with a file or inline JSON to add hooks for a single session.
How do I get a notification when Claude Code needs my input?
Add a Notification hook with the matcher permission_prompt|idle_prompt that runs osascript on macOS or notify-send on Linux. It fires about six seconds after a permission prompt appears; for an immediate signal, hook PermissionRequest instead.
Can a hook stop Claude Code from running a command?
Yes. A PreToolUse hook that exits with code 2, or prints a permissionDecision of "deny", blocks the tool call, even under --dangerously-skip-permissions. Exit code 1 does not block.
Why isn’t my Claude Code hook firing?
Check /hooks to see whether it loaded, that the matcher matches the tool name exactly (matchers are case-sensitive, and MCP tools need a .* suffix), that the script is executable, and that you accepted the folder’s trust dialog. Test it by piping sample JSON into the script.
Do hooks run in headless mode?
Yes. claude -p runs hooks too, including those committed in a repository’s .claude/settings.json, without a trust dialog. Use --bare or --settings '{"disableAllHooks": true}' when running over a repository you did not write.
Sources
Checked on 12 October 2026. These tools change often; where a source and this page disagree, the source is right, and we would like to hear about it at hello@aibridge.sh.
- Claude Code: hooks reference · code.claude.com · checked 12 Oct 2026
- Claude Code: automate actions with hooks · code.claude.com · checked 12 Oct 2026
- Claude Code: settings files and precedence · code.claude.com · checked 12 Oct 2026
- Claude Code: configure permissions · code.claude.com · checked 12 Oct 2026
- Claude Code: sandboxed Bash tool · code.claude.com · checked 12 Oct 2026
- Claude Code: run programmatically (headless) · code.claude.com · checked 12 Oct 2026
- Claude Code changelog · code.claude.com · to 2.1.296, 9 Oct 2026
- Codex hooks · learn.chatgpt.com · checked 12 Oct 2026
- Grok Build hooks · docs.x.ai · checked 12 Oct 2026
- OpenCode plugins · opencode.ai · checked 12 Oct 2026
Change log
- : First published.
Keep reading
- ComparisonsCodex vs Claude CodeCodex vs Claude Code as they ship in October 2026: price and limits, default models, sandboxing, instruction files, sessions and CI, and when to use both.
- How-toClaude Code permissions, explained: modes, sandbox and --dangerously-skip-permissionsHow Claude Code’s six permission modes, allow/ask/deny rules, /sandbox and --dangerously-skip-permissions work in October 2026, with a safe starting policy.
- ComparisonsCursor vs Claude CodeCursor is an AI editor, Claude Code a terminal agent, and they work together. Price, models, approvals, privacy and the SpaceX deal, checked October 2026.
- ComparisonsOpenCode vs Claude CodeOpenCode vs Claude Code in October 2026: why a Claude Pro or Max plan can’t be used in OpenCode, plus price, models, permissions, sessions and using both.