Skip to content

Instantly share code, notes, and snippets.

@stormychel
Last active May 25, 2026 09:15
Show Gist options
  • Select an option

  • Save stormychel/a2b6a437873b62b4e9750186babcc658 to your computer and use it in GitHub Desktop.

Select an option

Save stormychel/a2b6a437873b62b4e9750186babcc658 to your computer and use it in GitHub Desktop.
[Windows] Claude Code Stop hook — announces 'Claude is done' via SAPI Microsoft David Desktop voice at rate +2 (PowerShell System.Speech.Synthesis). macOS sibling: https://gist.github.com/stormychel/502e2a6da38bf5798750e5565a5324f7
#!/usr/bin/env python3
"""
Install a Claude Code "Stop" hook that announces completion out loud on Windows
using SAPI (System.Speech) with the Microsoft David Desktop voice at rate +2 —
i.e. it runs the equivalent of `say -v Zarvox "Claude is done"` from the macOS
sibling gist (https://gist.github.com/stormychel/502e2a6da38bf5798750e5565a5324f7).
System-wide: writes to %USERPROFILE%\.claude\settings.json (merges, preserves
existing keys). Idempotent: re-running won't add a duplicate.
The hook runs asynchronously (`async: true`) so Claude doesn't block waiting for
the speech to finish.
Usage:
python install-claude-david-hook.py
Disable later: remove the entry from the "hooks" -> "Stop" array in settings.json,
or use the Claude Code `/hooks` UI.
Change the phrase/voice: edit CMD below. To list installed SAPI voices, run in
PowerShell:
Add-Type -AssemblyName System.Speech
(New-Object System.Speech.Synthesis.SpeechSynthesizer).GetInstalledVoices() |
ForEach-Object { $_.VoiceInfo.Name }
Requires:
- Windows with PowerShell (any modern version)
- Microsoft David Desktop voice (default on Windows 10/11). Falls back to the
system default voice if David is not installed.
"""
import json
import pathlib
import subprocess
PHRASE = "Claude is done"
PREFERRED_VOICE = "Microsoft David Desktop"
RATE = 2 # -10..10; +2 is the closest "robotic" feel from a default voice
def voice_installed(name: str) -> bool:
ps = (
"Add-Type -AssemblyName System.Speech; "
"(New-Object System.Speech.Synthesis.SpeechSynthesizer)"
".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }"
)
try:
out = subprocess.run(
["powershell", "-NoProfile", "-Command", ps],
capture_output=True, text=True, timeout=15, check=True,
).stdout
except Exception:
return False
return any(name == line.strip() for line in out.splitlines())
def build_command() -> str:
if voice_installed(PREFERRED_VOICE):
return (
"Add-Type -AssemblyName System.Speech; "
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; "
f"$s.SelectVoice('{PREFERRED_VOICE}'); "
f"$s.Rate = {RATE}; "
f"$s.Speak('{PHRASE}')"
)
# Fallback: no voice selection, system default
return (
"Add-Type -AssemblyName System.Speech; "
f"(New-Object System.Speech.Synthesis.SpeechSynthesizer).Speak('{PHRASE}')"
)
def main() -> None:
cmd = build_command()
settings = pathlib.Path.home() / ".claude" / "settings.json"
cfg = json.loads(settings.read_text()) if settings.exists() else {}
stop = cfg.setdefault("hooks", {}).setdefault("Stop", [])
already = any(
hook.get("command") == cmd
for entry in stop
for hook in entry.get("hooks", [])
)
if not already:
stop.append({
"hooks": [{
"type": "command",
"shell": "powershell",
"async": True,
"command": cmd,
}]
})
settings.parent.mkdir(parents=True, exist_ok=True)
settings.write_text(json.dumps(cfg, indent=2))
state = "already present" if already else "installed"
print(f"OK: David Stop hook {state} at {settings}")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment