Board: a Notion kanban. SVG cards with real buttons and links on top

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 286 lines1// Board: the front end, on real data.
2// A horizontal kanban of a Notion database. An SVG draws each card.
3// Real Buttons and Links are laid over the SVG: move left, block, move right, open Notion, open the PR.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { BoardCard } from '../types'
8
9const PANE = 'board'
10// The id of your Notion connector, and the collection:// id of your database.
11const NOTION = 'YOUR-NOTION-CONNECTOR-ID'
12const SOURCE = 'collection://YOUR-DATABASE-ID'
13const FLOW = ['Needs Built', 'In progress', 'Needs Checked', 'Done']
14const SHORT = ['To build', 'Doing', 'Check', 'Done']
15const PARKED = ['Blocked', 'Held']
16const COLORS = ['#D29922', '#4C8DFF', '#A371F7', '#3FB950']
17const THEME = ['warning', 'suggestion', 'permission', 'success']
18const PER_COLUMN = 4
19const CARD_H = 112
20const PX_PER_CELL = 6.4
21
22// GitHub logo: Flaticon icon 25231, fetched through the Magnific connector.
23const GITHUB_BOX = 438.549
24const GITHUB_PATH =
25 "M409.132,114.573c-19.608-33.596-46.205-60.194-79.798-79.8C295.736,15.166,259.057,5.365,219.271,5.365 c-39.781,0-76.472,9.804-110.063,29.408c-33.596,19.605-60.192,46.204-79.8,79.8C9.803,148.168,0,184.854,0,224.63 c0,47.78,13.94,90.745,41.827,128.906c27.884,38.164,63.906,64.572,108.063,79.227c5.14,0.954,8.945,0.283,11.419-1.996 c2.475-2.282,3.711-5.14,3.711-8.562c0-0.571-0.049-5.708-0.144-15.417c-0.098-9.709-0.144-18.179-0.144-25.406l-6.567,1.136 c-4.187,0.767-9.469,1.092-15.846,1c-6.374-0.089-12.991-0.757-19.842-1.999c-6.854-1.231-13.229-4.086-19.13-8.559 c-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559 c-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-0.951-2.568-2.098-3.711-3.429c-1.142-1.331-1.997-2.663-2.568-3.997 c-0.572-1.335-0.098-2.43,1.427-3.289c1.525-0.859,4.281-1.276,8.28-1.276l5.708,0.853c3.807,0.763,8.516,3.042,14.133,6.851 c5.614,3.806,10.229,8.754,13.846,14.842c4.38,7.806,9.657,13.754,15.846,17.847c6.184,4.093,12.419,6.136,18.699,6.136 c6.28,0,11.704-0.476,16.274-1.423c4.565-0.952,8.848-2.383,12.847-4.285c1.713-12.758,6.377-22.559,13.988-29.41 c-10.848-1.14-20.601-2.857-29.264-5.14c-8.658-2.286-17.605-5.996-26.835-11.14c-9.235-5.137-16.896-11.516-22.985-19.126 c-6.09-7.614-11.088-17.61-14.987-29.979c-3.901-12.374-5.852-26.648-5.852-42.826c0-23.035,7.52-42.637,22.557-58.817 c-7.044-17.318-6.379-36.732,1.997-58.24c5.52-1.715,13.706-0.428,24.554,3.853c10.85,4.283,18.794,7.952,23.84,10.994 c5.046,3.041,9.089,5.618,12.135,7.708c17.705-4.947,35.976-7.421,54.818-7.421s37.117,2.474,54.823,7.421l10.849-6.849 c7.419-4.57,16.18-8.758,26.262-12.565c10.088-3.805,17.802-4.853,23.134-3.138c8.562,21.509,9.325,40.922,2.279,58.24 c15.036,16.18,22.559,35.787,22.559,58.817c0,16.178-1.958,30.497-5.853,42.966c-3.9,12.471-8.941,22.457-15.125,29.979 c-6.191,7.521-13.901,13.85-23.131,18.986c-9.232,5.14-18.182,8.85-26.84,11.136c-8.662,2.286-18.415,4.004-29.263,5.146 c9.894,8.562,14.842,22.077,14.842,40.539v60.237c0,3.422,1.19,6.279,3.572,8.562c2.379,2.279,6.136,2.95,11.276,1.995 c44.163-14.653,80.185-41.062,108.068-79.226c27.88-38.161,41.825-81.126,41.825-128.906 C438.536,184.851,428.728,148.168,409.132,114.573z"
26const NOTION_PATH =
27 'M4.459 4.208c.746.606 1.026.56 2.428.466l13.215-.793c.28 0 .047-.28-.046-.326L17.86 1.968c-.42-.326-.981-.7-2.055-.607L3.01 2.295c-.466.046-.56.28-.374.466zm.793 3.08v13.904c0 .747.373 1.027 1.214.98l14.523-.84c.841-.046.935-.56.935-1.167V6.354c0-.606-.233-.933-.748-.887l-15.177.887c-.56.047-.747.327-.747.933zm14.337.745c.093.42 0 .84-.42.888l-.7.14v10.264c-.608.327-1.168.514-1.635.514-.748 0-.935-.234-1.495-.933l-4.577-7.186v6.952L12.21 19s0 .84-1.168.84l-3.222.186c-.093-.186 0-.653.327-.746l.84-.233V9.854L7.822 9.76c-.094-.42.14-1.026.793-1.073l3.456-.233 4.764 7.279v-6.44l-1.215-.139c-.093-.514.28-.887.747-.933zM1.936 1.035l13.31-.98c1.634-.14 2.055-.047 3.082.7l4.249 2.986c.7.513.934.653.934 1.213v16.378c0 1.026-.373 1.634-1.68 1.726l-15.458.934c-.98.047-1.448-.093-1.962-.747l-3.129-4.06c-.56-.747-.793-1.306-.793-1.96V2.667c0-.839.374-1.54 1.447-1.632z'
28
29const SAMPLE: BoardCard[] = [
30 { id: 's1', url: 'https://www.notion.so', job: 101, title: 'Sample: payments ledger', pri: 'P1', owner: 'Sample', status: 'Needs Built', pr: '' },
31 { id: 's2', url: 'https://www.notion.so', job: 102, title: 'Sample: <b> & "quotes"', pri: 'P0', owner: 'Sample', status: 'In progress', pr: 'https://github.com' },
32 { id: 's3', url: 'https://www.notion.so', job: 103, title: 'Sample: time tracking', pri: 'P2', owner: 'Sample', status: 'Blocked', pr: '' },
33 { id: 's4', url: 'https://www.notion.so', job: 104, title: 'Sample: shipped job', pri: 'P2', owner: 'Sample', status: 'Done', pr: 'https://github.com' },
34]
35
36const cards = atom({ plugin: 'board', key: 'cards' } as const, [])
37const note = atom({ plugin: 'board', key: 'note' } as const, 'Loading from Notion...')
38
39function esc(s: string): string {
40 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
41}
42
43function cut(s: string, n: number): string {
44 return s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s
45}
46
47function wrapTitle(title: string, perLine: number, max: number): string[] {
48 const lines: string[] = []
49 let line = ''
50 for (const word of title.split(' ')) {
51 if (line && `${line} ${word}`.length > perLine) {
52 lines.push(line)
53 line = word
54 } else {
55 line = line ? `${line} ${word}` : word
56 }
57 }
58 if (line) lines.push(line)
59 const kept = lines.slice(0, max)
60 if (lines.length > max) kept[max - 1] = `${kept[max - 1]}…`
61 return kept.map(l => cut(l, perLine + 1))
62}
63
64// Braille blank: text that draws nothing, so the Link over a logo shows only the logo.
65const BLANK = '\u2800\u2800\u2800'
66
67function iconSvg(path: string, fill: string, label: string, box = 24): string {
68 return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><title>${label}</title><rect width="24" height="24" rx="6" fill="#F0F6FC"/><g transform="translate(3 3) scale(${(18 / box).toFixed(4)})"><path fill="${fill}" d="${path}"/></g></svg>`
69}
70
71// The look of one card. The top holds the job number and the title.
72// The lower part is left free: the buttons and links are laid over it.
73function cardSvg(c: BoardCard, W: number, col: number): string {
74 const stripe = COLORS[col] ?? '#8B949E'
75 const pri = c.pri === 'P0' ? '#F85149' : c.pri === 'P1' ? '#FF7235' : '#6E7681'
76 const perLine = Math.max(6, Math.floor((W - 12) / 5.6))
77 const text = wrapTitle(c.title, perLine, 2)
78 .map((l, i) => `<text x="7" y="${33 + i * 12}" font-family="system-ui, sans-serif" font-size="10" font-weight="600" fill="#F0F6FC">${esc(l)}</text>`)
79 .join('')
80 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${CARD_H}" viewBox="0 0 ${W} ${CARD_H}">
81 <rect x="0.5" y="0.5" width="${W - 1}" height="${CARD_H - 1}" rx="8" fill="#1C2026" stroke="#30363D"/>
82 <rect x="8" y="0" width="${W - 16}" height="3" rx="1.5" fill="${stripe}"/>
83 <rect x="6" y="9" width="22" height="11" rx="5.5" fill="${pri}"/>
84 <text x="17" y="17.5" font-family="system-ui, sans-serif" font-size="8" font-weight="700" fill="#fff" text-anchor="middle">${esc(c.pri || '-')}</text>
85 <text x="${W - 7}" y="18" font-family="system-ui, sans-serif" font-size="9" fill="#9BA3AD" text-anchor="end">#${c.job}</text>
86 ${text}
87 <rect x="7" y="53" width="${W - 14}" height="1" fill="#30363D"/>
88</svg>`
89}
90
91function pageId(url: string): string {
92 const m = /([0-9a-f]{32})(?:\?|$)/i.exec(url.replace(/-/g, ''))
93 return m ? m[1] : ''
94}
95
96function rank(p: string): number {
97 return p === 'P0' ? 0 : p === 'P1' ? 1 : p === 'P2' ? 2 : 3
98}
99
100type Row = { url?: string; Name?: string; Workstream?: string; Priority?: string; Owner?: string; PR?: string; 'Job ID'?: number }
101
102async function query($: EngineInterface, where: string, tail: string): Promise<BoardCard[]> {
103 const res = await $.mcp.call(NOTION, 'notion-query-data-sources', {
104 data: {
105 mode: 'sql',
106 data_source_urls: [SOURCE],
107 query: `SELECT url, "Name", "Workstream", "Priority", "Owner", "PR", "Job ID" FROM "${SOURCE}" WHERE ${where} ${tail}`,
108 },
109 })
110 const first = (res.content as { text?: string }[] | undefined)?.[0]?.text ?? ''
111 if (res.isError) throw new Error(cut(first || 'Notion returned an error', 140))
112 const rows = (JSON.parse(first) as { results?: Row[] }).results ?? []
113 return rows.map(r => ({
114 id: pageId(String(r.url ?? '')),
115 url: String(r.url ?? ''),
116 job: Number(r['Job ID'] ?? 0),
117 title: String(r.Name ?? '').replace(/^Job\s+\d+\s*[—–-]\s*/, ''),
118 pri: String(r.Priority ?? ''),
119 owner: String(r.Owner ?? ''),
120 status: String(r.Workstream ?? ''),
121 pr: String(r.PR ?? ''),
122 }))
123}
124
125async function sync($: EngineInterface) {
126 try {
127 const open = await query($, `"Workstream" IN ('Needs Built','In progress','Needs Checked','Blocked','Held')`, 'LIMIT 100')
128 const done = await query($, `"Workstream" = 'Done'`, 'ORDER BY "Job ID" DESC LIMIT 6')
129 await update($, cards, () => [...open, ...done])
130 await update($, note, () => `Synced with Notion at ${new Date().toLocaleTimeString()}`)
131 } catch (err) {
132 await update($, note, () => `Could not read Notion. ${cut(String(err), 120)}`)
133 }
134}
135
136async function setStatus($: EngineInterface, id: string, status: string) {
137 const list = (await read($, cards)) as BoardCard[]
138 const card = list.find(c => c.id === id)
139 if (!card || card.status === status) return
140 const before = card.status
141 await update($, cards, () => list.map(c => (c.id === id ? { ...c, status } : c)))
142 if (id.startsWith('s')) return
143 try {
144 const res = await $.mcp.call(NOTION, 'notion-update-page', {
145 page_id: id,
146 command: 'update_properties',
147 allow_async: false,
148 properties: { Workstream: status },
149 })
150 if (res.isError) throw new Error('Notion refused the change')
151 await update($, note, () => `Job ${card.job} moved to ${status} in Notion`)
152 } catch (err) {
153 const now = (await read($, cards)) as BoardCard[]
154 await update($, cards, () => now.map(c => (c.id === id ? { ...c, status: before } : c)))
155 await update($, note, () => `Job ${card.job} did not move. ${cut(String(err), 100)}`)
156 }
157}
158
159async function step($: EngineInterface, id: string, dir: number) {
160 const list = (await read($, cards)) as BoardCard[]
161 const card = list.find(c => c.id === id)
162 if (!card) return
163 const at = FLOW.indexOf(card.status)
164 const to = Math.max(0, Math.min(FLOW.length - 1, (at < 0 ? 0 : at) + dir))
165 await setStatus($, id, FLOW[to] ?? FLOW[0])
166}
167
168async function loadSample($: EngineInterface) {
169 await update($, cards, () => SAMPLE)
170 await update($, note, () => 'Sample cards. They are not in Notion.')
171}
172
173export const register: Register = on => {
174 on('session.start', async ($, e, next) => {
175 await $.command.register({ name: 'board', description: 'Open the Board pane (Notion kanban)' })
176 void sync($)
177 $.clock.every(300000, () => {
178 void sync($)
179 })
180 void $.ui.open({ id: PANE, title: 'Board' })
181 return next(e)
182 })
183
184 on('command.run', { command: 'board' }, async $ => {
185 await $.ui.open({ id: PANE, title: 'Board' })
186 void sync($)
187 return { text: 'Board pane opened.' }
188 })
189
190 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
191 const { Box, Text, Button, Link, Svg } = $.ui.resolve(e) as Record<string, any>
192 const list = (await read($, cards)) as BoardCard[]
193 const status = String(await read($, note))
194 const hasSvg = e.surface !== 'terminal'
195 const bodyColumns = Number(e.props.bodyColumns) || 51
196 const colCells = Math.max(9, Math.floor((bodyColumns - (FLOW.length - 1)) / FLOW.length))
197 const cardPx = Math.round(colCells * PX_PER_CELL)
198 const parked = list.filter(c => PARKED.includes(c.status))
199 const sorted = (i: number) =>
200 list
201 .filter(c => c.status === FLOW[i])
202 .sort((a, b) => (i === FLOW.length - 1 ? b.job - a.job : rank(a.pri) - rank(b.pri) || a.job - b.job))
203 const logo = (path: string, fill: string, name: string, box: number, href: string) =>
204 hasSvg ? (
205 <Box>
206 <Svg source={iconSvg(path, fill, name, box)} alt={`${name} logo`} width={16} height={16} />
207 <Box position="absolute" top={0} left={0}>
208 <Link href={href} label={BLANK} />
209 </Box>
210 </Box>
211 ) : (
212 <Link href={href} label={name} />
213 )
214 const controls = (c: BoardCard, i: number) => (
215 <Box flexDirection="column">
216 <Box flexDirection="row" flexWrap="wrap">
217 {i > 0 ? <Button key={`l-${c.id}`} label="⬅️" onPress={() => step($, c.id, -1)} /> : null}
218 <Button key={`b-${c.id}`} label="🛑" onPress={() => setStatus($, c.id, 'Blocked')} />
219 {i < FLOW.length - 1 ? <Button key={`r-${c.id}`} label="➡️" onPress={() => step($, c.id, 1)} /> : null}
220 </Box>
221 <Box flexDirection="row" columnGap={1} alignItems="center">
222 {logo(NOTION_PATH, '#000000', 'Notion', 24, c.url)}
223 {c.pr.startsWith('https://') ? logo(GITHUB_PATH, '#181717', 'GitHub', GITHUB_BOX, c.pr) : null}
224 </Box>
225 </Box>
226 )
227 return (
228 <Box flexDirection="column" gap={1}>
229 {list.length === 0 ? <Button key="sample" plain label="Load sample cards" onPress={() => loadSample($)} /> : null}
230 <Box flexDirection="row" columnGap={1}>
231 {FLOW.map((name, i) => {
232 const mine = sorted(i)
233 return (
234 <Box flexDirection="column" width={colCells} rowGap={1}>
235 <Text bold color={THEME[i]} wrap="truncate-end">
236 {SHORT[i]} <Text dimColor>{mine.length}</Text>
237 </Text>
238 {mine.length === 0 ? <Text dimColor>none</Text> : null}
239 {mine.slice(0, PER_COLUMN).map(c =>
240 hasSvg ? (
241 <Box key={`card-${c.id}`} flexDirection="column">
242 <Svg source={cardSvg(c, cardPx, i)} alt={`Job ${c.job}: ${c.title}`} width={cardPx} height={CARD_H} />
243 <Box position="absolute" top={3} left={1}>
244 {controls(c, i)}
245 </Box>
246 </Box>
247 ) : (
248 <Box key={`card-${c.id}`} flexDirection="column" borderStyle="round" borderColor={THEME[i]}>
249 <Text wrap="wrap">
250 #{c.job} {c.title}
251 </Text>
252 {controls(c, i)}
253 </Box>
254 ),
255 )}
256 {mine.length > PER_COLUMN ? <Text dimColor>+{mine.length - PER_COLUMN} more</Text> : null}
257 </Box>
258 )
259 })}
260 </Box>
261 {parked.length ? (
262 <Box flexDirection="column">
263 <Text bold color="error">
264 Blocked or held <Text dimColor>{parked.length}</Text>
265 </Text>
266 {parked.map(c => (
267 <Box flexDirection="row" columnGap={1} flexWrap="wrap">
268 <Button key={`u-${c.id}`} label="➡️" onPress={() => setStatus($, c.id, 'Needs Built')} />
269 <Text wrap="truncate-end">
270 #{c.job} {c.title}
271 </Text>
272 </Box>
273 ))}
274 </Box>
275 ) : null}
276 <Box flexDirection="row" columnGap={1} alignItems="center">
277 <Button key="refresh" plain label="↻" onPress={() => sync($)} />
278 <Text dimColor wrap="truncate-end">
279 {status}
280 </Text>
281 </Box>
282 </Box>
283 )
284 })
285}
286types/index.d.ts 17 lines1export type BoardCard = {
2 id: string
3 url: string
4 job: number
5 title: string
6 pri: string
7 owner: string
8 status: string
9 pr: string
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 board: { cards: BoardCard[]; note: string }
15 }
16}
17