SLOPSHOPPER

Sticky Notes

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

newpaneguardcommandtimer
v0.2.2MITupdated 2026-10-23rammoozz/agentic-sticky-notes
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sticky-notes
│ ┃ Sticky notes ✕ › fix the failing auth test and add an audit log call │ ┃ No notes for this project yet.close │ ┃ ⏺ Read(src/auth.ts) │ ┃ Claude pins what needs you, decisions and ⎿ Read 6 lines │ ┃ todos here as you talk. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ From a terminal: sticky add "text" ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /notes │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sticky notes
No notes for this project yet.close Claude pins what needs you, decisions and todos here as you talk. From a terminal: sticky add "text"
README

Sticky Notes

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.

What's in it

| 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.

Install

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.

Other MCP apps

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.

Develop

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.

Source 3 files
hooks/pane.tsx 230 lines
1import { 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}
230
src/types.ts 23 lines
1// 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
23
types/index.d.ts 28 lines
1// 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