productize.life
TH EN
Claude Code · Hooks

A voice from AI that tells you which task finished, and which tab

You send an agent off on a long run, step away, and come back unsure which of six identical tabs is done. Here is how to make it speak what finished and where, and see that a hook is the seam where you teach your agent new habits.

Yim· written with Dobby (AI Oracle)/Jul 3, 2026

The other night I set an agent loose on a whole test suite. I knew it would take a while, so I got up to make coffee. When I came back there were six terminal tabs open, all identical, and I had no idea which one had finished and which was still grinding. I had to click through them one at a time.

The problem was not that the agent worked badly. It worked fine. The problem was that I could not tell when it finished, or which task finished. The screen looked exactly the same whether it was thinking or long done.

What I wanted was small: a short spoken line when a long run ends, saying something like "tab api: fixed the failing tests." That alone would let me walk away without babysitting the screen. And chasing it led somewhere bigger than a notification sound. It led to the hook, the place where you actually reach in and shape what the agent does.

Part 1What a hook is, and why it is the seam you control

When we talk to an agent, we tend to assume there is only one way to shape its behavior: write a better prompt. But a prompt is a request, not a command. The model may follow it or may not. A hook is a different thing entirely. It is a command Claude Code itself runs every time, at a defined moment, with no model decision in the loop.

The word worth keeping is deterministic: the result is the same every time, because the host runs it, not the model. That is exactly what makes a hook trustworthy enough for the serious jobs, like blocking a dangerous command or stopping a secret from being committed. You cannot leave those to "please remember, model."

The events you will use most

Claude Code exposes several moments to hook into. This post uses two of them.

There are others, like PreToolUse and PostToolUse (before and after the agent calls a tool) and Notification (when the agent pauses to ask you something). Each opens a different door. Here we take just Stop and UserPromptSubmit, which is enough for the whole notification.

What a hook gets to work with

Every time it fires, Claude Code feeds the turn's data to the hook on stdin as a blob of JSON. Three fields carry us the whole way.

This is the seam: a fixed point, with the turn's data delivered to it, into which you slot your own code. Everything else is just deciding what to do with it.

Part 2The worked example: speak what finished, and which tab

Unpack the original problem and there are really two questions stacked together. One, when did the run finish? Two, what finished, and in which tab? We solve them one at a time.

Why it takes two hooks

The first question hides a trap. Stop does fire when a turn ends, true, but if the turn was short, a one-line question and answer, I do not want a sound every time. I only want it when a long run ends. The catch is that Stop on its own does not know when the turn started, so it cannot measure how long it ran.

The fix is to have UserPromptSubmit record the start time first, then have Stop subtract it from the current time to get the turn's duration. Two hooks working as a pair: one starts the clock, the other stops it and reads the value.

Hook one: record the turn's start time (UserPromptSubmit)

#!/usr/bin/env bash
# notify-turn-start.sh (UserPromptSubmit hook)
set -uo pipefail

sid="$(jq -r '.session_id // "nosession"')"
sid="${sid//[^A-Za-z0-9_-]/_}"          # safe for a filename

date +%s > "/tmp/cc-turn-start-${sid}"   # stamp the start
rm -f "/tmp/cc-done-note-${sid}"          # clear last turn's note
exit 0

The steps to get right when a turn ends

The second hook (Stop) does three things in order: gate out short turns, find what finished, then speak. But there is a trap I hit myself. Stop also fires on resume, /clear, and compact. If you do not guard against it, it will blurt something the moment you open a session. The guard is simple: those cases have no start-time file (they never went through UserPromptSubmit), so if the file is missing, just exit.

For "what finished" I use two tiers of source. The best one: have the agent write a one-line summary to a temp file itself during the run (a summary written by the worker is sharper than one scraped after). If a given turn did not write one, fall back to pulling the last message from the transcript and trimming it short.

Hook two: gate short turns, find what finished, speak (Stop)

#!/usr/bin/env bash
# notify-done.sh (Stop hook)
set -uo pipefail

MIN_SEC="${CC_NOTIFY_MIN_SEC:-600}"     # announce only turns >= 10 min
[ "${CC_NOTIFY:-1}" = "0" ] && exit 0   # mute without removing the hook

