SLOPSHOPPER

youtube-pulse

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

newpaneguardcommandtoolnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · youtube-pulse
│ ┃ ▶ Pulse ✕ › fix the failing auth test and add an audit log call │ ┃ Set your channel ID in /plugin → │ ┃ buildalone-mods → youtube-pulse ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pulse │ ⎿ youtube-pulse: Set your channel ID in /plugin → buildalone-mods │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ▶ Pulse
Set your channel ID in /plugin → buildalone-mods → youtube-pulse
README

youtube-pulse

/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.

Settings

SettingDefaultWhat it is
channel_idemptyRequired. Your channel ID, starting with UC. Find it in YouTube Studio under Settings → Channel → Advanced settings. A @handle won't work.
count6How many videos to show, 1 to 15.

Until channel_id is set, the pane shows a one-line reminder instead.

What it touches

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.

Source 1 files
hooks/register.tsx 119 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
14const decode = (s: string) => s.replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/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