A small pane in Claude Code with your project's current phase and two to-do lists: one for Claude, one for you

A small pane in Claude Code that keeps the state of the project you're in where you can see it:
Claude keeps the board up to date itself. You read it at a glance and click an item to tick it off.
shop ×
phase 2: public beta
AI 3 You 1 Bugs 2
──────────────────────────────────────
! rotate leaked webhook secret
· wire retries into uploader api
· paginate the orders page
✓ fix login redirect
Needs Claude Code 2.1.287 or newer. The board is a Claude Code mod (function hooks), which is an early-access API.
git clone https://github.com/Magazem/sticky-board ~/sticky-board
~/.claude/settings.json: {
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/sticky-board"
}
}
Use an absolute path (C:/Users/you/sticky-board on Windows). To list several folders, separate them with :, or with ; on Windows.
To try it for one session without installing: claude --plugin-dir ~/sticky-board.
/sticky shows or hides it at any width.1 2 3 switch lists and x closes it.▤ AI 3 · You 1.board tool to add items, tick them off, set the phase and read the board. It only writes the board file, so it never asks for permission.You can just talk to it: "put that on the board", "what's left for me?", "we're in beta now, update the phase", "review this branch and file what you find".
Each git repository gets one board. Subfolders and worktrees share it. Outside git, each folder gets its own board.
Every board lives in one plain JSON file, ~/.sticky/notes.json. Set STICKY_HOME to keep it somewhere else. The file is an array of notes:
{
"id": "4f1c…",
"project": "/users/you/shop",
"project_name": "shop",
"lane": "todo",
"title": "wire retries into uploader",
"priority": 2,
"area": "api",
"status": "open",
"created_at": "2026-10-05T08:04:49.464Z",
"updated_at": "2026-10-05T08:04:49.464Z"
}
lane: scope (the phase), todo (AI), human (you) or bugpriority: 1 to 3, where 1 means it blocks workproject: the repository's path. On Windows it's lowercase with backslashes.Scripts can write the file too, and the pane picks up changes within two seconds.
Remove the CLAUDE_CODE_PLUGIN_DIRS entry and delete the folder. Your boards stay in ~/.sticky until you delete that too.
claude plugin validate .
claude plugin test .
Once Claude Code has loaded the mod, it writes the API types into .claude-plugin/types/ and a tsconfig.json next to them, so tsc -p . type-checks the module.
MIT
hooks/register.tsx 457 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Board, Note } from '../types'
5
6// Sticky Board: a small pane with the current project's phase and three short
7// lists: what Claude still has to do, what only you can do, and known bugs.
8//
9// Claude keeps it current through the `board` tool registered below; you read it
10// at a glance and click an item to tick it off. Everything lives in one JSON file,
11// ~/.sticky/notes.json (or $STICKY_HOME/notes.json), one board per project.
12
13const PANE = 'sticky-board'
14const TOOL = 'mcp__sticky-board__board'
15const POLL_MS = 2000
16const WIDTH = 40 // columns asked for when docked beside a fullscreen transcript
17const MAX_ROWS = 10 // the list never grows past this; the rest is "+N more"
18const MAX_DONE = 2 // recently ticked items stay visible, dim, so a mis-click undoes
19const MAX_SEEN = 8 // items per list in what Claude is shown
20
21type Lane = 'todo' | 'human' | 'bug'
22
23const LISTS: { key: Lane; name: string; label: string; hotkey: string; empty: string }[] = [
24 { key: 'todo', name: 'ai', label: 'AI', hotkey: '1', empty: 'nothing queued' },
25 { key: 'human', name: 'you', label: 'You', hotkey: '2', empty: 'nothing needs you' },
26 { key: 'bug', name: 'bugs', label: 'Bugs', hotkey: '3', empty: 'no known bugs' },
27]
28const LANE_OF: Record<string, Lane> = { ai: 'todo', you: 'human', bugs: 'bug' }
29const LABEL_OF: Record<string, string> = { todo: 'AI to-do', human: 'You', bug: 'Bugs' }
30
31const board = atom({ plugin: 'sticky-board', key: 'board' } as const, {
32 projectName: '',
33 notes: [],
34 error: null,
35})
36const focus = atom({ plugin: 'sticky-board', key: 'focus' } as const, 'todo')
37
38const DESCRIPTION = `The Sticky Board: a small pane the user keeps open beside this session, showing this project's current phase and three short lists. The user reads it at a glance, so keep it accurate and terse.
39
40Lists:
41- ai: work you still have to do in this project
42- you: things only the user can do (decide, rotate a key, test by hand, create an account, review)
43- bugs: known defects not yet fixed
44
45Actions:
46- add: file an item (list and title; optional priority 1-3 where 1 blocks work, area such as ui or api). Adding a title that is already open updates it.
47- done: tick an item off by its title (a unique part of it is enough). Do this as soon as you finish one of your items.
48- reopen: undo a done.
49- scope: set the one-line phase, e.g. "phase 2: public beta". It replaces the previous line.
50- list: read the board.
51
52File when something durable happens: a bug found, work discovered or deferred, something handed to the user, the phase changing. At the end of a code review, file each real finding under bugs. If the board has no phase yet and you understand what the project is doing, set one. Do not file routine progress, passing tests or restatements of a plan.
53
54Style: titles are fragments of 3-6 words with no trailing punctuation ("webhook fires twice on 500s"), never sentences.`
55
56const SCHEMA = {
57 type: 'object',
58 properties: {
59 action: { type: 'string', enum: ['add', 'done', 'reopen', 'scope', 'list'] },
60 list: { type: 'string', enum: ['ai', 'you', 'bugs'], description: 'Which list, for add; optional for done and reopen.' },
61 title: { type: 'string', description: 'A 3-6 word fragment.' },
62 priority: { type: 'integer', minimum: 1, maximum: 3, description: '1 blocks work; 3 is the default.' },
63 area: { type: 'string', description: 'Optional short tag, e.g. ui, api, build.' },
64 },
65 required: ['action'],
66 additionalProperties: false,
67}
68
69// Mirrors how projects were always named: a path, lowercased on Windows with
70// backslashes, so one project stays one board however it was opened.
71const SPLITS = ['\\.claude\\worktrees\\', '\\.claude\\jobs\\', '/.claude/worktrees/', '/.claude/jobs/']
72
73function projectOf(dir: string) {
74 let p = dir.trim()
75 for (const marker of SPLITS) {
76 const at = p.indexOf(marker)
77 if (at !== -1) p = p.slice(0, at)
78 }
79 p = p.replace(/[\\/]+$/, '')
80 const isWindows = /^[A-Za-z]:/.test(p) || p.includes('\\')
81 if (isWindows) p = p.replace(/\//g, '\\')
82 const name = p.split(/[\\/]/).pop() || p
83
84 return { id: isWindows ? p.toLowerCase() : p, name }
85}
86
87// A board belongs to the repository, not to the folder Claude was started in: a
88// subfolder or a worktree of a repo shares the repo's board.
89async function resolveProject($: EngineInterface, cwd: string) {
90 const git = async (...args: string[]) => {
91 const ran = await $.process.run(['git', ...args], { timeoutMs: 5000 }).catch(() => undefined)
92 return ran?.exitCode === 0 ? ran.stdout.trim() : ''
93 }
94 const common = await git('rev-parse', '--path-format=absolute', '--git-common-dir')
95 if (/[\\/]\.git$/.test(common)) return projectOf(common.replace(/[\\/]\.git$/, ''))
96 const top = await git('rev-parse', '--show-toplevel')
97
98 return projectOf(top || cwd)
99}
100
101const norm = (s: string) =>
102 s
103 .toLowerCase()
104 .replace(/[^\p{L}\p{N}]+/gu, ' ')
105 .trim()
106
107const byPriority = (a: Note, b: Note) =>
108 a.priority - b.priority || a.created_at.localeCompare(b.created_at)
109
110const isOpenIn = (lane: string) => (n: Note) => n.lane === lane && n.status !== 'done'
111
112function newId() {
113 const c = (globalThis as any).crypto
114 if (c?.randomUUID) return c.randomUUID() as string
115 return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join('')
116}
117
118// Module variables start over on a hot reload; session.start sets them again.
119let root = ''
120let project = { id: '', name: '' }
121let lastMtime = -1
122let lastSeen = '' // the board as Claude last saw it, so it is only re-sent on change
123let lastStatus: string | undefined | null = null
124let writes: Promise<unknown> = Promise.resolve()
125
126const notesPath = () => `${root}/notes.json`
127
128function toNote(n: any): Note {
129 return {
130 id: String(n.id),
131 lane: n.lane ?? 'cue',
132 title: String(n.title ?? ''),
133 detail: n.detail ?? null,
134 area: n.area ?? null,
135 priority: Number.isFinite(n.priority) ? n.priority : 3,
136 status: n.status ?? 'open',
137 created_at: String(n.created_at ?? ''),
138 updated_at: String(n.updated_at ?? n.created_at ?? ''),
139 }
140}
141
142// A missing file is an empty board; an unreadable one throws, so a write can never
143// replace a board it failed to read.
144async function loadAll($: EngineInterface): Promise<any[]> {
145 if (!(await $.fs.exists(notesPath()))) return []
146 const all = JSON.parse((await $.fs.read(notesPath())).replace(/^/, ''))
147 if (!Array.isArray(all)) throw new Error('notes.json is not a list')
148 return all
149}
150
151async function refresh($: EngineInterface, force = false) {
152 const stat = await $.fs.stat(notesPath()).catch(() => undefined)
153 if (!stat) {
154 lastMtime = 0
155 await update($, board, () => ({ projectName: project.name, notes: [], error: null }))
156 return
157 }
158 if (!force && stat.mtimeMs === lastMtime) return
159
160 try {
161 const all = await loadAll($)
162 lastMtime = stat.mtimeMs
163 const notes = all.filter(n => (n.project ?? null) === (project.id || null)).map(toNote)
164 await update($, board, () => ({ projectName: project.name, notes, error: null }))
165 } catch {
166 // A half-written or corrupt file keeps the last good board on screen.
167 await update($, board, b => ({ ...b, error: 'notes.json unreadable' }))
168 }
169}
170
171// Every write re-reads the file, changes it and writes it back, one at a time.
172function mutate<T>($: EngineInterface, change: (all: any[], now: string) => T): Promise<T> {
173 const run = writes.then(async () => {
174 const all = await loadAll($)
175 const now = new Date(await $.clock.now()).toISOString()
176 const out = change(all, now)
177 await $.fs.write(notesPath(), JSON.stringify(all, null, 2) + '\n')
178 await refresh($, true)
179 return out
180 })
181 writes = run.catch(() => undefined)
182
183 return run
184}
185
186const toggle = ($: EngineInterface, id: string) =>
187 mutate($, (all, now) => {
188 const n = all.find(x => x.id === id)
189 if (!n) return
190 n.status = n.status === 'done' ? 'open' : 'done'
191 n.updated_at = now
192 })
193
194function snapshot(b: Board) {
195 const scope = b.notes.filter(isOpenIn('scope')).sort((x, y) => y.created_at.localeCompare(x.created_at))[0]
196 const line = (lane: Lane) => {
197 const items = b.notes.filter(isOpenIn(lane)).sort(byPriority)
198 const shown = items.slice(0, MAX_SEEN).map(n => (n.priority === 1 ? '! ' : '') + n.title + (n.area ? ` [${n.area}]` : ''))
199 const more = items.length > MAX_SEEN ? [`+${items.length - MAX_SEEN} more`] : []
200
201 return `${LABEL_OF[lane]} (${items.length}): ${items.length ? [...shown, ...more].join('; ') : 'none'}`
202 }
203
204 return [
205 `Sticky Board for ${b.projectName || 'this folder'}`,
206 `Phase: ${scope?.title ?? 'not set'}`,
207 line('todo'),
208 line('human'),
209 line('bug'),
210 ].join('\n')
211}
212
213const isBlank = (b: Board) => !b.notes.some(n => n.status !== 'done' && ['scope', 'todo', 'human', 'bug'].includes(n.lane))
214
215async function runTool($: EngineInterface, input: Record<string, unknown>): Promise<string> {
216 const action = String(input.action ?? '')
217 const title = String(input.title ?? '').trim().replace(/[.!]+$/, '')
218 const lane = input.list === undefined ? undefined : LANE_OF[String(input.list)]
219 const here = (n: any) => (n.project ?? null) === (project.id || null)
220
221 if (action === 'list') {
222 const b: Board = await read($, board)
223 lastSeen = snapshot(b)
224 return lastSeen
225 }
226 if (!title) throw new Error(`${action} needs a title`)
227
228 if (action === 'scope') {
229 await mutate($, (all, now) => {
230 for (const n of all) {
231 if (here(n) && n.lane === 'scope' && n.status !== 'done') {
232 n.status = 'done'
233 n.updated_at = now
234 }
235 }
236 all.push({
237 id: newId(), created_at: now, updated_at: now, lane: 'scope', project: project.id || null,
238 project_name: project.name || null, area: null, priority: 1, category: 'scope', title,
239 detail: null, status: 'open', session_id: 'claude',
240 })
241 })
242 lastSeen = snapshot(await read($, board))
243 return `Phase set: ${title}`
244 }
245
246 if (action === 'add') {
247 if (!lane) throw new Error('add needs list: ai, you or bugs')
248 const priority = Number.isInteger(input.priority) ? Math.min(3, Math.max(1, input.priority as number)) : undefined
249 const area = typeof input.area === 'string' && input.area.trim() ? input.area.trim() : undefined
250 const isNew = await mutate($, (all, now) => {
251 const same = all.find(n => here(n) && n.lane === lane && n.status !== 'done' && norm(String(n.title ?? '')) === norm(title))
252 if (same) {
253 if (priority) same.priority = priority
254 if (area) same.area = area
255 same.updated_at = now
256 return false
257 }
258 all.push({
259 id: newId(), created_at: now, updated_at: now, lane, project: project.id || null,
260 project_name: project.name || null, area: area ?? null, priority: priority ?? 3, category: lane,
261 title, detail: null, status: 'open', session_id: 'claude',
262 })
263 return true
264 })
265 if (isNew && lane === 'human') $.ui.toast(`For you: ${title}`, { timeoutMs: 8000 })
266 lastSeen = snapshot(await read($, board))
267 return `${isNew ? 'Added to' : 'Updated in'} ${LABEL_OF[lane]}: ${title}`
268 }
269
270 if (action === 'done' || action === 'reopen') {
271 const wantsDone = action === 'done'
272 const b: Board = await read($, board)
273 const pool = b.notes.filter(
274 n => ['todo', 'human', 'bug'].includes(n.lane) && (lane ? n.lane === lane : true) && (n.status === 'done') !== wantsDone,
275 )
276 const exact = pool.filter(n => norm(n.title) === norm(title))
277 const partial = pool.filter(n => norm(n.title).includes(norm(title)))
278 const hits = exact.length ? exact : partial
279 if (hits.length !== 1) {
280 const names = (hits.length ? hits : pool).slice(0, 12).map(n => `${n.title} (${LABEL_OF[n.lane]})`)
281 throw new Error(
282 `${hits.length ? 'several items match' : 'no item matches'} "${title}". ${wantsDone ? 'Open' : 'Done'} items: ${names.join('; ') || 'none'}`,
283 )
284 }
285 await toggle($, hits[0]!.id)
286 lastSeen = snapshot(await read($, board))
287 return `${wantsDone ? 'Done' : 'Reopened'}: ${hits[0]!.title}`
288 }
289
290 throw new Error(`unknown action "${action}"`)
291}
292
293// Without the pane on screen (closed, or a terminal too narrow for it), one line
294// under the prompt keeps the counts in view.
295async function syncStatus($: EngineInterface) {
296 const b: Board = await read($, board)
297 const panes = await $.ui.panes().catch(() => [])
298 const isShown = panes.some(p => p.id === PANE && p.isPlaced && p.isShown)
299 const parts = LISTS.map(l => [l.label, b.notes.filter(isOpenIn(l.key)).length] as const)
300 .filter(([, count]) => count > 0)
301 .map(([label, count]) => `${label} ${count}`)
302 const text = isShown || parts.length === 0 ? undefined : `▤ ${parts.join(' · ')}`
303 if (text === lastStatus) return
304 lastStatus = text
305 $.ui.status(text)
306}
307
308async function openPane($: EngineInterface) {
309 const b: Board = await read($, board)
310 const lane = await read($, focus)
311 const open = b.notes.filter(isOpenIn(lane)).length
312 const rows = 5 + Math.min(Math.max(open, 1), MAX_ROWS)
313
314 return $.ui.open({ id: PANE, title: 'Sticky Board', columns: WIDTH, rows })
315}
316
317export const register: Register = on => {
318 on('session.start', async ($, e, next) => {
319 const home = (await $.env.get('STICKY_HOME')) ?? `${(await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''}/.sticky`
320 root = home.replace(/[\\/]+$/, '')
321 project = await resolveProject($, e.cwd)
322 lastMtime = -1
323 lastSeen = ''
324 lastStatus = null
325
326 await $.tool.register({ name: 'board', description: DESCRIPTION, inputSchema: SCHEMA })
327 await $.command.register({
328 name: 'sticky',
329 description: 'Show or hide the Sticky Board for this project',
330 argumentHint: '[open|close]',
331 })
332 await refresh($, true)
333 $.clock.every(POLL_MS, async () => {
334 await refresh($)
335 await syncStatus($)
336 })
337 if ((await $.store.get('autoOpen')) !== false) void openPane($)
338
339 return next(e)
340 })
341
342 // Claude sees the board on its first prompt, and again whenever it changed by
343 // some other hand (a click in the pane, another session) since it last looked.
344 on('prompt.submit', async ($, e, next) => {
345 const b: Board = await read($, board)
346 const snap = snapshot(b)
347 if (isBlank(b) || snap === lastSeen) return next(e)
348 lastSeen = snap
349
350 return next({ ...e, context: [...(e.context ?? []), `<sticky-board>\n${snap}\n</sticky-board>`] })
351 })
352
353 on('tool.call', { tool: TOOL }, async ($, e) => {
354 try {
355 return { result: await runTool($, e as unknown as Record<string, unknown>) }
356 } catch (err) {
357 return { deny: `Sticky Board: ${err instanceof Error ? err.message : String(err)}` }
358 }
359 })
360
361 // A close by hand, by the × or by /sticky is remembered: the pane stays shut in
362 // later sessions until /sticky opens it again.
363 on('ui.close', { id: PANE }, async ($, e, next) => {
364 if (e.origin.kind !== 'unload') await $.store.set('autoOpen', false)
365 lastStatus = null
366 return next(e)
367 })
368
369 on('command.run', { command: 'sticky' }, async ($, e) => {
370 const arg = e.args.trim().toLowerCase()
371 const panes = await $.ui.panes()
372 const isShown = panes.some(p => p.id === PANE && p.isPlaced && p.isShown)
373 const wantsOpen = arg === 'open' || (arg !== 'close' && !isShown)
374
375 if (!wantsOpen) {
376 await $.ui.close({ id: PANE })
377 await syncStatus($)
378 return { text: 'Sticky Board hidden. /sticky brings it back.' }
379 }
380 await $.store.set('autoOpen', true)
381 await refresh($, true)
382 await openPane($)
383 await syncStatus($)
384
385 return { text: `Sticky Board: ${project.name || 'this folder'}` }
386 })
387
388 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
389 const { Box, Text, Button } = $.ui.resolve(e)
390 const b: Board = await read($, board)
391 const focused = await read($, focus)
392 const list = LISTS.find(l => l.key === focused) ?? LISTS[0]!
393
394 const scope = b.notes.filter(isOpenIn('scope')).sort((x, y) => y.created_at.localeCompare(x.created_at))[0]
395 const open = b.notes.filter(isOpenIn(list.key)).sort(byPriority)
396 const done = b.notes
397 .filter(n => n.lane === list.key && n.status === 'done')
398 .sort((x, y) => y.updated_at.localeCompare(x.updated_at))
399 .slice(0, MAX_DONE)
400
401 const room = Math.max(3, Math.min(MAX_ROWS, (e.viewport?.rows ?? 20) - 6))
402 const shown = open.slice(0, room)
403 const hidden = open.length - shown.length
404 const width = Math.max(8, Math.min(WIDTH, e.viewport?.columns ?? WIDTH))
405
406 const item = (n: Note) => {
407 const isDone = n.status === 'done'
408 const isUrgent = !isDone && n.priority === 1
409
410 return (
411 <Box key={`row-${n.id}`}>
412 <Text color={isUrgent ? 'warning' : 'inactive'}>{isDone ? '✓ ' : isUrgent ? '! ' : '· '}</Text>
413 <Button key={`toggle-${n.id}`} plain dimColor={isDone} label={n.title} onPress={() => toggle($, n.id)} />
414 {n.area && !isDone && <Text dimColor> {n.area}</Text>}
415 </Box>
416 )
417 }
418
419 return (
420 <Box flexDirection="column">
421 <Box justifyContent="space-between">
422 <Text dimColor>{b.projectName || 'this folder'}</Text>
423 <Button key="close" plain dimColor role="dismiss" hotkey="x" label="×" onPress={() => $.ui.close({ id: PANE })} />
424 </Box>
425 {scope ? <Text wrap="truncate-end">{scope.title}</Text> : <Text dimColor>no phase set</Text>}
426 {b.error && <Text color="error">{b.error}</Text>}
427
428 <Box marginTop={1}>
429 {LISTS.map(l => {
430 const count = b.notes.filter(isOpenIn(l.key)).length
431 const isFocused = l.key === list.key
432
433 return (
434 <Box key={`tab-box-${l.key}`} marginRight={2}>
435 <Button
436 key={`tab-${l.key}`}
437 plain
438 hotkey={l.hotkey}
439 dimColor={!isFocused}
440 label={count ? `${l.label} ${count}` : l.label}
441 onPress={() => update($, focus, () => l.key)}
442 />
443 </Box>
444 )
445 })}
446 </Box>
447 <Text color="inactive">{'─'.repeat(width - 2)}</Text>
448
449 {open.length === 0 && <Text dimColor>{list.empty}</Text>}
450 {shown.map(item)}
451 {hidden > 0 && <Text dimColor>+{hidden} more</Text>}
452 {done.map(item)}
453 </Box>
454 )
455 })
456}
457types/index.d.ts 24 lines1export type Note = {
2 id: string
3 lane: string
4 title: string
5 detail: string | null
6 area: string | null
7 priority: number
8 status: string
9 created_at: string
10 updated_at: string
11}
12
13export type Board = {
14 projectName: string
15 notes: Note[]
16 error: string | null
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 'sticky-board': { board: Board; focus: string }
22 }
23}
24