INPUT="$(cat)"
sid="$(printf '%s' "$INPUT"   | jq -r '.session_id // "nosession"')"
sid="${sid//[^A-Za-z0-9_-]/_}"
cwd="$(printf '%s' "$INPUT"   | jq -r '.cwd // empty')"
tpath="$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty')"

# --- gate short turns + resume/clear (no start file) ---
start_file="/tmp/cc-turn-start-${sid}"
[ -f "$start_file" ] || exit 0
start="$(cat "$start_file")"; rm -f "$start_file"
case "$start" in (*[!0-9]*|"") exit 0 ;; esac
elapsed=$(( $(date +%s) - start ))
[ "$elapsed" -lt "$MIN_SEC" ] && exit 0

# --- which tab: set CC_TAB_LABEL per tab, else the folder name ---
label="${CC_TAB_LABEL:-}"
[ -z "$label" ] && [ -n "$cwd" ] && [ "$cwd" != "$HOME" ] && label="$(basename "$cwd")"

# --- what finished: agent's own note first, else the transcript ---
note="/tmp/cc-done-note-${sid}"; msg=""
[ -s "$note" ] && msg="$(cat "$note")" && rm -f "$note"
[ -z "$msg" ] && [ -f "$tpath" ] && msg="$(tail -n 200 "$tpath" \
  | jq -rs '[.[]|select(.type=="assistant")|.message.content[]?|select(.type=="text")|.text]|last // empty' \
  | sed -E 's/\`[^\`]*\`//g; s/[#*_>|~-]//g' | tr '\n' ' ' | cut -c1-160)"
[ -z "$msg" ] && msg="done"

say -r 175 "${label:+$label: }$msg"   # macOS built-in TTS; swap freely

Wire the hooks into Claude Code

One step left: tell Claude Code which event runs which script. Put it in settings.json (for example ~/.claude/settings.json) under the hooks key, and run the Stop hook async so the voice does not delay handing the screen back.

~/.claude/settings.json

{
  "hooks": {
    "UserPromptSubmit": [
      { "hooks": [{ "type": "command",
        "command": "bash ~/.claude/hooks/notify-turn-start.sh" }] }
    ],
    "Stop": [
      { "hooks": [{ "type": "command", "async": true,
        "command": "bash ~/.claude/hooks/notify-done.sh" }] }
    ]
  }
}

chmod +x both scripts, open a new session, and try a long task (or drop CC_NOTIFY_MIN_SEC to 5 for a moment to test). When it finishes you should hear the tab name and a summary of the work.

Make "what finished" sharper

The transcript fallback works, but sometimes it grabs a lead-in phrase instead of the point. The sharper path is to teach the agent to write its own summary near the end of a turn. Say it once, for example: "before finishing a long turn, write a one-line summary to /tmp/cc-done-note-$session_id." Now the voice goes from "edited the file in folder x" to "fixed the null bug on login, all tests pass," which tells you far more.

Part 3Make it your own

The one thing to remember

The notification is just a fun excuse. The thing worth keeping is this: a hook is the fixed seam where you teach your agent new habits. Once you see it as a seam and not a gimmick, you start noticing how many behaviors you used to "remind the model" about can instead become a hook, and then simply happen every time, no luck involved.

What the same seam can do next

Every one is the same seam. You only change the event and what runs inside it.

Where to start

Do not build all the layers at once. Get a spark going first.

  1. Write the smallest possible Stop hook that just runs say "done". Wire it in settings.json and confirm you actually hear it when a turn ends.
  2. Add the short-turn gate: a UserPromptSubmit hook that records the time, and a Stop that checks the duration.
  3. Add "what finished": pull from the transcript first, then graduate to the agent writing its own note.
  4. Add "which tab": set CC_TAB_LABEL per tab.

Each layer is testable on its own, and the whole thing is plain shell, with nothing to install beyond jq, which many machines already have.

Once that first sound lands as a long run ends, the feeling changes. The screen stops being something you have to watch. And that is what a hook gives you: not just a voice, but a button to add habits to your own tools.

More in the Claude Code series

This is one layer (tools/skills) of the full production AI agent architecture (7 layers).

Sources and references
Follow along

Get new posts and free resources first

Leave your email. New posts and the occasional free resource land in your inbox. No spam.

Email only, for updates.

Comments

Join the conversation

Share a thought.

Name is shown publicly. Email stays private and is never shown.

Loading comments…