← All guidesHooks

The Claude Code Stop hook, explained

Of all Claude Code hooks, Stop is the one people reach for first. It answers the single most useful question when you have stepped away: is it done?

When it fires

Stop fires when Claude finishes its turn and hands control back to you: the moment the agent stops working and waits for your next prompt. It does not fire for every tool call or every message; it fires once, at the end of a turn.

That makes it the right signal for "the run I kicked off is finished", and the wrong one for "something happened mid-run" (for that, look at PostToolUse). It is also not the same as the session ending: a session usually fires Stop many times before SessionEnd fires once.

The minimal hook

Like every hook, Stop lives in a settings.json under the hooks key. It is a lifecycle event, so there is no tool matcher:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "echo \"done at $(date)\" >> ~/claude.log" }
        ]
      }
    ]
  }
}

What it receives

Claude pipes a JSON payload to your command on standard input:

FieldContains
session_idWhich session finished, useful when you run several.
transcript_pathPath to the conversation log on disk.
cwdThe directory the session is running in.
stop_hook_activetrue if this turn is already continuing because of a Stop hook.

cwd is the one you will use most: it turns a generic "done" into "the API project is done".

#!/usr/bin/env bash
# .claude/hooks/done.sh
payload=$(cat)
project=$(basename "$(echo "$payload" | jq -r '.cwd')")
notify-send "Claude Code" "$project finished"

Notify yourself

The most common use is a heads-up. On macOS:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "osascript -e 'display notification \"Run finished\" with title \"Claude Code\" sound name \"Glass\"'" }
        ]
      }
    ]
  }
}

On Linux, use notify-send 'Claude Code' 'Run finished'. Want a plain sound instead? A single printf '\\a' rings the terminal bell. There is a fuller walkthrough in how to get notified when Claude Code needs you (or finishes).

Chain the next step

Because the command is just a shell command, Stop can also do something, like run your test suite when a coding turn ends:

{
  "hooks": {
    "Stop": [
      { "hooks": [{ "type": "command", "command": "npm test --silent || notify-send 'Claude Code' 'tests failed'" }] }
    ]
  }
}

Keep these fast and safe: the hook runs automatically, so anything slow or destructive will bite you unattended. The timeout field (in seconds) caps how long Claude waits:

{ "type": "command", "command": "npm test --silent", "timeout": 120 }

Sending Claude back to work

Stop can do more than observe: exit with code 2 and whatever your script prints on stderr goes back to Claude, which continues the turn instead of stopping. That turns the hook into a completion check: if the linter fails, do not let the run end.

#!/usr/bin/env bash
payload=$(cat)

# Never block twice in a row, or the turn will never end.
if [ "$(echo "$payload" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi

if ! npm run lint --silent; then
  echo "Lint is failing. Fix the reported errors before finishing." >&2
  exit 2
fi
exit 0

That stop_hook_active guard is not optional. Without it, a hook that always blocks will send Claude back to work forever, burning tokens with nobody watching. Check the flag, or cap the retries yourself with a counter file.

Stop, SubagentStop and SessionEnd

Three events sound like "it is over" and only one of them means what you want:

EventFiresTimes per session
StopThe main agent finishes a turn.Once per turn, so many times.
SubagentStopA spawned subagent finishes.Once per subagent, often in bursts.
SessionEndThe session itself closes.Once, at the very end.

For "come back, it is done", Stop is the one. Wiring a notification to SubagentStop will alert you eight times during a single fan-out, and SessionEnd fires too late to be useful. More on all of them in what every Claude Code hook event means.

Saying what it finished

Stop tells you a turn ended, not what came out of it. If you want that in your notification, the payload hands you transcript_path, a JSON Lines file of the conversation, so the last assistant message is one jq away:

#!/usr/bin/env bash
payload=$(cat)
transcript=$(echo "$payload" | jq -r '.transcript_path')
last=$(tail -n 20 "$transcript" | jq -r 'select(.type == "assistant") | .message.content[]? | select(.type == "text") | .text' | tail -c 120)
notify-send "Claude Code" "${last:-Run finished}"

Be deliberate about this one. It is genuinely useful locally, and a bad idea for anything that leaves your machine: the transcript holds your prompts and your code, so never pipe it to a chat webhook or a remote logger.

Debugging it

A Stop hook that seems to do nothing is almost always one of two things. Either you edited settings.json mid-session, and Claude Code is still using the hooks it snapshotted at startup (restart, or review from /hooks). Or the command works in your terminal but not here, because hooks run without your interactive shell setup and cannot see an alias or a PATH entry from your profile. Use absolute paths, and run claude --debug to watch the hook fire and see its exit code.

One Stop hook per session, read at a glance

A Stop notification is perfect for one session. Run several at once and the pings blur together: which project just finished? Blooby uses the same Stop event, but instead of a notification it makes the mascot for that specific session celebrate, so you see which of your running sessions just wrapped up without reading a word.

See it for yourself

download Get Blooby free