A whiteboard for your Claude Code session: Claude draws Mermaid diagrams and sticky notes on a page in your browser, you answer there, and you can edit a…

This folder is the plugin, and all that installing it copies. Open source (MIT), not affiliated with or endorsed by Anthropic. The project, with its install and usage guide, is at https://github.com/mazzucci/whiteboard.
hooks/: the mod. It registers the post_to_board tool and the /whiteboard command, starts the board for a session, and passes what the person types there into the session.board/: the board. server.mjs serves the page on 127.0.0.1 with Node's standard library; page/ is the page; vendor/ holds Mermaid, which the page draws with.skills/drawing/: the skill that teaches Claude to draw legible, honest diagrams, with sticky notes for proposals.tests/: the mod's tests (claude plugin test . from this folder) and the board server's (node --test tests/server.test.mjs).Nothing is downloaded and nothing is sent anywhere: the board and its page run on this machine, and stop with the session.
hooks/register.tsx 1220 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// The whiteboard is a page in the person's browser (board/server.mjs: Node's
4// standard library, loopback only, a random token). Claude posts notes and
5// Mermaid diagrams to it; the page draws them with the Mermaid vendored in
6// board/vendor, and tells the plugin how each one drew, so Mermaid's errors
7// come back to Claude. What the person types there is submitted into this
8// session as their own words, so the whole discussion is in the conversation.
9
10const TOOL = 'post_to_board'
11const READ_TOOL = 'read_board'
12const EDIT_TOOL = 'edit_board'
13
14type Doc = { title: string; source: string }
15
16const SAMPLE: Doc = {
17 title: 'Sample: checkout',
18 // Told to Claude with its node ids, so "the email box" is `mail` when the person asks about it.
19 source: `flowchart LR
20 shopper([Shopper]) --> web[Storefront]
21 web --> api[Orders API]
22 api --> pay{{Payment provider}}
23 api --> db[(Orders DB)]
24 api -. order placed .-> mail[Email service]`,
25}
26
27/**
28 * Said to Claude once, with the session's first prompt: plugin tools may be
29 * loaded only when Claude looks for one, so without this a plain "how does
30 * OAuth work?" gets prose and no picture.
31 */
32const INTRO =
33 `The whiteboard plugin is available: ${TOOL} draws Mermaid diagrams and notes on a page in the user's browser ` +
34 'beside this conversation. Use it whenever a picture explains better than prose, in this project or not: how a ' +
35 'protocol, standard or system works (OAuth, TLS, DNS), architecture, request flows, sequences, state machines, ' +
36 'schemas, an investigation. Draw first, then keep your written answer short and point at the board. Read the ' +
37 `whiteboard:drawing skill before the first diagram. ${READ_TOOL} reads back what is on the page, Mermaid source ` +
38 'and sticky notes included, when the user talks about something there you did not draw in this conversation. ' +
39 `The board has two modes: diagrams (yours, as drawn; each new one a tab) for explaining and investigating, and ` +
40 'canvas (every diagram editable by both of you) for sketching together; a decision stays in diagrams mode, where the ' +
41 'board marks what is open and settled. Pick one with `mode` ' +
42 `when you post, or the user switches. On a canvas, ${EDIT_TOOL} amends a diagram in place (add, connect, ` +
43 'recolour, rename, remove boxes) instead of drawing it again.'
44let isIntroduced = false
45
46/** What Claude is told when the person switches the board to a canvas from the conversation. */
47const CANVAS_NOTE =
48 'The user switched the whiteboard to canvas mode: every diagram on it is editable by both of you. Their edits reach ' +
49 `you in words with their messages; amend diagrams with ${EDIT_TOOL} instead of drawing them again, and draw a new ` +
50 `one with ${TOOL} only when the picture changes as a whole.`
51
52/** What Claude is told when the person asks to discuss on the board. */
53const FOCUS_NOTE =
54 'Focus mode is on: the person is discussing on the whiteboard page, not in this conversation. Messages from them ' +
55 `start with "(on the whiteboard)". Answer them ON THE BOARD with ${TOOL}: a short note, a diagram (mermaid), ` +
56 'or both; keep your reply in the conversation to a line. Ask questions there too. When they wrap up, write a ' +
57 `summary of what was concluded in the conversation itself, then call ${TOOL} with end: true to close the page.`
58
59// ---------------------------------------------------------------- the board
60
61type Board = { url: string; port: number; token: string }
62let board: Promise<Board> | null = null
63
64/** The node binary: on PATH, or where installers put it when the app's PATH is thin. */
65async function nodePath($: EngineInterface): Promise<string> {
66 nodeFound ??= findNode($).catch(error => {
67 nodeFound = null
68 throw error
69 })
70 return nodeFound
71}
72let nodeFound: Promise<string> | null = null
73
74const NODE_MAJOR = 18
75
76/** The first node 18 or later: on PATH, where installers put it, then where version managers do. */
77async function findNode($: EngineInterface): Promise<string> {
78 const home = (await $.env.get('HOME')) ?? ''
79 const dirs = [
80 ...((await $.env.get('PATH')) ?? '').split(':').filter(Boolean),
81 '/opt/homebrew/bin',
82 '/usr/local/bin',
83 '/usr/bin',
84 ...(await managedNodeDirs($, home)),
85 ]
86 let tooOld = ''
87 for (const dir of [...new Set(dirs)]) {
88 const node = `${dir}/node`
89 if (!(await $.fs.exists(node))) continue
90 const version = await nodeVersion($, node)
91 if (version.major >= NODE_MAJOR) return node
92 tooOld ||= `${node} is ${version.text || 'an unknown version'}`
93 }
94 throw new Error(
95 tooOld
96 ? `The whiteboard needs Node.js ${NODE_MAJOR} or later; ${tooOld}. Install a newer one (nodejs.org, or your version manager).`
97 : `Node.js was not found. The whiteboard runs a small local server with it: install Node.js ${NODE_MAJOR} or later (nodejs.org).`,
98 )
99}
100
101/**
102 * Where nvm, volta, fnm, asdf and mise keep node: their installs first, newest
103 * version first (a shim may need a fuller PATH than the app gives), then their
104 * shims and default aliases.
105 */
106async function managedNodeDirs($: EngineInterface, home: string): Promise<string[]> {
107 if (!home) return []
108 const installs = [
109 `${home}/.nvm/versions/node`,
110 `${home}/.asdf/installs/nodejs`,
111 `${home}/.local/share/mise/installs/node`,
112 ]
113 const dirs: string[] = []
114 for (const base of installs) dirs.push(...(await versionDirs($, base)).map(v => `${base}/${v}/bin`))
115 return [
116 ...dirs,
117 `${home}/.volta/bin`,
118 `${home}/.fnm/aliases/default/bin`,
119 `${home}/.local/share/fnm/aliases/default/bin`,
120 `${home}/Library/Application Support/fnm/aliases/default/bin`,
121 `${home}/.asdf/shims`,
122 `${home}/.local/share/mise/shims`,
123 ]
124}
125
126/** The version folders in a version manager's install folder, newest first; links count. */
127async function versionDirs($: EngineInterface, base: string): Promise<string[]> {
128 try {
129 return (await $.fs.list(base))
130 .filter(e => (e.kind === 'dir' || e.isLink) && /^v?\d+/.test(e.name))
131 .map(e => e.name)
132 .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
133 } catch {
134 return []
135 }
136}
137
138async function nodeVersion($: EngineInterface, node: string): Promise<{ major: number; text: string }> {
139 try {
140 const text = (await $.process.run([node, '--version'])).stdout.trim()
141 return { major: Number(/^v(\d+)/.exec(text)?.[1] ?? 0), text }
142 } catch {
143 return { major: 0, text: '' }
144 }
145}
146
147/** Starts the board for this session; its stdout carries the person's messages. */
148function startBoard($: EngineInterface): Promise<Board> {
149 const self: Promise<Board> = new Promise<Board>((resolve, reject) => {
150 void (async () => {
151 try {
152 // Each session has its own board; its folder's name tells their tabs apart.
153 const folder = ((await $.env.get('PWD')) ?? '').split('/').filter(Boolean).at(-1)
154 const argv = [await nodePath($), `${$.plugin.root}/board/server.mjs`, ...(folder ? ['--label', folder] : [])]
155 let buffer = ''
156 let err = ''
157 // The loop is the board's life: it ends with the child or the module.
158 for await (const piece of $.process.spawn({ argv })) {
159 if (piece.stream !== 'stdout') {
160 err = (err + piece.text).slice(-2000)
161 continue
162 }
163 buffer += piece.text
164 const lines = buffer.split('\n')
165 buffer = lines.pop() ?? ''
166 for (const line of lines.filter(Boolean)) {
167 let message: { ready?: boolean; url?: string; port?: number; token?: string; say?: string }
168 try {
169 message = JSON.parse(line)
170 } catch {
171 continue
172 }
173 if (message.ready) resolve(message as Board)
174 // The person's own words, marked so Claude answers on the board.
175 else if (typeof message.say === 'string') {
176 said.push(message.say)
177 void deliver($)
178 }
179 }
180 }
181 // Only this board: after a wrap-up, a new one may already be starting.
182 if (board === self) board = null
183 reject(new Error(`The whiteboard page stopped. ${err.trim().split('\n').slice(-2).join(' ')}`))
184 } catch (error) {
185 if (board === self) board = null
186 reject(error instanceof Error ? error : new Error(String(error)))
187 }
188 })()
189 })
190 return self
191}
192
193/** Ends the discussion: the page says so and closes its tab, and the board stops. */
194async function endBoard($: EngineInterface, text: string | undefined): Promise<boolean> {
195 const current = board
196 if (!current) return false
197 board = null
198 const open = await current
199 await $.http.fetch(`http://127.0.0.1:${open.port}/post`, {
200 method: 'POST',
201 headers: { 'content-type': 'application/json', 'x-board-token': open.token },
202 body: JSON.stringify({ end: true, text }),
203 })
204 return true
205}
206
207/**
208 * What the person typed on the page, not yet in the conversation. Each is
209 * submitted as their own words, a turn of its own once the session is idle.
210 * One that arrives while a hook holds the turn (Claude waiting on a diagram,
211 * say) cannot be submitted then: it waits here for the turn to complete.
212 */
213const said: string[] = []
214/** How the person's words from the page start, in the conversation. */
215const FROM_BOARD = '(on the whiteboard)'
216/** Said beside each of them: they are looking at the page, not here. */
217const BOARD_NOTE =
218 `The user typed this on the whiteboard page and is watching the page, not this conversation: answer there with ${TOOL} ` +
219 '(a note, a diagram, sticky notes), and keep what you write here to a line. When it is about a section of the brief ' +
220 `("About the brief's section \`id\`"), answer under that section with ${EDIT_TOOL} ` +
221 "(sections: [{ op: 'answer', id, text }]), and update the section's line, or the bottom line, if the answer changes it. " +
222 "When it asks for more detail, write it as that section's body (sections: [{ op: 'update', id, body }]). " +
223 '"My choices on the board: …" are what the user settled there, suggestions they took or turned down included: ' +
224 'they are settled already (do not settle them again; anything the board could not settle is said after). Update ' +
225 'the diagram and the brief for them, and once none is open, write your proposal as the bottom line. A message that ' +
226 'starts "(On the side board `id`, …)" was typed there: answer on that board (it is where your posts go). ' +
227 '"I decide on the side board …" has settled its constraint on the main board and brought the user back there: ' +
228 'carry the decision into the main board (its line, the diagram).'
229/** Said when the person, after talking on the page, types in the conversation again. */
230const BACK_NOTE =
231 'The user is back in this conversation: they typed this here, not on the whiteboard. Focus mode, if it was on, ' +
232 `is over: answer here, and use ${TOOL} again only where a picture helps, as before.`
233/** Whether the person's last words came from the page (or they asked for focus mode). */
234let isOnBoard = false
235
236/**
237 * The turn answering a message from the page, while nothing of it has
238 * reached the page: if it ends that way, its answer is posted there, so the
239 * person never waits on a reply that went only to the conversation.
240 */
241let boardTurn: { turnId: string; isPosted: boolean } | null = null
242let delivering: Promise<void> | null = null
243/** One delivery at a time: a second caller waits on the first, so nothing is submitted twice. */
244function deliver($: EngineInterface): Promise<void> {
245 delivering ??= (async () => {
246 try {
247 while (said.length) {
248 try {
249 await $.prompt.submit({ text: `${FROM_BOARD} ${said[0]}`, asUser: true })
250 } catch {
251 return
252 }
253 said.shift()
254 }
255 } finally {
256 delivering = null
257 }
258 })()
259 return delivering
260}
261
262/** Posts the answer of a turn that came from the page and reached only the conversation. */
263async function postAnswer($: EngineInterface, answer: string) {
264 if (!board || !answer.trim()) return
265 try {
266 const open = await board
267 await $.http.fetch(`http://127.0.0.1:${open.port}/post`, {
268 method: 'POST',
269 headers: { 'content-type': 'application/json', 'x-board-token': open.token },
270 body: JSON.stringify({ text: answer.trim().slice(0, 20_000) }),
271 })
272 } catch {
273 // The board stopped: the answer is in the conversation.
274 }
275}
276
277/** Tells an open board whether Claude is in a turn; nothing when no board is open. */
278async function boardStatus($: EngineInterface, status: 'working' | 'idle') {
279 if (!board) return
280 try {
281 const open = await board
282 await $.http.fetch(`http://127.0.0.1:${open.port}/post`, {
283 method: 'POST',
284 headers: { 'content-type': 'application/json', 'x-board-token': open.token },
285 body: JSON.stringify({ status }),
286 })
287 } catch {
288 // The board stopped: nothing to tell.
289 }
290}
291
292/**
293 * Opens a URL in the person's browser: the command in $BROWSER when they set
294 * one (the usual convention), else `open` on macOS and `xdg-open` elsewhere.
295 */
296async function openInBrowser($: EngineInterface, url: string): Promise<boolean> {
297 const chosen = ((await $.env.get('BROWSER')) ?? '').split(':')[0]?.trim()
298 if (chosen) {
299 // `%s` stands for the URL, as in xdg-open's convention; words split on spaces.
300 const parts = chosen.split(/\s+/).filter(Boolean)
301 const argv = parts.includes('%s') ? parts.map(p => (p === '%s' ? url : p)) : [...parts, url]
302 try {
303 // The command must exist; then it starts detached, through sh with its
304 // words as arguments (never as script), so it neither holds up the call
305 // nor goes down with this plugin, which would close the person's browser.
306 const found = await $.process.run(['/bin/sh', '-c', 'command -v "$1" >/dev/null 2>&1', 'sh', argv[0] ?? ''])
307 if (found.exitCode !== 0) return false
308 const started = await $.process.run(['/bin/sh', '-c', 'nohup "$@" >/dev/null 2>&1 </dev/null &', 'sh', ...argv])
309 return started.exitCode === 0
310 } catch {
311 return false
312 }
313 }
314 const opener = (await $.fs.exists('/usr/bin/open')) ? '/usr/bin/open' : 'xdg-open'
315 try {
316 return (await $.process.run([opener, url])).exitCode === 0
317 } catch {
318 return false
319 }
320}
321
322/** A GET on the board, with its token: what it answers, as JSON. */
323async function boardGet<T>($: EngineInterface, open: Board, path: string): Promise<T> {
324 const res = await $.http.fetch(`http://127.0.0.1:${open.port}${path}`, { method: 'GET', headers: { 'x-board-token': open.token } })
325 if (!res.ok) throw new Error(`the page answered ${res.status}`)
326 return JSON.parse(res.text) as T
327}
328
329/** A POST to the board, with its token: what it answers, as JSON. */
330async function boardPost<T>($: EngineInterface, open: Board, body: Record<string, unknown>): Promise<T> {
331 const res = await $.http.fetch(`http://127.0.0.1:${open.port}/post`, {
332 method: 'POST',
333 headers: { 'content-type': 'application/json', 'x-board-token': open.token },
334 body: JSON.stringify(body),
335 })
336 if (!res.ok) throw new Error(`the page answered ${res.status}`)
337 return JSON.parse(res.text) as T
338}
339
340/** How many pages are showing the board; 0 when the person closed its tab. */
341async function viewersOf($: EngineInterface, open: Board): Promise<number> {
342 try {
343 return (await boardGet<{ viewers: number }>($, open, '/viewers')).viewers
344 } catch {
345 return 0
346 }
347}
348
349/** Whether the browser was asked to open the board (`isTried`), and whether it did. */
350type Started = { open: Board; isNew: boolean; isTried: boolean; isOpened: boolean }
351
352/**
353 * The board for this session, started on first use. It opens in the person's
354 * browser when it is new, when no page is showing it (they closed the tab), or
355 * always when they asked for it (`isAsked`); otherwise posts go to the tab
356 * they have.
357 */
358async function boardOpen($: EngineInterface, isAsked = false): Promise<Started> {
359 const isNew = !board
360 board ??= startBoard($)
361 const open = await board
362 const isTried = isNew || isAsked || (await viewersOf($, open)) === 0
363 return { open, isNew, isTried, isOpened: isTried && (await openInBrowser($, open.url)) }
364}
365
366type Legend = { label: string; stroke?: string; isDashed: boolean }
367type Note = { on?: string; text: string }
368type Card = { title?: string; text?: string; mermaid?: string; legend?: Legend[]; notes?: Note[]; mode?: Mode; brief?: BriefInput; isNew?: boolean; sideBoard?: { id: string; title?: string; for?: string }; board?: string }
369type Mode = 'diagrams' | 'canvas'
370
371/** Claude's sticky notes (`sticky_notes`), as the page takes them: text, and the node id each is pinned to. */
372function notesOf(value: unknown): Note[] | undefined {
373 if (!Array.isArray(value)) return undefined
374 const notes = value
375 .filter((n): n is { on?: unknown; text: string } => typeof n?.text === 'string' && n.text.trim() !== '')
376 .map(n => ({ text: n.text.trim(), ...(typeof n.on === 'string' && n.on.trim() ? { on: n.on.trim() } : {}) }))
377 return notes.length ? notes : undefined
378}
379/** How the page drew a diagram: drawn, Mermaid's error, or not seen (no page open, or it did not answer). */
380type Posted = { ok: boolean; viewers: number; drawn: boolean; error?: string; noDiagram?: boolean; noteError?: string; noteErrors?: string[]; briefError?: string; boardError?: string; board?: string }
381
382// ---------------------------------------------------------------- the brief
383
384/** A section of the brief, as Claude writes it. */
385type SectionInput = {
386 id: string
387 title?: string
388 line: string
389 body?: string
390 focus?: string[]
391 cites?: { label: string; url?: string }[]
392 kind?: 'point' | 'constraint' | 'idea'
393 choices?: { id: string; label: string; hint?: string }[]
394 lean?: string
395 status?: 'open' | 'assumed' | 'settled'
396 by?: 'you'
397 suggested?: boolean
398}
399type BriefInput = { bottomLine: string; sections: SectionInput[]; mode?: 'brief' | 'decide'; options?: { id: string; label: string; hint?: string }[] }
400/** A section as the board holds it: with the person's questions and Claude's answers, the line it had before, and what was chosen. */
401type Section = SectionInput & { was?: string; asks: { question?: string; answer?: string }[]; why?: string; chosen?: string; settledBy?: 'you' | 'claude' }
402type Brief = { bottomLine: string; wasBottomLine?: string; mode?: 'brief' | 'decide'; options?: { id: string; label: string }[]; sections: Section[]; dropped: Section[] }
403/** A board: the main one, or a side board opened from it for one question. */
404type BoardInfo = { id: string; title?: string; for?: string; state: 'open' | 'decided' | 'parked' | 'dropped'; why?: string; brief: Brief | null }
405
406/** Every board, as Claude reads them back: the one the user is on in full, the main board's lines, the rest in a line each. */
407function boardsText(all: BoardInfo[], viewing: string): string {
408 const main = all.find(b => b.id === 'main')
409 const sides = all.filter(b => b.id !== 'main')
410 const on = all.find(b => b.id === viewing) ?? main
411 const parts: string[] = []
412 if (on && on.id !== 'main') parts.push(`The user is on the side board \`${on.id}\`, "${on.title}"${on.for ? ` (opened to decide the main board's \`${on.for}\`)` : ''}. Your posts and edits go there unless you name another board.`)
413 if (on?.brief) parts.push(briefText(on.brief, on.id === 'main' ? '' : ` on the side board \`${on.id}\``))
414 if (on?.id !== 'main' && main?.brief) parts.push(briefText(main.brief, ' on the main board'))
415 const others = sides.filter(b => b !== on)
416 if (others.length) parts.push(`Side boards: ${others.map(b => `\`${b.id}\` "${b.title}" (${b.state}${b.for ? `, for ${b.for}` : ''}${b.why ? `: ${b.why}` : ''})`).join('; ')}`)
417 return parts.join('\n\n')
418}
419
420/** Sections as given, kept to what the board takes; the server checks the rest. */
421function sectionsOf(value: unknown): SectionInput[] {
422 if (!Array.isArray(value)) return []
423 return value.filter((s): s is SectionInput => !!s && typeof s === 'object' && typeof s.id === 'string' && typeof s.line === 'string')
424}
425
426/** A constraint's state, an idea's or a suggestion's, in a few words before its line. */
427function stateOf(s: Section): string {
428 const label = (id?: string) => s.choices?.find(c => c.id === id)?.label ?? id
429 if (s.suggested) return `[your suggestion, not yet taken${s.choices?.length ? `; choices ${s.choices.map(c => c.id).join(', ')}` : ''}] `
430 if (s.kind === 'idea') return `[idea${s.by === 'you' ? ' from the user' : ''}] `
431 if (s.kind !== 'constraint') return ''
432 const options = s.choices?.length ? `; choices ${s.choices.map(c => c.id).join(', ')}` : ''
433 if (s.status === 'settled') return `[settled by ${s.settledBy === 'you' ? 'the user' : 'you'}: ${label(s.chosen) ?? 'yes'}] `
434 const mine = s.by === 'you' ? '; the user\'s' : ''
435 return `[${s.status === 'assumed' ? `assumed${s.lean ? `: ${label(s.lean)}` : ''}` : 'open'}${options}${s.lean && s.status !== 'assumed' ? `; you lean ${s.lean}` : ''}${mine}] `
436}
437
438/** The brief as Claude reads it back: the bottom line and each section's line, never the bodies. */
439function briefText(b: Brief, where = ''): string {
440 const open = (s: Section) => s.asks.filter(a => a.question && !a.answer).length
441 const lines = [
442 `The brief${where} beside the diagrams (each section by id: its line; bodies and answers left out):`,
443 ...(b.options?.length ? [`A comparison of: ${b.options.map(o => `\`${o.id}\` ${o.label}`).join(', ')}`] : []),
444 `Bottom line: ${b.bottomLine}`,
445 ...b.sections.map(s => {
446 const waiting = open(s)
447 return `- \`${s.id}\` ${stateOf(s)}${s.title && s.title !== s.id ? `${s.title}: ` : ''}${s.line}${s.focus?.length ? ` (on ${s.focus.join(', ')})` : ''}${waiting ? ` — ${waiting} question${waiting === 1 ? '' : 's'} from the user waiting for an answer` : ''}`
448 }),
449 ]
450 if (b.mode === 'decide') {
451 const open = b.sections.filter(s => s.kind === 'constraint' && s.status === 'open' && !s.suggested)
452 lines.splice(1, 0, `A decision: ${open.length ? `${open.length} constraint${open.length === 1 ? '' : 's'} still open (${open.map(s => s.id).join(', ')}); propose the design once none is` : 'every constraint is settled: the bottom line should be the proposal'}.`)
453 }
454 if (b.dropped.length) lines.push(`Dropped: ${b.dropped.map(s => `\`${s.id}\`${s.why ? ` (${s.why})` : ''}`).join(', ')}`)
455 return lines.join('\n')
456}
457
458async function postToBoard($: EngineInterface, card: Card): Promise<{ started: Started; posted: Posted }> {
459 const started = await boardOpen($)
460 const res = await $.http.fetch(`http://127.0.0.1:${started.open.port}/post`, {
461 method: 'POST',
462 headers: { 'content-type': 'application/json', 'x-board-token': started.open.token },
463 // A page that is just opening is waited for, so the first diagram is checked too.
464 body: JSON.stringify({ ...card, waitForPage: started.isOpened }),
465 })
466 if (!res.ok) throw new Error(`the page answered ${res.status}`)
467 return { started, posted: JSON.parse(res.text) as Posted }
468}
469
470/** Where the post went, said to Claude. */
471function postedWhere({ started, posted }: { started: Started; posted: Posted }, isDiagram: boolean): string {
472 if (started.isTried && !started.isOpened) {
473 return `Posted to the whiteboard page, but the browser could not be opened: give the user this link: ${started.open.url}`
474 }
475 if (!posted.viewers && !started.isOpened) {
476 return 'Posted, but no whiteboard page is open, so nobody has seen it yet. Tell the user that /whiteboard opens it.'
477 }
478 const opened = started.isNew
479 ? ', which just opened in the browser'
480 : started.isOpened
481 ? ', which opened again in the browser (its tab had been closed)'
482 : ''
483 if (!isDiagram) return `Posted to the whiteboard page${opened}.`
484 if (posted.drawn) return `Drawn on the whiteboard page${opened}.`
485 return `Posted to the whiteboard page${opened}; the page did not confirm the drawing in time.`
486}
487
488// ---------------------------------------------------------------- diagrams
489
490/** A classDef's stroke colour and dash, for the legend's swatch. */
491function classStyle(source: string, name: string): { stroke?: string; isDashed: boolean } | undefined {
492 const def = new RegExp(`^\\s*classDef\\s+${name.replace(/[^\w-]/g, '')}\\s+([^\\n]+)`, 'm').exec(source)
493 if (!def) return undefined
494 const props = def[1] ?? ''
495 const stroke = /(?:^|,)\s*stroke\s*:\s*(#[0-9a-fA-F]{3,8}|[a-zA-Z]+)/.exec(props)?.[1]
496 return { stroke, isDashed: /stroke-dasharray/.test(props) }
497}
498
499/** The legend Claude passed, at most six short entries, with the unknown classes it names. */
500function legendOf(value: unknown, source: string): { legend?: Legend[]; unknown: string[] } {
501 if (!Array.isArray(value)) return { unknown: [] }
502 const entries = value
503 .filter((x): x is { label: string; class: string } => typeof x?.label === 'string' && typeof x?.class === 'string')
504 .map(x => ({ label: x.label.trim().slice(0, 40), class: x.class.trim() }))
505 .filter(x => x.label && /^[\w-]+$/.test(x.class))
506 .slice(0, 6)
507 const unknown = entries.filter(x => !classStyle(source, x.class)).map(x => x.class)
508 const legend = entries.map(x => ({ label: x.label, isDashed: false, ...classStyle(source, x.class) }))
509 return { legend: legend.length ? legend : undefined, unknown }
510}
511
512/** Mermaid's message without the stack. */
513function mermaidError(error: string): string {
514 return error.replace(/\s+at\s.*$/s, '').trim().slice(0, 1200)
515}
516
517/** The diagram in a file: a fenced mermaid block in Markdown, or the whole file. */
518function mermaidOf(text: string): string {
519 const fence = /```mermaid\s*\n([\s\S]*?)```/.exec(text)
520 return (fence?.[1] ?? text).trim()
521}
522
523/** An edited diagram's canvas, as the page summarizes it. */
524type Scene = {
525 boxes?: { ref: string; text: string; class: string }[]
526 notes?: { ref: string; text: string; on?: string }[]
527 arrows?: { from: string | null; to: string | null; text: string }[]
528 texts?: { text: string; near: string | null }[]
529 drawings?: { near: string | null }[]
530 images?: number
531 selected?: string[]
532}
533
534/** A canvas in lines: every box by its ref, the arrows between them, notes, text and drawings. */
535function sceneText(s: Scene): string[] {
536 const q = (t: string) => `"${t.replace(/\s+/g, ' ').trim()}"`
537 const lines = ['Edited on the canvas; as it is now (the Mermaid above is where it started; amend it with edit_board):']
538 for (const b of s.boxes ?? []) lines.push(`- box \`${b.ref}\` ${q(b.text)}${b.class !== 'plain' ? ` (${b.class})` : ''}`)
539 for (const a of s.arrows ?? []) lines.push(`- arrow ${a.from ? `\`${a.from}\`` : '(loose)'} → ${a.to ? `\`${a.to}\`` : '(loose)'}${a.text ? ` ${q(a.text)}` : ''}`)
540 for (const n of s.notes ?? []) lines.push(`- sticky note \`${n.ref}\`${n.on ? ` by \`${n.on}\`` : ''}: ${q(n.text)}`)
541 for (const t of s.texts ?? []) lines.push(`- text ${q(t.text)}${t.near ? ` near \`${t.near}\`` : ''}`)
542 const drawn = s.drawings?.length ?? 0
543 if (drawn) lines.push(`- ${drawn} freehand mark${drawn === 1 ? '' : 's'} (only a picture shows them: ${READ_TOOL} with image: true)`)
544 if (s.selected?.length) lines.push(`- selected on the page now: ${s.selected.join(', ')}`)
545 if (s.images) lines.push(`- ${s.images} pasted image${s.images === 1 ? '' : 's'} (not in pictures yet: ask the user what they show)`)
546 return lines
547}
548
549type BoardCard = {
550 id: number
551 board?: string
552 kind: 'diagram' | 'note' | 'sticky' | 'you' | 'end'
553 title?: string
554 text?: string
555 mermaid?: string
556 notes?: Note[]
557 on?: string
558 diagram?: number
559}
560
561/** A sticky note, as Claude reads it back. */
562const noteLine = (n: Note) => `- sticky note${n.on ? ` on ${n.on}` : ''}: ${n.text}`
563
564/** The board as Claude reads it back: every card in order, diagrams with their source and sticky notes. */
565function boardText(viewers: number, cards: BoardCard[], isLatest: boolean, scenes: Record<string, Scene> = {}, mode: Mode = 'diagrams', brief: Brief | null = null): string {
566 const said = boardCardsText(viewers, cards, isLatest, scenes, mode)
567 return brief ? `${said}\n\n${briefText(brief)}` : said
568}
569
570function boardCardsText(viewers: number, cards: BoardCard[], isLatest: boolean, scenes: Record<string, Scene>, mode: Mode): string {
571 const diagrams = cards.filter(c => c.kind === 'diagram')
572 // Diagrams are numbered on their own board, as the page's tabs are.
573 const boardOf = (c: BoardCard) => c.board ?? 'main'
574 const onItsBoard = (c: BoardCard) => diagrams.filter(d => boardOf(d) === boardOf(c))
575 const stickiesOf = (id: number) => cards.filter(c => c.kind === 'sticky' && c.diagram === id).map(c => noteLine({ on: c.on, text: c.text ?? '' }))
576 const diagramText = (c: BoardCard) => {
577 const n = onItsBoard(c).indexOf(c) + 1
578 const scene = scenes[String(c.id)]
579 // An edited diagram's notes are on its canvas.
580 const notes = scene ? [] : [...(c.notes ?? []).map(noteLine), ...stickiesOf(c.id)]
581 return [
582 `Diagram ${n} of ${onItsBoard(c).length}${boardOf(c) !== 'main' ? ` on the side board \`${boardOf(c)}\`` : ''}${c.title ? `, "${c.title}"` : ''}:`,
583 ...(c.text ? [c.text] : []),
584 '```mermaid',
585 c.mermaid ?? '',
586 '```',
587 ...notes,
588 ...(scene ? sceneText(scene) : []),
589 ].join('\n')
590 }
591 const seen = `${viewers ? `open in ${viewers === 1 ? 'one tab' : `${viewers} tabs`}` : 'not open in any tab'}; ${mode === 'canvas' ? `canvas mode: every diagram is editable, amend with ${EDIT_TOOL}` : 'diagrams mode'}`
592 if (isLatest) {
593 const last = diagrams.at(-1)
594 return last ? `The whiteboard page (${seen}); its latest diagram:\n\n${diagramText(last)}` : `The whiteboard page (${seen}) has no diagram yet.`
595 }
596 const parts = cards.flatMap(c =>
597 c.kind === 'diagram'
598 ? [diagramText(c)]
599 : c.kind === 'note' && c.text
600 ? [`Your note${c.title ? `, "${c.title}"` : ''}: ${c.text}`]
601 : c.kind === 'you' && c.text
602 ? [`The user wrote: ${c.text}`]
603 : [],
604 )
605 return parts.length ? `The whiteboard page (${seen}), oldest first:\n\n${parts.join('\n\n')}` : `The whiteboard page (${seen}) is empty.`
606}
607
608const KEYWORDS = new Set(['flowchart', 'graph', 'subgraph', 'end', 'classDef', 'class', 'style', 'linkStyle', 'click', 'direction'])
609/** The node ids a diagram's Mermaid names with a shape (`api[...]`, `db[(...)]`, `x{...}`), or its participants. */
610function nodeIdsOf(source: string): Set<string> {
611 const ids = new Set<string>()
612 for (const m of source.matchAll(/(?:^|[\s;&|>-])([A-Za-z_][\w.-]*?)\s*(?:\[|\(|\{|>(?![>-]))/gm)) if (!KEYWORDS.has(m[1] ?? '')) ids.add(m[1] ?? '')
613 for (const m of source.matchAll(/^\s*(?:participant|actor)\s+([\w.-]+)/gm)) ids.add(m[1] ?? '')
614 return ids
615}
616
617/**
618 * The diagram already on the board that a new one mostly redraws (60% of
619 * their boxes in common), when it is editable: any on a canvas board, an
620 * edited one on a drawing board. Claude amends that one instead, keeping the
621 * person's layout. Null when there is none.
622 */
623async function redrawnOnCanvas($: EngineInterface, mermaid: string): Promise<string | null> {
624 if (!board) return null
625 let read: { mode?: Mode; cards: BoardCard[]; scenes?: Record<string, Scene> }
626 try {
627 read = await boardGet($, await board, '/cards')
628 } catch {
629 return null
630 }
631 const fresh = nodeIdsOf(mermaid)
632 if (fresh.size < 2) return null
633 const diagrams = read.cards.filter(c => c.kind === 'diagram')
634 for (const c of diagrams) {
635 // Numbered on its own board, as edit_board takes it.
636 const i = diagrams.filter(d => (d.board ?? 'main') === (c.board ?? 'main')).indexOf(c)
637 const where = (c.board ?? 'main') !== 'main' ? ` on the side board \`${c.board}\`` : ''
638 const scene = read.scenes?.[String(c.id)]
639 // On a drawing board only an edited diagram is amended; the rest are a story told in tabs.
640 if (read.mode !== 'canvas' && !scene) continue
641 const old = scene?.boxes ? new Set(scene.boxes.map(b => b.ref)) : nodeIdsOf(c.mermaid ?? '')
642 const common = [...fresh].filter(id => old.has(id))
643 if (old.size >= 2 && common.length >= 0.6 * Math.max(fresh.size, old.size)) {
644 return (
645 `${read.mode === 'canvas' ? 'The board is a canvas, and d' : 'D'}iagram ${i + 1}${where}${c.title ? ` "${c.title}"` : ''}${scene ? ', edited on the board,' : ''} already shows these boxes ` +
646 `(${common.slice(0, 6).map(id => `\`${id}\``).join(', ')}${common.length > 6 ? ', …' : ''}). Amend it with ${EDIT_TOOL} ` +
647 '(class or color to recolour as the evidence comes in, text to update a label, add, connect, remove): that keeps ' +
648 'the layout the user may have arranged. If you mean a separate diagram, post again with as_new: true.'
649 )
650 }
651 }
652 return null
653}
654
655const failed = (error: unknown) => `The whiteboard page could not start: ${error instanceof Error ? error.message : String(error)}`
656
657// ---------------------------------------------------------------- register
658
659export const register: Register = on => {
660 on('session.start', async ($, e, next) => {
661 await $.command.register({
662 name: 'whiteboard',
663 description: 'A whiteboard page in your browser, beside the conversation: /whiteboard [focus|canvas|sample|file.mmd|file.md]',
664 })
665 await $.tool.register({
666 name: TOOL,
667 description:
668 "Post to the whiteboard: a page in the user's browser beside this conversation, where you draw and they " +
669 'can answer. A card is a Mermaid diagram, a short Markdown note (paragraphs, bullets, **bold**, `code`), ' +
670 'or both. Draw whenever a picture explains something better than prose, in this project or not: how a ' +
671 'protocol, standard or system works (OAuth, TLS, DNS, a consensus algorithm), architecture, data flow, call ' +
672 'sequences, state machines, schemas. A "how does X work?" question is one: draw the flow, and keep your ' +
673 'answer in the conversation short, pointing at the board. Any Mermaid 12 diagram type works, with ' +
674 'classDef, themes and front-matter config; the page has zoom and panning, so size is not a problem. Each ' +
675 'diagram becomes a tab, so a sequence of posts tells a story. Before drawing, read the whiteboard:drawing ' +
676 'skill. The first post opens the page in the browser. If Mermaid rejects the source, the call fails with ' +
677 'its error and the card is taken off the page: fix the source and post again. ' +
678 'Sticky notes (`sticky_notes`) add a short note without changing the diagram: a proposal, a question or ' +
679 'an aside. With `on` (a node id in the Mermaid source) a note sits beside that flowchart box, or on a ' +
680 "chart's slice, bar or point named by its label; in other diagram types, or without `on`, notes line up " +
681 'beside the diagram. Charts (pie, xychart-beta, quadrantChart) are clickable: what the user selects comes ' +
682 'with their message ("Selected on the board, in …: the slice …"). Whenever you have a fix or a ' +
683 'change to propose, pin it as a sticky note on the box it changes and ask on the board (in `text`) whether ' +
684 'the user wants to see it; redraw the diagram with the change only once they say so. With a `mermaid` notes ' +
685 'go on that diagram; without one, on the latest diagram. ' +
686 'A brief (`bottom_line` and `sections`) sits beside the diagrams: the answer first, then up to nine ' +
687 'one-line sections the user can open, question and steer, each pointing at the boxes it is about (`focus`). ' +
688 `Post it once, with the diagram it explains, then change it in place with ${EDIT_TOOL} as the conversation ` +
689 'goes on (see the drawing skill, "Briefs"). For a design or a choice still to make, post it with ' +
690 "`brief_mode: 'decide'`: constraints with choices the user settles on the page, then your proposal " +
691 '(the drawing skill, "Deciding"). ' +
692 'Messages that begin "(on the whiteboard)" were typed by the user on the page: answer them there with ' +
693 'this tool, and keep what you write in the conversation to a line. When they wrap up, write the summary in ' +
694 'the conversation itself, then call this tool with end: true: the page says the discussion is over and closes.',
695 inputSchema: {
696 type: 'object',
697 properties: {
698 title: { type: 'string', description: 'A short heading for the card' },
699 text: { type: 'string', description: 'A note, in simple Markdown, above the diagram if there is one' },
700 mermaid: { type: 'string', description: 'A Mermaid diagram, starting with the diagram type' },
701 as_new: {
702 type: 'boolean',
703 description:
704 `true to post a diagram as a new one even though it shares most boxes with one on a canvas (otherwise amend that one with ${EDIT_TOOL}), ` +
705 `or a brief on another subject when one is on the board (otherwise change that one with ${EDIT_TOOL})`,
706 },
707 bottom_line: {
708 type: 'string',
709 description: 'A brief: the answer or verdict first, in one or two sentences of inline Markdown. With `sections`.',
710 },
711 brief_mode: {
712 type: 'string',
713 enum: ['brief', 'decide'],
714 description: "brief (default): an explanation or a summary. decide: a design or a choice still to make: constraints first, settled on the page, then your proposal",
715 },
716 side_board: {
717 type: 'object',
718 description:
719 'Open a side board for one question (when the user says "let\'s whiteboard this", or agrees to your offer): ' +
720 'the rest of this post (its brief, its diagram) goes on it, and the user is taken there. `for`: the main ' +
721 "board's constraint it decides; its decision settles that one. At most three open. A parked side board opens again " +
722 'with its id alone (its brief as it was), or with a new brief.',
723 properties: { id: { type: 'string' }, title: { type: 'string' }, for: { type: 'string', description: "The main board's constraint id this side board decides" } },
724 required: ['id', 'title'],
725 },
726 board: { type: 'string', description: "Which board this goes on: 'main' or a side board's id (default: the one the user is looking at)" },
727 options: {
728 type: 'array',
729 maxItems: 4,
730 description: "A comparison (on a side board): the options compared, as columns; each section is a criterion, with `cells`. Pick the ids of the main board constraint's choices when they match",
731 items: { type: 'object', properties: { id: { type: 'string' }, label: { type: 'string' }, hint: { type: 'string' } }, required: ['id', 'label'] },
732 },
733 sections: {
734 type: 'array',
735 maxItems: 9,
736 description: "A brief's sections, in reading order: each one line the user can open, question or have you change",
737 items: {
738 type: 'object',
739 properties: {
740 id: { type: 'string', description: 'A short, stable id (letters, digits, - and _): how you and the user name it later' },
741 title: { type: 'string', description: 'A label of one to three words' },
742 line: { type: 'string', description: 'The section in one line of inline Markdown (under 160 characters)' },
743 body: { type: 'string', description: 'More detail, in simple Markdown: only when asked for, or when the line cannot stand alone' },
744 focus: { type: 'array', items: { type: 'string' }, description: "What on the diagram this section is about: flowchart node ids, sequence participant ids, or chart labels. They light up when the user points at it" },
745 kind: { type: 'string', enum: ['point', 'constraint', 'idea'], description: 'point (default); constraint: a question to settle before the design (with `choices`); idea: one not yet weighed' },
746 choices: { type: 'array', maxItems: 4, description: 'A constraint: its options, each `id` and a short `label` (`hint`: a few words more)', items: { type: 'object', properties: { id: { type: 'string' }, label: { type: 'string' }, hint: { type: 'string' } }, required: ['id', 'label'] } },
747 lean: { type: 'string', description: 'A constraint: the choice id you would pick, shown as your lean' },
748 status: { type: 'string', enum: ['open', 'assumed'], description: 'A constraint: open (default), or assumed (your guess, on `lean`, for the user to confirm)' },
749 by: { type: 'string', enum: ['you'], description: "'you' when the section is the user's own idea" },
750 suggested: { type: 'boolean', description: 'true for a section you suggest: the user takes it or not' },
751 cells: {
752 type: 'object',
753 description: 'A criterion in a comparison: by option id, `mark` (yes, part, no, unknown) and `text` (one short clause). A mark without a source is your judgement: cite one where it is a fact',
754 additionalProperties: { type: 'object', properties: { mark: { type: 'string', enum: ['yes', 'part', 'no', 'unknown'] }, text: { type: 'string' } } },
755 },
756 cites: {
757 type: 'array',
758 maxItems: 4,
759 description: 'Where it comes from: a spec, a doc, a file',
760 items: {
761 type: 'object',
762 properties: { label: { type: 'string', description: 'e.g. "RFC 7636" or "src/auth.ts:42"' }, url: { type: 'string', description: 'A web address, when there is one' } },
763 required: ['label'],
764 },
765 },
766 },
767 required: ['id', 'line'],
768 },
769 },
770 mode: {
771 type: 'string',
772 enum: ['diagrams', 'canvas'],
773 description:
774 'How the board works from now on: diagrams (yours, as drawn; each new one a tab: for explaining and ' +
775 'investigating) or canvas (every diagram editable by both of you, amended in place: for sketching ' +
776 'together, when the user wants to move boxes themselves). A decision (brief_mode decide) stays in diagrams ' +
777 'mode: the board marks each constraint on drawn diagrams, and you redraw as choices settle. Set it with ' +
778 'your first post when the conversation calls for one; the user can switch it on the page.',
779 },
780 sticky_notes: {
781 type: 'array',
782 maxItems: 8,
783 description: 'Sticky notes pinned beside boxes: a proposal, a question, an aside. Without `mermaid`, they go on the latest diagram.',
784 items: {
785 type: 'object',
786 properties: {
787 on: { type: 'string', description: "The node id of the flowchart box it is about, or the label of a chart's slice, bar or point, as written in the Mermaid source" },
788 text: { type: 'string', description: 'The note: a line or two of inline Markdown' },
789 },
790 required: ['text'],
791 },
792 },
793 end: {
794 type: 'boolean',
795 description:
796 'Only after the user wraps up and your summary is in the conversation: closes the page. `text` may ' +
797 'carry one line for the page, such as where the summary is.',
798 },
799 legend: {
800 type: 'array',
801 maxItems: 6,
802 description:
803 "A key to the diagram's colours, drawn above it instead of inside it: " +
804 'one entry per classDef the diagram uses, in reading order.',
805 items: {
806 type: 'object',
807 properties: {
808 label: { type: 'string', description: 'What the colour means, in a few words' },
809 class: { type: 'string', description: 'The name of a classDef in the diagram' },
810 },
811 required: ['label', 'class'],
812 },
813 },
814 },
815 },
816 })
817 await $.tool.register({
818 name: READ_TOOL,
819 description:
820 "Read back what is on this session's whiteboard page: each diagram's Mermaid source (so its node ids), " +
821 'its sticky notes, your notes, and what the user typed there, oldest first. Use it when the user talks ' +
822 'about something on the board that you did not draw in this conversation (a /whiteboard sample or file ' +
823 'they opened, or anything from before the conversation was summarized), or to check what a diagram says ' +
824 `before changing it with ${TOOL}.`,
825 inputSchema: {
826 type: 'object',
827 properties: {
828 latest: { type: 'boolean', description: 'Only the latest diagram, with its sticky notes' },
829 board: { type: 'string', description: "With `image` and `diagram`: the board the diagram is on, 'main' or a side board's id (default: the one the user is looking at)" },
830 image: {
831 type: 'boolean',
832 description:
833 'Also a picture of one diagram (the latest edited, else the latest, unless `diagram` says): for what words ' +
834 'cannot carry, such as freehand marks the user drew or where things sit',
835 },
836 diagram: { type: 'integer', description: 'With `image`: which diagram, by its number on the board (1 is the first)' },
837 },
838 },
839 })
840 await $.tool.register({
841 name: EDIT_TOOL,
842 description:
843 'Amend a diagram on the whiteboard in place, keeping everything else as it is, the layout the user arranged ' +
844 `included, instead of drawing it again with ${TOOL}. Use it for changes to a diagram already on the board, ` +
845 'above all one the user has edited (their changes reach you as "I changed … on the board"): add a box beside ' +
846 'another, connect or disconnect two, rename one, recolour one with a drawing-skill class, remove one, or pin a ' +
847 `sticky note. Text on the canvas is plain, no Markdown. Boxes are named by their ref: the node id from the ` +
848 `Mermaid, or the ref ${READ_TOOL} shows for a ` +
849 'box the user drew. A diagram that has not been edited yet becomes editable on the board when you amend it. ' +
850 `Draw a new diagram with ${TOOL} when the picture changes as a whole. ` +
851 'It also changes the brief in place (`sections`, `bottom_line`): answer a question the user asked about a ' +
852 'section under that section, rewrite a line when what you learned changes it (the user sees the old one ' +
853 'struck through), add a section for a new idea, drop one that no longer matters, with why.',
854 inputSchema: {
855 type: 'object',
856 properties: {
857 sections: {
858 type: 'array',
859 minItems: 1,
860 maxItems: 30,
861 description: 'Changes to the brief, applied in order; each one that fails is reported and the rest still apply',
862 items: {
863 type: 'object',
864 properties: {
865 op: {
866 type: 'string',
867 enum: ['add', 'update', 'drop', 'restore', 'answer', 'settle', 'reopen'],
868 description:
869 'add: a new section `id` with `line` (title, body, focus, cites; `after`: the id it goes after). ' +
870 'update: new `line`, `title`, `body`, `focus`, `cites`, `kind`, `choices`, `lean`, `status` (open or assumed), ' +
871 '`suggested: false` (the user took your suggestion in words) or `by` for section `id` (give only what changes; ' +
872 'an empty body, focus or cites clears it). drop: take section `id` out, saying `why`; it is listed as dropped. ' +
873 'restore: bring a dropped one back, at the end. ' +
874 "answer: `text` under section `id`, answering the user's oldest unanswered question there (a note under it when there is none). " +
875 'settle: settle constraint `id` on `choice` (when the user decided in words; their clicks on the page settle it already). ' +
876 'reopen: open a settled constraint again.',
877 },
878 id: { type: 'string' },
879 title: { type: 'string' },
880 line: { type: 'string' },
881 body: { type: 'string' },
882 focus: { type: 'array', items: { type: 'string' } },
883 cites: { type: 'array', items: { type: 'object', properties: { label: { type: 'string' }, url: { type: 'string' } }, required: ['label'] } },
884 after: { type: 'string', description: 'add: the id of the section it goes after (default: last)' },
885 why: { type: 'string', description: 'drop: why it no longer matters, in a few words' },
886 text: { type: 'string', description: 'answer: the answer, in simple Markdown' },
887 kind: { type: 'string', enum: ['point', 'constraint', 'idea'] },
888 choices: { type: 'array', maxItems: 4, description: 'A constraint: its options, each `id` and a short `label` (`hint`: a few words more)', items: { type: 'object', properties: { id: { type: 'string' }, label: { type: 'string' }, hint: { type: 'string' } }, required: ['id', 'label'] } },
889 lean: { type: 'string' },
890 status: { type: 'string', enum: ['open', 'assumed'] },
891 by: { type: 'string', enum: ['you'] },
892 suggested: { type: 'boolean' },
893 choice: { type: 'string', description: 'settle: the choice id' },
894 cells: { type: 'object', description: 'A criterion in a comparison: by option id, { mark, text }; only the options given change' },
895 },
896 required: ['op', 'id'],
897 },
898 },
899 bottom_line: { type: 'string', description: "The brief's new bottom line, when what you learned changes it (in a decision: your proposal, once every constraint is settled)" },
900 brief_mode: { type: 'string', enum: ['brief', 'decide'], description: 'Turn the brief into a decision (decide) when the user starts choosing, or back; say so when you do' },
901 board: { type: 'string', description: "Which board's brief or diagrams to change: 'main' or a side board's id (default: the one the user is looking at); diagrams are numbered on their own board" },
902 side_board: {
903 type: 'object',
904 description:
905 "Done with a side board: return (with `choice`, one of its options: that settles the main board's constraint it was opened for), " +
906 'park (for later) or drop (with `why`). The user is back on the main board.',
907 properties: { op: { type: 'string', enum: ['return', 'park', 'drop'] }, id: { type: 'string' }, choice: { type: 'string' }, why: { type: 'string' } },
908 required: ['op', 'id'],
909 },
910 diagram: { type: 'integer', description: 'Which diagram, by its number on the board (1 is the first); default: the latest edited one, else the latest' },
911 look: { type: 'boolean', description: 'false: no picture of the result (one comes back by default, small)' },
912 ops: {
913 type: 'array',
914 minItems: 1,
915 maxItems: 50,
916 description: 'The amendments, applied in order; each one that fails is reported and the rest still apply',
917 items: {
918 type: 'object',
919 properties: {
920 op: {
921 type: 'string',
922 enum: ['add', 'connect', 'disconnect', 'text', 'class', 'color', 'remove', 'note'],
923 description:
924 'add: a box `id` with `text` (near: a box to put it beside, joined to it by an arrow unless connect: false; side: right, below, left or above; class; shape: rectangle, ellipse or diamond). connect / disconnect: an arrow `from` → `to` (label). text: new `text` for box `id`. class: recolour box `id` with an evidence class (unverified, fine, problem, proposed, suspect, plain). color: any other colour for box `id`: `color` a name (blue, green, red, orange, yellow, purple, pink, teal, grey, white, black) or #hex, or `fill`, `stroke`, `ink` (text) in #hex; add takes these too. remove: box `id` and its arrows. note: a sticky note `id` with `text`, `on` a box.',
925 },
926 id: { type: 'string', description: "The box's ref (for add and note: a new, short ref)" },
927 text: { type: 'string' },
928 near: { type: 'string', description: 'add: the ref of the box to put it beside' },
929 side: { type: 'string', enum: ['right', 'below', 'left', 'above'] },
930 connect: { type: 'boolean', description: 'add: false for no arrow from `near`' },
931 shape: { type: 'string', enum: ['rectangle', 'ellipse', 'diamond'] },
932 class: { type: 'string', enum: ['unverified', 'fine', 'problem', 'proposed', 'suspect', 'plain'] },
933 color: { type: 'string', description: 'color (or add): a colour name or #hex' },
934 fill: { type: 'string', description: 'color (or add): the fill, #hex' },
935 stroke: { type: 'string', description: 'color (or add): the border, #hex' },
936 ink: { type: 'string', description: 'color (or add): the text, #hex' },
937 from: { type: 'string' },
938 to: { type: 'string' },
939 label: { type: 'string', description: 'connect (or add with near): text on the arrow' },
940 on: { type: 'string', description: 'note: the ref of the box it is about' },
941 },
942 required: ['op'],
943 },
944 },
945 },
946 },
947 })
948 return next(e)
949 })
950
951 // Never in the way of the prompt: anything going wrong here sends it on as typed.
952 on('prompt.submit', async ($, e, next) => {
953 if (e.text.startsWith(FROM_BOARD)) {
954 isOnBoard = true
955 return next({ ...e, context: [...(e.context ?? []), BOARD_NOTE] })
956 }
957 // Typed here after talking there: they are back, and Claude answers here.
958 if (isOnBoard && ['composer', 'bridge', 'sdk'].includes(e.origin?.kind)) {
959 isOnBoard = false
960 return next({ ...e, context: [...(e.context ?? []), BACK_NOTE] })
961 }
962 if (isIntroduced) return next(e)
963 let hasScreen = false
964 try {
965 hasScreen = (await $.session.surfaces()).length > 0
966 } catch {
967 // Unknown: say nothing this time.
968 }
969 if (!hasScreen) return next(e)
970 isIntroduced = true
971 return next({ ...e, context: [...(e.context ?? []), INTRO] })
972 })
973
974 // The page shows whether Claude is working, so a message sent there is
975 // never met with silence.
976 on('turn.start', async ($, e, next) => {
977 boardTurn = e.text.startsWith(FROM_BOARD) ? { turnId: e.turnId, isPosted: false } : null
978 void boardStatus($, 'working')
979 return next(e)
980 })
981
982 on('turn.complete', async ($, e, next) => {
983 const done = await next(e)
984 if (!e.agentId && boardTurn?.turnId === e.turnId) {
985 if (!boardTurn.isPosted && e.reason === 'answer') await postAnswer($, e.answer)
986 boardTurn = null
987 }
988 void boardStatus($, 'idle')
989 if (said.length) void deliver($)
990 return done
991 })
992
993 on('tool.call', { tool: 'mcp__whiteboard__post_to_board' }, async ($, e) => {
994 const text = typeof e.text === 'string' ? e.text.trim() : ''
995 const mermaid = typeof e.mermaid === 'string' ? mermaidOf(e.mermaid) : ''
996 if (e.end === true) {
997 try {
998 const isEnded = await endBoard($, text || undefined)
999 return { result: isEnded ? 'The whiteboard page is closing; the discussion is over.' : 'No whiteboard page was open.' }
1000 } catch (error) {
1001 return { result: `The whiteboard page had already stopped (${error instanceof Error ? error.message : String(error)}).` }
1002 }
1003 }
1004 const notes = notesOf(e.sticky_notes)
1005 const sections = sectionsOf(e.sections)
1006 const given = Array.isArray(e.sections) ? e.sections.length : 0
1007 if (sections.length < given) {
1008 const bad = (e.sections as unknown[]).findIndex(s => !sectionsOf([s]).length) + 1
1009 return { deny: `Each section needs an \`id\` and a \`line\`: section #${bad} does not. Nothing was posted.` }
1010 }
1011 const bottomLine = typeof e.bottom_line === 'string' ? e.bottom_line.trim() : ''
1012 if (sections.length && !bottomLine) return { deny: 'A brief starts with its bottom line: give `bottom_line` with the sections.' }
1013 const options = Array.isArray(e.options) ? (e.options as { id: string; label: string }[]) : undefined
1014 const brief = bottomLine ? { bottomLine, sections, ...(e.brief_mode === 'decide' ? { mode: 'decide' as const } : {}), ...(options?.length ? { options } : {}) } : undefined
1015 const sideBoard = e.side_board && typeof e.side_board === 'object' ? (e.side_board as { id: string; title?: string; for?: string }) : undefined
1016 const onBoard = typeof e.board === 'string' ? e.board : undefined
1017 if (!text && !mermaid && !notes && !e.mode && !brief) return { deny: 'Nothing posted: give `text`, `mermaid`, `sticky_notes`, a brief, or a mix.' }
1018
1019 if (!(await $.session.surfaces()).length) {
1020 return { deny: 'Nobody can see the whiteboard from this session (it has no screen attached). Explain in prose instead.' }
1021 }
1022 const title = typeof e.title === 'string' && e.title.trim() ? e.title.trim() : undefined
1023 // On a canvas, the same diagram again is an amendment, not a new tab.
1024 if (mermaid && e.as_new !== true) {
1025 const redrawn = await redrawnOnCanvas($, mermaid)
1026 if (redrawn) return { deny: redrawn }
1027 }
1028 const { legend, unknown } = legendOf(e.legend, mermaid)
1029 let out: Awaited<ReturnType<typeof postToBoard>>
1030 try {
1031 const mode = e.mode === 'canvas' || e.mode === 'diagrams' ? e.mode : undefined
1032 out = await postToBoard($, { title, text: text || undefined, mermaid: mermaid || undefined, legend, notes, mode, brief, ...(e.as_new === true ? { isNew: true } : {}), ...(sideBoard ? { sideBoard } : {}), ...(onBoard ? { board: onBoard } : {}) })
1033 } catch (error) {
1034 return { deny: failed(error) }
1035 }
1036 if (out.posted.briefError === 'a brief is on the board already') {
1037 return {
1038 deny:
1039 `A brief is on the board already: change it in place with ${EDIT_TOOL} (sections: add, update, drop, restore, ` +
1040 'answer; bottom_line), which keeps what the user has read and asked there. Post with as_new: true only for a ' +
1041 'brief on another subject. Nothing was posted.',
1042 }
1043 }
1044 if (out.posted.briefError) return { deny: `The board could not take this brief: ${out.posted.briefError}. Nothing was posted.` }
1045 if (out.posted.boardError) return { deny: `The board could not do that: ${out.posted.boardError}. Nothing was posted.` }
1046 if (out.posted.error) {
1047 return {
1048 deny: `Mermaid could not draw this diagram:\n${mermaidError(out.posted.error)}\nIt was taken off the page${brief ? ', and the brief with it' : ''}. Fix the source and post again${brief ? ', brief and all' : ''}.`,
1049 }
1050 }
1051 if (out.posted.noDiagram) return { deny: 'No diagram on the board to pin these sticky notes to: post the diagram with them.' }
1052 if (out.posted.noteError) return { deny: `The board could not pin these sticky notes: ${out.posted.noteError}.` }
1053 if (!text && !mermaid && !notes && !brief) return { result: `The whiteboard is in ${e.mode} mode now.` }
1054 if (boardTurn) boardTurn.isPosted = true
1055 // Notes whose box is not on the canvas: the rest of the post is on the board.
1056 const notPinned = out.posted.noteErrors?.length ? ` Not pinned: ${out.posted.noteErrors.join('; ')}.` : ''
1057 const unknownNote = unknown.length ? ` The legend names classes with no classDef: ${unknown.join(', ')}.` : ''
1058 const briefNote = brief
1059 ? sideBoard
1060 ? ` The side board "${sideBoard.title ?? sideBoard.id}" is open and the user is on it: answer there; when it is decided (the user picks an option on the page, or you ${EDIT_TOOL} side_board return), they are back on the main board.`
1061 : ` The brief is beside the diagrams with ${sections.length} section${sections.length === 1 ? '' : 's'}: from now on change it in place with ${EDIT_TOOL}.`
1062 : ''
1063 // Where it went: the board the user is on, unless named.
1064 const onSide = out.posted.board && out.posted.board !== 'main' ? ` It went on the side board \`${out.posted.board}\`.` : ''
1065 return { result: `${postedWhere(out, Boolean(mermaid))}${onSide}${briefNote}${notPinned}${unknownNote}` }
1066 })
1067
1068 on('tool.call', { tool: `mcp__whiteboard__${READ_TOOL}` }, async ($, e) => {
1069 if (!board) return { result: 'There is no whiteboard page in this session yet (or the last one was wrapped up): nothing is on it.' }
1070 let text: string
1071 let open: Board
1072 try {
1073 open = await board
1074 const read = await boardGet<{ viewers: number; cards: BoardCard[]; scenes?: Record<string, Scene>; mode?: Mode; brief?: Brief | null; boards?: BoardInfo[]; viewing?: string }>($, open, '/cards')
1075 text = boardText(read.viewers, read.cards, e.latest === true, read.scenes, read.mode, read.brief ?? null)
1076 // Side boards: the one the user is on in full, the main board's lines, the rest in a line each.
1077 if (read.boards?.some(b => b.id !== 'main')) text = `${boardCardsText(read.viewers, read.cards, e.latest === true, read.scenes ?? {}, read.mode ?? 'diagrams')}\n\n${boardsText(read.boards, read.viewing ?? 'main')}`
1078 } catch (error) {
1079 return { result: `The whiteboard page has stopped (${error instanceof Error ? error.message : String(error)}): nothing to read.` }
1080 }
1081 if (e.image !== true) return { result: text }
1082 // A picture as well, from the page: the image goes to Claude with the words.
1083 const shot = await boardPost<{ ok: boolean; png?: string; diagram?: string; error?: string }>($, open, {
1084 snapshot: true,
1085 ...(Number.isInteger(e.diagram) ? { diagram: e.diagram } : {}),
1086 ...(typeof e.board === 'string' ? { board: e.board } : {}),
1087 }).catch(error => ({ ok: false, error: error instanceof Error ? error.message : String(error) }) as { ok: boolean; png?: string; diagram?: string; error?: string })
1088 if (!shot.png) return { result: `${text}\n\n(No picture: ${shot.error ?? 'the page did not send one'}.)` }
1089 return {
1090 result: [
1091 { type: 'text', text: `${text}\n\nThe picture below is "${shot.diagram ?? ''}" as it is on the page.` },
1092 { type: 'image', source: { type: 'base64', media_type: 'image/png', data: shot.png } },
1093 ],
1094 }
1095 })
1096
1097 on('tool.call', { tool: `mcp__whiteboard__${EDIT_TOOL}` }, async ($, e) => {
1098 const ops = Array.isArray(e.ops) ? e.ops.filter((op): op is Record<string, unknown> => !!op && typeof op === 'object') : []
1099 const briefOps = Array.isArray(e.sections) ? e.sections.filter((op): op is Record<string, unknown> => !!op && typeof op === 'object') : []
1100 const bottomLine = typeof e.bottom_line === 'string' && e.bottom_line.trim() ? e.bottom_line.trim() : undefined
1101 const briefMode = e.brief_mode === 'brief' || e.brief_mode === 'decide' ? e.brief_mode : undefined
1102 const onBoard = typeof e.board === 'string' ? e.board : undefined
1103 const side = e.side_board && typeof e.side_board === 'object' ? (e.side_board as { op: string; id: string; choice?: string; why?: string }) : undefined
1104 if (side) {
1105 if (!board) return { deny: `There is no whiteboard page in this session yet.` }
1106 let closed: { ok: boolean; boardError?: string }
1107 try {
1108 const started = await boardOpen($)
1109 closed = await boardPost($, started.open, { sideBoardOp: side })
1110 } catch (error) {
1111 return { deny: failed(error) }
1112 }
1113 if (closed.boardError) return { deny: `The board could not do that: ${closed.boardError}.` }
1114 if (!ops.length && !briefOps.length && !bottomLine && !briefMode) {
1115 return { result: `Side board ${side.id}: ${side.op === 'return' ? (side.choice ? `decided on ${side.choice}, which settles its constraint on the main board` : 'decided') : side.op === 'park' ? 'parked' : 'dropped'}; the user is back on the main board.` }
1116 }
1117 }
1118 if (!ops.length && !briefOps.length && !bottomLine && !briefMode) return { deny: 'Nothing to amend: give `ops` for a diagram, or `sections` or `bottom_line` for the brief.' }
1119 if (!board) return { deny: `There is no whiteboard page in this session yet: draw the diagram with ${TOOL} first.` }
1120 let briefSaid = ''
1121 if (briefOps.length || bottomLine || briefMode) {
1122 let changed: { ok: boolean; done?: number; errors?: string[]; briefError?: string; boardError?: string; board?: string }
1123 try {
1124 const started = await boardOpen($)
1125 changed = await boardPost($, started.open, { briefOps, ...(bottomLine ? { bottomLine } : {}), ...(briefMode ? { briefMode } : {}), ...(onBoard ? { board: onBoard } : {}) })
1126 } catch (error) {
1127 return { deny: failed(error) }
1128 }
1129 if (changed.boardError) return { deny: `The board could not do that: ${changed.boardError}.` }
1130 const where = changed.board && changed.board !== 'main' ? ` on the side board \`${changed.board}\`` : ''
1131 if (changed.briefError) return { deny: `There is no brief${where || ' on the board'} yet: post one with ${TOOL} (bottom_line and sections).` }
1132 // An answer under a section is Claude answering on the board: nothing more is posted for the turn.
1133 if (boardTurn && briefOps.some(op => op.op === 'answer') && (changed.done ?? 0) > 0) boardTurn.isPosted = true
1134 const errors = changed.errors?.length ? ` Not applied: ${changed.errors.join('; ')}.` : ''
1135 briefSaid = `Changed the brief${where}: ${changed.done ?? 0} of ${briefOps.length + (bottomLine ? 1 : 0) + (briefMode ? 1 : 0)} applied.${errors}`
1136 if (!ops.length) return { result: briefSaid }
1137 }
1138 let out: { ok: boolean; diagram?: string; tab?: number; done?: string[]; errors?: string[]; error?: string; look?: string }
1139 try {
1140 // A closed tab opens again, so the amendment is seen.
1141 const started = await boardOpen($)
1142 out = await boardPost($, started.open, { ops, ...(Number.isInteger(e.diagram) ? { diagram: e.diagram } : {}), ...(e.look === false ? { look: false } : {}), ...(onBoard ? { board: onBoard } : {}) })
1143 } catch (error) {
1144 return { deny: failed(error) }
1145 }
1146 if (out.error) return { deny: `The board could not apply it: ${out.error}.` }
1147 // Not counted as an answer on the board: what Claude then writes still goes there.
1148 const errors = out.errors?.length ? ` Not applied: ${out.errors.join('; ')}.` : ''
1149 const said = `${briefSaid ? `${briefSaid} ` : ''}Amended diagram ${out.tab ?? ''} "${out.diagram ?? ''}" on the board: ${out.done?.length ?? 0} of ${ops.length} applied.${errors}`
1150 if (!out.look) return { result: said }
1151 // A small picture of the result: worth a glance for crowded labels or arrows across boxes.
1152 return {
1153 result: [
1154 { type: 'text', text: `${said} Below, how it looks now; fix anything crowded or overlapping with another amendment.` },
1155 { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: out.look } },
1156 ],
1157 }
1158 })
1159
1160 on('command.run', { command: 'whiteboard' }, async ($, e) => {
1161 const arg = e.args.trim()
1162 const draw = async (d: Doc, said: string) => {
1163 try {
1164 const out = await postToBoard($, { title: d.title, mermaid: d.source })
1165 if (out.posted.error) return { text: `Mermaid could not draw ${d.title}: ${mermaidError(out.posted.error)}` }
1166 const where = out.started.isNew && !out.started.isOpened ? ` Open it in your browser: ${out.started.open.url}` : ''
1167 return { text: `${said} on the whiteboard page.${where}` }
1168 } catch (error) {
1169 return { text: failed(error) }
1170 }
1171 }
1172 if (!arg || arg === 'focus' || arg === 'canvas') {
1173 try {
1174 // Asked for: it opens again even with a tab showing it, which may be out of sight.
1175 const started = await boardOpen($, true)
1176 if (arg === 'canvas') {
1177 await boardPost($, started.open, { mode: 'canvas' })
1178 const where = started.isOpened ? 'opened in your browser' : `open it in your browser: ${started.open.url}`
1179 return { text: `Whiteboard ${where}, in canvas mode: edit the diagrams together.`, context: [CANVAS_NOTE] }
1180 }
1181 const where = started.isOpened ? 'opened in your browser' : `open it in your browser: ${started.open.url}`
1182 if (arg === 'focus') isOnBoard = true
1183 if (arg === 'focus') return { text: `Whiteboard ${where}. Discuss there; Claude answers on the board.`, context: [FOCUS_NOTE] }
1184 return { text: `Whiteboard ${where}.` }
1185 } catch (error) {
1186 return { text: failed(error) }
1187 }
1188 }
1189 if (arg === 'sample') {
1190 const shown = await draw(SAMPLE, 'Sample diagram drawn')
1191 // Claude did not draw it, so it is told what is there: the user's next
1192 // question is likely about it ("pin a note on the email service").
1193 return {
1194 ...shown,
1195 context: [
1196 `The user opened the whiteboard's sample diagram, "${SAMPLE.title}", to try the board; it is on the page now. ` +
1197 'It is a sample, not their project. If they ask about it or for a change, work from this source: pin ' +
1198 `sticky notes with \`on\` set to a node id, or redraw it with ${TOOL} keeping the ids and labels that ` +
1199 `stay the same.\n\n\`\`\`mermaid\n${SAMPLE.source}\n\`\`\``,
1200 ],