SLOPSHOPPER

pulse

Pulse: a Node sidecar feeds a live CPU, memory and git pane

newpanecommandprocessnetworktimer
v0.1.0no licenseupdated 2026-10-05gfsaaser24/claude-code-mod-examples/pulse
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pulse
│ ┃ Pulse ✕ › fix the failing auth test and add an audit log call │ ┃ Pulse shows the technical layer. The mod │ ┃ itself is a thin script. A Node sidecar runs ⏺ Read(src/auth.ts) │ ┃ next to it, samples the CPU, and reads git. ⎿ Read 6 lines │ ┃ The mod asks it for JSON on localhost each 3 ⏺ Update(src/auth.ts) │ ┃ seconds and draws the answer. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Starting the sidecar... ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pulse │ ⎿ pulse: Pulse pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pulse
Pulse shows the technical layer. The mod itself is a thin script. A Node sidecar runs next to it, samples the CPU, and reads git. The mod asks it for JSON on localhost each 3 seconds and draws the answer. Starting the sidecar...
README

Claude Code mod examples

Three working mods for the Claude Code mod system. Each one shows one layer of it.

The long write-up is here: What Claude Code mods can and cannot do.

ModLayerWhat it shows
pulseTechnicalA Node sidecar next to a sealed mod. The mod talks JSON to it over localhost.
tapTranslationHooks on the chain between you and the model. It records what crosses, and it can hide keys.
boardFront endA Notion kanban. SVG draws the cards. Real buttons and links sit on top.

Pulse

Pulse pane

The mod itself can't read the CPU or run git. It lives in a sealed worker with no Node. So it starts a small Node server with $.process.spawn, then asks it for JSON every 3 seconds with $.http.fetch. The chart and the memory bar are SVG strings.

Read pulse/hooks/register.tsx and pulse/sidecar/server.mjs.

Tap

Tap pane

Tap hooks tool.call, prompt.submit, session.append and turn.complete. Every hook calls next(e), so nothing changes for the model unless you turn the secret guard on. With the guard on, key-shaped strings in a tool result are swapped for a mask before the model reads them.

Read tap/hooks/register.tsx.

Board

Board pane

Board reads a Notion database through $.mcp.call and draws one SVG per card. A click inside an SVG never reaches a mod, so the arrows are real Buttons laid over the card with position="absolute", and each logo has a Link laid over it. The arrows write the new status back to Notion.

Board needs 2 values from you, at the top of board/hooks/register.tsx:

  • NOTION: the id of your Notion connector.
  • SOURCE: the collection:// id of your database.

The database needs these columns: Name, Workstream (status), Priority, Owner, PR, Job ID. Change FLOW to match your own status names. With no ids set, the pane shows a "Load sample cards" button, and sample cards never write to Notion.

Setup

1. What you need

  • Claude Code 2.1.286 or later. Check with claude --version.
  • Node on your PATH. Only Pulse needs it, for its sidecar.
  • A Notion connector in Claude Code. Only Board needs it.

2. Get the code

git clone https://github.com/gfsaaser24/claude-code-mod-examples.git
cd claude-code-mod-examples

3. Read the mod, then check it

Open hooks/register.tsx in the mod you want. Then run the validator:

claude plugin validate ./pulse

A pass with warnings is normal.

4. Start Claude Code with the mod

claude --plugin-dir ./pulse

Use ./tap or ./board for the other two. The pane opens when the session starts. If you close it, type /pulse, /tap or /board to open it again.

SVG shows only in the desktop app. In a terminal, Tap and Board fall back to text, and Pulse shows its numbers with no chart.

5. Board only: point it at your Notion database

Board ships with placeholder ids, so it starts with a "Load sample cards" button. Sample cards never write to Notion. To use your own data:

  1. Find your Notion connector id. Ask Claude Code: "List your Notion tool names." The names look like mcp__<id>__notion-fetch. The <id> part is the value for NOTION.
  2. Find your database id. Ask Claude Code: "Fetch this Notion database and give me its collection:// id", and paste the database link. The answer looks like collection://xxxxxxxx-.... That is the value for SOURCE.
  3. Put both values at the top of board/hooks/register.tsx.
  4. Make sure the database has these columns: Name, Workstream (the status), Priority (P0, P1, P2), Owner, PR (a URL), Job ID (a number).
  5. Change FLOW, SHORT and PARKED in the same file to match your own status names.

