Making Claude Code Talk: Terminal Bells and the Stop Hook
Claude Code runs long. You ask it to analyze a codebase, refactor a module, or write tests. It thinks for 30 seconds, a minute, sometimes longer. You tab over to Slack, check email, read docs. When you come back, it's been sitting there waiting for you to approve a file write, answer a question, or confirm a plan.
I needed a notification sound.
Not a visual indicator -- I wanted an audible alert that would reach me even when Claude Code wasn't the active window. The kind of thing that would pull me back from wherever I'd wandered off to, without being intrusive enough to annoy me every time.
This is what the Stop hook is for.
What Is the Stop Hook?#
Claude Code has five hook types. I've written about SessionEnd (for archiving transcripts) and PostToolUse (for logging operations). The Stop hook is different: it fires when Claude finishes its turn and needs user input.
That means:
- Permission prompts (file writes, shell commands)
- Questions that need answers
- Plans that need approval
- Anything where Claude is waiting on you
It doesn't fire when Claude is still thinking or streaming output. It fires at the exact moment you need to look at the screen.
Perfect for a notification sound.
The PowerShell Implementation#
Here's the version I'm using now:
# prompt-notify.ps1
# Hook event: Stop (fires when Claude's turn ends and user input is needed)
try {
# Play the Windows "Asterisk" system sound -- a gentle notification tone.
# Other options: Beep, Exclamation, Hand, Question
[System.Media.SystemSounds]::Asterisk.Play()
} catch {
# Fallback: send BEL character to the console (terminal bell)
[Console]::Beep(800, 200) # 800 Hz tone for 200ms
}
What's happening: This uses the .NET System.Media.SystemSounds API, which is built into PowerShell on Windows. No dependencies, no external binaries, no audio files to manage. It just plays one of the system sounds Windows already has configured.
The options:
| Sound | Tone | Use Case |
|---|---|---|
| Asterisk | Soft, pleasant | Notifications (my choice) |
| Beep | Default system beep | General alerts |
| Exclamation | Warning tone | Something needs attention |
| Hand | Error sound (harsher) | Something went wrong |
| Question | Prompt sound | Decisions needed |
If the SystemSounds call fails (rare, but possible in some environments), it falls back to [Console]::Beep(), which generates a tone directly: 800 Hz for 200 milliseconds. Short, not annoying, audible enough to notice.
The Bash Implementation#
I also wrote a bash version. It's more portable -- works on Linux, macOS, Windows (if you have Git Bash or WSL) -- and it taught me more about how terminal notifications actually work.
#!/bin/bash
# prompt-notify.sh
# Hook event: Stop (fires when Claude's turn ends and user input is needed)
# Send terminal bell to stdout
printf '\a'
# Also write directly to the controlling terminal device, in case the hook
# system captures stdout. /dev/tty always refers to the user's terminal.
printf '\a' > /dev/tty 2>/dev/null || true
# Send OSC 9 notification (supported by iTerm2, Windows Terminal, and others).
printf '\e]9;Claude Code needs your attention\a' > /dev/tty 2>/dev/null || true
# Send OSC 777 notification (supported by rxvt-unicode, some other terminals).
printf '\e]777;notify;Claude Code;Needs your attention\a' > /dev/tty 2>/dev/null || true
This does four things. Let me explain each one.
Terminal Bell Protocols: A Brief History#
BEL (ASCII 7, \a)#
The oldest and simplest. In the 1970s, physical terminals had actual bells -- literal metal bells that would ring when they received the BEL character. Modern terminal emulators emulate this by playing a sound, flashing the window, or showing a notification badge.
When you printf '\a', your terminal emulator intercepts that character and does whatever it's configured to do for "bell" events.
Client-Side Magic
The key insight: this happens on the client side. If you're SSH'd into a remote server and run printf '\a', the BEL character travels through the SSH stream back to your local terminal, which then plays the sound on your machine. The server doesn't need speakers. The server doesn't even need to know what a sound is.
This is why terminal bell works in web-based terminals, over SSH, in tmux sessions -- the character just flows through the stream until it reaches something that can interpret it.
OSC Sequences (Operating System Command)#
OSC sequences are escape codes that let programs communicate with the terminal emulator in richer ways. The format is:
\e]<number>;<params>\a
Where \e is the escape character (ASCII 27), ] starts the OSC sequence, and \a (BEL) terminates it.
OSC 9 is a non-standard extension used by iTerm2, Windows Terminal, and some others. It triggers a desktop notification with custom text:
printf '\e]9;Claude Code needs your attention\a'
Windows Terminal will pop a toast notification. iTerm2 will show a notification banner. Some terminals ignore it entirely.
OSC 777 is an alternative protocol used by rxvt-unicode and some others:
printf '\e]777;notify;Title;Message\a'
Same idea, different syntax.
Terminal Compatibility
The bash script sends all of them because terminal compatibility is a mess. Some terminals support OSC 9, some support OSC 777, some only support BEL, and some support none of them. Sending all three maximizes the chance that something will work.
Why Write to /dev/tty?#
Notice the script writes to both stdout and /dev/tty:
printf '\a' # To stdout
printf '\a' > /dev/tty 2>/dev/null # To /dev/tty
Insurance Against Redirection
/dev/tty is a special file that always refers to the controlling terminal of the current process, even if stdout has been redirected. Writing to /dev/tty bypasses any intermediate buffering or redirection and sends the character directly to the terminal device.
If Claude Code's hook system captures stdout for logging or error handling, the \a might never reach your terminal. /dev/tty is insurance.
The 2>/dev/null || true suppresses errors if the process doesn't have a controlling terminal (rare, but possible in some environments).
The Windows Gotcha#
Bash Not in PATH
Here's the hook configuration from the first attempt:
{
"hooks": {
"Stop": [{
"hooks": [{
"type": "command",
"command": "bash .claude/hooks/prompt-notify.sh"
}]
}]
}
}
This failed with:
Stop hook error: Failed with non-blocking status code: 'bash' is not recognized
as an internal or external command, operable program or batch file.
The problem: I was running Claude Code in PowerShell on Windows. Git Bash wasn't in the PATH that Claude Code's hook system uses. The hook tried to run bash, couldn't find it, and failed.
I could have added Git Bash to PATH. But that felt fragile -- it would break if I moved the Git Bash install, used a different terminal, or shared the config with someone.
The fix: Use PowerShell, which is always available on Windows:
{
"hooks": {
"Stop": [{
"hooks": [{
"type": "command",
"command": "powershell -NoProfile -ExecutionPolicy Bypass -Command \". '.claude/hooks/prompt-notify.ps1'\""
}]
}]
}
}
This matches the pattern already established for the SessionEnd and PostToolUse hooks. Consistent, reliable, no PATH dependencies.
The Full Hooks Ecosystem#
Here's what my .claude/settings.local.json looks like now, with all three hooks working together:
{
"hooks": {
"SessionEnd": [{
"hooks": [{
"type": "command",
"command": "powershell -NoProfile -ExecutionPolicy Bypass -Command \". '.claude/hooks/save-session.ps1'\""
}]
}],
"PostToolUse": [{
"matcher": "Bash|Edit|Write|NotebookEdit",
"hooks": [{
"type": "command",
"command": "powershell -NoProfile -ExecutionPolicy Bypass -Command \". '.claude/hooks/log-activity.ps1'\"",
"async": true
}]
}],
"Stop": [{
"hooks": [{
"type": "command",
"command": "powershell -NoProfile -ExecutionPolicy Bypass -Command \". '.claude/hooks/prompt-notify.ps1'\""
}]
}]
}
}
Three hooks, three different event types:
| Hook | Event | Purpose | Async? |
|---|---|---|---|
save-session.ps1 | SessionEnd | Archives conversation transcripts | No |
log-activity.ps1 | PostToolUse | Logs file edits and shell commands | Yes |
prompt-notify.ps1 | Stop | Plays notification sound | No |
Async Matters
Notice the PostToolUse hook uses "async": true because logging shouldn't block Claude Code while it's working. The Stop hook doesn't need async -- it fires after Claude is done, so a brief sound delay doesn't matter.
Customizing the Sound#
If you're on Linux or macOS and use the bash version, the sound you hear depends entirely on your terminal emulator's configuration.
| Terminal | Setting Location |
|---|---|
| Windows Terminal | Settings > Profiles > Advanced > Bell notification style |
| iTerm2 | Preferences > Profiles > Terminal > Notifications |
| macOS Terminal.app | Preferences > Profiles > Advanced > Audible bell |
| GNOME Terminal | Preferences > Profiles > Sound > Terminal bell |
| Alacritty | alacritty.yml: bell.command or bell.duration |
| Kitty | kitty.conf: enable_audio_bell and visual_bell_duration |
Most terminals let you choose between audible bell (plays a sound), visual bell (flashes the window), both, or none. Some, like iTerm2, let you choose a custom sound file. Others use the system notification sound.
On Windows with the PowerShell version, you can change the system sound through Control Panel > Sound > Sounds tab > Asterisk and assign any .wav file you want.
What I Learned#
1. Platform Consistency Matters
Using PowerShell for all hooks -- not mixing bash and PowerShell -- eliminated an entire class of PATH and environment issues. Pick one shell and stick with it.
2. Terminal Bell Is Client-Side Magic
The BEL character travels through SSH, tmux, even web terminals, and gets interpreted by the final terminal emulator on the user's machine. That's surprisingly elegant for a protocol from the 1970s.
3. Escape Sequences Are Not Portable
OSC 9 works in some terminals, OSC 777 works in others, and some support neither. Sending multiple protocols is the only way to maximize compatibility. Don't assume your terminal supports any specific escape sequence.
4. /dev/tty Is Insurance
Even if you think stdout will reach the terminal, writing to /dev/tty guarantees the notification gets through. It costs one extra line and eliminates an entire class of "it works here but not there" bugs.
5. The Windows API Is Simpler
[System.Media.SystemSounds]::Asterisk.Play() is three lines of PowerShell. No escape codes, no protocol negotiation, no terminal compatibility matrix. Just a function call that plays a sound. Sometimes the platform-native approach wins.
Why This Matters#
The Stop hook is the smallest of my three hooks. It's 10 lines of PowerShell. It doesn't write files, doesn't log data, doesn't archive anything. It just plays a sound.
But it changed how I use Claude Code.
I don't sit and watch it think anymore. I ask a question, tab away, and come back when I hear the notification. I'm more productive because I'm not context-switching in anticipation of when it might finish -- I switch when it tells me it's ready.
And building it taught me more about terminal protocols than I expected to learn. The BEL character, OSC sequences, /dev/tty, the Windows sound API -- none of that was obvious when I started. I just wanted a notification sound. I ended up understanding how terminals work.
That's the pattern with this project. Every small automation teaches something deeper.
Written by Chris Johnson and edited by Claude Code (Opus 4.6). The full source code is at github.com/chris2ao/cryptoflexllc. This post is part of a series about AI-assisted development. Previous: I Audited My Own Code. 19 Security Findings Later.... Next: Evaluating Free WAFs So You Don't Have To: Cloudflare vs Vercel.
Weekly Digest
Get a weekly email with what I learned, summaries of new posts, and direct links. No spam, unsubscribe anytime.
Related Posts
From 70K tokens per session to 7K. A 7-agent audit, 23 evaluation documents, 11 component scorecards, 5 optimization patterns, and an 8-agent implementation team. This is the full story of cutting context consumption by 90%.
A deep dive into configuring Claude Code for real-world use - from modular rules and session logging hooks to MCP servers and the everything-claude-code plugin ecosystem.
How I turned a functional web port of a 1991 game into a full-featured modern 4X strategy game across four feature phases and a wiring plan, using Claude Code as the primary development engine.
Comments
Subscribers only — enter your subscriber email to comment
