BubbleCodeFounding access

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

EventFires whenCan it change behaviour?
SessionStartA session begins (including resume).Yes — can inject additionalContext.
SessionEndA session ends.No. Cleanup and logging only.
UserPromptSubmitYou submit a prompt, before Claude sees it.Yes — can inject context or block the prompt.
PreToolUseBefore a tool call runs.Yes — can allow, deny, or ask via permissionDecision.
PostToolUseAfter a tool call returns.Yes — can feed the result back for another pass.
PermissionRequestA tool call needs a permission decision.Yes — the hook for routing approvals somewhere you'll see them.
NotificationClaude Code emits a notification (including idle waits).No. The classic 'alert me' hook.
StopThe main agent finishes its turn.Yes — can force it to keep going.
SubagentStartA subagent is spawned.No.
SubagentStopA subagent finishes.Yes, same shape as Stop.
TaskCompletedA tracked task is marked complete.No.
PreCompactBefore context compaction runs.No.
PostCompactAfter 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.

json
{
  "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'.

FieldEffect
continueSet to false to stop the session after this hook.
stopReasonMessage shown when continue is false.
systemMessageA message surfaced to you in the session.
suppressOutputHide the hook's own stdout from the transcript.
decisionOn Stop/SubagentStop, block forces the agent to continue working.
hookSpecificOutput.additionalContextText injected into Claude's context. Works on SessionStart, UserPromptSubmit, PostCompact.
hookSpecificOutput.permissionDecisionOn PreToolUse: allow, deny, or ask. This is programmable policy.
hookSpecificOutput.permissionDecisionReasonExplanation 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.

bash
#!/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

bash
#!/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
    ;;
esac

The || 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'.

bash
#!/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
fi

Printing 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 hooks filters debug output to hook execution — the fastest way to see whether your command ran at all.
  • claude doctor checks 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-sources controls 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