Keeps open decisions, the task list and links Claude creates in a sidebar pane, updated through its own tool

What needs you, pinned where you can see it.<br> Open decisions, the task list and the links Claude creates stay in a pane beside the conversation, so they don't scroll out of view.
<img src="docs/board-in-progress.png" alt="Pinboard pane beside a Claude Code session, with one open decision and two of four todos done" width="820">
The story of how it came together: Pinboard: a Claude Code mod.
▸ in the warning color (one at a time), open items show ○, and finished items fold into a single dim ✓ N done line so open work stays on top.gh pr|issue|release|repo|gist create, gh pr|issue comment, git push, and MCP tools that create, draft, send, publish, share or upload), newest first, up to 12. GitHub PRs and issues get short labels such as repo PR #12. Press l (or the clear button) to empty the list.Everything is session state: /clear and /resume start an empty board.
Pinboard registers a tool, mcp__pinboard__update, that Claude calls to add todos, start one, check them off or remove them by id, open decisions, and close them. Its description carries a few working rules borrowed from opencode's todowrite: update in real time, mark a todo done only after the work (and any verification) is actually done, and when blocked, leave it in progress and add a follow-up todo for the blocker. Each call shows as one dim line in the transcript (Pinboard: +2 todo, 1 decided), so lists don't have to be repeated in replies.
The current board, with ids, is added to the end of the system prompt on every request, so Claude always knows what's open. Pinboard doesn't depend on the TodoWrite or Task tools, which newer models don't get by default.
Questions are the easiest thing to leave in a reply, where they scroll away. When Claude finishes a turn whose last lines end in a question mark (code blocks aside) while the board has no open decision, Pinboard's Stop hook sends it back once to pin the question with open_decisions. It only nudges once per stop, so a rhetorical question can still end the turn.
/pinboard./pinboard opens it at any width, even while Claude is working. Ctrl+X then X closes it.claude plugin marketplace add sirkitree/pinboard
claude plugin install pinboard@pinboard
claude plugin validate --strict .
claude plugin test .
claude --plugin-dir .
Built on the mods API as of Claude Code 2.1.288. That API is in early access and changes between releases.
hooks/register.tsx 242 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Decision, Pin, Todo } from '../types'
5
6const PANE = 'pinboard'
7const TITLE = 'Pinboard'
8const TOOL = 'mcp__pinboard__update'
9
10const decisions = atom({ plugin: 'pinboard', key: 'decisions' } as const, [] as Decision[])
11const todos = atom({ plugin: 'pinboard', key: 'todos' } as const, [] as Todo[])
12const links = atom({ plugin: 'pinboard', key: 'links' } as const, [] as Pin[])
13
14const DESCRIPTION = [
15 "Keep the session's task list and open decisions on the user's Pinboard, a sidebar that stays in view while the transcript scrolls.",
16 'Any question you end a reply on that needs the user to answer goes in open_decisions, however small the task, even a single yes/no.',
17 'Use it in place of writing task lists in your reply whenever the work takes 3+ distinct steps or the user gives new instructions.',
18 'add_todos: one action per item. start_todo: the todo id you are working on now; exactly one is in progress at a time. done_todos / remove_todos: todo ids.',
19 'Update in real time; do not batch completions. Mark a todo done only after the work is actually done, including any verification it needs, never based on intent.',
20 'If blocked or partly done, leave it in progress and add a follow-up todo describing the blocker.',
21 'open_decisions: questions that need the user to choose. decide: close a decision by id once the user has answered.',
22 'The current board, with ids, is at the end of your system prompt.',
23].join(' ')
24
25const strings = { type: 'array', items: { type: 'string' } }
26const SCHEMA = {
27 type: 'object',
28 properties: {
29 add_todos: strings,
30 start_todo: { type: 'string' },
31 done_todos: strings,
32 remove_todos: strings,
33 open_decisions: strings,
34 decide: {
35 type: 'array',
36 items: { type: 'object', properties: { id: { type: 'string' }, answer: { type: 'string' } }, required: ['id', 'answer'] },
37 },
38 },
39}
40
41export type Update = {
42 add_todos?: string[]
43 start_todo?: string
44 done_todos?: string[]
45 remove_todos?: string[]
46 open_decisions?: string[]
47 decide?: { id: string; answer: string }[]
48}
49
50type Board = { todos: Todo[]; decisions: Decision[] }
51
52// The next id for a prefix: one past the highest in use
53const nextId = (prefix: string, ids: string[]) =>
54 prefix + (Math.max(0, ...ids.map(id => Number(id.slice(prefix.length)) || 0)) + 1)
55
56export function applyUpdate(board: Board, change: Update): Board {
57 let { todos: t, decisions: d } = board
58 for (const text of change.add_todos ?? []) t = [...t, { id: nextId('t', t.map(x => x.id)), text, isDone: false }]
59 for (const text of change.open_decisions ?? []) d = [...d, { id: nextId('d', d.map(x => x.id)), text }]
60 const done = new Set(change.done_todos ?? [])
61 const removed = new Set(change.remove_todos ?? [])
62 const decided = new Set((change.decide ?? []).map(x => x.id))
63 t = t.filter(x => !removed.has(x.id)).map(x => (done.has(x.id) ? { ...x, isDone: true } : x))
64 // One todo in progress at a time; finishing it ends its turn too
65 if (change.start_todo) t = t.map(x => ({ ...x, isActive: x.id === change.start_todo }))
66 t = t.map(x => (x.isDone && x.isActive ? { ...x, isActive: false } : x))
67 d = d.filter(x => !decided.has(x.id))
68 return { todos: t, decisions: d }
69}
70
71export function describeBoard(board: Board): string {
72 if (board.todos.length + board.decisions.length === 0) return 'Pinboard is empty.'
73 return [
74 'Pinboard now:',
75 ...board.todos.map(t => `${t.id} [${t.isDone ? 'x' : t.isActive ? '>' : ' '}] ${t.text}`),
76 ...board.decisions.map(d => `${d.id} [?] ${d.text}`),
77 ].join('\n')
78}
79
80// A reply asks the user something when one of its last lines, outside code, ends in a question mark
81export function asksUser(reply: string): boolean {
82 const prose = reply.replace(/```[\s\S]*?```/g, '')
83 const lines = prose.split('\n').map(l => l.trim()).filter(Boolean).slice(-3)
84 return lines.some(l => /\?[*_`)"'\]]*$/.test(l))
85}
86
87const NUDGE =
88 'Your reply ends on a question for the user, but the Pinboard has no open decision. ' +
89 'Call mcp__pinboard__update with open_decisions for it (close it with decide once answered), then end your turn. ' +
90 'If it was rhetorical, end your turn as is.'
91
92const MAKES_COMMAND = /\bgh\s+(?:(?:pr|issue|release|repo|gist)\s+create|(?:pr|issue)\s+comment)\b|\bgit\s+push\b/
93const MAKES_MCP = /^mcp__.*(?:create|draft|send|publish|share|canvas|upload)/i
94
95const URL = /https:\/\/[A-Za-z0-9.-]+(?::\d+)?(?:\/[A-Za-z0-9\-._~:/?#[\]!$&'()*+,;=%@]*)?/g
96
97export const urlPins = (text: string): Pin[] =>
98 [...new Set([...text.matchAll(URL)].map(m => m[0].replace(/[)\].,;:'!?*]+$/, '')))]
99 .filter(href => !href.includes('@') && href.length <= 2048)
100 .map(href => {
101 const gh = /github\.com\/[^/]+\/([^/]+)\/(pull|issues)\/(\d+)/.exec(href)
102 return { href, label: gh ? `${gh[1]} ${gh[2] === 'pull' ? 'PR' : 'issue'} #${gh[3]}` : href.slice(8) }
103 })
104
105const isEmpty = async ($: EngineInterface) =>
106 (await read($, decisions)).length + (await read($, todos)).length + (await read($, links)).length === 0
107
108// Runs a capture; opens the pane when it puts the first thing on an empty board
109async function capture($: EngineInterface, change: () => Promise<unknown>): Promise<void> {
110 const wasEmpty = await isEmpty($)
111 await change()
112 if (wasEmpty && !(await isEmpty($))) await $.ui.open({ id: PANE, title: TITLE })
113}
114
115export const register: Register = on => {
116 on('session.start', async ($, e, next) => {
117 await $.command.register({ name: 'pinboard', description: 'Open the pane of open decisions, todos and links', immediate: true })
118 await $.tool.register({ name: 'update', description: DESCRIPTION, inputSchema: SCHEMA })
119 // Todos parsed from replies by older versions have no id; the tool can't reach them
120 await update($, todos, old => old.filter(t => typeof t.id === 'string'))
121 return next(e)
122 })
123
124 on('command.run', { command: 'pinboard' }, async $ => {
125 await $.ui.open({ id: PANE, title: TITLE })
126 return {}
127 })
128
129 // The board rides at the end of the system prompt, so it never has to be repeated in replies
130 on('prompt.compose', async ($, e, next) => {
131 const { sections } = await next(e)
132 const board = describeBoard({ todos: await read($, todos), decisions: await read($, decisions) })
133 return { sections: [...sections, { id: 'pinboard:board', text: board, scope: 'session' }] }
134 })
135
136 on('tool.call', { tool: TOOL }, async ($, e) => {
137 if (e.agentId) return { deny: 'Only the main conversation updates the Pinboard.' }
138 let board: Board = { todos: [], decisions: [] }
139 await capture($, async () => {
140 board = applyUpdate({ todos: await read($, todos), decisions: await read($, decisions) }, e as Update)
141 await update($, todos, () => board.todos)
142 await update($, decisions, () => board.decisions)
143 })
144 return { result: describeBoard(board) }
145 })
146
147 // A question left only in the reply scrolls away; send Claude back once to pin it
148 on('classic.Stop', async ($, e, next) => {
149 const ran = await next(e)
150 if (ran.block || e.stop_hook_active || !asksUser(e.last_assistant_message ?? '')) return ran
151 if ((await read($, decisions)).length > 0) return ran
152 return { ...ran, block: NUDGE }
153 })
154
155 // Links only from actions that make something; reads, fetches and test output just mention URLs
156 on('tool.call', async ($, e, next) => {
157 const ran = await next(e)
158 const makes = e.tool === 'Bash' ? MAKES_COMMAND.test(e.command) : MAKES_MCP.test(e.tool)
159 if (e.agentId || !makes || !('text' in ran) || ran.isError) return ran
160 const found = urlPins(ran.text ?? '')
161 if (found.length > 0 && found.length <= 3) {
162 await capture($, () => update($, links, old => [...found, ...old.filter(p => !found.some(f => f.href === p.href))].slice(0, 12)))
163 }
164 return ran
165 })
166
167 // An update is one dim line in the transcript; the board itself is in the pane
168 on('ui.render', { component: 'ToolUse', props: { tool: TOOL } }, async ($, e) => {
169 const { Text } = $.ui.resolve(e)
170 const change = (e.props.input ?? {}) as Update
171 const parts = [
172 change.add_todos?.length && `+${change.add_todos.length} todo`,
173 change.start_todo && `started ${change.start_todo}`,
174 change.done_todos?.length && `${change.done_todos.length} done`,
175 change.remove_todos?.length && `-${change.remove_todos.length} todo`,
176 change.open_decisions?.length && `+${change.open_decisions.length} decision`,
177 change.decide?.length && `${change.decide.length} decided`,
178 ].filter(Boolean)
179 return <Text dimColor>{'Pinboard: ' + (parts.join(', ') || 'no change')}</Text>
180 })
181
182 on('ui.render', { component: 'ToolResult', props: { tool: TOOL } }, async ($, e, next) =>
183 e.props.isErrored ? next(e) : $.ui.resolve(e).Text({ children: [''] }),
184 )
185
186 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
187 const { Box, Text, Button, Link } = $.ui.resolve(e)
188 // One cell of padding on every side
189 const inner = Math.max(10, e.props.bodyColumns - 2)
190 const allDecisions = await read($, decisions)
191 const allTodos = await read($, todos)
192 const allLinks = await read($, links)
193 const doneCount = allTodos.filter(t => t.isDone).length
194
195 const header = (title: string, count: string) => (
196 <Text bold>
197 {title} <Text dimColor>{count}</Text>
198 </Text>
199 )
200 const empty = (text: string) => <Text dimColor> {text}</Text>
201 // The bullet stays in its own column, so wrapped lines indent under the text
202 const item = (bullet: string, text: string, isDim = false, color?: string) => (
203 <Box flexDirection="row" width={inner}>
204 <Text dimColor={isDim} color={color}>{' ' + bullet + ' '}</Text>
205 <Box flexShrink={1} flexGrow={1}>
206 <Text dimColor={isDim} color={color} wrap="wrap">
207 {text}
208 </Text>
209 </Box>
210 </Box>
211 )
212
213 return (
214 <Box flexDirection="column" width={inner + 2} padding={1}>
215 {header('Open decisions', allDecisions.length ? String(allDecisions.length) : '')}
216 {allDecisions.length === 0 && empty('No open decisions.')}
217 {allDecisions.map(d => item('?', d.text))}
218 <Text> </Text>
219
220 {header('Todos', allTodos.length ? `${doneCount}/${allTodos.length}` : '')}
221 {allTodos.length === 0 && empty('No todos yet.')}
222 {allTodos.filter(t => !t.isDone).map(t => (t.isActive ? item('▸', t.text, false, 'warning') : item('○', t.text)))}
223 {/* Finished todos fold into one line so open work stays on top */}
224 {doneCount > 0 && <Text dimColor>{` ✓ ${doneCount} done`}</Text>}
225 <Text> </Text>
226
227 <Box flexDirection="row" justifyContent="space-between" width={inner}>
228 {header('Links', allLinks.length ? String(allLinks.length) : '')}
229 <Button key="clear-links" label="clear" hotkey="l" plain dimColor onPress={() => update($, links, () => [])} />
230 </Box>
231 {allLinks.length === 0 && empty('Nothing created yet.')}
232 {allLinks.map(p => (
233 <Text wrap="truncate-middle">
234 {' '}
235 <Link href={p.href} label={p.label} />
236 </Text>
237 ))}
238 </Box>
239 )
240 })
241}
242types/index.d.ts 16 lines1export type Todo = { id: string; text: string; isDone: boolean; isActive?: boolean }
2
3export type Decision = { id: string; text: string }
4
5export type Pin = { href: string; label: string }
6
7declare module 'claude-code' {
8 interface PluginState {
9 pinboard: {
10 decisions: Decision[]
11 todos: Todo[]
12 links: Pin[]
13 }
14 }
15}
16