Calls you by name out loud when Claude needs you or finishes a task

A Claude Code mod that calls you by name, out loud, when Claude needs you:
minSeconds to change it, 0 for every task), with a one-line spoken summary of what was doneIt stays quiet while you're in a meeting, i.e. while any app is using a microphone or camera (set muteInMeetings to off to disable). The first check compiles a tiny Swift helper into ~/.cache/call-my-name/, so it needs the Xcode command line tools; without them the mod just keeps talking.
Available in English, 中文, Deutsch and 日本語, with a female, male or alternating voice. macOS only.
From any shell (works for the desktop app too):
claude plugin marketplace add juhaodong/call-my-name
claude plugin install call-my-name@haodong-mods
uv tool install edge-tts # optional, much nicer voices
Or, inside a Claude Code terminal session:
/plugin install call-my-name --marketplace juhaodong/call-my-name
Restart Claude Code afterwards.
With /plugin install, answer y to add the marketplace, pick the user scope, then set your options. With the shell install, set them in /config:
| Option | Values | Default |
|---|---|---|
nickname | anything; empty skips the name | empty |
language | en, zh, de, ja | en |
minSeconds | seconds; 0 announces every finished task | 10 |
voice | random, alternate, female, male | random |
muteInMeetings | on, off | on |
Change them later in /config.
The mod uses edge-tts neural voices when available and falls back to macOS say otherwise:
uv tool install edge-tts
(or pipx install edge-tts). For the say fallback, a Premium/Enhanced system voice for your language sounds much better: System Settings → Accessibility → Spoken Content → System Voice → Manage Voices.
安装(在终端里运行):
claude plugin marketplace add juhaodong/call-my-name
claude plugin install call-my-name@haodong-mods
uv tool install edge-tts # 可选,声音更自然
装好后重启 Claude Code,在 /config 里设置 nickname(昵称)、language 选 zh、voice(女声 / 男声 / 轮流)。
hooks/register.ts 269 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3type Language = 'zh' | 'en' | 'de' | 'ja'
4type VoiceChoice = 'random' | 'alternate' | 'female' | 'male'
5
6type Phrases = {
7 edgeVoices: { female: string; male: string }
8 macVoices: string[]
9 separator: string
10 tools: Record<string, string>
11 otherTool: (name: string) => string
12 permission: (label: string) => string
13 question: (text: string) => string
14 error: string
15 doneFallback: string
16 summarySystem: string
17 summaryPrompt: (prompt: string, answer: string) => string
18}
19
20const PHRASES: Record<Language, Phrases> = {
21 zh: {
22 edgeVoices: { female: 'zh-CN-XiaoxiaoNeural', male: 'zh-CN-YunxiNeural' },
23 macVoices: ['Lilian (Premium)', 'Lili (Premium)', 'Tingting (Premium)', 'Tingting (Enhanced)', 'Tingting'],
24 separator: ',',
25 tools: { Bash: '跑个命令', Edit: '改个文件', Write: '写个文件', WebFetch: '上网看个网页', WebSearch: '上网搜一下' },
26 otherTool: name => `用一下 ${name}`,
27 permission: label => `我想${label},你点个同意呗。`,
28 question: text => `问你个事儿:${text}`,
29 error: '哎,出了点岔子,你来瞅一眼吧。',
30 doneFallback: '活儿干完啦,你来看看吧。',
31 summarySystem:
32 '你是用户的搭档,刚帮他干完一件活儿。用一句很口语、很随意的中文告诉他干完了什么,不超过二十五个字,不要称呼他,不要引号。例如:登录页那个报错我搞定啦,你来瞅一眼吧。',
33 summaryPrompt: (prompt, answer) => `用户的请求:${prompt}\n\n助手的最终回复:${answer}`,
34 },
35 en: {
36 edgeVoices: { female: 'en-US-AvaNeural', male: 'en-US-AndrewNeural' },
37 macVoices: ['Ava (Premium)', 'Zoe (Premium)', 'Ava (Enhanced)', 'Samantha (Enhanced)', 'Samantha'],
38 separator: ', ',
39 tools: {
40 Bash: 'run a command',
41 Edit: 'edit a file',
42 Write: 'write a file',
43 WebFetch: 'open a web page',
44 WebSearch: 'search the web',
45 },
46 otherTool: name => `use ${name}`,
47 permission: label => `I want to ${label}, mind giving me the okay?`,
48 question: text => `quick question: ${text}`,
49 error: 'oops, something went wrong, come take a look.',
50 doneFallback: 'all done, come take a look.',
51 summarySystem:
52 "You are the user's teammate and just finished a task for them. In one short, casual spoken English sentence (max 15 words), tell them what you got done. Do not address them by name, no quotes. Example: Fixed that login page error, come check it out.",
53 summaryPrompt: (prompt, answer) => `User's request: ${prompt}\n\nAssistant's final reply: ${answer}`,
54 },
55 de: {
56 edgeVoices: { female: 'de-DE-KatjaNeural', male: 'de-DE-ConradNeural' },
57 macVoices: ['Anna (Premium)', 'Petra (Premium)', 'Anna (Enhanced)', 'Anna'],
58 separator: ', ',
59 tools: {
60 Bash: 'einen Befehl ausführen',
61 Edit: 'eine Datei ändern',
62 Write: 'eine Datei schreiben',
63 WebFetch: 'eine Webseite öffnen',
64 WebSearch: 'im Web suchen',
65 },
66 otherTool: name => `${name} benutzen`,
67 permission: label => `ich möchte ${label}, gibst du mir kurz das Okay?`,
68 question: text => `kurze Frage: ${text}`,
69 error: 'hoppla, da ist was schiefgelaufen, schau mal kurz rein.',
70 doneFallback: 'fertig, schau mal rein.',
71 summarySystem:
72 'Du bist der Teamkollege des Nutzers und hast gerade eine Aufgabe für ihn erledigt. Sag ihm in einem kurzen, lockeren gesprochenen deutschen Satz (höchstens 15 Wörter), was du erledigt hast. Keine Anrede mit Namen, keine Anführungszeichen. Beispiel: Den Fehler auf der Login-Seite hab ich behoben, schau mal rein.',
73 summaryPrompt: (prompt, answer) => `Anfrage des Nutzers: ${prompt}\n\nLetzte Antwort des Assistenten: ${answer}`,
74 },
75 ja: {
76 edgeVoices: { female: 'ja-JP-NanamiNeural', male: 'ja-JP-KeitaNeural' },
77 macVoices: ['Kyoko (Premium)', 'Kyoko (Enhanced)', 'Kyoko'],
78 separator: '、',
79 tools: {
80 Bash: 'コマンドを実行し',
81 Edit: 'ファイルを編集し',
82 Write: 'ファイルを作成し',
83 WebFetch: 'ウェブページを開き',
84 WebSearch: 'ウェブで検索し',
85 },
86 otherTool: name => `${name} を使い`,
87 permission: label => `${label}たいんだけど、許可してもらえる?`,
88 question: text => `ちょっと聞いていい?${text}`,
89 error: 'あれ、何か問題が起きたみたい。ちょっと見てくれる?',
90 doneFallback: '終わったよ、見てみて。',
91 summarySystem:
92 'あなたはユーザーの相棒で、たった今作業を一つ終えたところです。何を終えたかを、くだけた話し言葉の日本語一文(三十文字以内)で伝えてください。名前で呼びかけず、引用符も使わないこと。例:ログイン画面のエラー直しといたよ、見てみて。',
93 summaryPrompt: (prompt, answer) => `ユーザーの依頼:${prompt}\n\nアシスタントの最終回答:${answer}`,
94 },
95}
96
97type Settings = { voice: VoiceChoice; nickname: string; minTurnMs: number; muteInMeetings: boolean; phrases: Phrases }
98
99function readOptions(options: PluginOptions): Settings {
100 const language: Language = Object.hasOwn(PHRASES, String(options.language)) ? (options.language as Language) : 'en'
101 const voice: VoiceChoice = ['female', 'male', 'alternate'].includes(String(options.voice)) ? (options.voice as VoiceChoice) : 'random'
102 const nickname = typeof options.nickname === 'string' ? options.nickname.trim() : ''
103 const minSeconds = Number(options.minSeconds)
104 const minTurnMs = Number.isFinite(minSeconds) && minSeconds > 0 ? minSeconds * 1000 : 0
105 const muteInMeetings = options.muteInMeetings !== 'off'
106 return { voice, nickname, minTurnMs, muteInMeetings, phrases: PHRASES[language] }
107}
108
109function toolLabel({ phrases }: Settings, tool: string): string {
110 if (phrases.tools[tool]) return phrases.tools[tool]
111 const name = tool.startsWith('mcp__') ? (tool.split('__').at(-1) ?? tool).replaceAll('_', ' ') : tool
112 return phrases.otherTool(name)
113}
114
115function withName({ nickname, phrases }: Settings, text: string): string {
116 return nickname ? `${nickname}${phrases.separator}${text}` : text
117}
118
119// Prints `busy` while any app records from a microphone or a camera, `free` otherwise.
120const IN_USE_SWIFT = `import AVFoundation
121import CoreAudio
122
123func micInUse() -> Bool {
124 var addr = AudioObjectPropertyAddress(mSelector: kAudioHardwarePropertyDevices, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain)
125 var size: UInt32 = 0
126 AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size)
127 var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size)
128 AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &ids)
129 return ids.contains { id in
130 var input = AudioObjectPropertyAddress(mSelector: kAudioDevicePropertyStreamConfiguration, mScope: kAudioObjectPropertyScopeInput, mElement: kAudioObjectPropertyElementMain)
131 var s: UInt32 = 0
132 guard AudioObjectGetPropertyDataSize(id, &input, 0, nil, &s) == noErr, s > 0 else { return false }
133 let buf = UnsafeMutableRawPointer.allocate(byteCount: Int(s), alignment: 16)
134 defer { buf.deallocate() }
135 guard AudioObjectGetPropertyData(id, &input, 0, nil, &s, buf) == noErr,
136 UnsafeMutableAudioBufferListPointer(buf.assumingMemoryBound(to: AudioBufferList.self)).count > 0 else { return false }
137 var running = AudioObjectPropertyAddress(mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain)
138 var value: UInt32 = 0
139 var valueSize = UInt32(MemoryLayout<UInt32>.size)
140 return AudioObjectGetPropertyData(id, &running, 0, nil, &valueSize, &value) == noErr && value != 0
141 }
142}
143
144func cameraInUse() -> Bool {
145 AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera], mediaType: .video, position: .unspecified)
146 .devices.contains { $0.isInUseByAnotherApplication }
147}
148
149print(micInUse() || cameraInUse() ? "busy" : "free")
150`
151
152const IN_USE_BINARY = 'in-use-v1'
153
154let inUseBinary: Promise<string | undefined> | undefined
155
156async function buildInUseBinary($: EngineInterface, home: string): Promise<string | undefined> {
157 const dir = `${home}/.cache/call-my-name`
158 const binary = `${dir}/${IN_USE_BINARY}`
159 if (await $.fs.exists(binary)) return binary
160 await $.fs.write(`${dir}/${IN_USE_BINARY}.swift`, IN_USE_SWIFT)
161 const built = await $.process.run(['swiftc', '-O', `${dir}/${IN_USE_BINARY}.swift`, '-o', binary], { timeoutMs: 180_000 })
162 return built.exitCode === 0 ? binary : undefined
163}
164
165async function isInMeeting($: EngineInterface, { muteInMeetings }: Settings): Promise<boolean> {
166 if (!muteInMeetings) return false
167 const home = await $.env.get('HOME')
168 if (!home) return false
169 inUseBinary ??= buildInUseBinary($, home).catch(() => undefined)
170 const binary = await inUseBinary
171 if (!binary) {
172 inUseBinary = undefined
173 return false
174 }
175 const { stdout } = await $.process.run([binary], { timeoutMs: 5_000 })
176 return stdout.trim() === 'busy'
177}
178
179let spokenCount = 0
180
181function nextEdgeVoice({ voice, phrases }: Settings): string {
182 if (voice === 'female' || voice === 'male') return phrases.edgeVoices[voice]
183 const useFemale = voice === 'alternate' ? spokenCount++ % 2 === 0 : Math.random() < 0.5
184 return useFemale ? phrases.edgeVoices.female : phrases.edgeVoices.male
185}
186
187async function sayWithEdge($: EngineInterface, settings: Settings, text: string): Promise<boolean> {
188 const edgeVoice = nextEdgeVoice(settings)
189 const file = `/tmp/call-my-name-${await $.clock.now()}.mp3`
190 const home = await $.env.get('HOME')
191 for (const bin of home ? ['edge-tts', `${home}/.local/bin/edge-tts`] : ['edge-tts']) {
192 const made = await $.process
193 .run([bin, '--voice', edgeVoice, '--text', text, '--write-media', file], { timeoutMs: 10_000 })
194 .catch(() => undefined)
195 if (made?.exitCode !== 0) continue
196 const played = await $.process.run(['afplay', file], { timeoutMs: 60_000 })
197 await $.process.run(['rm', '-f', file])
198 return played.exitCode === 0
199 }
200 return false
201}
202
203async function sayWithMacVoice($: EngineInterface, { phrases }: Settings, text: string): Promise<void> {
204 const { stdout } = await $.process.run(['say', '-v', '?'])
205 const installed = stdout.split('\n').map(line => line.split(/\s{2,}/)[0].trim())
206 const macVoice = phrases.macVoices.find(v => installed.includes(v))
207 await $.process.run(macVoice ? ['say', '-v', macVoice, text] : ['say', text], { timeoutMs: 60_000 })
208}
209
210async function say($: EngineInterface, settings: Settings, text: string): Promise<void> {
211 const spoken = await sayWithEdge($, settings, text).catch(() => false)
212 if (!spoken) await sayWithMacVoice($, settings, text)
213}
214
215async function sayUnlessInMeeting($: EngineInterface, settings: Settings, text: () => Promise<string>): Promise<void> {
216 if (await isInMeeting($, settings).catch(() => false)) return
217 await say($, settings, withName(settings, await text()))
218}
219
220function speak($: EngineInterface, settings: Settings, text: string): void {
221 void sayUnlessInMeeting($, settings, async () => text).catch(() => undefined)
222}
223
224async function describeDone($: EngineInterface, { phrases }: Settings, prompt: string, answer: string): Promise<string> {
225 const reply = await $.model.complete({
226 model: 'haiku',
227 maxTokens: 80,
228 system: phrases.summarySystem,
229 prompt: phrases.summaryPrompt(prompt.slice(0, 1500), answer.slice(0, 3000)),
230 })
231 const sentence = reply.isAnswered ? reply.text.trim().replace(/^["“”'「」]+|["“”'「」]+$/g, '') : ''
232 return sentence || phrases.doneFallback
233}
234
235async function announceDone($: EngineInterface, settings: Settings, prompt: string, answer: string): Promise<void> {
236 await sayUnlessInMeeting($, settings, () => describeDone($, settings, prompt, answer))
237}
238
239export const register: Register = (on, options) => {
240 const settings = readOptions(options)
241 const { phrases } = settings
242 const prompts = new Map<string, string>()
243
244 on('turn.start', ($, e, next) => {
245 prompts.set(e.turnId, e.text)
246 return next(e)
247 })
248
249 on('classic.PermissionRequest', ($, e, next) => {
250 speak($, settings, phrases.permission(toolLabel(settings, e.tool_name)))
251 return next(e)
252 }).catch(($, e, next) => next(e))
253
254 on('tool.call', { tool: 'AskUserQuestion' }, ($, e, next) => {
255 speak($, settings, phrases.question(e.questions[0]?.question ?? ''))
256 return next(e)
257 }).catch(($, e, next) => next(e))
258
259 on('turn.complete', async ($, e, next) => {
260 const done = await next(e)
261 const prompt = prompts.get(e.turnId) ?? ''
262 prompts.delete(e.turnId)
263 if (e.agentId !== undefined || e.isAborted || e.durationMs < settings.minTurnMs) return done
264 if (e.reason === 'error') speak($, settings, phrases.error)
265 else void announceDone($, settings, prompt, e.answer).catch(() => speak($, settings, phrases.doneFallback))
266 return done
267 })
268}
269