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

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.
| Mod | Layer | What it shows |
|---|---|---|
pulse | Technical | A Node sidecar next to a sealed mod. The mod talks JSON to it over localhost. |
tap | Translation | Hooks on the chain between you and the model. It records what crosses, and it can hide keys. |
board | Front end | A Notion kanban. SVG draws the cards. Real buttons and links sit on top. |

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 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 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.
claude --version.PATH. Only Pulse needs it, for its sidecar.git clone https://github.com/gfsaaser24/claude-code-mod-examples.git
cd claude-code-mod-examples
Open hooks/register.tsx in the mod you want. Then run the validator:
claude plugin validate ./pulse
A pass with warnings is normal.
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.
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:
mcp__<id>__notion-fetch. The <id> part is the value for NOTION.collection://xxxxxxxx-.... That is the value for SOURCE.board/hooks/register.tsx.Name, Workstream (the status), Priority (P0, P1, P2), Owner, PR (a URL), Job ID (a number).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.
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.
claude plugin test on the mod to see the reason.node runs from the folder where you started Claude Code.NOTION or SOURCE value is wrong, or the connector is not signed in.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.
hooks/register.tsx 162 lines1// 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}
162types/index.d.ts 23 lines1export 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