The agent's working model of the user, shown above the prompt, and a nudge to rewrite it before a string of writes.

A Claude Code mod that makes Claude write down what it thinks you want, and shows that note above your prompt.
When an agent misreads a request, you usually find out after it has done the work. This mod moves that moment to the start. Claude keeps a short note with two fields: what you want, in your own words, and what you know and what you are missing. The note sits above the prompt box, so a wrong reading is on screen before the first file changes. You correct it the ordinary way, by telling Claude.
Nothing in the mod blocks a tool call.
Claude writes the note with one tool:
mcp__mental-model__note(want, in_their_head)
Claude rewrites it when its understanding changes, not on every message. The tool result tells Claude which field changed and what it said before, so the model sees its own drift. The last twelve versions are kept as history.
The note and its last change also ride beside each of your messages, so Claude reads them where it reads your request. They never go into the system prompt. A system prompt that changes in the middle of a session misses the prompt cache for the whole conversation after it, and the note changes often.
× Remove deletes the current note. The versions before it stay, and Claude's next note follows the last one.
The note and its history are saved on disk under the session's id. When you restart Claude Code or resume the session, the note comes back. A new session, or a /clear, starts with no note. The mod keeps the 50 most recent sessions.
The mod adds a line for the model at four moments. The note line shows Claude its current note. The other three ask Claude to write or update the note. None of them blocks anything, and subagents never get them.
| Attached to | Fires when | Starts with | |
|---|---|---|---|
| Note | your message | a note exists | Your working model of the user (mental-model, note vN):, then the note and its last change |
| Reminder | your message | no note exists | nothing on your first message, IMPORTANT: from your second message on |
| Update reminder | your message | you pressed U (Update on next prompt), until Claude rewrites the note | nothing |
| Nudge | the result of Claude's first write after your message | Claude writes something before it rewrites the note | THIS IS VERY IMPORTANT: when no note exists, nothing when a note exists |
Both reminders carry the same text:
Write down/update your understanding of user's intent, knowledge and gaps using the `mcp__mental-model__note` tool. This will help you tailor your effort to user's needs, and help the user to catch drift early on.
The nudge tells Claude its note is older than your last message, or that it has no note yet. The write it rides on has already run. A write is any tool call that Claude Code did not find read-only by the tool's own check. Creating, editing and deleting files all count, and reading or searching never does. Only the first write after each message carries the nudge, and a note written before that write means no nudge at all.
The band is the panel above the prompt.
− Claude's model of you v3 · changed: intent · 2 min ago ≡ U × ∅
Want merge the branch and prepare the repo for release
────────────────────────────────────────────────────────────
Knows knows the tests pass, has not picked a license yet
The header row shows the title, the note's version, which field changed last and how long ago. A thin line separates the Want row from the Knows row. The − Hide button sits at the top left. The other buttons sit on the right. Each button shows its name under the pointer:
| Button | Key | Does |
|---|---|---|
| − Hide | h | folds the band into one line with a + Show button (m) |
| ≡ History | s | opens the older versions under the note, newest first |
| U Update on next prompt | u | turns on the Update reminder, and shows ✓ until Claude rewrites the note |
| × Remove | d | deletes the current note; the versions before it stay |
| ∅ Remove all | a | asks first: a row under the header names what goes, with Remove all (y) and Keep (n). Remove all deletes the note and its history in this session, with no undo |
The keys work while the band holds the keyboard: after ctrl+x tab, or after a click on the band. Typing in the prompt never presses them. An armed Remove all disarms when you press Keep, ∅ again or any other band button, when you send a message, or when Claude writes a new note.
Type this at the prompt of a Claude Code terminal session:
/plugin install mental-model --marketplace esinecan/mental-model-mod
Answer y to add the marketplace, then pick a scope. The user scope loads the mod in every session you start after, including sessions the desktop app runs on the same machine.
To run a local checkout instead, start Claude Code with claude --plugin-dir <folder>, or add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. A running session keeps the version it loaded, and a change takes effect in the next session.
claude plugin validate . checks the manifest and lists the hooks. claude plugin test . runs hooks/register.test.ts. The tests cover:
IMPORTANT: prefix, and Update on next promptclaude plugin test is in Claude Code 2.1.295. Claude Code 2.1.258 does not have it. Run claude --version to see which one you have.
tsc -p . type-checks the mod and the tests. Its tsconfig.json extends .claude-plugin/types/tsconfig.json, which Claude Code writes when it loads the mod from this folder.
The code is one hooks module, hooks/register.tsx, with its state types in types/index.d.ts.
MIT. See LICENSE.
hooks/register.tsx 310 lines1import { atom, derive, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { Change, Note, Origin } from '../types'
5
6const PLUGIN = 'mental-model'
7const NOTE_TOOL = `mcp__${PLUGIN}__note`
8const HISTORY_CAP = 12
9const SESSION_CAP = 50
10
11const note = atom({ plugin: 'mental-model', key: 'note' } as const, null)
12const history = atom({ plugin: 'mental-model', key: 'history' } as const, [])
13const settled = atom({ plugin: 'mental-model', key: 'settled' } as const, false)
14const isHidden = atom({ plugin: 'mental-model', key: 'isHidden' } as const, false)
15const showHistory = atom({ plugin: 'mental-model', key: 'showHistory' } as const, false)
16const remind = atom({ plugin: 'mental-model', key: 'remind' } as const, false)
17const isRemoveAllArmed = atom({ plugin: 'mental-model', key: 'isRemoveAllArmed' } as const, false)
18const prompts = atom({ plugin: 'mental-model', key: 'prompts' } as const, 0)
19const loadedFor = atom({ plugin: 'mental-model', key: 'loadedFor' } as const, null)
20
21const origin = derive([note, history], (current, past) => (current ? originOf(past, current) : null))
22
23export const REMINDER =
24 `Write down/update your understanding of user's intent, knowledge and gaps using the \`${NOTE_TOOL}\` tool. ` +
25 "This will help you tailor your effort to user's needs, and help the user to catch drift early on."
26
27export const IMPORTANT = 'IMPORTANT: '
28
29export const NUDGE =
30 `${PLUGIN}: your note of the user predates the user's last message. ` +
31 `If what the user wants, knows or is missing has changed, rewrite it with ${NOTE_TOOL} before continuing this string of writes.`
32
33export const NUDGE_EMPTY =
34 `THIS IS VERY IMPORTANT: ${PLUGIN}: you have not written a note of the user yet. ` +
35 `Write it with ${NOTE_TOOL} before continuing this string of writes.`
36
37const entryKey = (id: string) => `session/${id}`
38
39async function save($: EngineInterface) {
40 const id = await $.session.id()
41 const [current, past] = await Promise.all([read($, note), read($, history)])
42 await $.store.set(entryKey(id), { note: current, history: past })
43 const order = ((await $.store.get('order')) as string[] | undefined) ?? []
44 const kept = [...order.filter(k => k !== id), id]
45 for (const old of kept.slice(0, Math.max(0, kept.length - SESSION_CAP))) await $.store.delete(entryKey(old))
46 await $.store.set('order', kept.slice(-SESSION_CAP))
47}
48
49async function load($: EngineInterface) {
50 const id = await $.session.id()
51 if ((await read($, loadedFor)) === id) return
52 const saved = (await $.store.get(entryKey(id))) as { note: Note | null; history: Note[] } | undefined
53 await Promise.all([
54 update($, loadedFor, () => id),
55 update($, note, () => saved?.note ?? null),
56 update($, history, () => saved?.history ?? []),
57 update($, prompts, () => 0),
58 update($, settled, () => false),
59 update($, remind, () => false),
60 update($, isRemoveAllArmed, () => false),
61 ])
62}
63
64const nextVersion = (current: Note | null, past: Note[]) =>
65 Math.max(current?.version ?? 0, ...past.map(n => n.version)) + 1
66
67export function diffNotes(previous: Note | null, next: Pick<Note, 'want' | 'inTheirHead'>): Change[] {
68 if (!previous) return []
69 const changes: Change[] = []
70 if (previous.want !== next.want) changes.push({ field: 'want', was: previous.want, now: next.want })
71 if (previous.inTheirHead !== next.inTheirHead) changes.push({ field: 'inTheirHead', was: previous.inTheirHead, now: next.inTheirHead })
72 return changes
73}
74
75const label = (field: Change['field']) => (field === 'want' ? 'intent' : 'knowledge')
76
77export function describeChanges(changes: Change[]): string {
78 if (!changes.length) return 'unchanged'
79 return changes.map(c => `${label(c.field)}: was «${c.was}» now «${c.now}»`).join('; ')
80}
81
82export function originOf(past: Note[], current: Note | null = null): Origin {
83 const previous = past[past.length - 1]
84 if (!previous) return { kind: 'first' }
85 return { kind: 'changed', previous, changes: current ? diffNotes(previous, current) : [] }
86}
87
88function originForModel(o: Origin): string {
89 if (o.kind === 'changed') return `Changed since v${o.previous.version}: ${describeChanges(o.changes)}.`
90 return 'First note.'
91}
92
93function originForBand(o: Origin): string {
94 if (o.kind === 'changed') return ` · changed: ${o.changes.map(c => label(c.field)).join(', ')}`
95 return ''
96}
97
98function noteForModel(current: Note, o: Origin): string {
99 const lines = [
100 `Your working model of the user (${PLUGIN}, note v${current.version}):`,
101 `Intent: ${current.want}`,
102 `Knowledge and gaps: ${current.inTheirHead}`,
103 ]
104 if (o.kind === 'changed') lines.push(originForModel(o))
105 return lines.join('\n')
106}
107
108function ago(now: number, at: number): string {
109 const s = Math.max(0, Math.round((now - at) / 1000))
110 if (s < 60) return `${s}s ago`
111 const m = Math.round(s / 60)
112 if (m < 60) return `${m} min ago`
113 return `${Math.round(m / 60)} h ago`
114}
115
116const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
117
118export const register: Register = on => {
119 on('session.start', async ($, e, next) => {
120 const result = await next(e)
121 await load($)
122 await $.tool.register({
123 name: 'note',
124 description:
125 'Your working model of the user, shown to the user above the prompt. ' +
126 'Rewrite it when your understanding of the user changes, before you act on the new understanding.',
127 inputSchema: {
128 type: 'object',
129 properties: {
130 want: { type: 'string', description: "The outcome the user wants, in the user's own words." },
131 in_their_head: {
132 type: 'string',
133 description: 'What the user knows and believes about the current state, and what the user is missing.',
134 },
135 },
136 required: ['want', 'in_their_head'],
137 },
138 })
139 return result
140 })
141
142 on('tool.call', { tool: NOTE_TOOL }, async ($, e) => {
143 const args = e as unknown as Record<string, unknown>
144 const want = String(args.want ?? '').trim()
145 const inTheirHead = String(args.in_their_head ?? '').trim()
146 if (!want || !inTheirHead) return { deny: `${NOTE_TOOL}: want and in_their_head are both required.` }
147 const [previous, past] = await Promise.all([read($, note), read($, history)])
148 await Promise.all([
149 update($, settled, () => true),
150 update($, remind, () => false),
151 update($, isRemoveAllArmed, () => false),
152 ])
153 if (previous && !diffNotes(previous, { want, inTheirHead }).length) {
154 return { result: `Note unchanged (v${previous.version}).` }
155 }
156 const entry: Note = { want, inTheirHead, version: nextVersion(previous, past), at: await $.clock.now() }
157 if (previous) await update($, history, list => [...list, previous].slice(-HISTORY_CAP))
158 await update($, note, () => entry)
159 await save($)
160 return { result: `Note v${entry.version} written. ${originForModel(originOf(await read($, history), entry))}` }
161 })
162
163 on('tool.call', async ($, e, next) => {
164 const inMainLoop = (e as { agentId?: string }).agentId === undefined
165 if (!inMainLoop || e.tool === NOTE_TOOL || (await read($, settled))) return next(e)
166 const answer = await next(e)
167 if (answer.deny !== undefined || answer.isError || answer.isReadOnly) return answer
168 await update($, settled, () => true)
169 const text = (await read($, note)) ? NUDGE : NUDGE_EMPTY
170 return { ...answer, context: [...(answer.context ?? []), text] }
171 })
172
173 on('prompt.submit', async ($, e, next) => {
174 const fromUser = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
175 if (!fromUser) return next(e)
176 await load($)
177 await Promise.all([
178 update($, settled, () => false),
179 update($, prompts, n => n + 1),
180 update($, isRemoveAllArmed, () => false),
181 ])
182 const [current, o, reminded, count] = await Promise.all([read($, note), read($, origin), read($, remind), read($, prompts)])
183 const lines: string[] = []
184 if (current && o) lines.push(noteForModel(current, o))
185 if (!current || reminded) lines.push(!current && count >= 2 ? IMPORTANT + REMINDER : REMINDER)
186 return next({ ...e, context: [...(e.context ?? []), ...lines] })
187 })
188
189 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
190 if (e.props.hasSurvey) return next(e)
191 const { Box, Button, Text } = $.ui.resolve(e)
192 const [current, past, o, hidden, open, reminded, armed, now] = await Promise.all([
193 read($, note), read($, history), read($, origin), read($, isHidden),
194 read($, showHistory), read($, remind), read($, isRemoveAllArmed), $.clock.now(),
195 ])
196 const stamp = current && o ? `v${current.version}${originForBand(o)}` : ''
197
198 const disarm = () => update($, isRemoveAllArmed, () => false)
199 const andDisarm = (act: () => unknown) => async () => {
200 await disarm()
201 await act()
202 }
203
204 if (hidden) {
205 return (
206 <Box>
207 <Text dimColor>{`Model hidden${stamp ? ` · ${stamp}` : ''} `}</Text>
208 <Button key="show" label="+ Show" hotkey="m" plain onPress={() => update($, isHidden, () => false)} />
209 </Box>
210 )
211 }
212
213 const LABEL = 12
214 const row = (key: string, head: RenderElement, body: RenderElement) => (
215 <Box key={key}>
216 <Box width={LABEL} flexShrink={0}>{head}</Box>
217 <Box flexGrow={1}>{body}</Box>
218 </Box>
219 )
220
221 const glyph = (key: string, hotkey: string, icon: string, word: string, onPress: () => void, primary = false) => (
222 <Box key={`${key}-hover`} marginLeft={key === 'hide' ? 0 : 1} marginRight={key === 'hide' ? 2 : 0}>
223 <Button key={key} label={icon} hotkey={hotkey} plain variant={primary ? 'primary' : undefined} onPress={onPress} />
224 <Box position="absolute" top={1} left={0} display="none" hover={{ display: 'flex' }}>
225 <Text backgroundColor="subtle" color="inverseText">{` ${word} `}</Text>
226 </Box>
227 </Box>
228 )
229
230 const removeNote = async () => {
231 await update($, note, () => null)
232 await save($)
233 }
234 const removeAll = async () => {
235 await Promise.all([
236 update($, note, () => null),
237 update($, history, () => []),
238 update($, showHistory, () => false),
239 disarm(),
240 ])
241 await save($)
242 }
243
244 const header = (
245 <Box justifyContent="space-between">
246 <Box>
247 {glyph('hide', 'h', '−', 'Hide', andDisarm(() => update($, isHidden, () => true)))}
248 <Text bold>Claude's model of you</Text>
249 {current ? <Text dimColor>{` ${stamp} · ${ago(now, current.at)}`}</Text> : null}
250 </Box>
251 <Box>
252 {past.length
253 ? glyph('history', 's', '≡', open ? 'Hide history' : `History (${past.length})`, andDisarm(() => update($, showHistory, v => !v)), open)
254 : null}
255 {glyph('remind', 'u', reminded ? '✓' : 'U', reminded ? 'Updates on next prompt' : 'Update on next prompt', andDisarm(() => update($, remind, () => true)), reminded)}
256 {current ? glyph('remove', 'd', '×', 'Remove', andDisarm(removeNote)) : null}
257 {current || past.length
258 ? glyph('removeAll', 'a', '∅', armed ? 'Cancel Remove all' : 'Remove all, history too', () => update($, isRemoveAllArmed, v => !v), armed)
259 : null}
260 </Box>
261 </Box>
262 )
263
264 const older = plural(past.length, 'older version')
265 const question = current ? (past.length ? `Remove the note and ${older}?` : 'Remove the note?') : `Remove all ${older}?`
266 const confirm = armed && (current || past.length) ? (
267 <Box key="confirm">
268 <Text>{`${question} There is no undo. `}</Text>
269 <Button key="confirmRemoveAll" label="Remove all" hotkey="y" variant="primary" onPress={removeAll} />
270 <Box marginLeft={1}>
271 <Button key="cancelRemoveAll" label="Keep" hotkey="n" onPress={disarm} />
272 </Box>
273 </Box>
274 ) : null
275
276 if (!current) {
277 return (
278 <Box flexDirection="column">
279 {header}
280 {confirm}
281 <Text dimColor wrap="wrap">Claude has not written down what you want yet.</Text>
282 </Box>
283 )
284 }
285
286 return (
287 <Box flexDirection="column">
288 {header}
289 {confirm}
290 {row('want', <Text bold>Want</Text>, <Text wrap="wrap">{current.want}</Text>)}
291 <Text key="separator" dimColor wrap="truncate">{'─'.repeat(Math.max(8, e.props.bodyColumns ?? 40))}</Text>
292 {row('knows', <Text bold>Knows</Text>, <Text wrap="wrap">{current.inTheirHead}</Text>)}
293 {open ? <Text dimColor>History, newest first</Text> : null}
294 {open
295 ? past
296 .slice()
297 .reverse()
298 .map(n =>
299 row(
300 `v${n.version}`,
301 <Text dimColor>{`v${n.version} · ${ago(now, n.at)}`}</Text>,
302 <Text dimColor wrap="wrap">{`${n.want} — ${n.inTheirHead}`}</Text>,
303 ),
304 )
305 : null}
306 </Box>
307 )
308 })
309}
310types/index.d.ts 34 lines1
2export type Note = {
3 want: string
4 inTheirHead: string
5 version: number
6 at: number
7}
8
9export type Change = {
10 field: 'want' | 'inTheirHead'
11 was: string
12 now: string
13}
14
15export type Origin =
16 | { kind: 'first' }
17 | { kind: 'changed'; previous: Note; changes: Change[] }
18
19declare module 'claude-code' {
20 interface PluginState {
21 'mental-model': {
22 note: Note | null
23 history: Note[]
24 settled: boolean
25 isHidden: boolean
26 showHistory: boolean
27 remind: boolean
28 isRemoveAllArmed: boolean
29 prompts: number
30 loadedFor: string | null
31 }
32 }
33}
34