Skip to main content

Claude Code

Configuring the Claude Code CLI through settings.json, with a worked example: a spoken notification when a task finishes.

Contents

Settings files

FileScopeCommit?
~/.claude/settings.jsonAll projects for this userN/A
.claude/settings.jsonThis project, shared with the teamYes
.claude/settings.local.jsonThis project, personal overridesNo — 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

EventFires when
StopClaude finishes responding — one per completed turn
SubagentStopA subagent finishes
NotificationClaude needs attention, such as a permission prompt
SessionEndThe session ends
PostToolUseAfter 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:

PlatformCommand
WindowsPowerShell with System.Speech, or the SAPI.SpVoice COM object
macOSsay "Claude's task completed."
Linuxspd-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

WantChange
Fewer repetitions$repeat
Different wording$phrase
Slower or faster$rate, from -10 to 10
Quieter$volume, from 0 to 100
A specific voiceReplace the selection block with $synth.SelectVoice('Microsoft Zira Desktop')
A different languagePick 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

GoalAction
Review or edit interactivelyRun /hooks in an interactive session
Silence temporarilySet "disableAllHooks": true in settings.json
Remove permanentlyDelete the Stop entry from hooks in settings.json
Keep the hook, mute the soundSet $volume = 0 in the script

Related: Windows for PowerShell and the speech stack, Tools for editor setup, and AI.