Claude Code
Configuring the Claude Code CLI through settings.json, with a worked example:
a spoken notification when a task finishes.
Contents
- Settings files
- Hooks
- Notification events
- Terminal bell
- Playing a sound file
- Spoken notification
- The configured setup
- Tuning the voice
- Verifying a hook
- Turning it off
Settings files
| File | Scope | Commit? |
|---|---|---|
~/.claude/settings.json | All projects for this user | N/A |
.claude/settings.json | This project, shared with the team | Yes |
.claude/settings.local.json | This project, personal overrides | No — gitignore it |
They load user → project → local, so a later file overrides an earlier one. Personal preferences such as a completion sound belong in the user file; put team-wide hooks (formatters, test runners) in the project file.
Invalid JSON in any of these silently disables all settings from that file, so validate after editing:
jq -e . ~/.claude/settings.json > /dev/null && echo "valid"
Hooks
A hook runs a command when something happens. This is the only way to get an automatic reaction to an event — a stated preference cannot trigger one.
{
"hooks": {
"EVENT": [
{
"hooks": [
{
"type": "command",
"command": "your-command",
"async": true,
"timeout": 30
}
]
}
]
}
}
The hook receives a JSON payload on stdin describing the event. For tool events
you also supply a matcher such as Write|Edit or Bash; lifecycle events
such as Stop take no matcher.
async: true matters for anything slow: the hook runs in the background instead
of delaying the turn. A notification that speaks for nine seconds should always
be async.
Notification events
| Event | Fires when |
|---|---|
Stop | Claude finishes responding — one per completed turn |
SubagentStop | A subagent finishes |
Notification | Claude needs attention, such as a permission prompt |
SessionEnd | The session ends |
PostToolUse | After a tool runs, filtered by matcher |
For "tell me when the task is done", Stop is the event. Note that it fires
after every assistant turn, including one-line answers — not only after long
jobs.
Terminal bell
The cheapest option, with no scripting:
{
"hooks": {
"Stop": [
{ "hooks": [{ "type": "command", "command": "printf '\\a'" }] }
]
}
}
Whether it produces a sound depends on the terminal's bell setting. Claude Code
also has a built-in preferredNotifChannel setting (terminal_bell, iterm2,
kitty, ghostty, and others) for OS-level notifications.
Playing a sound file
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -c \"(New-Object Media.SoundPlayer 'C:/Windows/Media/notify.wav').PlaySync()\"",
"async": true
}
]
}
]
}
}
The equivalent elsewhere is afplay /System/Library/Sounds/Glass.aiff on macOS
and paplay /usr/share/sounds/freedesktop/stereo/complete.oga on Linux.
Spoken notification
Every desktop platform ships a speech synthesiser:
| Platform | Command |
|---|---|
| Windows | PowerShell with System.Speech, or the SAPI.SpVoice COM object |
| macOS | say "Claude's task completed." |
| Linux | spd-say, or espeak |
On Windows, list the voices actually installed before choosing one — the set varies by machine and by which language packs are present:
Add-Type -AssemblyName System.Speech
$s = New-Object System.Speech.Synthesis.SpeechSynthesizer
$s.GetInstalledVoices() | ForEach-Object {
$i = $_.VoiceInfo
"{0} | {1} | {2}" -f $i.Name, $i.Gender, $i.Culture
}
A typical English Windows install has Microsoft David Desktop (male, en-US) and Microsoft Zira Desktop (female, en-US). Additional language packs add their own, such as Microsoft Hanhan Desktop (female, zh-TW).
To check that synthesis works without listening to it, render to a file and look at the size — a few seconds of speech is on the order of a hundred kilobytes:
Add-Type -AssemblyName System.Speech
$s = New-Object System.Speech.Synthesis.SpeechSynthesizer
$s.SetOutputToWaveFile("$env:TEMP\tts-test.wav")
$s.Speak("Claude's task completed.")
$s.SetOutputToDefaultAudioDevice()
$s.Dispose()
(Get-Item "$env:TEMP\tts-test.wav").Length
The configured setup
Script at ~/.claude/hooks/task-complete.ps1:
# Speaks "Claude's task completed." three times in a gentle female English voice.
$ErrorActionPreference = 'Stop'
$phrase = "Claude's task completed."
$repeat = 3
$rate = -2 # -10..10, below 0 is slower and reads as calmer
$volume = 80 # 0..100
try {
Add-Type -AssemblyName System.Speech
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
} catch {
exit 0 # no speech stack available: stay silent rather than fail the hook
}
try {
$installed = $synth.GetInstalledVoices() | Where-Object { $_.Enabled }
# Preference order: female English -> any English -> system default
$voice = $installed | Where-Object {
$_.VoiceInfo.Gender -eq 'Female' -and
$_.VoiceInfo.Culture.TwoLetterISOLanguageName -eq 'en'
} | Select-Object -First 1
if (-not $voice) {
$voice = $installed | Where-Object {
$_.VoiceInfo.Culture.TwoLetterISOLanguageName -eq 'en'
} | Select-Object -First 1
}
if ($voice) { $synth.SelectVoice($voice.VoiceInfo.Name) }
$synth.Rate = $rate
$synth.Volume = $volume
for ($i = 1; $i -le $repeat; $i++) {
$synth.Speak($phrase)
if ($i -lt $repeat) { Start-Sleep -Milliseconds 350 }
}
} catch {
exit 0 # never let a notification failure surface as a hook error
} finally {
if ($synth) { $synth.Dispose() }
}
Selecting the voice by attribute rather than by name means the script still
speaks on a machine where Zira is not installed. Both catch blocks exit 0 on
purpose: a missing audio device should not turn into a hook error on every turn.
Wired up in ~/.claude/settings.json:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "powershell -NoProfile -ExecutionPolicy Bypass -File \"C:/Users/rojar/.claude/hooks/task-complete.ps1\"",
"async": true,
"timeout": 30
}
]
}
]
}
}
Use forward slashes in the path. They work in PowerShell and avoid backslash
escaping in both JSON and any shell that passes the command along.
-NoProfile skips the user profile so the hook starts faster and does not
inherit profile side effects; -ExecutionPolicy Bypass avoids the script being
blocked by policy.
Tuning the voice
| Want | Change |
|---|---|
| Fewer repetitions | $repeat |
| Different wording | $phrase |
| Slower or faster | $rate, from -10 to 10 |
| Quieter | $volume, from 0 to 100 |
| A specific voice | Replace the selection block with $synth.SelectVoice('Microsoft Zira Desktop') |
| A different language | Pick a voice whose Culture matches, and change the phrase |
At $rate = -2 with three repetitions, the script takes roughly nine seconds
end to end. That is why it runs async — otherwise every turn would appear to
hang while it spoke.
Verifying a hook
Hooks run silently when they succeed; the interface only reports them when they error or run long. To confirm one works, run the command by hand with the stdin payload it will receive:
echo '{}' | powershell -NoProfile -ExecutionPolicy Bypass -File "C:/Users/rojar/.claude/hooks/task-complete.ps1"
echo "exit=$?"
Then confirm the settings file parses and the hook is where you think it is:
jq -e '.hooks.Stop[] | .hooks[] | select(.type=="command") | .command' ~/.claude/settings.json
Exit 0 with the command printed means the schema nesting is right. If the
pipeline works but nothing happens in a session, the settings watcher may not
have picked up the change — reload with the /hooks command in an interactive
session, or restart Claude Code. claude --debug prints hook execution.
Turning it off
| Goal | Action |
|---|---|
| Review or edit interactively | Run /hooks in an interactive session |
| Silence temporarily | Set "disableAllHooks": true in settings.json |
| Remove permanently | Delete the Stop entry from hooks in settings.json |
| Keep the hook, mute the sound | Set $volume = 0 in the script |
Related: Windows for PowerShell and the speech stack, Tools for editor setup, and AI.