Earpiece

← Blog

Claude Code hooks explained: Stop, Notification and the rest

By Aditya Vernekar · · 5 min read

A hook is a command Claude Code runs at a fixed point: before a prompt, before or after a tool call, when it notifies you, when a turn ends. It gets JSON on stdin and talks back through its exit code and stdout. Most useful automations need only five events: UserPromptSubmit, PreToolUse, PostToolUse, Notification and Stop.

We built Earpiece on Claude Code hooks, so we have written, broken and debugged a lot of them. This is the practical version of the reference: what each common event is for, what it receives, and the mistakes that make hooks slow, noisy or stuck in a loop. The official hooks reference has every field.

Where hooks live

Hooks go in a settings file. Claude Code reads several, and each can define hooks:

  • ~/.claude/settings.json: yours, for every project.
  • .claude/settings.json: the project’s, committed so the team shares it.
  • .claude/settings.local.json: the project’s, but only on your machine (git-ignored).
  • Organization-wide managed settings, if your company uses them.

Running sessions normally pick up edits automatically through a file watcher. If a change doesn’t show up after a few seconds, restart the session.

The shape of a hook

There are three levels: the event (when), an optional matcher (only for some tools or types), and one or more handlers (what runs):

~/.claude/settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "jq -r '.tool_input.file_path' | xargs npx prettier --write" }
        ]
      }
    ]
  }
}

That one formats every file Claude edits. The matcher means different things per event: a tool name for PreToolUse and PostToolUse, a notification type for Notification, and the start reason for SessionStart. Events like Stop don’t use one. Handlers can also be an HTTP endpoint, an MCP tool, a prompt or an agent; this post sticks to shell commands.

What every hook receives

Each hook gets one JSON object on stdin. These fields are always there:

FieldWhat it is
session_idThe conversation. Use it to tell parallel sessions apart.
cwdThe working directory, a good name for the project.
hook_event_nameWhich event fired, so one script can handle several.
transcript_pathThe conversation file. It’s written asynchronously and can lag the latest turn.
permission_modedefault, plan, acceptEdits and so on.

The five events worth knowing

UserPromptSubmit: a turn starts

Runs when you submit a prompt, before Claude sees it. Use it to add context, block prompts that match a rule, or record when a turn started. It blocks the model until it returns, so its default timeout is 30 seconds rather than 600. Keep it fast.

PreToolUse: before a tool runs

The guard rail. It receives tool_name and tool_input and can deny the call, for example refusing any Bash command that contains rm -rf. Match on the tool name so it doesn’t run for every read.

PostToolUse: after a tool runs

Receives the tool’s input and its response. Good for formatters, linters and logs. It’s also the cleanest signal that a permission prompt was answered: if the tool ran, you approved it. Earpiece uses that to drop a “needs your permission” alert you already dealt with.

Notification: Claude would notify you

Receives message, an optional title and a notification_type. The two that matter most:

  • permission_prompt: Claude needs approval, and the prompt has waited about six seconds without a keystroke.
  • idle_prompt: Claude finished about 60 seconds ago and you haven’t typed since.

Both are timed the same way as desktop notifications, so they mostly fire when you’re away from the terminal. To react the moment a permission prompt appears, use PermissionRequest instead. Notification hooks can’t block or change the notification; they are for side effects.

Stop: the turn is done

Runs when Claude finishes responding (not when you interrupt; API errors fire StopFailure). Besides the common fields it receives:

  • last_assistant_message: the text of Claude’s final reply. Use this for notifications or read-aloud hooks; the transcript file isn’t guaranteed to contain it yet.
  • stop_hook_active: true when Claude is already continuing because a Stop hook told it to.
  • background_tasks and session_crons: work still running or scheduled, so you can tell “done” from “paused until a background task wakes it”.

A Stop hook can also send Claude back to work, which brings us to exit codes.

Exit codes and output

  • 0: success. Claude Code reads stdout, as plain text or as JSON with fields like decision and reason.
  • 2: a blocking error, on events that support blocking (PreToolUse, UserPromptSubmit, Stop, and a few others). On Stop, that means “don’t stop yet”, and stderr goes to Claude as the reason.
  • Anything else: a non-blocking error. It’s logged and the session carries on.

A Stop hook that blocks while a condition is unmet (“tests still failing, keep going”) is powerful. It is also how you get a session that never ends. Check stop_hook_active and give up after a while. Claude Code caps it as well: after eight continuations in a row, it ends the turn anyway.

Mistakes we made so you don’t have to

  • Slow hooks slow everything. The default timeout for command hooks is 600 seconds, and a hook that blocks holds up the event it belongs to. Do the slow part (an API call, audio) in the background, or set "async": true on the handler. Earpiece’s hooks return in milliseconds and hand the work to a detached process.
  • PostToolUse runs after every tool call. One Node start costs about 50 ms. Fine once, noticeable a few hundred times a session. Use a matcher, or keep the script tiny.
  • Hooks don’t get your interactive shell. A command that works in your terminal may not find node, or find a different one. If you switch versions with nvm, write the absolute path to the binary.
  • Quoting. The command is a string inside JSON inside a shell. Spaces in paths and nested quotes break silently. Put anything non-trivial in a script file and call that.
  • Don’t print secrets. Hook input includes your prompts, tool inputs and Claude’s replies. Anything you log or send somewhere may contain tokens or keys.
  • Mind your duplicates. Editing settings by hand after an installer has run can leave two copies of the same hook, and you get every alert twice.

Try one in five minutes

The quickest useful hook: a notification when Claude needs you. Add this to ~/.claude/settings.json on a Mac:

~/.claude/settings.json
{
  "hooks": {
    "Notification": [
      {
        "matcher": "permission_prompt|idle_prompt",
        "hooks": [
          {
            "type": "command",
            "command": "osascript -e 'display notification \"Claude Code needs you\" with title \"Claude Code\"'"
          }
        ]
      }
    ]
  }
}

For “done” alerts and a spoken version, see how to get notified when Claude Code finishes. The official hooks guide has more recipes. If you want to see a complete hook integration, Earpiece’s Claude Code adapter is one file, claude-code.mjs, and it’s open source.