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.
- UserPromptSubmit fires when you press Enter to send a prompt, before the agent starts thinking.
- Stop fires when the agent finishes that turn and is about to hand the screen back to you.
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.
session_idthe session id, used to name per-session temp files so sessions never collide.cwdthe working folder, which we can turn into a tab name.transcript_pathwhere the conversation file lives, in case we want to pull the last message to speak.
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
- Ping when the agent asks a question use the
Notificationevent to play a sound when it pauses for you, so it never sits waiting unnoticed. - Block a dangerous command use
PreToolUseto inspect a shell call before it runs, and refuse anrm -rfor a push to main. - Auto-format code use
PostToolUseto run a formatter every time it edits a file. - Keep secrets from leaking check the diff before a commit for a key or a
.env.
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.
- 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. - Add the short-turn gate: a UserPromptSubmit hook that records the time, and a Stop that checks the duration.
- Add "what finished": pull from the transcript first, then graduate to the agent writing its own note.
- Add "which tab": set
CC_TAB_LABELper 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.
This is one layer (tools/skills) of the full production AI agent architecture (7 layers).
- Claude Code hooks reference (official docs) · docs.claude.com/en/docs/claude-code/hooks
- Claude Code settings (the
hookskey in settings.json) · docs.claude.com/en/docs/claude-code/settings - All scripts and behavior in this post were tested on the author's machine (macOS, built-in
say). The examples are runnable, minimal versions.