Plays a sound, with optional on-screen signals, when a task is complete.

A Claude Code mod that plays a short sound when Claude finishes a task.
| Claude Code | A version that supports mods (plugins with function hooks) |
| Windows 10 or 11 | For the sound. Uses the built-in Windows PowerShell 5.1 and Windows media player. Nothing to install. |
| macOS | Claude Code plays the clip itself. The PowerShell step is skipped automatically. |
| Linux | No sound. Claude Code has no audio player there and the mod only supports Windows and macOS. |
For every session (recommended). Add this repository as a marketplace and install the plugin. It then loads in all your sessions, including the ones the Claude desktop app starts:
claude plugin marketplace add ialoni66/task-chime
claude plugin install task-chime@task-chime
Start a new session, or run /reload-plugins in an open one. To remove it later: claude plugin uninstall task-chime@task-chime.
I tested this route with a marketplace pointing at a local clone of this repository. Adding it straight from GitHub (the command above) uses the same mechanism but I have not run it from a second machine.
Just try it for one session:
git clone https://github.com/ialoni66/task-chime.git
claude --plugin-dir ./task-chime
Check that it loads:
claude plugin validate ./task-chime
On each finished answer, the mod:
assets/notification.mp3 (this works on macOS).scripts/chime.ps1 through PowerShell. The script has no window and no network access. It:If the script fails, you get a short toast naming the problem.
The mod needs permission to start a local PowerShell process. Read scripts/chime.ps1 first if you want to see exactly what it does. It is short.
Type /chime in Claude Code to open the settings pane. It shows each setting with buttons to change it, and a Play test chime button so you can hear the result straight away. Click a button, or give the pane focus (click it, or press ctrl+x then tab) and press its key:
| Key | Action |
|---|---|
m | Mute / unmute |
1 / 2 | Quieter / louder |
3 / 4 | Fewer / more loudness copies |
5 / 6 | Shorter / longer Bluetooth wake-up delay |
v | Turn on-screen signals on / off |
r | Show / hide the quick controls row |
t | Play test chime (plays even when muted) |
The same settings are also rows in the /config menu in the terminal version of Claude Code. The desktop app has no /config, which is why the pane exists. Either way, changing a setting reloads the mod with the new value, and the pane reopens by itself.
| Setting | Default | What it does |
|---|---|---|
| Mute the chime | off | Turns the sound off |
| Chime volume (0-100) | 75 | Volume of each copy of the chime |
| Loudness boost (1-4) | 2 | Identical copies played together. More is louder but can distort. |
| Bluetooth wake-up delay (ms) | 1000 | Silence played before the chime, 0 to 3000. Raise it if a Bluetooth headset still misses the start. |
| Show on-screen signals | off | Shows a banner above the prompt, a toast, and a status line entry |
| Show the quick controls row | on | A one-line row above the prompt: sound state, Mute and Settings buttons |
Values outside a setting's range are refused. The settings are also stored in your Claude Code settings under pluginConfigs, keyed by the plugin name.
If you mute the chime and leave the on-screen signals off, nothing tells you a task finished. Turn on one of them.
To use your own sound, replace assets/notification.mp3.
/config./config.claude plugin validate . # checks the manifest and what the code calls
claude plugin test . # runs tests/register.test.tsx
The tests check the logic: which turns trigger the sound, the command and settings passed to PowerShell, and that nothing visual appears while the visuals are off. They do not play audio, so listening to it is still a manual check.
Project layout:
.claude-plugin/plugin.json name, version, description
hooks/hooks.json points to the code
hooks/register.tsx the mod: hooks on turn.complete and the prompt band
scripts/chime.ps1 Windows sound player
assets/notification.mp3 the chime
types/index.d.ts type contract for the mod's stored state
tests/register.test.tsx tests
The code is released under the MIT licence.
The sound assets/notification.mp3 is "New Notification 08" by Universfield. It is not covered by the MIT licence. Check the terms of the site you download it from before reusing or redistributing it, or swap in a sound of your own.
hooks/register.tsx 224 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4const SOUND = 'assets/notification.mp3'
5const SOUND_SCRIPT = 'scripts\\chime.ps1'
6const PANE = 'task-chime-settings'
7const BANNER_MS = 6000
8const STATUS_MS = 5000
9
10const isBannerShown = atom({ plugin: 'task-chime', key: 'isBannerShown' } as const, false)
11const isSettingsOpen = atom({ plugin: 'task-chime', key: 'isSettingsOpen' } as const, false)
12const note = atom({ plugin: 'task-chime', key: 'note' } as const, '')
13
14type Settings = { volumePercent: number; copies: number; leadMs: number }
15
16// A number setting, held to its range; anything else falls back to the default.
17const setting = (value: unknown, fallback: number, min: number, max: number) =>
18 typeof value === 'number' && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback
19
20const step = (value: number, delta: number, min: number, max: number) =>
21 Math.min(max, Math.max(min, value + delta))
22
23// Plays the chime now. macOS plays the clip through audio.play; Windows and Linux have no
24// player there, so on Windows scripts/chime.ps1 plays the mp3 with no window.
25const playChime = ($: any, s: Settings) => {
26 $.audio.play({ asset: SOUND }).catch((err: unknown) => {
27 $.ui.log(`task-chime: audio.play failed: ${String(err)}`, { to: 'debug' })
28 })
29
30 // spawn, not run: a spawned child and its loop outlive the hook's return, so the turn
31 // is never held up.
32 void (async () => {
33 // PowerShell and the Windows media player exist only on Windows (it sets OS=Windows_NT).
34 if ((await $.env.get('OS')) !== 'Windows_NT') return
35 try {
36 const child = $.process.spawn({
37 argv: [
38 'powershell.exe',
39 '-NoProfile',
40 '-NonInteractive',
41 '-WindowStyle',
42 'Hidden',
43 '-ExecutionPolicy',
44 'Bypass',
45 '-File',
46 `${$.plugin.root}\\${SOUND_SCRIPT}`,
47 ],
48 env: {
49 CHIME_SOUND: `${$.plugin.root}\\${SOUND.replace('/', '\\')}`,
50 CHIME_LEAD_MS: String(s.leadMs),
51 CHIME_COPIES: String(s.copies),
52 CHIME_VOLUME: String(s.volumePercent / 100),
53 },
54 })[Symbol.asyncIterator]()
55 let stderr = ''
56 for (;;) {
57 const next = await child.next()
58 if (next.done) {
59 if (next.value?.code !== 0) $.ui.toast(`Chime sound failed: ${stderr.slice(0, 120)}`)
60 break
61 }
62 if (next.value.stream === 'stderr') stderr += next.value.text
63 }
64 } catch (err) {
65 $.ui.toast(`Chime sound could not start: ${String(err).slice(0, 120)}`)
66 }
67 })()
68}
69
70// Changes one setting. Claude Code writes it and reloads the mod, which closes the pane;
71// session.start reopens it.
72const change = async ($: any, field: string, value: boolean | number) => {
73 const done = await $.config.set({ key: `task-chime.${field}`, value })
74 if (done.deny !== undefined) {
75 await update($, note, () => `Could not change that setting: ${done.deny}`)
76 }
77}
78
79// Opens the settings pane; used by /chime and by the quick row's Settings button.
80const openSettings = async ($: any) => {
81 await update($, isSettingsOpen, () => true)
82 await update($, note, () => '')
83 await $.ui.open({ id: PANE, title: 'Task chime' })
84}
85
86// The user's settings are declared as userConfig in plugin.json, so they also live in the
87// /config menu where there is one. The desktop app has none, so /chime opens a pane that
88// changes the same settings. A change reloads the mod, so they are read once here.
89export const register: Register = (on, options) => {
90 const isMuted = options.muted === true
91 const showVisuals = options.showVisuals === true
92 // On unless the person turned it off.
93 const showQuickRow = options.showQuickRow !== false
94 const settings: Settings = {
95 volumePercent: Math.round(setting(options.volume, 75, 0, 100)),
96 copies: Math.round(setting(options.copies, 2, 1, 4)),
97 leadMs: Math.round(setting(options.leadMs, 1000, 0, 3000)),
98 }
99
100 on('session.start', async ($, e, next) => {
101 // The banner flag and status line outlive a reload, but the timers that clear them
102 // do not. Clear any leftover when the mod loads (a reload fires session.start again).
103 await update($, isBannerShown, () => false)
104 $.ui.status(undefined)
105
106 await $.command.register({ name: 'chime', description: 'Open the task-chime settings pane' })
107 // A setting change reloads the mod and closes the pane; bring it back.
108 if (await read($, isSettingsOpen)) void $.ui.open({ id: PANE, title: 'Task chime' })
109
110 return next(e)
111 })
112
113 on('command.run', { command: 'chime' }, async $ => {
114 await openSettings($)
115
116 return { text: 'Task chime settings opened.' }
117 })
118
119 // Remember when the person closes the pane themselves, so a reload does not reopen it.
120 on('ui.close', async ($, e, next) => {
121 if (e.id === PANE && e.origin.kind !== 'unload') await update($, isSettingsOpen, () => false)
122 return next(e)
123 })
124
125 on('turn.complete', async ($, e, next) => {
126 const result = await next(e)
127
128 // Only the main conversation's finished answers, not subagents or interrupts.
129 if (e.agentId !== undefined || e.reason !== 'answer') return result
130
131 // In-app signals: the banner above the prompt, a toast and a status entry.
132 if (showVisuals) {
133 await update($, isBannerShown, () => true)
134 $.ui.toast('Task complete')
135 $.ui.status('Task complete')
136 // Clear them later; if the mod reloads meanwhile the wait is aborted, which is fine.
137 $.clock.sleep(BANNER_MS).then(() => update($, isBannerShown, () => false), () => {})
138 $.clock.sleep(STATUS_MS).then(() => $.ui.status(undefined), () => {})
139 }
140
141 if (!isMuted) playChime($, settings)
142
143 return result
144 })
145
146 // The row above the prompt: the "Task complete" banner while it is showing, and the
147 // quick controls (sound state, Mute, Settings) while that row is switched on.
148 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
149 if (e.props.hasSurvey) return next(e)
150
151 const isBanner = showVisuals && (await read($, isBannerShown))
152 if (!isBanner && !showQuickRow) return next(e)
153
154 const { Box, Button, Text } = $.ui.resolve(e)
155
156 return (
157 <Box flexDirection="column">
158 {isBanner && (
159 <Box>
160 <Text bold>Task complete </Text>
161 <Button key="dismiss" label="Dismiss" onPress={() => update($, isBannerShown, () => false)} />
162 </Box>
163 )}
164 {showQuickRow && (
165 <Box>
166 <Text dimColor>{`Chime: ${isMuted ? 'muted' : 'on'} `}</Text>
167 <Button key="quick-mute" label={isMuted ? 'Unmute' : 'Mute'} onPress={() => change($, 'muted', !isMuted)} />
168 <Text> </Text>
169 <Button key="quick-settings" label="Settings" onPress={() => openSettings($)} />
170 </Box>
171 )}
172 </Box>
173 )
174 })
175
176 // The settings pane. Every control is a Button with a letter or digit key, so it works
177 // from the keyboard once the pane has focus (ctrl+x tab, or a click).
178 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
179 const { Box, Button, Text } = $.ui.resolve(e)
180 const message = await read($, note)
181 const { volumePercent, copies, leadMs } = settings
182
183 return (
184 <Box flexDirection="column">
185 <Text bold>Task chime settings</Text>
186 {message !== '' && <Text>{message}</Text>}
187 <Box>
188 <Text>{`Sound: ${isMuted ? 'muted' : 'on'} `}</Text>
189 <Button key="mute" hotkey="m" label={isMuted ? 'Unmute' : 'Mute'} onPress={() => change($, 'muted', !isMuted)} />
190 </Box>
191 <Box>
192 <Text>{`Volume: ${volumePercent} `}</Text>
193 <Button key="volume-down" hotkey="1" label="Quieter" onPress={() => change($, 'volume', step(volumePercent, -10, 0, 100))} />
194 <Text> </Text>
195 <Button key="volume-up" hotkey="2" label="Louder" onPress={() => change($, 'volume', step(volumePercent, 10, 0, 100))} />
196 </Box>
197 <Box>
198 <Text>{`Loudness boost: ${copies} ${copies === 1 ? 'copy' : 'copies'} `}</Text>
199 <Button key="copies-down" hotkey="3" label="Fewer" onPress={() => change($, 'copies', step(copies, -1, 1, 4))} />
200 <Text> </Text>
201 <Button key="copies-up" hotkey="4" label="More" onPress={() => change($, 'copies', step(copies, 1, 1, 4))} />
202 </Box>
203 <Box>
204 <Text>{`Bluetooth wake-up delay: ${leadMs} ms `}</Text>
205 <Button key="lead-down" hotkey="5" label="Shorter" onPress={() => change($, 'leadMs', step(leadMs, -250, 0, 3000))} />
206 <Text> </Text>
207 <Button key="lead-up" hotkey="6" label="Longer" onPress={() => change($, 'leadMs', step(leadMs, 250, 0, 3000))} />
208 </Box>
209 <Box>
210 <Text>{`On-screen signals: ${showVisuals ? 'on' : 'off'} `}</Text>
211 <Button key="visuals" hotkey="v" label={showVisuals ? 'Turn off' : 'Turn on'} onPress={() => change($, 'showVisuals', !showVisuals)} />
212 </Box>
213 <Box>
214 <Text>{`Quick controls row above the prompt: ${showQuickRow ? 'on' : 'off'} `}</Text>
215 <Button key="quick-row" hotkey="r" label={showQuickRow ? 'Hide' : 'Show'} onPress={() => change($, 'showQuickRow', !showQuickRow)} />
216 </Box>
217 <Box>
218 <Button key="test" hotkey="t" variant="primary" label="Play test chime" onPress={() => playChime($, settings)} />
219 </Box>
220 </Box>
221 )
222 })
223}
224types/index.d.ts 8 lines1export type BannerFlag = boolean
2
3declare module 'claude-code' {
4 interface PluginState {
5 'task-chime': { isBannerShown: BannerFlag; isSettingsOpen: boolean; note: string }
6 }
7}
8