A one-line summary of the project's shared Kanban artifact above the prompt, read through Claude's own ArtifactData tool

A shared Kanban board for a project, with a live one-line summary of it above the prompt.
The board is a claude.ai artifact with a database: one document per card, { title, column, owner }. Every Claude session working on the project reads and writes the same cards, so the board is shared project state rather than one session's notes. This mod draws a line like this above the prompt:
▦ board todo 4 doing 2 review 1 done 9 · read 14:32 · Fix login → done
ArtifactData tool through $.tool.call (action: 'list') and counts the cards per column. That's a tool call, not a model call, so it costs no tokens.tool.call hook on ArtifactData sees every card Claude adds, moves or deletes on the linked board, and updates the summary immediately. Until the next read confirms them, the line says unconfirmed. A write whose if_version pin missed (db_write.committed: false) is ignored.projectBoard block (about 130 tokens) to the first message: the board's URL, the card shape, and the rule to read before writing, pin every write with if_version, and only move its own task's cards.$.tool.check. If the answer isn't allow, it skips the read and the line says reading needs approval: run /board refresh.| When | Model tokens | Other |
|---|---|---|
| First message of a conversation | about 130 input tokens, once (the projectBoard block). It sits in the cached prefix after that. | none |
/board <url> mid-session | about 130 input tokens, once (the same brief, as the command's context) | one ArtifactData list |
| Turn end, after Claude wrote to the board | 0 | one ArtifactData list, if reading is allowed |
| Turn end, mirror older than 2 minutes | 0 | one ArtifactData list, if reading is allowed |
| Any other turn end | 0 | nothing |
| Drawing the line | 0 | reads $.state only |
The mod never calls $.model.* or spawns an agent. When Claude moves a card, that's Claude's own tool call and is billed like any other tool call. Turn the mod off with /board off: the line and the automatic reads stop at once, at session start too, and stay off after a restart. The brief is not taken back from a conversation that already carries it (that would re-render the cached first message); conversations started while the mod is off get no brief.
cards, one document per card with title, column (backlog, todo, doing, review, blocked, done) and owner." Claude builds the page with the artifact db capability./board https://claude.ai/artifact/<id> (optionally followed by a collection name), which is stored per project root in this mod's $.store. Or link it once for everyone by committing .claude/board.json: ``json { "url": "https://claude.ai/artifact/<id>", "collection": "cards" } `` The repo file wins over the stored link, so every session in the project finds the same board.ArtifactData in your permission settings. Without that, run /board refresh when you want a read. An allow rule for ArtifactData also lets Claude write without asking, so choose deliberately./board shows the status. The subcommands:
/board <url> [collection] links a board./board refresh reads it now, and may ask for permission./board off and /board on turn the mod off and on./board forget drops the stored link.{await next(e)} under its own line, so other mods' bands still show.$.state that any plugin can read: $.state.get({ plugin: 'artifact-dashboard', key: 'view' }) gives { columns, total, readAt, isConfirmed, lastMove, note }. A supervisor mod can put that in its fork's prompt and ask "did this turn move the card it should have?" without a board read of its own (a fork has no tools).state.set on { plugin: 'artifact-dashboard', key: 'isOff' }.claude plugin validate templates/artifact-dashboard
claude plugin test templates/artifact-dashboard
claude --plugin-dir templates/artifact-dashboard
It needs a session where ArtifactData exists, meaning one signed in to claude.ai with artifacts. Without it, the line shows the last stored mirror and says the tool isn't available.
/board refresh). Nothing pushes them to the mod.list page). Above that, the line says the counts are partial.column (or status, or lane). Documents without one are not counted.out_dir (documents saved to files) or a lowered as_level are ignored: neither shows the board as it stands. profiles lookups are ignored too.hooks/register.tsx 483 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
3
4import type { BoardLink, BoardView, Card } from '../types'
5
6type Engine = EngineInterface
7
8const view = atom({ plugin: 'artifact-dashboard', key: 'view' } as const, null as BoardView | null)
9const isOff = atom({ plugin: 'artifact-dashboard', key: 'isOff' } as const, false)
10
11const TOOL = 'ArtifactData'
12const COMMAND = 'board'
13const ORDER = ['backlog', 'todo', 'doing', 'review', 'blocked', 'done']
14const STALE_MS = 2 * 60_000
15const MAX_CARDS = 500
16const WRITES = ['set', 'update', 'delete', 'str_replace', 'batch']
17
18type Write = {
19 op: string
20 collection?: string
21 doc_id?: string
22 data?: {}
23 file_path?: string
24 field?: string
25 old_str?: string
26 new_str?: string
27 replace_all?: boolean
28}
29
30// Module state is rebuilt lazily by ensure(), so a hot reload costs one store read.
31let loading: Promise<BoardLink | null> | undefined
32let cards: Record<string, Card> = {}
33let readAt = 0
34let isDirty = false
35let lastMove: string | undefined
36let note: string | undefined
37let isCommandRegistered = false
38
39const isObject = (value: unknown): value is Record<string, unknown> =>
40 typeof value === 'object' && value !== null && !Array.isArray(value)
41
42const text = (value: unknown) => (typeof value === 'string' && value.trim() ? value.trim() : undefined)
43
44const idOf = (url: string | undefined) => url?.match(/artifact\/([A-Za-z0-9_-]+)/)?.[1]
45
46const asLink = (value: unknown): BoardLink | null => {
47 if (!isObject(value) || !idOf(text(value.url))) return null
48 return { url: String(value.url), collection: text(value.collection) ?? 'cards' }
49}
50
51const mirrorKey = (board: BoardLink) => `mirror:${idOf(board.url)}:${board.collection}`
52
53const quietly = async <T,>($: Engine, what: string, work: () => Promise<T>): Promise<T | undefined> => {
54 try {
55 return await work()
56 } catch (err) {
57 $.ui.log(`artifact-dashboard: ${what} failed: ${err}`)
58 return undefined
59 }
60}
61
62// A card is any document with a column; status and lane are accepted so a board
63// Claude built with other field names still counts.
64const toCard = (data: unknown, id: string): Card | undefined => {
65 if (!isObject(data)) return undefined
66 const column = text(data.column) ?? text(data.status) ?? text(data.lane)
67 if (!column) return undefined
68 const owner = text(data.owner) ?? text(data.assignee)
69 return { title: text(data.title) ?? text(data.name) ?? id, column, ...(owner ? { owner } : {}) }
70}
71
72const docOf = (item: unknown) => {
73 if (!isObject(item)) return undefined
74 const id = text(item.doc_id) ?? text(item.id) ?? text(item.docId)
75 if (!id) return undefined
76 const card = toCard(isObject(item.data) ? item.data : item, id)
77 return card ? { id, card } : undefined
78}
79
80const parseJson = (raw: string): unknown => {
81 try {
82 return JSON.parse(raw)
83 } catch {
84 const at = raw.indexOf('{')
85 if (at === -1) return undefined
86 try {
87 return JSON.parse(raw.slice(at))
88 } catch {
89 return undefined
90 }
91 }
92}
93
94// Fallback for a result with no structured record: walk whatever JSON the text
95// holds for arrays of documents instead of trusting one wrapper shape.
96const parseDocs = (raw: string | undefined) => {
97 const json = raw ? parseJson(raw) : undefined
98 if (json === undefined) return undefined
99 const found: Record<string, Card> = {}
100 let isList = false
101 const walk = (node: unknown, depth: number) => {
102 if (depth > 6 || node === null || typeof node !== 'object') return
103 if (Array.isArray(node)) {
104 isList = true
105 for (const item of node) {
106 const doc = docOf(item)
107 if (doc) found[doc.id] = doc.card
108 else walk(item, depth + 1)
109 }
110 return
111 }
112 const single = depth === 0 ? docOf(node) : undefined
113 if (single) {
114 found[single.id] = single.card
115 isList = true
116 return
117 }
118 for (const value of Object.values(node)) walk(value, depth + 1)
119 }
120 walk(json, 0)
121 return isList ? { cards: found, isPartial: /next_cursor"\s*:\s*"/.test(raw ?? '') } : undefined
122}
123
124// The declared record (BuiltinToolResults.ArtifactData, its db_read arm) first;
125// the model-facing text only when a build answers without it.
126const docsOf = (ran: ToolCallResult) => {
127 const record = isObject(ran.result) ? ran.result.db_read : undefined
128 // A record without docs (out_dir saved them to files, or a get found nothing)
129 // says nothing about the cards; reading its text would empty the mirror.
130 if (isObject(record) && !Array.isArray(record.docs)) return undefined
131 if (isObject(record) && Array.isArray(record.docs)) {
132 const cards: Record<string, Card> = {}
133 for (const item of record.docs) {
134 const doc = docOf(item)
135 if (doc) cards[doc.id] = doc.card
136 }
137 return { cards, isPartial: typeof record.next_cursor === 'string' }
138 }
139 return parseDocs(ran.text)
140}
141
142// db_write.committed is false when an if_version pin missed: nothing was written.
143const isCommitted = (ran: ToolCallResult) => {
144 const record = isObject(ran.result) ? ran.result.db_write : undefined
145 return !(isObject(record) && record.committed === false)
146}
147
148const columnsOf = (all: Card[]) => {
149 const counts = new Map<string, number>()
150 for (const card of all) counts.set(card.column, (counts.get(card.column) ?? 0) + 1)
151 const rank = (name: string) => {
152 const at = ORDER.indexOf(name.toLowerCase())
153 return at === -1 ? ORDER.length : at
154 }
155 return [...counts]
156 .sort((a, b) => rank(a[0]) - rank(b[0]) || a[0].localeCompare(b[0]))
157 .map(([name, count]) => ({ name, count }))
158}
159
160const brief = (board: BoardLink) =>
161 [
162 `This project's shared task board is the artifact ${board.url}, database collection "${board.collection}".`,
163 'Each card is one document: { title, column, owner }, column one of backlog, todo, doing, review, blocked, done.',
164 'Other Claude sessions read and write the same board. Use the ArtifactData tool: read before you write,',
165 'pin every write with if_version, and only move or add the cards your own task touches.',
166 'When you start a task that has a card, move it to doing; when you finish, move it to review or done.',
167 ].join(' ')
168
169const publish = async ($: Engine, board: BoardLink | null) => {
170 if (!board) {
171 await update($, view, () => null)
172 return
173 }
174 const all = Object.values(cards)
175 const next: BoardView = {
176 columns: columnsOf(all),
177 total: all.length,
178 readAt,
179 isConfirmed: !isDirty,
180 ...(lastMove ? { lastMove } : {}),
181 ...(note ? { note } : {}),
182 }
183 await update($, view, () => next)
184 await $.store
185 .set(mirrorKey(board), { cards: trimmed(cards), readAt })
186 .catch(err => $.ui.log(`artifact-dashboard: saving the mirror failed: ${err}`))
187}
188
189// The store outlives this code: re-check each saved card rather than trust its shape.
190const restored = (saved: Record<string, unknown>) => {
191 const out: Record<string, Card> = {}
192 for (const [id, data] of Object.entries(saved)) {
193 const card = toCard(data, id)
194 if (card) out[id] = card
195 }
196 return out
197}
198
199const trimmed = (all: Record<string, Card>) => Object.fromEntries(Object.entries(all).slice(-MAX_CARDS))
200
201// The repo file wins so every session in the project, on any machine, finds the
202// same board without anyone running /board.
203const loadLink = async ($: Engine) => {
204 const root = await $.session.root()
205 const fromRepo = await $.fs
206 .read(`${root}/.claude/board.json`)
207 .then(raw => asLink(parseJson(raw)))
208 .catch(() => null)
209 if (fromRepo) return fromRepo
210 return asLink(await $.store.get(`board:${root}`).catch(() => undefined))
211}
212
213const ensure = ($: Engine): Promise<BoardLink | null> => {
214 loading ??= (async () => {
215 // $.state lives for the session; the off switch has to outlive it.
216 if ((await $.store.get('isOff').catch(() => undefined)) === true) await update($, isOff, () => true)
217 const board = (await quietly($, 'loading the board link', () => loadLink($))) ?? null
218 if (board) {
219 const saved = await $.store.get(mirrorKey(board)).catch(() => undefined)
220 if (isObject(saved) && isObject(saved.cards)) {
221 cards = restored(saved.cards)
222 readAt = typeof saved.readAt === 'number' ? saved.readAt : 0
223 }
224 }
225 await quietly($, 'drawing the mirror', () => publish($, board))
226 return board
227 })()
228 // A failed load is retried on the next call instead of being cached for the session.
229 const current = loading
230 current.catch(() => {
231 if (loading === current) loading = undefined
232 })
233 return current
234}
235
236const relink = async ($: Engine, board: BoardLink | null) => {
237 loading = Promise.resolve(board)
238 cards = {}
239 readAt = 0
240 isDirty = false
241 lastMove = undefined
242 note = undefined
243 if (board) {
244 const saved = await $.store.get(mirrorKey(board)).catch(() => undefined)
245 if (isObject(saved) && isObject(saved.cards)) cards = restored(saved.cards)
246 }
247 await publish($, board)
248}
249
250const failure = (ran: ToolCallResult) => {
251 if (ran.deny !== undefined) return `refused: ${ran.deny}`
252 if (ran.isError) return `failed: ${(ran.text ?? '').slice(0, 120)}`
253 return undefined
254}
255
256// Reads the whole collection with Claude's own tool. Unasked, it never opens a
257// permission dialog: it reads only where the check already says allow.
258const refresh = async ($: Engine, isAsked: boolean): Promise<string> => {
259 const board = await ensure($)
260 if (!board) return 'No board linked. Run /board <artifact url>, or commit .claude/board.json.'
261 // Every unasked read (session start, turn end) honours /board off.
262 if (!isAsked && (await read($, isOff))) return 'Board summary is off; run /board on.'
263 const input = { action: 'list' as const, url: board.url, collection: board.collection, query: { limit: 1000 } }
264 const stop = async (why: string) => {
265 note = why
266 await publish($, board)
267 return why
268 }
269 // $.tool.list is no gate: a deferred tool may be missing from it and still
270 // callable, so a rejected check or call is what says the tool is not here.
271 try {
272 if (!isAsked && (await $.tool.check({ tool: TOOL, input })).decision !== 'allow') {
273 return await stop('reading needs approval: run /board refresh')
274 }
275 } catch {
276 return await stop(`${TOOL} is not available here; showing the last mirror`)
277 }
278 const ran = await $.tool.call({ tool: TOOL, ...input }).catch(() => undefined)
279 if (!ran) return await stop(`${TOOL} is not available here; showing the last mirror`)
280 const why = failure(ran)
281 const docs = why ? undefined : docsOf(ran)
282 if (!docs) return await stop(why ? `read ${why}` : 'could not parse the list result; showing the last mirror')
283 cards = docs.cards
284 readAt = await $.clock.now()
285 isDirty = false
286 note = docs.isPartial ? 'more than 1000 cards; counts are partial' : undefined
287 await publish($, board)
288 return `Read ${Object.keys(cards).length} cards from the board.`
289}
290
291const writeData = async ($: Engine, write: Write) => {
292 if (isObject(write.data)) return write.data
293 if (!write.file_path) return undefined
294 const raw = await $.fs.read(write.file_path).catch(() => undefined)
295 return raw === undefined ? undefined : parseJson(raw)
296}
297
298const move = (id: string, before: Card | undefined, after: Card | undefined) => {
299 if (after && after.column !== before?.column) lastMove = `${after.title} → ${after.column}`
300 if (after) cards[id] = after
301 else delete cards[id]
302}
303
304const applyWrite = async ($: Engine, board: BoardLink, write: Write) => {
305 const id = write.doc_id
306 if (!id || write.collection !== board.collection) return
307 const before = cards[id]
308 if (write.op === 'delete') return move(id, before, undefined)
309 if (write.op === 'str_replace') {
310 if (!before || !write.field || !write.old_str) return
311 const field = write.field as keyof Card
312 const old = before[field]
313 if (typeof old !== 'string') return
314 const swapped = write.replace_all
315 ? old.split(write.old_str).join(write.new_str ?? '')
316 : old.replace(write.old_str, write.new_str ?? '')
317 return move(id, before, { ...before, [field]: swapped })
318 }
319 const data = await writeData($, write)
320 if (!isObject(data)) return
321 if (write.op === 'set') return move(id, before, toCard(data, id))
322 // An update merges; a field written as { __delete__: true } is removed.
323 const merged: Record<string, unknown> = { ...(before ?? {}) }
324 for (const [key, value] of Object.entries(data)) {
325 if (isObject(value) && value.__delete__ === true) delete merged[key]
326 else merged[key] = value
327 }
328 move(id, before, toCard(merged, id))
329}
330
331// Sees every ArtifactData call that reaches the engine: the model's, and this
332// mod's own reads. Writes become unconfirmed until the next read lands.
333const observe = async ($: Engine, e: Record<string, unknown>, ran: ToolCallResult) => {
334 const board = await ensure($)
335 if (!board || failure(ran) || idOf(text(e.url)) !== idOf(board.url)) return
336 const action = String(e.action)
337 if (action === 'list' || action === 'query' || action === 'get') {
338 // out_dir answers with file paths, and a lowered as_level reads refused
339 // documents as missing: neither is the board as it stands.
340 if (e.collection !== board.collection || e.out_dir !== undefined || e.as_level !== undefined) return
341 const docs = docsOf(ran)
342 if (!docs) return
343 const query = isObject(e.query) ? e.query : {}
344 const isWhole = action === 'list' && !docs.isPartial && !query.cursor
345 cards = isWhole ? docs.cards : { ...cards, ...docs.cards }
346 if (isWhole) {
347 readAt = await $.clock.now()
348 isDirty = false
349 }
350 } else if (WRITES.includes(action)) {
351 if (!isCommitted(ran)) return
352 const writes = action === 'batch' ? (Array.isArray(e.writes) ? (e.writes as Write[]) : []) : [{ ...e, op: action } as Write]
353 for (const write of writes) await applyWrite($, board, write)
354 isDirty = true
355 } else {
356 return
357 }
358 await publish($, board)
359}
360
361const clock = (at: number) => {
362 const time = new Date(at)
363 return `${String(time.getHours()).padStart(2, '0')}:${String(time.getMinutes()).padStart(2, '0')}`
364}
365
366const registerCommand = async ($: Engine) => {
367 if (isCommandRegistered) return
368 await $.command.register({
369 name: COMMAND,
370 description: 'Shared Kanban artifact: /board <url> [collection] | refresh | off | on | forget',
371 argumentHint: '[url | refresh | off | on | forget]',
372 })
373 isCommandRegistered = true
374}
375
376const status = async ($: Engine) => {
377 const board = await ensure($)
378 if (!board) return 'No board linked. Run /board <artifact url> [collection].'
379 const shown = await read($, view)
380 const counts = shown?.columns.map(c => `${c.name} ${c.count}`).join(', ') || 'no cards yet'
381 const when = readAt ? `last read ${clock(readAt)}` : 'never read'
382 const pending = isDirty ? ', with writes not yet read back' : ''
383 const moved = lastMove ? ` Last move: ${lastMove}.` : ''
384 return `Board ${board.url} (${board.collection}): ${counts}; ${when}${pending}.${moved}`
385}
386
387export const register: Register = on => {
388 on('session.start', async ($, e, next) => {
389 const result = await next(e)
390 await quietly($, 'registering /board', () => registerCommand($))
391 await quietly($, 'reading the board', () => refresh($, false))
392 return result
393 })
394
395 on('command.run', { command: COMMAND }, async ($, e) => {
396 const [word = '', extra] = e.args.trim().split(/\s+/)
397 const answer = await quietly($, `/board ${word}`, async () => {
398 if (word === '') return { text: await status($) }
399 if (word === 'refresh') return { text: await refresh($, true) }
400 if (word === 'off' || word === 'on') {
401 await update($, isOff, () => word === 'off')
402 await $.store.set('isOff', word === 'off')
403 return { text: word === 'off' ? 'Board summary hidden; no more reads.' : 'Board summary back on.' }
404 }
405 const root = await $.session.root()
406 if (word === 'forget') {
407 await $.store.delete(`board:${root}`)
408 await relink($, null)
409 return { text: 'Board link forgotten for this project (.claude/board.json, if any, still wins).' }
410 }
411 const board = asLink({ url: word, collection: extra })
412 if (!board) return { text: `Not an artifact link: ${word}` }
413 await $.store.set(`board:${root}`, board)
414 await relink($, board)
415 const outcome = await refresh($, true)
416 // Mid-session the first message's context is already sent, so the brief
417 // rides on this command's output instead of invalidating that context.
418 return { text: `Linked ${board.url} (${board.collection}). ${outcome}`, context: [brief(board)] }
419 })
420 return answer ?? { text: '/board failed; see the debug log.' }
421 })
422
423 on('prompt.context', async ($, e, next) => {
424 const result = await next(e)
425 const board = await quietly($, 'loading the board link', () => ensure($))
426 if (!board || (await read($, isOff))) return result
427 return { ...result, blocks: [...result.blocks, { name: 'projectBoard', text: brief(board) }] }
428 })
429
430 on('tool.call', { tool: TOOL }, async ($, e, next) => {
431 const ran = await next(e)
432 await quietly($, 'mirroring a board call', () => observe($, e as unknown as Record<string, unknown>, ran))
433 return ran
434 })
435
436 // No model call ever: a turn end reads the board only after a write needs
437 // confirming or once the mirror is older than STALE_MS.
438 on('turn.complete', async ($, e, next) => {
439 const result = await next(e)
440 if (e.agentId !== undefined) return result
441 await quietly($, 'registering /board', () => registerCommand($))
442 const board = await quietly($, 'loading the board link', () => ensure($))
443 if (!board || (await read($, isOff))) return result
444 const now = await $.clock.now()
445 if (isDirty || now - readAt > STALE_MS) await quietly($, 'reading the board', () => refresh($, false))
446 return result
447 })
448
449 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
450 const board = await read($, view)
451 if (e.props.hasSurvey || board === null || (await read($, isOff))) return next(e)
452 const { Box, Text } = $.ui.resolve(e)
453 const columns = board.columns.length ? board.columns : [{ name: 'empty', count: 0 }]
454 const freshness = board.isConfirmed
455 ? board.readAt
456 ? `read ${clock(board.readAt)}`
457 : 'not read yet'
458 : 'unconfirmed'
459
460 return (
461 <Box flexDirection="column">
462 <Box>
463 <Text wrap="truncate-end">
464 <Text bold color="cyan">
465 ▦ board{' '}
466 </Text>
467 {columns.map(c => (
468 <Text key={c.name}>
469 {c.name} <Text bold>{c.count}</Text>
470 {' '}
471 </Text>
472 ))}
473 <Text dimColor>· {freshness}</Text>
474 {board.lastMove ? <Text dimColor> · {board.lastMove}</Text> : null}
475 {board.note ? <Text color="yellow"> · {board.note}</Text> : null}
476 </Text>
477 </Box>
478 {await next(e)}
479 </Box>
480 )
481 })
482}
483types/index.d.ts 23 lines1export type Card = { title: string; column: string; owner?: string }
2
3export type BoardLink = { url: string; collection: string }
4
5export type BoardColumn = { name: string; count: number }
6
7// isConfirmed is false while the counts include writes this session observed
8// but has not yet read back from the artifact.
9export type BoardView = {
10 columns: BoardColumn[]
11 total: number
12 readAt: number
13 isConfirmed: boolean
14 lastMove?: string
15 note?: string
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'artifact-dashboard': { view: BoardView | null; isOff: boolean }
21 }
22}
23