📞 Call Claude Code: puts the chat you are in on a live ElevenLabs voice call (local mic and speakers, animated orb); hang up any time

Mods for Claude Code (terminal and the desktop Code tab).
| Mod | What it does |
|---|---|
| usage-band | A band above the prompt: 5h and 7d limits as bars with time to reset, session cost with a mood emoji, total session tokens, and a pacing hint |
| chat-roster | A floating org-chart window for the sidebar group of the chat you're in: the orchestrator on top, the group's chats below, running subagents under their chat. Filled = working, hover a dot for its title, a flash runs along the lines when messages pass. Shown only while Claude is in front; opens when a chat starts |
| cube | /cube, /cube 2, /cube 4: random-state scrambles (the WCA method, via cubing.js) |
| handoff | /handoff [focus]: runs the project's checks, writes the dev log entry and HANDOFF.md update, commits (never pushes), and summarizes |
| streak | Your streak of days in a row using Claude Code in the status line (streak 🔥 3 days); /streak adds your longest streak and total days |
| elevenlabs-call | 📞 Call Claude Code above the prompt puts the chat you are in on a live ElevenLabs voice call through your mic and speakers (no browser), with an animated orb. What you say is typed into the chat; the agent handles small talk and reads back a short summary of each reply. Hang up from the band or say bye. Needs the claude-voice bridge |
| prompt-cleanup | Fixes typos in what you type before Claude reads it (Haiku; skips commands, code and long pastes) |
5h ████░░░ 52% (1h 30m) 7d ██░░░░░ 23% (3d 5h) $1.23 💩 6.1M tokens 👌 on pace
Cost emoji: 💩 $0–$5 · 😒 >$5 · 🥱 >$10 · 😮💨 >$25 · >$50 · 😛 >$100 · 🤑 >$500 · 🙀 >$1000. Pacing: 🚀 spend freely (under an hour to reset, 70% or less used) · 🐢 slow down (15+ points ahead of the clock) · 👌 on pace · 🛑 nearly out (90%+).
Needs: python3 (usage-band tokens, chat-roster, streak), Xcode command line tools for swiftc (chat-roster builds its window on first use, macOS only), node and npm (cube installs cubing.js on first use).
In a Claude Code terminal session, one line per mod you want:
/plugin install usage-band --marketplace bhargava-gumpula/claude-mods
/plugin install chat-roster --marketplace bhargava-gumpula/claude-mods
/plugin install cube --marketplace bhargava-gumpula/claude-mods
/plugin install handoff --marketplace bhargava-gumpula/claude-mods
/plugin install prompt-cleanup --marketplace bhargava-gumpula/claude-mods
/plugin install streak --marketplace bhargava-gumpula/claude-mods
/plugin install elevenlabs-call --marketplace bhargava-gumpula/claude-mods
Answer y to add the marketplace, then pick the user scope.
hooks/register.tsx 200 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import type { CallState, Task } from '../types'
5
6const BRIDGE = 'http://127.0.0.1:8787'
7const DIR = '"$HOME/Projects/claude-voice"'
8// run.sh keeps the bridge + tunnel alive after this session ends
9const START_BRIDGE = ['/bin/sh', '-c', `cd ${DIR} && nohup ./run.sh > bridge.log 2>&1 &`]
10const READ_TOKEN = ['/bin/sh', '-c', `cat ${DIR}/.token`]
11const HANG_UP = ['/bin/sh', '-c', `kill $(cat ${DIR}/.call.pid) 2>/dev/null; rm -f ${DIR}/.call.pid`]
12
13const call = atom({ plugin: 'elevenlabs-call', key: 'call' } as const, null)
14
15let token = ''
16let isOnCall = false // the session.start ticker polls the bridge only while true
17let isPolling = false
18let hasTicker = false
19let hasConnected = false // the call reported in once; 'ended' after that means it hung up
20// ponytail: replies are matched to voice tasks in order; a prompt you type mid-call shifts them
21const inflight: string[] = []
22
23function post($: Engine, path: string, body: object) {
24 return $.http.fetch(BRIDGE + path, {
25 method: 'POST',
26 headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
27 body: JSON.stringify(body),
28 })
29}
30
31// The bridge answers 401 without its token: that means it is up.
32async function isBridgeUp($: Engine) {
33 try {
34 return (await $.http.fetch(BRIDGE + '/status', { method: 'POST' })).status === 401
35 } catch {
36 return false
37 }
38}
39
40async function ensureBridge($: Engine) {
41 if (!(await isBridgeUp($))) {
42 await $.process.run(START_BRIDGE)
43 await $.clock.sleep(2500)
44 }
45 token = (await $.process.run(READ_TOKEN)).stdout.trim()
46}
47
48// A voice task becomes a prompt in this chat, as if typed; the orb follows the call.
49async function pollOnce($: Engine) {
50 if (isPolling) return
51 isPolling = true
52 try {
53 const res = await post($, '/poll', {})
54 if (!res.ok) return
55 const { task, orb } = JSON.parse(res.text) as { task: Task | null; orb: { mode: string } }
56 if (task) {
57 inflight.push(task.id)
58 void $.prompt.submit({ text: `🎙️ ${task.text}`, asUser: true })
59 }
60 if (orb.mode !== 'ended') hasConnected = true
61 else if (hasConnected) return void (await hangUp($)) // you said bye, or the call dropped
62 const mode = orb.mode === 'ended' ? 'ended' : inflight.length > 0 && orb.mode === 'listening' ? 'working' : orb.mode
63 const now = await read($, call)
64 if (now && now.mode !== mode) await update($, call, () => ({ mode }))
65 } catch {
66 // bridge restarting: try again next tick
67 } finally {
68 isPolling = false
69 }
70}
71
72async function hangUp($: Engine) {
73 isOnCall = false
74 hasConnected = false
75 inflight.length = 0
76 await $.process.run(HANG_UP)
77 await update($, call, () => null)
78}
79
80// Call: this chat goes on the call, through this Mac's mic and speakers.
81async function goOnCall($: Engine, agentId: string) {
82 if (!agentId) {
83 $.ui.toast('Set your ElevenLabs agent ID: /config → elevenlabs-call')
84 return
85 }
86 await ensureBridge($)
87 await $.process.run(['/bin/sh', '-c', `cd ${DIR} && nohup .venv/bin/python call.py '${agentId.replace(/[^\w-]/g, '')}' > call.log 2>&1 &`])
88 isOnCall = true
89 if (!hasTicker) {
90 hasTicker = true
91 $.clock.every(400, () => {
92 if (isOnCall) void pollOnce($)
93 })
94 }
95 await update($, call, () => ({ mode: 'listening' }))
96}
97
98// An ElevenLabs-style orb: soft blue, cyan and violet light turning inside a sphere.
99function orb(mode: string) {
100 const [spin, pulse, grow] =
101 mode === 'speaking' ? [2.4, 0.7, 1.09] : mode === 'working' ? [4, 1.4, 1.04] : mode === 'ended' ? [0, 0, 1] : [9, 3.5, 1.03]
102 const turn = (dir: number) =>
103 spin ? `<animateTransform attributeName="transform" type="rotate" from="0 60 60" to="${360 * dir} 60 60" dur="${spin}s" repeatCount="indefinite"/>` : ''
104 const breathe = pulse
105 ? `<animateTransform attributeName="transform" type="scale" values="1;${grow};1" dur="${pulse}s" repeatCount="indefinite"/>`
106 : ''
107 const ring = mode === 'working'
108 ? `<circle cx="60" cy="60" r="56" fill="none" stroke="#7CC4FA" stroke-width="1.5" stroke-dasharray="6 10" opacity=".7">${turn(1)}</circle>`
109 : ''
110 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">
111<defs>
112<radialGradient id="base" cx="35%" cy="30%" r="80%"><stop offset="0" stop-color="#EAF6FF"/><stop offset=".45" stop-color="#8CCBFA"/><stop offset="1" stop-color="#3D5AFE"/></radialGradient>
113<filter id="soft" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="9"/></filter>
114<clipPath id="ball"><circle cx="60" cy="60" r="50"/></clipPath>
115</defs>
116${ring}
117<g transform="translate(60 60)"><g>${breathe}<g transform="translate(-60 -60)">
118<g clip-path="url(#ball)">
119<circle cx="60" cy="60" r="50" fill="url(#base)"${mode === 'ended' ? ' opacity=".35"' : ''}/>
120<g filter="url(#soft)">
121<ellipse cx="42" cy="72" rx="36" ry="22" fill="#A78BFA" opacity=".85">${turn(1)}</ellipse>
122<ellipse cx="78" cy="44" rx="32" ry="20" fill="#22D3EE" opacity=".8">${turn(-1)}</ellipse>
123<ellipse cx="62" cy="90" rx="30" ry="14" fill="#F0ABFC" opacity=".55">${turn(1)}</ellipse>
124</g>
125<ellipse cx="44" cy="36" rx="20" ry="12" fill="#FFFFFF" opacity=".35"/>
126</g>
127<circle cx="60" cy="60" r="50" fill="none" stroke="#FFFFFF" stroke-opacity=".35"/>
128</g></g></g>
129</svg>`
130}
131
132const LABEL: Record<string, string> = {
133 listening: 'Listening',
134 speaking: 'Speaking',
135 working: 'Working on it',
136 ended: 'Call ended',
137}
138
139export const register: Register = (on, options) => {
140 const agentId = String(options.agentId ?? '').trim()
141
142 on('session.start', async ($, e, next) => {
143 await $.command.register({ name: 'call', description: 'Put this chat on a voice call (ElevenLabs); /call again hangs up' })
144 return next(e)
145 })
146
147 on('command.run', { command: 'call' }, async $ => {
148 if (await read($, call)) {
149 await hangUp($)
150 return { text: 'Call ended.' }
151 }
152 await goOnCall($, agentId)
153 return { text: agentId ? 'On the call. Talk; this chat does the work.' : 'Set your ElevenLabs agent ID first: /config → elevenlabs-call.' }
154 })
155
156 // The chat's answer to a voice task goes back to be spoken.
157 on('turn.complete', async ($, e, next) => {
158 const id = e.agentId === undefined ? inflight.shift() : undefined
159 if (id) {
160 try {
161 await post($, '/done', { id, text: e.answer })
162 } catch {
163 // bridge gone: the call has ended
164 }
165 }
166 return next(e)
167 })
168
169 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
170 if (e.props.hasSurvey) return next(e)
171 const state: CallState | null = await read($, call)
172 if (!state) {
173 const { Box, Button } = $.ui.resolve(e)
174 return (
175 <Box>
176 <Button key="call" label="📞 Call Claude Code" onPress={() => goOnCall($, agentId)} />
177 </Box>
178 )
179 }
180 const label = `${LABEL[state.mode] ?? 'On call'} `
181 if (e.surface === 'desktop') {
182 const { Box, Button, Svg, Text } = $.ui.resolve(e)
183 return (
184 <Box flexDirection="row" alignItems="center" gap={2}>
185 <Svg source={orb(state.mode)} alt={`Voice call: ${label}`} width={64} height={64} />
186 <Text>{label}</Text>
187 <Button key="hangup" label="Hang up" onPress={() => hangUp($)} />
188 </Box>
189 )
190 }
191 const { Box, Button, Text } = $.ui.resolve(e)
192 return (
193 <Box>
194 <Text color={state.mode === 'speaking' ? 'magenta' : 'cyan'}>● {label}</Text>
195 <Button key="hangup" label="Hang up" onPress={() => hangUp($)} />
196 </Box>
197 )
198 })
199}
200types/index.d.ts 9 lines1export type Task = { id: string; text: string }
2export type CallState = { mode: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'elevenlabs-call': { call: CallState | null }
7 }
8}
9