ElevenLabs characters and Higgsfield credits at a glance, with a heads-up before a tool call spends them

/credits opens a pane with two balances: ElevenLabs characters left (with a ring and the reset date) and Higgsfield credits left. Before Claude runs a tool that spends either one, a toast shows what you have; after it runs, a second toast shows what the call cost.
Calls it watches: ElevenLabs text-to-speech, sound effects, music and voice cloning (through the ElevenLabs MCP server or a curl to the API), and Higgsfield image, video, audio, 3D, upscale, preset and motion tools.
| Setting | Default | What it is |
|---|---|---|
elevenlabs_api_key | empty | Optional, stored as a secret. If empty, the mod uses $ELEVENLABS_API_KEY, then the key your elevenlabs MCP server has in ~/.claude.json. |
higgsfield_server | claude.ai Higgsfield | The Higgsfield MCP server name as /mcp shows it. higgsfield is tried too. |
A secret setting is not a row in the /plugin menu. Set it with claude plugin configure credit-meter@buildalone-mods --values-stdin, or just export ELEVENLABS_API_KEY.
If a provider isn't set up, its half of the card says "not connected" and the rest works.
Reads ELEVENLABS_API_KEY and HOME from the environment and ~/.claude.json (only for the ElevenLabs key). Calls api.elevenlabs.io/v1/user/subscription and the Higgsfield balance tool. Run claude plugin validate on this folder to see the full list.
hooks/register.tsx 138 lines1// credit-meter: /credits opens a card with ElevenLabs characters left and Higgsfield credits.
2// Before a tool call that spends credits (voice generation, image/video generation) it toasts
3// what you have left, and after it toasts what the call cost.
4import type { Register } from 'claude-code'
5
6type Balances = { el?: { left: number; limit: number; resetsAt?: number }; hf?: number; at: number }
7const PANE = 'credits'
8let last: Balances = { at: 0 }
9let configuredKey = ''
10let servers: string[] = []
11
12// Key order: the plugin setting, then $ELEVENLABS_API_KEY, then the ElevenLabs MCP server's env in ~/.claude.json.
13async function elevenLabsKey($: any, configured: string): Promise<string | undefined> {
14 if (configured) return configured
15 const fromEnv = await $.env.get('ELEVENLABS_API_KEY')
16 if (fromEnv) return fromEnv
17 try {
18 const home = await $.env.get('HOME')
19 if (!home) return undefined
20 const cfg = JSON.parse(await $.fs.read(`${home}/.claude.json`))
21 return cfg?.mcpServers?.elevenlabs?.env?.ELEVENLABS_API_KEY || undefined
22 } catch { return undefined }
23}
24async function elevenLabs($: any, configured: string): Promise<Balances['el']> {
25 try {
26 const key = await elevenLabsKey($, configured)
27 if (!key) return undefined
28 const r = await $.http.fetch('https://api.elevenlabs.io/v1/user/subscription', { headers: { 'xi-api-key': key } })
29 if (!r.ok) return undefined
30 const j = JSON.parse(r.text)
31 return { left: j.character_limit - j.character_count, limit: j.character_limit, resetsAt: j.next_character_count_reset_unix }
32 } catch { return undefined }
33}
34async function higgsfield($: any, servers: string[]): Promise<number | undefined> {
35 for (const server of servers) {
36 try {
37 const res = await $.mcp.call(server, 'balance', {})
38 const txt = (res.content ?? []).map((c: any) => c.text ?? '').join(' ')
39 const m = txt.match(/"?credits"?\s*[:=]\s*([\d.]+)/i) ?? txt.match(/([\d][\d,.]*)\s*credits/i)
40 if (m) return parseFloat(m[1].replace(/,/g, ''))
41 } catch { /* try next name */ }
42 }
43 return undefined
44}
45async function refresh($: any) {
46 const [el, hf] = await Promise.all([elevenLabs($, configuredKey), higgsfield($, servers)])
47 last = { el, hf, at: await $.clock.now() }
48 $.ui.invalidate('ui.render')
49 return last
50}
51// Connectors from claude.ai carry an id rather than a name in their tool names, so Higgsfield's
52// spending tools are matched by tool name on any MCP server.
53const SPENDS = /(api\.elevenlabs\.io\/v1\/(text-to-speech|sound-generation))|(^mcp__.*__(generate_(image|video|audio|3d)(_batch)?|upscale_(image|video)|execute_preset|motion_control)$)|(^mcp__elevenlabs__(text_to_speech|text_to_sound|compose_music|voice_clone))/i
54
55const STYLE = `<style>
56 svg{--bg:#ffffff;--fg:#141413;--mu:#73726c;--ln:#e8e6dc;--tr:#f0eee6;--ac:#d97757;--ok:#788c5d;--wa:#c98a3a;--bad:#bf4d43}
57 @media (prefers-color-scheme: dark){svg{--bg:#262624;--fg:#faf9f5;--mu:#a3a199;--ln:#3d3d3a;--tr:#30302e}}
58 .card{fill:var(--bg);stroke:var(--ln);stroke-width:1}
59 .fg{fill:var(--fg)} .mu{fill:var(--mu)} .tr{fill:var(--tr)} .trs{stroke:var(--tr)} .ln{stroke:var(--ln)}
60 .ac{fill:var(--ac)} .acs{stroke:var(--ac)} .ok{fill:var(--ok)} .oks{stroke:var(--ok)} .wa{fill:var(--wa)} .was{stroke:var(--wa)} .bad{fill:var(--bad)} .bads{stroke:var(--bad)}
61 .t{font-family:ui-serif,"Tiempos Text",Georgia,serif}
62 text{font-family:-apple-system,BlinkMacSystemFont,"Inter",sans-serif}
63</style>`
64function card(b: Balances) {
65 const el = b.el, pct = el ? Math.round((el.left / el.limit) * 100) : 0
66 const cls = pct < 15 ? 'bad' : pct < 35 ? 'wa' : 'ok'
67 const c = 2 * Math.PI * 54, dash = (c * pct) / 100
68 const reset = el?.resetsAt ? new Date(el.resetsAt * 1000).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : ''
69 const hfCls = b.hf === undefined ? 'mu' : b.hf < 60 ? 'wa' : 'fg'
70 return `<svg xmlns="http://www.w3.org/2000/svg" width="460" height="250" viewBox="0 0 460 250">${STYLE}
71 <rect x="0.5" y="0.5" width="459" height="249" rx="14" class="card"/>
72 <text x="24" y="34" font-size="13" class="mu">◆ Credits</text>
73 <circle cx="110" cy="122" r="54" fill="none" class="trs" stroke-width="10"/>
74 <circle cx="110" cy="122" r="54" fill="none" class="${cls}s" stroke-width="10" stroke-linecap="round" stroke-dasharray="${dash} ${c}" transform="rotate(-90 110 122)">
75 <animate attributeName="stroke-dasharray" from="0 ${c}" to="${dash} ${c}" dur="1s" fill="freeze"/></circle>
76 <text x="110" y="122" text-anchor="middle" font-size="22" font-weight="600" class="fg">${el ? (el.left / 1000).toFixed(1) + 'k' : '—'}</text>
77 <text x="110" y="141" text-anchor="middle" font-size="11" class="mu">characters left</text>
78 <text x="110" y="208" text-anchor="middle" font-size="15" class="fg t">ElevenLabs</text>
79 <text x="110" y="228" text-anchor="middle" font-size="12" class="mu">${el ? `${pct}% left · resets ${reset}` : 'not connected'}</text>
80 <line x1="222" y1="64" x2="222" y2="226" class="ln"/>
81 <text x="340" y="112" text-anchor="middle" font-size="40" font-weight="600" class="${hfCls}">${b.hf !== undefined ? b.hf.toFixed(0) : '—'}</text>
82 <text x="340" y="134" text-anchor="middle" font-size="11" class="mu">credits left</text>
83 <text x="340" y="208" text-anchor="middle" font-size="15" class="fg t">Higgsfield</text>
84 <text x="340" y="228" text-anchor="middle" font-size="12" class="mu">${b.hf === undefined ? 'not connected' : b.hf < 60 ? 'running low' : 'plenty'}</text>
85</svg>`
86}
87
88export const register: Register = (on, options) => {
89 configuredKey = String(options.elevenlabs_api_key ?? '').trim()
90 servers = [...new Set([String(options.higgsfield_server ?? '').trim(), 'higgsfield'].filter(Boolean))]
91
92 on('session.start', async ($, e, next) => {
93 await $.tool.register({ name: 'show', description: 'Open the ◆ Credits pane in Claude Code for the person' })
94 await $.command.register({ name: 'credits', description: 'Show ElevenLabs characters and Higgsfield credits left' })
95 return next(e)
96 })
97
98 on('command.run', { command: 'credits' }, async $ => {
99 await $.ui.open({ id: PANE, title: '◆ Credits' })
100 const b = await refresh($)
101 return { text: `Credits: ElevenLabs ${b.el ? b.el.left.toLocaleString() + ' chars' : 'not connected'} · Higgsfield ${b.hf ?? 'not connected'}` }
102 })
103
104 on('tool.call', async ($, e, next) => {
105 const probe = e.tool === 'Bash' ? String((e as any).command ?? '') : e.tool
106 if (!SPENDS.test(probe)) return next(e)
107 const before = await refresh($)
108 $.ui.toast(`◆ This spends credits. Left now: ElevenLabs ${before.el ? before.el.left.toLocaleString() : '?'} chars · Higgsfield ${before.hf ?? '?'}`)
109 const ran = await next(e)
110 const after = await refresh($)
111 const elSpent = before.el && after.el ? before.el.left - after.el.left : 0
112 const hfSpent = before.hf !== undefined && after.hf !== undefined ? before.hf - after.hf : 0
113 if (elSpent > 0 || hfSpent > 0) $.ui.toast(`◆ Spent ${elSpent > 0 ? elSpent.toLocaleString() + ' ElevenLabs chars ' : ''}${hfSpent > 0 ? hfSpent + ' Higgsfield credits' : ''}`)
114 return ran
115 })
116
117 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
118 if (!last.at) await refresh($)
119 if (e.surface === 'terminal') {
120 const { Text } = $.ui.resolve(e)
121 return <Text>ElevenLabs {last.el ? last.el.left + ' chars' : 'not connected'} · Higgsfield {last.hf ?? 'not connected'}</Text>
122 }
123 const { Box, Svg, Button } = $.ui.resolve(e)
124 return (
125 <Box flexDirection="column">
126 <Svg source={card(last)} alt="ElevenLabs and Higgsfield credit balances" width={460} isInteractive />
127 <Button key="refresh" label="Refresh" onPress={() => { void refresh($) }} />
128 </Box>
129 )
130 })
131
132 on('tool.call', { tool: 'mcp__credit-meter__show' }, async $ => {
133 try { await $.ui.close({ id: PANE }) } catch {}
134 await $.ui.open({ id: PANE, title: '◆ Credits' })
135 return { result: '◆ Credits pane opened.' }
136 })
137}
138