SLOPSHOPPER

board

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

newpanecommandtimer
v0.1.0no licenseupdated 2026-10-05gfsaaser24/claude-code-mod-examples/board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · board
│ ┃ Board ✕ › fix the failing auth test and add an audit log call │ ┃ Load sample cards │ ┃ ⏺ Read(src/auth.ts) │ ┃ To build 0 Doing 0 Check 0 Do ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ none none none no ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ↻ Could not read Notion. SyntaxError: JSON P ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /board │ ⎿ board: Board pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Board
Load sample cards To build 0 Doing 0 Check 0 Done 0 none none none none ↻ Could not read Notion. SyntaxError: JSON Parse error: Unex
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 286 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
286
types/index.d.ts 17 lines
1export 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