Your channel's latest videos and view counts, with gains since you last looked

/pulse opens a card with your channel's latest videos, a bar per video for its views, and the gain since the last time you opened it (kept across sessions). It refreshes every 10 minutes while Claude Code is open.
It reads YouTube's public RSS feed for the channel, so it needs no API key or login.
| Setting | Default | What it is |
|---|---|---|
channel_id | empty | Required. Your channel ID, starting with UC. Find it in YouTube Studio under Settings → Channel → Advanced settings. A @handle won't work. |
count | 6 | How many videos to show, 1 to 15. |
Until channel_id is set, the pane shows a one-line reminder instead.
Fetches https://www.youtube.com/feeds/videos.xml?channel_id=… and stores the last view counts it showed you in the mod's own store.
hooks/register.tsx 119 lines1// youtube-pulse: /pulse opens a card with the channel's latest videos, their views as bars and
2// the gain since the last check (kept across sessions). Refreshes every 10 minutes.
3import type { Register } from 'claude-code'
4
5const PANE = 'pulse'
6const SETUP = 'Set your channel ID in /plugin → buildalone-mods → youtube-pulse'
7type Video = { id: string; title: string; published: string; views: number }
8let channel = ''
9let count = 6
10let videos: Video[] = []
11let prev: Record<string, number> = {}
12
13const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
14const decode = (s: string) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>')
15const fmt = (n: number) => (n >= 1000 ? (n / 1000).toFixed(n >= 10000 ? 0 : 1) + 'K' : String(n))
16
17async function pull($: any) {
18 if (!channel) return
19 const r = await $.http.fetch(`https://www.youtube.com/feeds/videos.xml?channel_id=${encodeURIComponent(channel)}`)
20 if (!r.ok) return
21 const list: Video[] = []
22 for (const m of r.text.matchAll(/<entry>([\s\S]*?)<\/entry>/g)) {
23 const x = m[1]
24 list.push({
25 id: x.match(/<yt:videoId>(.*?)</)?.[1] ?? '',
26 title: decode(x.match(/<title>(.*?)</)?.[1] ?? ''),
27 published: (x.match(/<published>(.*?)</)?.[1] ?? '').slice(0, 10),
28 views: parseInt(x.match(/views="(\d+)"/)?.[1] ?? '0', 10),
29 })
30 }
31 videos = list.slice(0, count)
32 $.ui.invalidate('ui.render')
33}
34
35const STYLE = `<style>
36 svg{--bg:#ffffff;--fg:#141413;--mu:#73726c;--ln:#e8e6dc;--tr:#f0eee6;--ac:#d97757;--ok:#788c5d;--wa:#c98a3a;--bad:#bf4d43}
37 @media (prefers-color-scheme: dark){svg{--bg:#262624;--fg:#faf9f5;--mu:#a3a199;--ln:#3d3d3a;--tr:#30302e}}
38 .card{fill:var(--bg);stroke:var(--ln);stroke-width:1}
39 .fg{fill:var(--fg)} .mu{fill:var(--mu)} .tr{fill:var(--tr)} .trs{stroke:var(--tr)} .ln{stroke:var(--ln)}
40 .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)}
41 .t{font-family:ui-serif,"Tiempos Text",Georgia,serif}
42 text{font-family:-apple-system,BlinkMacSystemFont,"Inter",sans-serif}
43</style>`
44function setupCard() {
45 return `<svg xmlns="http://www.w3.org/2000/svg" width="460" height="96" viewBox="0 0 460 96">${STYLE}
46 <rect x="0.5" y="0.5" width="459" height="95" rx="14" class="card"/>
47 <text x="24" y="34" font-size="13" class="mu">▶ YouTube</text>
48 <text x="24" y="66" font-size="13" class="fg">${esc(SETUP)}</text></svg>`
49}
50function card() {
51 const max = Math.max(1, ...videos.map(v => v.views))
52 const total = videos.reduce((a, v) => a + v.views, 0)
53 const H = 76 + videos.length * 44
54 const rows = videos.map((v, i) => {
55 const y = 70 + i * 44, w = Math.max(3, (300 * v.views) / max)
56 const was = prev[v.id], gain = was !== undefined ? v.views - was : 0
57 const t = v.title.length > 46 ? v.title.slice(0, 45) + '…' : v.title
58 return `<text x="24" y="${y}" font-size="13" class="fg">${esc(t)}</text>
59 <rect x="24" y="${y + 9}" width="300" height="6" rx="3" class="tr"/>
60 <rect x="24" y="${y + 9}" width="${w}" height="6" rx="3" class="ac"><animate attributeName="width" from="0" to="${w}" dur="${0.5 + i * 0.1}s" fill="freeze"/></rect>
61 <text x="340" y="${y + 16}" font-size="13" font-weight="600" class="fg">${fmt(v.views)}</text>
62 <text x="436" y="${y + 16}" text-anchor="end" font-size="12" class="${gain > 0 ? 'ok' : 'mu'}">${gain > 0 ? '+' + gain : '—'}</text>`
63 }).join('')
64 return `<svg xmlns="http://www.w3.org/2000/svg" width="460" height="${H}" viewBox="0 0 460 ${H}">${STYLE}
65 <rect x="0.5" y="0.5" width="459" height="${H - 1}" rx="14" class="card"/>
66 <text x="24" y="34" font-size="13" class="mu">▶ YouTube · latest ${videos.length}</text>
67 <text x="436" y="34" text-anchor="end" font-size="13" class="mu"><tspan class="fg" font-weight="600">${fmt(total)}</tspan> views</text>
68 ${rows}</svg>`
69}
70
71export const register: Register = (on, options) => {
72 channel = String(options.channel_id ?? '').trim()
73 count = Math.min(15, Math.max(1, Math.round(Number(options.count) || 6)))
74
75 on('session.start', async ($, e, next) => {
76 await $.tool.register({ name: 'show', description: 'Open the ▶ Pulse pane in Claude Code for the person' })
77 await $.command.register({ name: 'pulse', description: 'Show your latest YouTube videos and their views' })
78 prev = ((await $.store.get('seen')) as Record<string, number>) ?? {}
79 if (channel) $.clock.every(600000, () => { void pull($) })
80 return next(e)
81 })
82
83 on('command.run', { command: 'pulse' }, async $ => {
84 await $.ui.open({ id: PANE, title: '▶ Pulse' })
85 if (!channel) return { text: SETUP }
86 await pull($)
87 const latest = videos[0]
88 // remember what was seen now, so next time the gain shows what changed since this look
89 const seen: Record<string, number> = {}
90 for (const v of videos) seen[v.id] = v.views
91 await $.store.set('seen', seen)
92 return { text: latest ? `Latest: "${latest.title}" · ${latest.views} views` : 'Pulse opened.' }
93 })
94
95 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
96 if (!channel) {
97 if (e.surface === 'terminal') {
98 const { Text } = $.ui.resolve(e)
99 return <Text>{SETUP}</Text>
100 }
101 const { Svg } = $.ui.resolve(e)
102 return <Svg source={setupCard()} alt={SETUP} width={460} isInteractive />
103 }
104 if (!videos.length) await pull($)
105 if (e.surface === 'terminal') {
106 const { Box, Text } = $.ui.resolve(e)
107 return <Box flexDirection="column">{videos.map(v => <Text>{fmt(v.views)} {v.title}</Text>)}</Box>
108 }
109 const { Svg } = $.ui.resolve(e)
110 return <Svg source={card()} alt="Latest YouTube videos and view counts" width={460} isInteractive />
111 })
112
113 on('tool.call', { tool: 'mcp__youtube-pulse__show' }, async $ => {
114 try { await $.ui.close({ id: PANE }) } catch {}
115 await $.ui.open({ id: PANE, title: '▶ Pulse' })
116 return { result: channel ? '▶ Pulse pane opened.' : `▶ Pulse pane opened. ${SETUP}.` }
117 })
118}
119