Even as someone with only a very loose understanding of web technologies, I was able to ship a real business tool to users around the world in just one week — while even taking security into account.
Lately, that has honestly made me feel like people who can only do technical implementation may gradually become unnecessary... and it’s kind of terrifying.
The tool that made this possible was ClaudeCode.
However, while using it, the one thing that kept bothering me was this:
- I wouldn’t notice when processing had finished, and would just leave it sitting there
- ClaudeCode would ask “Do you want to run this command?”, but I wouldn’t notice the prompt and would again leave it sitting there
That wasted idle time felt like such a waste!
So I decided to set things up so that sounds would play and notifications would appear when:
- processing finishes
- a confirmation dialog appears
My environment was:
Windows11 + VS Code (ClaudeCode Extension V2.1.143)
However, this can also be used in the CLI version as well
(if anything, the CLI version makes refresh behavior easier to understand).
📌Play a Sound When Processing Finishes
ClaudeCode allows you to hook into several events and execute custom commands.
The following example runs hooks/complete.ps1 (a PowerShell script) when processing finishes.
📄settings.json
Without removing your existing settings, add the following.
We’ll also add PreToolUse and PostToolUse here since they’ll be used later.
C:/Users/[user]/.claude/settings.json
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "powershell.exe -ExecutionPolicy Bypass -File C:/Users/[user]/.claude/hooks/complete.ps1"
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "powershell.exe -ExecutionPolicy Bypass -File C:/Users/[user]/.claude/hooks/pretool-resolve.ps1"
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "powershell.exe -ExecutionPolicy Bypass -File C:/Users/[user]/.claude/hooks/posttool-resolve.ps1"
}
]
}
]
}
}
Note
Replace
[user]with your own username.
(There are 3 occurrences in thesettings.jsonexample above.)Path separators were only recognized when using
/(forward slashes).
(Confirmed with ClaudeCode 2.1.143. Older versions may behave differently.)
📄PowerShell Scripts
📄config.ps1
config.ps1 is a configuration file
(for things like the paths to the sounds you want to play).
We’ll need it later as well, so create it first.
⚠️ Warning
Any.ps1file containing Japanese text must be saved as UTF-8 with BOM.
Without BOM, Japanese characters become corrupted and the hooks will fail to run properly.
(This is a Windows PowerShell 5.1 limitation.)
# Adjust these paths for your environment
$SoundDir = "D:\sound"
$SoundPermission = Join-Path $SoundDir "notify.wav"
$SoundComplete = Join-Path $SoundDir "complete.wav"
$DelayMs = 700
📄complete.ps1
This script runs whenever one Claude turn finishes.
It:
- shows a Windows notification
- plays a completion sound
- writes debug logs
- rotates logs automatically
The script is longer than strictly necessary because I wanted it to remain stable during long-term usage.
C:/Users/[user]/.claude/hooks/complete.ps1
$log = "$env:USERPROFILE\.claude\hooks\hook-debug.log"
$cfg = "$env:USERPROFILE\.claude\hooks\config.ps1"
function Write-Log {
param([string]$Message)
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $Message" |
Out-File -FilePath $log -Append -Encoding utf8
}
function Invoke-LogRotation {
try {
$fi = Get-Item -LiteralPath $log -ErrorAction Stop
if ($fi.Length -le 50KB) { return }
$lines = @(Get-Content -LiteralPath $log -Encoding UTF8)
$kept = New-Object System.Collections.Generic.List[string]
$bytes = 0
for ($i = $lines.Count - 1; $i -ge 0; $i--) {
$lineBytes = [System.Text.Encoding]::UTF8.GetByteCount($lines[$i]) + 2
if ($bytes + $lineBytes -gt 15KB -and $kept.Count -gt 0) { break }
$kept.Insert(0, $lines[$i])
$bytes += $lineBytes
}
$stamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$kept.Insert(0, "--- log trimmed $stamp (was $([int]$fi.Length) bytes) ---")
$tmp = "$log.tmp"
Set-Content -LiteralPath $tmp -Value $kept -Encoding UTF8
Move-Item -LiteralPath $tmp -Destination $log -Force
} catch { }
}
function Get-CompletionSound {
if (Test-Path -LiteralPath $cfg) {
try { . $cfg } catch { }
}
return $SoundComplete
}
function Show-CompletionToast {
try {
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$icon = New-Object System.Windows.Forms.NotifyIcon
$icon.Icon = [System.Drawing.SystemIcons]::Information
$icon.Visible = $true
$icon.ShowBalloonTip(5000, "Claude Code", "Task completed", [System.Windows.Forms.ToolTipIcon]::Info)
Write-Log "toast shown OK"
return $icon
} catch {
Write-Log "ERROR(toast): $_"
return $null
}
}
function Invoke-CompletionNotice {
param([string]$SoundPath)
$icon = $null
try {
Add-Type -AssemblyName PresentationCore
$mp = New-Object System.Windows.Media.MediaPlayer
$mp.Volume = 1.0
$mp.Open([System.Uri]::new($SoundPath))
$waited = 0
while (-not $mp.NaturalDuration.HasTimeSpan -and $waited -lt 3000) {
Start-Sleep -Milliseconds 100
$waited += 100
}
if ($mp.NaturalDuration.HasTimeSpan) {
$durMs = [int]$mp.NaturalDuration.TimeSpan.TotalMilliseconds
} else {
$durMs = 6000
}
$icon = Show-CompletionToast
$mp.Play()
Start-Sleep -Milliseconds ($durMs + 200)
$mp.Stop()
$mp.Close()
Write-Log "sound played OK (dur=${durMs}ms)"
} catch {
Write-Log "ERROR(sound): $_"
} finally {
if ($null -ne $icon) {
try { $icon.Dispose() } catch {}
}
}
}
Write-Log "Stop hook fired (PID=$PID, Session=$((Get-Process -Id $PID).SessionId))"
Invoke-LogRotation
$snd = Get-CompletionSound
if ($snd) {
Invoke-CompletionNotice -SoundPath $snd
} else {
Write-Log 'ERROR: not found $SoundComplete in config.ps1'
}
Refreshing the Settings
Even if you configured everything correctly, if the sound still does not play for some reason, you may need to refresh the settings depending on your ClaudeCode version.
Recommended Method
- Close the ClaudeCode window once
- Run
Ctrl + Shift + P > Developer: Reload Window - Open a new ClaudeCode window
- Give it any instruction and confirm that sound + notification appear when it finishes
If It Still Does Not Work (Last Resort)
- Close ClaudeCode and VSCode
- Open
C:\Users\[user]\.claude.jsonin Notepad or another editor - Find the folder you had open in VSCode, clear its
lastSessionId, and save the file - Restart VSCode
Things That Seem Like They Would Refresh It, But Did Not
The following are things I tried, but they did not refresh the settings:
- Pressing the
+button in the upper-right corner of the ClaudeCode window to open a new window - Simply restarting VSCode
- Simply restarting the PC
📌 Play a Sound for Permission Dialogs
Those dialogs that keep popping up and asking you to click Yes are easy to leave unattended.
I wanted to play a sound when they appeared, but unfortunately, in VSCode + ClaudeCode, the Notification setting currently does not seem to work properly.
(This is also mentioned in GitHub issues, and it seems to have remained broken since 2.0.8. Incidentally, it does work in the CLI.)
So I decided to implement it using a slightly hacky workaround.
The idea is to hook both the event before a tool runs (such as a bash command) and the event after it runs, then play a sound based on the following logic:
If the pre-execution event fires, but the post-execution event still has not fired after X msec, assume that a dialog is being shown → 🔊Play sound!
Note
The simplest approach would be to play a sound unconditionally whenever the pre-execution event fires.
However, that would also play sounds for commands that run without showing a dialog, which is why this ended up being such a roundabout solution...
📄PowerShell Scripts
📄pretool-resolve.ps1
C:/Users/[user]/.claude/hooks/pretool-resolve.ps1
$log = "$env:USERPROFILE\.claude\hooks\hook-debug.log"
$cfg = "$env:USERPROFILE\.claude\hooks\config.ps1"
$delayed = "$env:USERPROFILE\.claude\hooks\delayed-sound.ps1"
$pendDir = "$env:USERPROFILE\.claude\hooks\pending"
function Write-Log {
param([string]$Message)
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $Message" |
Out-File -FilePath $log -Append -Encoding utf8
}
if (Test-Path -LiteralPath $cfg) {
try { . $cfg } catch { }
}
$sound = $SoundPermission
if (-not $sound) {
Write-Log 'ERROR: not found $SoundPermission in config.ps1'
exit 0
}
$delayMs = if ($DelayMs) { [int]$DelayMs } else { 700 }
function Read-HookInput {
$stdin = [Console]::OpenStandardInput()
$ms = New-Object System.IO.MemoryStream
$stdin.CopyTo($ms)
$raw = [System.Text.Encoding]::UTF8.GetString($ms.ToArray())
return ($raw | ConvertFrom-Json)
}
function Get-AllowPatterns {
param([string]$Cwd)
$allow = New-Object System.Collections.Generic.List[string]
$files = @(
(Join-Path $env:USERPROFILE ".claude\settings.json"),
(Join-Path $Cwd ".claude\settings.json"),
(Join-Path $Cwd ".claude\settings.local.json")
)
foreach ($f in $files) {
if ($f -and (Test-Path -LiteralPath $f)) {
try {
$s = Get-Content -Raw -LiteralPath $f | ConvertFrom-Json
if ($s.permissions -and $s.permissions.allow) {
foreach ($a in $s.permissions.allow) { $allow.Add([string]$a) }
}
} catch { }
}
}
return $allow
}
function Test-NeedsPermission {
param($Tool, $Json, $Allow)
if ($Tool -eq "Bash") {
$cmd = [string]$Json.tool_input.command
if ($null -ne $cmd) { $cmd = $cmd.Trim() }
foreach ($p in $Allow) {
$m = [regex]::Match($p, '^Bash\((.+)\)$')
if (-not $m.Success) { continue }
$inner = $m.Groups[1].Value
if ($inner -eq '*') {
return $false
} elseif ($inner.EndsWith(' *')) {
$prefix = $inner.Substring(0, $inner.Length - 2)
if ($cmd -eq $prefix -or $cmd.StartsWith($prefix + ' ')) { return $false }
} else {
if ($cmd -eq $inner) { return $false }
}
}
return $true
}
foreach ($p in $Allow) {
if ($p -eq $Tool -or $p -eq ($Tool + '(*)')) { return $false }
}
return $true
}
function Start-PendingSound {
param([string]$RawId, [string]$Tool)
$id = if ([string]::IsNullOrEmpty($RawId)) { [guid]::NewGuid().ToString() } else { $RawId }
$id = ($id -replace '[^A-Za-z0-9_\-]', '_')
if (-not (Test-Path -LiteralPath $pendDir)) {
New-Item -ItemType Directory -Force -Path $pendDir | Out-Null
}
$pf = Join-Path $pendDir $id
Get-Date -Format 'yyyy-MM-dd HH:mm:ss' | Out-File -FilePath $pf -Encoding utf8
Write-Log "PreToolUse pending: $Tool id=$id"
Start-Process -FilePath "powershell.exe" `
-ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$delayed`"", '-Pending', "`"$pf`"", '-Sound', "`"$sound`"", '-DelayMs', $delayMs, '-Id', $id) `
-WindowStyle Hidden | Out-Null
}
try {
$j = Read-HookInput
$tool = [string]$j.tool_name
$cwd = [string]$j.cwd
$allow = Get-AllowPatterns -Cwd $cwd
if (Test-NeedsPermission -Tool $tool -Json $j -Allow $allow) {
Start-PendingSound -RawId ([string]$j.tool_use_id) -Tool $tool
}
} catch {
Write-Log "ERROR(pretool): $_"
}
exit 0
📄posttool-resolve.ps1
C:/Users/[user]/.claude/hooks/posttool-resolve.ps1
$log = "$env:USERPROFILE\.claude\hooks\hook-debug.log"
$pendDir = "$env:USERPROFILE\.claude\hooks\pending"
function Write-Log {
param([string]$Message)
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $Message" |
Out-File -FilePath $log -Append -Encoding utf8
}
function Read-HookInput {
$stdin = [Console]::OpenStandardInput()
$ms = New-Object System.IO.MemoryStream
$stdin.CopyTo($ms)
$raw = [System.Text.Encoding]::UTF8.GetString($ms.ToArray())
return ($raw | ConvertFrom-Json)
}
function ConvertTo-SafeId {
param([string]$Id)
return ($Id -replace '[^A-Za-z0-9_\-]', '_')
}
function Remove-ResolvedMarker {
param([string]$RawId)
if ([string]::IsNullOrEmpty($RawId)) { return }
$id = ConvertTo-SafeId $RawId
$pf = Join-Path $pendDir $id
if (Test-Path -LiteralPath $pf) {
Remove-Item -LiteralPath $pf -Force -ErrorAction SilentlyContinue
Write-Log "PostToolUse resolved (no dialog) id=$id"
}
}
function Clear-OrphanMarkers {
if (-not (Test-Path -LiteralPath $pendDir)) { return }
Get-ChildItem -LiteralPath $pendDir -File -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -lt (Get-Date).AddSeconds(-120) } |
ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue }
}
try {
$j = Read-HookInput
Remove-ResolvedMarker -RawId ([string]$j.tool_use_id)
Clear-OrphanMarkers
} catch {
Write-Log "ERROR(posttool): $_"
}
exit 0
📄delayed-sound.ps1
C:/Users/[user]/.claude/hooks/delayed-sound.ps1
param(
[Parameter(Mandatory = $true)][string]$Pending,
[Parameter(Mandatory = $true)][string]$Sound,
[Parameter(Mandatory = $true)][int]$DelayMs,
[string]$Id = ""
)
$log = "$env:USERPROFILE\.claude\hooks\hook-debug.log"
function Write-Log {
param([string]$Message)
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $Message" |
Out-File -FilePath $log -Append -Encoding utf8
}
function Play-Wav {
param([string]$Path)
Add-Type -AssemblyName PresentationCore
$mp = New-Object System.Windows.Media.MediaPlayer
$mp.Volume = 1.0
$mp.Open([System.Uri]::new($Path))
$waited = 0
while (-not $mp.NaturalDuration.HasTimeSpan -and $waited -lt 3000) {
Start-Sleep -Milliseconds 100
$waited += 100
}
if ($mp.NaturalDuration.HasTimeSpan) {
$durMs = [int]$mp.NaturalDuration.TimeSpan.TotalMilliseconds
} else {
$durMs = 4000
}
$mp.Play()
Start-Sleep -Milliseconds ($durMs + 200)
$mp.Stop()
$mp.Close()
return $durMs
}
try {
Start-Sleep -Milliseconds $DelayMs
if (-not (Test-Path -LiteralPath $Pending)) {
Write-Log "delayed-sound SKIPPED (auto-approved, no dialog) id=$Id"
return
}
try { Remove-Item -LiteralPath $Pending -Force -ErrorAction Stop } catch {}
Write-Log "delayed-sound PLAYED (dialog shown) id=$Id"
$durMs = Play-Wav -Path $Sound
Write-Log "delayed-sound OK (dur=${durMs}ms) id=$Id"
} catch {
Write-Log "ERROR(delayed-sound): $_"
}
How It Works
① Route Where a Dialog Appears
Before executing a command that requires dialog confirmation, pretool-resolve.ps1 runs.
⭕ Create a log file in pending/ using the current tool call ID
↓
🎯 Dialog is being shown...
↓
After the command is executed, posttool-resolve.ps1 runs.
❌ If a log file for the current tool call ID exists in pending/, delete it.
② Route Where No Dialog Appears
Since no dialog appears, the log file is deleted immediately after it is created.
Before executing a command that may require dialog confirmation, pretool-resolve.ps1 runs.
⭕ Create a log file in pending/ using the current tool call ID
↓
After the command is executed, posttool-resolve.ps1 runs.
❌ If a log file for the current tool call ID exists in pending/, delete it immediately.
Note
pending/is created automatically.
🔊 delayed-sound.ps1: Playing the Sound
delayed-sound.ps1 plays a sound if the log file for the current tool call ID still exists in pending/ after X msec.
- In case ①, the dialog is shown, so the log file still exists after X msec → 🔊 play sound
- In case ②, the log file is deleted immediately, so it no longer exists after X msec → no sound
This method has one weakness: if the command itself takes X msec or longer to execute, the sound may play even in case ②.
If sounds keep playing even when no dialog is shown, increase $DelayMs in config.ps1 to adjust the timing.
📌Conclusion
A few additional notes:
- Since the target environment is Windows PowerShell 5.1, save the files as UTF-8 with BOM
- Windows 11 notification settings, such as Focus Assist, may prevent notifications from appearing
I have barely used PowerShell myself, so this was almost entirely done with ClaudeCode.
Depending on your Windows environment, you may need to modify the code.
The PowerShell scripts include error logging, log rotation, and other defensive features, so they may contain extra code if you only want to use the core functionality. Feel free to cut anything you do not need.




Top comments (0)