Sticky notes Claude pins while it works, like when it needs you: MCP tools, a sticky CLI, a skill, and a pane beside the chat

Sticky notes Claude pins while it works, so nothing gets lost in a long chat. The main one: when Claude needs you (a choice only you can make, a login, an approval, a manual step), it pins a red NEEDS YOU note and your terminal sends a notification. It also pins decisions, todos for later, and rules to keep.
Notes show in a pane beside the chat in Claude Code. They come back into Claude's context at every session start, /clear, resume and /compact. They are plain JSON files, one per note, in ~/.sticky-notes/<project>/, so a sticky CLI can read and change them from any terminal.
| Part | What it does | | :- | :- | | MCP server notes | Tools note_add, note_list, note_update, note_delete. Works in any MCP app. | | sticky CLI | sticky add "text" --kind human, sticky ls, sticky done <id>, sticky undo <id>, sticky edit <id> "text", sticky rm <id> | | Skill note | Tells Claude when to pin (your CLAUDE.md can change the rule). Also runs as /sticky-notes:note <text>. | | SessionStart hook | Hands Claude the open notes (up to 25) when a session starts, clears, resumes or compacts. | | Mod (Claude Code only) | The pane, /notes to open it, the notification for human notes, and the live session id on note tool calls. |
Project notes show in every session in the repo (the git root, so starting Claude in src/ is the same board). Chat notes show only in the conversation that pinned them. Claude picks for each note.
Needs Node 22.18 or later. Tested with Claude Code 2.1.309. Mods are new and can change between Claude Code releases. If the mod stops loading, everything except the pane keeps working.
Install from GitHub, in one line at the Claude Code prompt:
/plugin install sticky-notes --marketplace rammoozz/agentic-sticky-notes
Or load a local copy for one session:
claude --plugin-dir /path/to/agentic-sticky-notes
By default Claude pins when it needs you, when you decide something, and when work is pushed to later. For your own rule, add a line to your CLAUDE.md, for example: Pin sticky notes only when you need me.
To use sticky in your own terminal, run npm link in this repo. Inside Claude Code, the plugin puts sticky on the Bash tool's PATH for you.
Point them at the same server. Notes go to the same files:
{ "mcpServers": { "sticky-notes": { "command": "node", "args": ["/path/to/agentic-sticky-notes/dist/sticky.js", "mcp"] } } }
They don't get the pane. Chat notes there use the session id the server started with.
npm install
npm run check # tsc (src + mod), node tests, build dist/sticky.js
claude plugin validate .
claude plugin test # mod tests in hooks/*.test.tsx
dist/sticky.js is committed so installs need no npm install. Run npm run build before you commit.
The landing page in docs/ is the Firebase Hosting site agentic-sticky (https://agentic-sticky.web.app). Deploy it with firebase deploy --only hosting. Bump version in .claude-plugin/plugin.json when the plugin changes, or installed copies won't update. STICKY_NOTES_HOME moves the notes folder, which is handy for tests.
hooks/pane.tsx 230 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, McpToolResult, Register } from 'claude-code'
3import { isVisible, type Kind, type Note } from '../src/types.ts'
4
5// The mod holds no note logic: it reads and writes through the plugin's own
6// MCP server, so the store and its zod checks stay the only path to the files.
7
8const PANE = 'sticky-notes'
9const NOTE_TOOL = /^mcp__plugin_sticky-notes_notes__/
10const ADD_TOOL = 'mcp__plugin_sticky-notes_notes__note_add'
11
12// Bump the shape tag when Note changes: it declines what an older version stored.
13const notes = atom({ plugin: 'sticky-notes', key: 'notes' } as const, [], { shape: 'note-v2' })
14const showDone = atom({ plugin: 'sticky-notes', key: 'showDone' } as const, false)
15// True once the person closes the pane: only a note that needs them opens it again.
16const hidden = atom({ plugin: 'sticky-notes', key: 'hidden' } as const, false)
17
18const LABEL: Record<Kind, string> = {
19 human: 'NEEDS YOU',
20 todo: 'TODO',
21 decision: 'DECISION',
22 remember: 'REMEMBER',
23}
24const COLOR: Record<Kind, string> = { human: 'red', todo: 'yellow', decision: 'green', remember: 'cyan' }
25
26/** Calls one of the `notes` server's tools, stamped with this session's live id. */
27async function callNotes($: EngineInterface, tool: string, args: Record<string, unknown>) {
28 try {
29 const conn = await $.mcp.connect('notes')
30 if (!conn.isConnected) return undefined
31 const res = await $.mcp.call(conn.server, tool, { ...args, session: await $.session.id() })
32 return res.isError ? undefined : res
33 } catch {
34 return undefined // server not up yet, or its tools are turned off: the next poll tries again
35 }
36}
37
38/**
39 * The notes in a note_list result. Claude Code hands a tool's structured result
40 * back as JSON text in `content` (seen on 2.1.309), so read either shape.
41 */
42function notesOf(res: McpToolResult | undefined): Note[] | undefined {
43 if (!res) return undefined
44 let data = res.structuredContent
45 const first = res.content[0]
46 if (data === undefined && first?.text !== undefined) {
47 try {
48 data = JSON.parse(first.text)
49 } catch {
50 return undefined
51 }
52 }
53 const list = (data as { notes?: unknown } | null | undefined)?.notes
54 return Array.isArray(list) ? (list as Note[]) : undefined
55}
56
57async function refresh($: EngineInterface): Promise<Note[]> {
58 const list = notesOf(await callNotes($, 'note_list', { include_done: true }))
59 if (list) await update($, notes, () => list)
60 return list ?? []
61}
62
63async function setDone($: EngineInterface, id: string, done: boolean) {
64 await callNotes($, 'note_update', { id, done })
65 await refresh($)
66}
67
68async function remove($: EngineInterface, id: string) {
69 await callNotes($, 'note_delete', { id })
70 await refresh($)
71}
72
73/** A quick reply answers Claude as the user's own message, and checks the note off. */
74async function answer($: EngineInterface, note: Note, reply: string) {
75 // Not awaited: submit waits until Claude is idle, and the press shouldn't hang on that.
76 void $.prompt.submit({ text: 'Re sticky note ' + note.id + ' (' + note.text + '): ' + reply, asUser: true })
77 await setDone($, note.id, true)
78}
79
80async function openPane($: EngineInterface, focus: boolean) {
81 await update($, hidden, () => false)
82 await $.ui.open({ id: PANE, title: 'Sticky notes', closeOnEscape: true, ...(focus ? { focus: true } : {}) })
83}
84
85async function closePane($: EngineInterface) {
86 await update($, hidden, () => true)
87 await $.ui.close({ id: PANE })
88}
89
90async function toggleDone($: EngineInterface) {
91 await update($, showDone, (shown) => !shown)
92}
93
94export const register: Register = (on) => {
95 on('session.start', async ($, e, next) => {
96 const list = await refresh($)
97 // Picks up notes changed by the CLI or another session: mods can't watch files.
98 $.clock.every(3000, () => void refresh($))
99 const session = await $.session.id()
100 if (list.some((n) => !n.done && isVisible(n, session)) && !(await read($, hidden))) {
101 void openPane($, false)
102 }
103 await $.command.register({ name: 'notes', description: 'Show or hide the sticky notes pane' })
104 return next(e)
105 })
106
107 // /clear, /resume and /branch start over $.state, and session.start does not fire again.
108 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
109 await refresh($)
110 return next(e)
111 }).catch(($, e, next) => next(e)) // a failed refresh must not hold up the session
112
113 // /notes toggles the pane.
114 on('command.run', { command: 'notes' }, async ($) => {
115 if ((await $.ui.panes()).some((p) => p.id === PANE)) {
116 await closePane($)
117 } else {
118 await refresh($)
119 await openPane($, true)
120 }
121 return {}
122 })
123
124 // Closed with its close mark or Esc: remember it, so plain notes don't pop it back up.
125 on('ui.close', { id: PANE }, async ($, e, next) => {
126 if (e.origin.kind === 'person') await update($, hidden, () => true)
127 return next(e)
128 }).catch(($, e, next) => next(e)) // never keep the pane from closing
129
130 // The MCP server keeps the session id it was started with, which goes stale
131 // after /clear. Stamp the live one onto every note tool call.
132 on('tool.call', { tool: NOTE_TOOL }, async ($, e, next) => {
133 // The matcher only lets note tools through, which take a session argument.
134 const result = await next({ ...e, session: await $.session.id() } as typeof e)
135 await refresh($)
136 if (e.tool === ADD_TOOL && !result.deny && !result.isError) {
137 const needsYou = e.kind === 'human'
138 if (needsYou || !(await read($, hidden))) void openPane($, false)
139 if (needsYou) void $.ui.notify('Needs you: ' + String(e.text), { title: 'Sticky note' })
140 }
141 return result
142 }).catch(($, e, next) => next(e)) // fail open: a broken stamp or redraw never blocks a note
143
144 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
145 const { Box, Text, Button } = $.ui.resolve(e)
146 const session = await $.session.id()
147 const all = (await read($, notes)).filter((n) => isVisible(n, session))
148 const shown = await read($, showDone)
149 const open = all.filter((n) => !n.done)
150 const done = all.filter((n) => n.done)
151 const needYou = open.filter((n) => n.kind === 'human').length
152
153 // The quote border already leaves a blank row above and below each card.
154 const card = (n: Note) => (
155 <Box key={n.id} flexDirection="column" borderStyle="quote" borderColor={n.done ? 'gray' : COLOR[n.kind]}>
156 <Box flexDirection="row" columnGap={2}>
157 <Text dimColor>
158 {LABEL[n.kind]} · {n.id}
159 </Text>
160 <Box flexGrow={1} />
161 <Button
162 key={'done-' + n.id}
163 label={n.done ? 'undo' : 'done'}
164 plain
165 onPress={() => setDone($, n.id, !n.done)}
166 />
167 <Button key={'rm-' + n.id} label="delete" plain dimColor onPress={() => remove($, n.id)} />
168 </Box>
169 <Text dimColor={n.done} strikethrough={n.done}>
170 {n.text}
171 </Text>
172 {!n.done && n.actions.length > 0 && (
173 <Box flexDirection="row" columnGap={2}>
174 {n.actions.map((reply, i) => (
175 // The first reply is the one Claude recommends, so it is the one that stands out.
176 <Button key={'reply-' + n.id + '-' + i} label={reply} plain={i > 0 || undefined} onPress={() => answer($, n, reply)} />
177 ))}
178 </Box>
179 )}
180 </Box>
181 )
182
183 const section = (title: string, list: Note[]) =>
184 list.length === 0 ? null : (
185 <Box key={title} flexDirection="column">
186 <Text dimColor>{title}</Text>
187 {list.map(card)}
188 </Box>
189 )
190
191 const closeButton = <Button key="close" label="close" plain dimColor onPress={() => closePane($)} />
192
193 if (all.length === 0) {
194 return (
195 <Box flexDirection="column" rowGap={1}>
196 <Box flexDirection="row">
197 <Text>No notes for this project yet.</Text>
198 <Box flexGrow={1} />
199 {closeButton}
200 </Box>
201 <Text dimColor>Claude pins what needs you, decisions and todos here as you talk.</Text>
202 <Text dimColor>From a terminal: sticky add "text"</Text>
203 </Box>
204 )
205 }
206
207 return (
208 <Box flexDirection="column">
209 <Box flexDirection="row">
210 <Text weight="semibold">
211 {open.length} open{needYou > 0 ? ' · ' + needYou + ' need you' : ''}
212 </Text>
213 <Box flexGrow={1} />
214 {closeButton}
215 </Box>
216 <Text> </Text>
217 {section('THIS CHAT', open.filter((n) => n.scope === 'chat'))}
218 {section('PROJECT', open.filter((n) => n.scope === 'project'))}
219 {done.length > 0 && (
220 <Box flexDirection="row" columnGap={2}>
221 <Text dimColor>{done.length} done</Text>
222 <Button key="toggle-done" label={shown ? 'hide' : 'show'} plain onPress={() => toggleDone($)} />
223 </Box>
224 )}
225 {shown && done.length > 0 && section('DONE', done)}
226 </Box>
227 )
228 })
229}
230src/types.ts 23 lines1// No runtime imports on purpose: the mod in hooks/ imports this file, and a mod
2// may only import local files and 'claude-code'.
3import type { Kind, Note, Scope } from '../types/index.d.ts'
4
5export type { Kind, Note, Scope }
6
7/** In the order the pane and `sticky context` list them: what needs you first. */
8export const KINDS = ['human', 'todo', 'decision', 'remember'] as const satisfies readonly Kind[]
9export const SCOPES = ['project', 'chat'] as const satisfies readonly Scope[]
10
11/** "n_" + 5 base36 chars. Ids become file names, so nothing else is accepted. */
12export const NOTE_ID = /^n_[a-z0-9]{5}$/
13
14// Stops compiling if types/index.d.ts gains a kind or scope these lists lack.
15const allKinds: [Exclude<Kind, (typeof KINDS)[number]>] extends [never] ? true : false = true
16const allScopes: [Exclude<Scope, (typeof SCOPES)[number]>] extends [never] ? true : false = true
17void allKinds
18void allScopes
19
20/** Project notes show everywhere in the project; chat notes only in their own session. */
21export const isVisible = (note: Note, session: string | null): boolean =>
22 note.scope === 'project' || note.session === session
23types/index.d.ts 28 lines1// The one definition of a note. Self-contained on purpose: the engine reads
2// this contract for the mod's $.state and refuses imports in it. src/types.ts
3// re-exports these for the CLI, the MCP server and the store.
4
5export type Kind = 'human' | 'todo' | 'decision' | 'remember'
6export type Scope = 'project' | 'chat'
7
8export interface Note {
9 v: 1
10 id: string // "n_" + 5 base36 chars, e.g. "n_k3f9x"
11 text: string // one idea, 1-280 chars
12 kind: Kind
13 scope: Scope
14 session: string | null // Claude Code session that pinned it
15 project: string // git root (or folder) it belongs to
16 done: boolean
17 /** Quick replies for a human note, the recommended one first. Pressing one answers Claude. */
18 actions: string[]
19 createdAt: string // ISO 8601
20 updatedAt: string
21}
22
23declare module 'claude-code' {
24 interface PluginState {
25 'sticky-notes': { notes: Shaped<Note[]>; showDone: boolean; hidden: boolean }
26 }
27}
28