Claude Code hooks: a practical guide
Updated July 27, 2026 · 10 min read
Hooks are shell commands Claude Code runs at fixed points in a session. Your script gets a JSON event on stdin and can print JSON back to inject context, block a tool call, or decide a permission — which makes them the main supported way to bolt your own behaviour onto the agent loop.
Verified against Claude Code 2.1.220
The event list below was extracted from the 2.1.220 binary itself, not from documentation. Event names are stable across recent 2.1.x releases but check your own build before relying on the rarer ones.
A hook is a shell command registered in settings.json against a lifecycle event. When the event fires, Claude Code runs your command, pipes a JSON object describing the event to its stdin, and — for some events — reads JSON back from stdout to decide what to do next.
That's the whole model. It's small, and it's enough to build notifications, auto-formatting, audit logs, and policy guardrails without touching the agent itself.
The thirteen events
| Event | Fires when | Can it change behaviour? |
|---|---|---|
SessionStart | A session begins (including resume). | Yes — can inject additionalContext. |
SessionEnd | A session ends. | No. Cleanup and logging only. |
UserPromptSubmit | You submit a prompt, before Claude sees it. | Yes — can inject context or block the prompt. |
PreToolUse | Before a tool call runs. | Yes — can allow, deny, or ask via permissionDecision. |
PostToolUse | After a tool call returns. | Yes — can feed the result back for another pass. |
PermissionRequest | A tool call needs a permission decision. | Yes — the hook for routing approvals somewhere you'll see them. |
Notification | Claude Code emits a notification (including idle waits). | No. The classic 'alert me' hook. |
Stop | The main agent finishes its turn. | Yes — can force it to keep going. |
SubagentStart | A subagent is spawned. | No. |
SubagentStop | A subagent finishes. | Yes, same shape as Stop. |
TaskCompleted | A tracked task is marked complete. | No. |
PreCompact | Before context compaction runs. | No. |
PostCompact | After compaction. | Yes — can inject context back into the freshly compacted window. |
How to register one
Hooks live under the hooks key in any settings file. Each event maps to an array of matcher groups; each group has a matcher and a list of commands to run.
{
"hooks": {
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/notify.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/format.sh"
}
]
}
]
}
}An empty matcher means *every* occurrence of that event. For tool events, the matcher is permission-rule syntax — the CLI describes it as: *Permission rule syntax to filter when this hook runs (e.g., "Bash(git *)"). Only runs if the tool call matches the pattern. Avoids spawning hooks for non-matching commands.* Use it. A hook that shells out on every single tool call is a real latency cost.
What your script receives
Every hook gets a JSON object on stdin. The fields that are always there:
session_id— the session that triggered the event. This is your correlation key if you're tracking several agents.hook_event_name— which event fired, so one script can handle several registrations.cwd— the session's working directory. In a multi-project setup this is how you know *which* project needs you.transcript_path— path to the session transcript on disk.permission_mode— the mode the session is running in.
Tool events add tool_input (the arguments Claude wants to use) and, for PostToolUse, tool_response.
What your script can send back
Print a JSON object to stdout and Claude Code will act on it. Exit code 0 with no output means 'carry on'.
| Field | Effect |
|---|---|
continue | Set to false to stop the session after this hook. |
stopReason | Message shown when continue is false. |
systemMessage | A message surfaced to you in the session. |
suppressOutput | Hide the hook's own stdout from the transcript. |
decision | On Stop/SubagentStop, block forces the agent to continue working. |
hookSpecificOutput.additionalContext | Text injected into Claude's context. Works on SessionStart, UserPromptSubmit, PostCompact. |
hookSpecificOutput.permissionDecision | On PreToolUse: allow, deny, or ask. This is programmable policy. |
hookSpecificOutput.permissionDecisionReason | Explanation shown alongside the decision. |
Example: tell me when an agent needs me
The single most useful hook if you run agents in the background. Notification fires when Claude Code wants your attention, including when it's been idle waiting on you.
#!/usr/bin/env bash
# ~/.claude/hooks/notify.sh
set -euo pipefail
payload=$(cat)
cwd=$(printf '%s' "$payload" | /usr/bin/python3 -c \
'import json,sys; print(json.load(sys.stdin).get("cwd",""))')
project=$(basename "$cwd")
/usr/bin/osascript -e "display notification \"Needs a decision\" \
with title \"Claude Code — $project\" sound name \"Ping\""Make it executable (chmod +x) and register it against Notification. Note the absolute paths — hooks don't necessarily inherit the PATH from your interactive shell, and a hook that silently fails because python3 wasn't found is an annoying afternoon.
A notification is fire-and-forget
macOS Notification Center coalesces and hides banners, and there's no delivery guarantee. If you're away from the keyboard, or in fullscreen, or three notifications deep, the one that mattered is gone. This is the ceiling on the hook-based approach — see notifications on macOS for what's above it.
Example: format files after Claude edits them
#!/usr/bin/env bash
# ~/.claude/hooks/format.sh — register on PostToolUse, matcher "Edit"
set -euo pipefail
file=$(cat | /usr/bin/python3 -c \
'import json,sys; print(json.load(sys.stdin).get("tool_input",{}).get("file_path",""))')
case "$file" in
*.ts|*.tsx|*.js|*.jsx|*.json|*.css)
npx --no-install prettier --write "$file" >/dev/null 2>&1 || true
;;
esacThe || true matters. A hook that exits non-zero on a file Prettier doesn't understand will interrupt the session for no good reason.
Example: a hard guardrail
PreToolUse can deny a tool call outright, regardless of permission mode. This is a stronger guarantee than a deny rule, because you can express conditions that rule syntax can't — like 'never push while on main'.
#!/usr/bin/env bash
# ~/.claude/hooks/guard-push.sh
# Register on PreToolUse with matcher "Bash(git push *)"
set -euo pipefail
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
if [ "$branch" = "main" ] || [ "$branch" = "master" ]; then
cat <<'JSON'
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Direct pushes to the default branch are blocked. Open a PR."
}
}
JSON
fiPrinting nothing means the call proceeds to normal permission handling. Printing the deny object stops it and hands Claude the reason, so it can adapt rather than retry blindly.
Debugging hooks
claude --debug hooksfilters debug output to hook execution — the fastest way to see whether your command ran at all.claude doctorchecks the health of your installation and reads settings files without a trust prompt.- Log the raw payload to a file first (
tee /tmp/hook.json) before writing any parsing logic. The field names are easier to read than to remember. - Use absolute paths for interpreters and binaries.
- Remember settings merge across user, project, and local scopes — a hook you can't find may be registered in a scope you're not looking at.
--setting-sourcescontrols which layers load.
Where hooks stop being enough
Hooks are excellent at *emitting* a signal and quite good at *enforcing* policy. What they can't do is guarantee the signal reaches you. PermissionRequest will fire reliably every time an agent blocks; whether you notice depends entirely on what the hook does next and where you happen to be looking.
That gap is exactly what BubbleCode was built to close — it registers SessionStart, SessionEnd, Stop, and PermissionRequest hooks plus a statusline to track sessions started from your terminal, then surfaces them in an overlay that renders above whatever you're doing.
FAQ
What hook events does Claude Code support?
As of 2.1.220: SessionStart, SessionEnd, UserPromptSubmit, PreToolUse, PostToolUse, PermissionRequest, Notification, Stop, SubagentStart, SubagentStop, TaskCompleted, PreCompact, and PostCompact.
Where do I configure Claude Code hooks?
Under the `hooks` key in a settings.json file — ~/.claude/settings.json for all projects, .claude/settings.json inside a repo for project-wide hooks, or .claude/settings.local.json for personal per-project hooks that shouldn't be committed.
How does a hook block a tool call?
Register it on PreToolUse and print a JSON object with hookSpecificOutput.permissionDecision set to "deny", plus a permissionDecisionReason explaining why. Claude receives the reason and can adjust its approach.
Can a hook send me a notification when Claude needs permission?
Yes — register a script on the Notification or PermissionRequest event and have it call osascript, terminal-notifier, or an HTTP endpoint. The limitation is delivery: system notification banners are easy to miss and don't appear over fullscreen apps.
Why isn't my hook running?
Most often the matcher doesn't match, the script isn't executable, or a binary isn't on the hook's PATH. Run `claude --debug hooks` to see execution attempts, and use absolute paths inside the script.
Keep reading
claude settings.json: an annotated reference
Where the files live, how scopes merge, and what each key that matters actually does.
Claude Code notifications on macOS
Six ways to get told an agent is blocked, from a terminal bell to a phone push — and where each one drops the signal.
Building a custom Claude Code statusline
The complete stdin JSON schema (context window, rate limits, PR, worktree) and a script that uses it.