Hear when Claude Code is done (or needs you)

Set up notification sounds so you can step away while Claude works, and know the moment it needs your attention.

By on

Loading the Elevenlabs Text to Speech AudioNative Player...

Here’s how I got Claude Code to play a completion sound when it finisehes responding, and an approval sound when it needs my sign-off to run a command.

I set it up to play a random sound from a folder of audio clips to keep it interesting, but you can use a single sound if you prefer.

All we need is a single global config file and two small scripts, and it works automatically in every project.

No more waiting around. I can minimize the terminal, switch to another app, and know Claude will call me when it’s done.

How Claude Code Hooks Work

Claude Code has a hook system that lets you run shell commands in response to lifecycle events. You define hooks in ~/.claude/settings.json under a hooks key. Each hook entry specifies an event type and a command to run.

The two events we care about:

These are global hooks, so they apply to every project automatically. The full list of available hook events is in the Claude Code hooks reference.

Step 1: Create the Sounds Directories

Create two folders inside .claude/sounds/ to keep the pools separate:

Windows:

C:\Users\YOUR_USERNAME\.claude\sounds\completion\
C:\Users\YOUR_USERNAME\.claude\sounds\approval\

macOS / Linux:

~/.claude/sounds/completion/
~/.claude/sounds/approval/

Add MP3 or WAV files to each. I create voice clips with ElevenLabs. Keep them short so they don’t overstay their welcome.

Step 2: Create the Scripts

You need two scripts: one that picks randomly from completion/, one that picks randomly from approval/.

Windows

Create these two PowerShell scripts in your sounds folder.

play.ps1 (random completion sound):

Add-Type -AssemblyName presentationCore
$soundDir = 'C:/Users/YOUR_USERNAME/.claude/sounds/completion'
$sounds = @(Get-ChildItem -Path "$soundDir/*.mp3") + @(Get-ChildItem -Path "$soundDir/*.wav")
$pick = $sounds | Get-Random
$mp = New-Object System.Windows.Media.MediaPlayer
$mp.Open([uri]$pick.FullName)
$mp.Play()
Start-Sleep 4

play-approval.ps1 (random approval sound):

Add-Type -AssemblyName presentationCore
$soundDir = 'C:/Users/YOUR_USERNAME/.claude/sounds/approval'
$sounds = @(Get-ChildItem -Path "$soundDir/*.mp3") + @(Get-ChildItem -Path "$soundDir/*.wav")
$pick = $sounds | Get-Random
$mp = New-Object System.Windows.Media.MediaPlayer
$mp.Open([uri]$pick.FullName)
$mp.Play()
Start-Sleep 4

Replace YOUR_USERNAME with your actual Windows username in both files.

The Start-Sleep 4 keeps the PowerShell process alive long enough for the audio to finish before the process exits. Without it, the sound gets cut off immediately. The scripts use System.Windows.Media.MediaPlayer, which is built into Windows and supports MP3 and WAV without any extra dependencies.

macOS

Create these two shell scripts in your sounds folder.

play.sh (random completion sound):

#!/bin/bash
SOUND_DIR="$HOME/.claude/sounds/completion"
FILES=($(ls "$SOUND_DIR"/*.mp3 "$SOUND_DIR"/*.wav 2>/dev/null))
PICK="${FILES[$RANDOM % ${#FILES[@]}]}"
afplay "$PICK"

play-approval.sh (random approval sound):

#!/bin/bash
SOUND_DIR="$HOME/.claude/sounds/approval"
FILES=($(ls "$SOUND_DIR"/*.mp3 "$SOUND_DIR"/*.wav 2>/dev/null))
PICK="${FILES[$RANDOM % ${#FILES[@]}]}"
afplay "$PICK"

afplay is built into macOS and handles both MP3 and WAV. After creating the files, make them executable:

chmod +x ~/.claude/sounds/play.sh ~/.claude/sounds/play-approval.sh

On Linux, replace afplay with aplay for WAV files, or install mpg123 for MP3 support.

Step 3: Configure the Hooks

Open (or create) ~/.claude/settings.json and add the hooks section. You also need to pre-approve the script commands in permissions.allow so Claude can run them automatically without prompting you each time.

Windows

{
  "permissions": {
    "allow": [
      "Bash(powershell -WindowStyle Hidden -File \"C:/Users/YOUR_USERNAME/.claude/sounds/play.ps1\")",
      "Bash(powershell -WindowStyle Hidden -File \"C:/Users/YOUR_USERNAME/.claude/sounds/play-approval.ps1\")"
    ]
  },
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "powershell -WindowStyle Hidden -File \"C:/Users/YOUR_USERNAME/.claude/sounds/play.ps1\""
          }
        ]
      }
    ],
    "PermissionRequest": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "powershell -WindowStyle Hidden -File \"C:/Users/YOUR_USERNAME/.claude/sounds/play-approval.ps1\""
          }
        ]
      }
    ]
  }
}

The -WindowStyle Hidden flag runs PowerShell in the background without opening a visible terminal window.

macOS / Linux

{
  "permissions": {
    "allow": [
      "Bash(bash \"/Users/YOUR_USERNAME/.claude/sounds/play.sh\")",
      "Bash(bash \"/Users/YOUR_USERNAME/.claude/sounds/play-approval.sh\")"
    ]
  },
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "bash \"/Users/YOUR_USERNAME/.claude/sounds/play.sh\""
          }
        ]
      }
    ],
    "PermissionRequest": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "bash \"/Users/YOUR_USERNAME/.claude/sounds/play-approval.sh\""
          }
        ]
      }
    ]
  }
}

Replace YOUR_USERNAME with your macOS username in both the permissions.allow entries and the hook commands.

The matcher field can filter which tool calls trigger the hook. An empty string matches everything, which is what we want here.

If your settings.json already has other content (permissions, model preferences, etc.), merge these sections in rather than replacing the whole file.

That’s it: Two folders, two scripts, a few lines of JSON. Enjoy.