After that, the arrows change the status of the real Notion page. There is no confirm step.

6. Test a mod

claude plugin test ./board

Each mod has a test that mounts its pane on the desktop and terminal surfaces. Board also has one that presses the buttons on the sample cards.

If something is wrong

  • The pane is blank. One bad prop makes the engine refuse the whole tree, and it says nothing. Run claude plugin test on the mod to see the reason.
  • Pulse does not show "sidecar alive". Check that node runs from the folder where you started Claude Code.
  • Board says "Could not read Notion". The NOTION or SOURCE value is wrong, or the connector is not signed in.

Before you run these

A mod runs as you. It can start processes, call the network, and call your connectors with no prompt. Read the hooks file before you run it. That goes for these 3 as well.

Tested on Claude Code 2.1.286 and 2.1.289, Windows. The mod system is new and it will change.

Source 2 files
hooks/register.tsx 162 lines
1// Pulse: the technical layer.
2// The mod is thin. A Node sidecar does the work and the mod draws its JSON.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5
6import type { PulseStats } from '../types'
7
8const PANE = 'pulse'
9const stats = atom({ plugin: 'pulse', key: 'stats' } as const, null)
10
11let port = 0
12let isStarted = false
13
14async function poll($: EngineInterface) {
15  if (!port) return
16  try {
17    const res = await $.http.fetch(`http://127.0.0.1:${port}/stats`)
18    if (res.ok) {
19      const data = JSON.parse(res.text) as PulseStats
20      await update($, stats, () => ({ ...data, port }))
21    }
22  } catch {}
23}
24
25async function startSidecar($: EngineInterface) {
26  if (isStarted) return
27  isStarted = true
28  const cwd = await $.session.cwd()
29  const script = `${$.plugin.root}/sidecar/server.mjs`.split('\\').join('/')
30  let seen = ''
31  try {
32    for await (const piece of $.process.spawn({ argv: ['node', script, cwd] })) {
33      if (!port) {
34        seen += piece.text
35        const m = /PORT=(\d+)/.exec(seen)
36        if (m) {
37          port = Number(m[1])
38          void poll($)
39        }
40      }
41    }
42  } catch {}
43  port = 0
44  isStarted = false
45}
46
47function cpuChart(samples: readonly number[]): string {
48  const W = 340
49  const H = 96
50  const n = Math.max(samples.length, 2)
51  const x = (i: number) => (i / (n - 1)) * W
52  const y = (v: number) => H - 6 - (Math.min(v, 100) / 100) * (H - 16)
53  const pts = samples.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`)
54  const line = pts.length ? `M${pts.join(' L')}` : ''
55  const area = pts.length ? `${line} L${W},${H} L0,${H} Z` : ''
56  const last = samples.length ? samples[samples.length - 1] : 0
57  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
58  <defs><linearGradient id="a" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#FF7235" stop-opacity="0.7"/><stop offset="1" stop-color="#FF7235" stop-opacity="0.05"/></linearGradient></defs>
59  <rect width="${W}" height="${H}" rx="10" fill="#1b1b1b"/>
60  <g stroke="#333" stroke-width="1"><line x1="0" y1="${y(25)}" x2="${W}" y2="${y(25)}"/><line x1="0" y1="${y(50)}" x2="${W}" y2="${y(50)}"/><line x1="0" y1="${y(75)}" x2="${W}" y2="${y(75)}"/></g>
61  <path d="${area}" fill="url(#a)"/><path d="${line}" fill="none" stroke="#FF7235" stroke-width="2"/>
62  <text x="10" y="20" font-family="system-ui, sans-serif" font-size="12" fill="#bbb">CPU, last ${samples.length} s</text>
63  <text x="${W - 10}" y="22" font-family="system-ui, sans-serif" font-size="18" font-weight="700" fill="#fff" text-anchor="end">${last}%</text>
64</svg>`
65}
66
67function memBar(used: number, total: number): string {
68  const W = 340
69  const pct = total > 0 ? Math.min(used / total, 1) : 0
70  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="30" viewBox="0 0 ${W} 30">
71  <rect width="${W}" height="30" rx="8" fill="#1b1b1b"/>
72  <rect x="3" y="3" width="${((W - 6) * pct).toFixed(1)}" height="24" rx="6" fill="#4C8DFF"/>
73  <text x="12" y="20" font-family="system-ui, sans-serif" font-size="12" font-weight="600" fill="#fff">Memory ${used} of ${total} GB (${Math.round(pct * 100)}%)</text>
74</svg>`
75}
76
77export const register: Register = on => {
78  on('session.start', async ($, e, next) => {
79    await $.command.register({ name: 'pulse', description: 'Open the Pulse pane (sidecar stats)' })
80    void startSidecar($)
81    $.clock.every(3000, () => {
82      void poll($)
83    })
84    void $.ui.open({ id: PANE, title: 'Pulse' })
85    return next(e)
86  })
87
88  on('command.run', { command: 'pulse' }, async $ => {
89    await $.ui.open({ id: PANE, title: 'Pulse' })
90    return { text: 'Pulse pane opened.' }
91  })
92
93  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
94    const { Box, Text, Markdown, Svg } = $.ui.resolve(e) as Record<string, any>
95    const s = (await read($, stats)) as PulseStats | null
96    const hasSvg = e.surface !== 'terminal'
97    const intro = (
98      <Markdown
99        text={
100          '**Pulse** shows the *technical layer*. The mod itself is a thin script. A **Node sidecar** runs next to it, samples the CPU, and reads git. The mod asks it for JSON on `localhost` each 3 seconds and draws the answer.'
101        }
102      />
103    )
104    if (!s) {
105      return (
106        <Box flexDirection="column" gap={1}>
107          {intro}
108          <Text dimColor>Starting the sidecar...</Text>
109        </Box>
110      )
111    }
112    const lastCpu = s.cpu.length ? s.cpu[s.cpu.length - 1] : 0
113    return (
114      <Box flexDirection="column" gap={1}>
115        {intro}
116        <Box borderStyle="round" borderColor="success" paddingX={1} flexDirection="column">
117          <Text>
118            <Text color="success">● sidecar alive</Text> node {s.node} / pid {s.pid}
119          </Text>
120          <Text dimColor>
121            127.0.0.1:{s.port} / up {s.upSec} s / {s.cores} cores
122          </Text>
123        </Box>
124        {hasSvg ? <Svg source={cpuChart(s.cpu)} alt={`CPU chart, now ${lastCpu} percent`} width={340} height={96} /> : <Text>CPU {lastCpu}%</Text>}
125        {hasSvg ? (
126          <Svg source={memBar(s.memUsedGb, s.memTotalGb)} alt={`Memory ${s.memUsedGb} of ${s.memTotalGb} GB`} width={340} height={30} />
127        ) : (
128          <Text>
129            Memory {s.memUsedGb} of {s.memTotalGb} GB
130          </Text>
131        )}
132        {s.repo.isRepo ? (
133          <Box flexDirection="column">
134            <Text bold>
135              Git <Text color="claude">{s.repo.branch}</Text>{' '}
136              <Text dimColor>{s.repo.changed.length ? `${s.repo.changed.length} changed files` : 'working tree clean'}</Text>
137            </Text>
138            {s.repo.changed.slice(0, 6).map(f => (
139              <Text color={f.code === '??' ? 'warning' : f.code.includes('D') ? 'error' : 'suggestion'} wrap="truncate-end">
140                {f.code.padEnd(2)} {f.path}
141              </Text>
142            ))}
143          </Box>
144        ) : (
145          <Text dimColor>This folder is not a git repo.</Text>
146        )}
147        {s.repo.commits.length ? (
148          <Box flexDirection="column">
149            <Text bold>Last commits</Text>
150            {s.repo.commits.map(c => (
151              <Text wrap="truncate-end">
152                <Text color="claude">{c.hash}</Text> {c.subject} <Text dimColor>({c.age})</Text>
153              </Text>
154            ))}
155          </Box>
156        ) : null}
157        <Text dimColor>How: $.process.spawn starts Node. $.http.fetch reads it. $.state redraws the pane.</Text>
158      </Box>
159    )
160  })
161}
162
types/index.d.ts 23 lines
1export type PulseStats = {
2  port: number
3  pid: number
4  node: string
5  upSec: number
6  cores: number
7  cpu: number[]
8  memUsedGb: number
9  memTotalGb: number
10  repo: {
11    isRepo: boolean
12    branch: string
13    changed: { code: string; path: string }[]
14    commits: { hash: string; subject: string; age: string }[]
15  }
16}
17
18declare module 'claude-code' {
19  interface PluginState {
20    pulse: { stats: PulseStats | null }
21  }
22}
23