Plays a sound when Claude finishes, needs permission, asks a question, or otherwise needs attention.

▶ Watch the 36-second showcase with sound (the preview above is silent)
A Claude Code mod that plays a sound when Claude needs you, so you can do other things while it works.
| Event | Rings when |
|---|---|
done | Claude finished its turn |
permission | Claude waits for a permission answer |
question | Claude asks you a question |
attention | any other notification |
Each event can use its own sound, follow the default sound, or be muted. Picks are kept across sessions.
At the prompt of a terminal claude session:
/plugin install attention-sounds --marketplace peeratop02/attention-sounds
Answer y to add the marketplace, then pick a scope. Installed at the user scope, it also loads in the desktop app's Code tab.
/sound open the sound panel (or list picks)
/sound <event> <name> use that sound for the event
/sound <event> default follow the default sound
/sound <event> off mute the event
/sound sound <name> change the default sound
/sound test <name|event> hear a sound, or what an event plays now
/sound info the manual and every sound name
In the desktop app, /sound opens a panel: pick an event, pick a library, click a sound to hear it and use it.
In the terminal the panel takes the keyboard:
| Key | Does |
|---|---|
| arrows / Tab | move between sounds and buttons |
| Enter | hear the sound and use it |
1-5 | choose the event to edit (default, done, permission, question, attention) |
n / b | next / previous library |
t | test what the event plays now |
d / m | follow the default / mute |
| Esc | close |
claude plugin update attention-sounds@peeratop02
Then /reload-plugins in a running session, or start a new one.
Ships with 18 interface sounds from Kenney (CC0), trimmed and re-encoded.
/sound info. It prints the mod's sounds folder (Sounds folder: ...).Mine/. Each folder shows up as a library in the panel..m4a, .mp3, .wav, .aiff or .caf. The file name without its extension is the sound's name (Vine Boom.mp3 becomes Vine Boom)./sound again. No restart needed.Good sources: /System/Library/Sounds on macOS, Kenney and freesound for free packs, any soundboard site for memes.
Short clips that start at once work best. To cut silence from both ends and even out the volume with ffmpeg:
ffmpeg -i in.mp3 -af "silenceremove=start_periods=1:start_threshold=-45dB,areverse,silenceremove=start_periods=1:start_threshold=-50dB,areverse,loudnorm" "Mine/My Sound.m4a"
A plugin update replaces the installed folder, so keep a copy of your own sounds somewhere else.
claude plugin validate .
claude plugin test .
Code: MIT. Bundled sounds: CC0, by Kenney.
hooks/register.tsx 307 lines1import type { Hook, PluginOptions, Register } from 'claude-code'
2
3export type Action = 'done' | 'permission' | 'question' | 'attention'
4type Engine = Parameters<Hook<'turn.complete'>>[0]
5
6// Each folder in sounds/ is a group; every file in one is a sound, named by its
7// file name without the extension and mapped to its path under sounds/.
8// The order the libraries are listed in; folders not named here follow, Mine last.
9const ORDER = ['Kenney', 'macOS', 'iOS Modern', 'iOS New', 'iOS Classic']
10let files: Map<string, string> | undefined
11let groups: Map<string, string[]> = new Map()
12async function library($: Engine): Promise<Map<string, string>> {
13 if (files === undefined) {
14 const root = `${$.plugin.root}/sounds`
15 const rank = (dir: string) => (ORDER.indexOf(dir) + 1 || ORDER.length) + (dir === 'Mine' ? ORDER.length : 0)
16 const dirs = (await $.fs.list(root)).filter(one => one.kind === 'dir').map(one => one.name).sort((x, y) => rank(x) - rank(y) || x.localeCompare(y))
17 const listed = await Promise.all(dirs.map(async dir => {
18 const names = (await $.fs.list(`${root}/${dir}`)).filter(one => one.kind === 'file').map(one => one.name)
19 return [dir, names.sort((x, y) => x.localeCompare(y))] as const
20 }))
21 const strip = (name: string) => name.replace(/\.[^.]+$/, '')
22 groups = new Map(listed.map(([dir, names]) => [dir, names.map(strip)]))
23 files = new Map(listed.flatMap(([dir, names]) => names.map(name => [strip(name), `${dir}/${name}`] as const)))
24 }
25 return files
26}
27
28// What the panel shows: the event being edited and the library listed for it.
29// ponytail: module variables, so a hot reload resets them; move to $.state if that matters.
30let editing = 'done'
31let shown: string | undefined
32const ROOM = 60
33
34const HINTS: Record<string, string> = {
35 sound: 'Used by every event set to Default',
36 done: 'Claude finished its turn',
37 permission: 'Claude is waiting for your approval',
38 question: 'Claude asked you something',
39 attention: 'Any other notification',
40}
41
42const ACCENT = '#D97757'
43const icon = (paths: string): string =>
44 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${ACCENT}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`
45const ICONS: Record<string, string> = {
46 bell: icon('<path d="M6 9a6 6 0 0 1 12 0c0 6 2.5 7.5 2.5 7.5h-17S6 15 6 9z"/><path d="M10 20a2 2 0 0 0 4 0"/>'),
47 sound: icon('<path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5z"/><path d="M15.5 9a4.5 4.5 0 0 1 0 6"/><path d="M18 6.5a8 8 0 0 1 0 11"/>'),
48 done: icon('<circle cx="12" cy="12" r="9"/><path d="M8 12.5l3 3 5.5-6.5"/>'),
49 permission: icon('<path d="M12 3l7.5 3v5.5c0 4.5-3.2 8-7.5 9.5-4.3-1.5-7.5-5-7.5-9.5V6z"/><path d="M12 9.5v3.5"/><path d="M12 16v.1"/>'),
50 question: icon('<circle cx="12" cy="12" r="9"/><path d="M9.5 9.5a2.6 2.6 0 1 1 3.8 2.3c-.8.5-1.3 1-1.3 2"/><path d="M12 17v.1"/>'),
51 attention: icon('<path d="M6 9a6 6 0 0 1 12 0c0 6 2.5 7.5 2.5 7.5h-17S6 15 6 9z"/><path d="M10 20a2 2 0 0 0 4 0"/>'),
52}
53
54// Case and spaces are ignored, so `notmarimba` and a pick saved as `NotMarimba` find `Not Marimba`.
55const fold = (name: string) => name.toLowerCase().replace(/[\s_]+/g, '')
56const named = (sounds: Map<string, string>, what: string): string | undefined =>
57 [...sounds.keys()].find(one => fold(one) === fold(what))
58
59const FIELDS = ['sound', 'done', 'permission', 'question', 'attention']
60const PANE = 'sounds'
61const LABELS: Record<string, string> = {
62 sound: 'Default sound',
63 done: 'Done',
64 permission: 'Permission',
65 question: 'Question',
66 attention: 'Other attention',
67}
68
69// Notification kinds that already ring through their own event.
70const ALREADY_RUNG = new Set(['permission_prompt', 'idle_prompt'])
71
72// A pick made with /sound (kept in $.store) wins over the manifest's option.
73async function pick($: Engine, options: PluginOptions, field: string): Promise<string> {
74 const stored = await $.store.get(field)
75 const raw = typeof stored === 'string' ? stored : String(options[field])
76 return named(await library($), raw) ?? raw
77}
78
79async function play($: Engine, options: PluginOptions, action: Action): Promise<void> {
80 const own = await pick($, options, action)
81 const name = own === 'default' ? await pick($, options, 'sound') : own
82 const file = (await library($)).get(name)
83 if (file !== undefined) await $.audio.play({ asset: `sounds/${file}` })
84}
85
86// Never awaited: a sound must not hold up the event it announces.
87function ring($: Engine, options: PluginOptions, action: Action): void {
88 play($, options, action).catch((err: unknown) => $.ui.log(`${$.plugin.name}: ${action} sound failed: ${String(err)}`))
89}
90
91const INFO = [
92 'Plays a sound when Claude needs you.',
93 '',
94 'Events:',
95 ' done Claude finished its turn',
96 ' permission Claude waits for a permission answer',
97 ' question Claude asks you a question',
98 ' attention any other notification',
99 '',
100 'Commands:',
101 ' /sound open the sound panel (or list picks)',
102 ' /sound <event> <name> use that sound for the event',
103 ' /sound <event> default follow the default sound',
104 ' /sound <event> off mute the event',
105 ' /sound sound <name> change the default sound',
106 ' /sound test <name|event> hear a sound, or what an event plays now',
107 ' /sound info this manual',
108 '',
109 'Sounds: each folder in the mod\'s sounds/ is a group; drop a file in one to add it.',
110 'Picks are kept across sessions.',
111].join('\n')
112
113const names = async ($: Engine): Promise<string> => {
114 await library($)
115 return [...groups].map(([group, list]) => `${group}: ${list.join(', ')}`).join('\n')
116}
117
118// What `/sound test <what>` plays: a sound by name, or an event's current pick.
119async function resolve($: Engine, options: PluginOptions, what: string): Promise<string | undefined> {
120 const sound = named(await library($), what)
121 if (sound !== undefined) return sound
122 if (!FIELDS.includes(what)) return undefined
123 const own = await pick($, options, what)
124 return own === 'default' ? pick($, options, 'sound') : own
125}
126
127async function show($: Engine, options: PluginOptions): Promise<string> {
128 const lines = await Promise.all(FIELDS.map(async field => `${field}: ${await pick($, options, field)}`))
129 return [...lines, `Usage: /sound <${FIELDS.join('|')}> <name|default|off> · /sound test <name|event> · /sound info`, await names($)].join('\n')
130}
131
132export const register: Register = (on, options) => {
133 on('session.start', async ($, e, next) => {
134 await $.command.register({
135 name: 'sound',
136 description: 'Show or set notification sounds',
137 argumentHint: '[event] [name|default|off] | test [name|event] | info',
138 })
139 return next(e)
140 })
141
142 on('command.run', { command: 'sound' }, async ($, e) => {
143 const [field, ...rest] = e.args.trim().split(/\s+/)
144 const value = rest.length > 0 ? rest.join(' ') : undefined
145 const sounds = await library($)
146 if (field === 'info') return { text: `${INFO}\n\nSounds folder: ${$.plugin.root}/sounds\n\n${await names($)}` }
147 if (field === 'test') {
148 const name = await resolve($, options, value ?? 'sound')
149 if (name === undefined) return { text: `Unknown "${value}". Give a sound name or an event: ${FIELDS.join(', ')}` }
150 if (name === 'off') return { text: `${value} is off: nothing plays.` }
151 void $.audio.play({ asset: `sounds/${sounds.get(name)}` }).catch(() => undefined)
152 return { text: `Playing ${name}` }
153 }
154 if (!field) {
155 // Listed afresh on each open, so a file dropped into sounds/ shows up without a reload.
156 files = undefined
157 // The panel where a surface draws it, the same picks as text where none does.
158 const { isPlaced } = await $.ui.open({ id: PANE, title: 'Notification sounds', focus: true, closeOnEscape: true })
159 return { text: isPlaced ? 'Sound panel opened.' : await show($, options) }
160 }
161 if (!FIELDS.includes(field) || value === undefined) return { text: await show($, options) }
162 const special = field === 'sound' ? [] : ['default', 'off']
163 const picked = special.find(one => one === value.toLowerCase()) ?? named(sounds, value)
164 if (picked === undefined) return { text: `Unknown sound "${value}". ${await names($)}` }
165 await $.store.set(field, picked)
166 if (sounds.has(picked)) void $.audio.play({ asset: `sounds/${sounds.get(picked)}` }).catch(() => undefined)
167 return { text: `${field} sound: ${picked}` }
168 })
169
170 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
171 const { Box, Text, Button } = $.ui.resolve(e)
172 // The terminal's table has no Svg: the icons are for the other surfaces.
173 const Svg = e.surface === 'terminal' ? undefined : $.ui.resolve(e).Svg
174 const sounds = await library($)
175 const picks = Object.fromEntries(await Promise.all(FIELDS.map(async field => [field, await pick($, options, field)] as const)))
176 const current = picks[editing] ?? 'default'
177 // An event on Default or Mute opens on the default sound's library.
178 const heard = sounds.has(current) ? current : picks.sound ?? ''
179 const home = [...groups].find(([, list]) => list.includes(heard))?.[0]
180 const group = shown !== undefined && groups.has(shown) ? shown : home ?? [...groups.keys()][0] ?? ''
181 const chips = (groups.get(group) ?? []).slice(0, ROOM)
182 // Terminal only: hotkeys, and the focus ring starting on the current sound.
183 const key = (hotkey: string | undefined) => (e.surface === 'terminal' && hotkey !== undefined ? { hotkey } : {})
184 const tabs = [...groups.keys()].slice(0, ROOM)
185 const at = tabs.indexOf(group)
186 const start = chips.includes(current) ? current : chips[0]
187 const assign = async (name: string) => {
188 await $.store.set(editing, name)
189 play(name === 'default' ? picks.sound ?? '' : name)
190 $.ui.invalidate('ui.render')
191 }
192 const play = (name: string) => {
193 const file = sounds.get(name)
194 if (file !== undefined) void $.audio.play({ asset: `sounds/${file}` }).catch(() => undefined)
195 }
196 const described = (field: string) =>
197 picks[field] === 'default' ? `Default · ${picks.sound}` : picks[field] === 'off' ? 'Muted' : picks[field]
198
199 return (
200 <Box flexDirection="column" gap={1} padding={1}>
201 <Box flexDirection="row" gap={1} alignItems="center">
202 {Svg && <Svg source={ICONS.bell ?? ''} alt="Bell" width={28} height={28} />}
203 <Box flexDirection="column">
204 <Text bold>Notification sounds</Text>
205 <Text dimColor>Choose what plays when Claude needs you.</Text>
206 </Box>
207 </Box>
208
209 <Box flexDirection="column" gap={1} paddingX={1} borderStyle="round" borderColor="subtle">
210 {FIELDS.map(field => (
211 <Box key={`row-${field}`} flexDirection="row" gap={1} alignItems="center">
212 {Svg && <Svg source={ICONS[field] ?? ''} alt={LABELS[field] ?? field} width={20} height={20} />}
213 <Box flexDirection="column" flexGrow={1}>
214 <Text bold color={field === editing ? 'claude' : 'text'}>
215 {LABELS[field]}
216 </Text>
217 <Text dimColor>{HINTS[field]}</Text>
218 </Box>
219 <Button key={`play-${field}`} plain dimColor {...key(field === editing ? 't' : undefined)} onPress={async () => play((await resolve($, options, field)) ?? '')}>
220 ▶
221 </Button>
222 <Button
223 key={`edit-${field}`}
224 {...key(String(FIELDS.indexOf(field) + 1))}
225 variant={field === editing ? 'primary' : 'secondary'}
226 onPress={() => {
227 editing = field
228 shown = undefined
229 $.ui.invalidate('ui.render')
230 }}
231 >
232 {described(field)}
233 </Button>
234 </Box>
235 ))}
236 </Box>
237
238 <Box flexDirection="column" gap={1} paddingX={1} borderStyle="round" borderColor="claude">
239 <Box flexDirection="row" gap={1} alignItems="center">
240 <Box flexGrow={1}>
241 <Text bold>
242 Sound for <Text color="claude">{LABELS[editing]}</Text>
243 </Text>
244 </Box>
245 {editing !== 'sound' &&
246 ['default', 'off'].map(name => (
247 <Button key={`chip-${name}`} variant={name === current ? 'primary' : 'secondary'} {...key(name === 'default' ? 'd' : 'm')} onPress={() => assign(name)}>
248 {name === 'default' ? `Use default (${picks.sound})` : 'Mute'}
249 </Button>
250 ))}
251 </Box>
252 <Box flexDirection="row" flexWrap="wrap" gap={1}>
253 {tabs.map((name, i) => (
254 <Button
255 key={`lib-${name}`}
256 {...key(tabs.length < 2 ? undefined : i === (at + 1) % tabs.length ? 'n' : i === (at + tabs.length - 1) % tabs.length ? 'b' : undefined)}
257 plain
258 dimColor={name !== group}
259 onPress={() => {
260 shown = name
261 $.ui.invalidate('ui.render')
262 }}
263 >
264 {name === group ? `● ${name}` : name} <Text dimColor>{groups.get(name)?.length}</Text>
265 </Button>
266 ))}
267 </Box>
268 <Box flexDirection="row" flexWrap="wrap" gap={1}>
269 {chips.map(name => (
270 <Button key={`chip-${name}`} variant={name === current ? 'primary' : 'secondary'} {...(name === start ? { autoFocus: true as const } : {})} onPress={() => assign(name)}>
271 {name}
272 </Button>
273 ))}
274 </Box>
275 <Text dimColor>
276 {e.surface === 'terminal'
277 ? 'arrows move · enter hears and uses · 1-5 event · n/b library · t test · d default · m mute · esc closes'
278 : 'Click a sound to hear it and use it.'}{' '}
279 Add your own: see /sound info.
280 </Text>
281 </Box>
282 </Box>
283 )
284 })
285
286 on('turn.complete', ($, e, next) => {
287 // Subagent turns and the user's own interrupts need no ring.
288 if (e.agentId === undefined && !e.isAborted) ring($, options, 'done')
289 return next(e)
290 })
291
292 on('classic.PermissionRequest', ($, e, next) => {
293 ring($, options, 'permission')
294 return next(e)
295 })
296
297 on('tool.call', { tool: 'AskUserQuestion' }, ($, e, next) => {
298 ring($, options, 'question')
299 return next(e)
300 })
301
302 on('classic.Notification', ($, e, next) => {
303 if (!ALREADY_RUNG.has(e.notification_type)) ring($, options, 'attention')
304 return next(e)
305 })
306}
307