/keystroke: compose replies through type/backspace/send tool calls, a few characters per call; the reply still lands as a normal assistant message

A Claude Code mod (function hooks plugin) for one experiment: does Claude write differently when each reply is composed a few characters at a time?
With the mode on, the model does not write its reply as text. It calls three tools:
| tool | what it does |
|---|---|
mcp__keystroke__type | appends text to the draft, at most N characters per call (N = 1 by default), one call per response |
mcp__keystroke__backspace | deletes the last count characters |
mcp__keystroke__send | delivers the draft as the reply and ends the turn |
To the person the session looks almost unchanged: the keystroke tool rows are hidden, the draft is drawn live in the band above the prompt with a cursor, and the sent reply arrives as an ordinary assistant message. Under the hood that message was never produced by a model call: after send, the mod answers the next model step itself, yielding the draft as the step's text with end_turn, so the transcript holds a normal assistant text row and the next request carries a valid tool_use → tool_result → assistant text sequence.
/keystroke on # 1 character per type() call
/keystroke 5 # 5 characters per call
/keystroke off
/keystroke status
The setting is kept per session id in the plugin store, so claude -r keeps it. The status line shows keystroke N/call while it is on.
type call longer than N characters is refused and nothing is typed.type calls, which is the message written in one go. Of several keystroke calls in one response only the first is typed; the rest are refused with a reason. After one refused batch the model goes one call per response.classic.Stop hook blocks a turn that ends with direct text or an unsent draft, at most twice per turn, so a session where the tools are unavailable still gets an answer on the third try.One hooks module, hooks/register.tsx:
session.start registers the tools (isDeferred follows the mode) and the command, and loads the mode from $.store.prompt.compose appends a keystroke:mode section to the system prompt while on.tool.call serves the three tools from $.state (keystroke.draft); tool.check allows them without a prompt.turn.step (streaming) records the response's keystroke calls for the one-per-response rule, drops narration, and, on the step after send, answers without a model call.ui.render draws an empty Box for keystroke ToolUse/ToolResult/ToolGroup rows and the draft in AbovePrompt.Every character is one model request against the session's cached prefix plus two transcript rows (about 60 tokens). Measured with Sonnet 5.5 at medium effort on 2.1.293, thinking between keystrokes: about 3 s per character (17 characters in 58 s, 41 in two minutes), so a 50-character reply takes two to three minutes and adds roughly 3k tokens to the context. That is the experiment, not a flaw, but it rules out chunk 1 for long replies; /keystroke 5 or more is the usable setting for anything beyond a sentence.
claude plugin test <this folder> runs hooks/register.test.ts (parsing, the draft view, type/backspace/send, the chunk rule, the stop hook, the synthesized step, the narration rule). The one-per-response rule depends on tool_use ids the test kit assigns itself, so it is covered by an interactive probe instead.
hooks/register.tsx 305 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, TurnStepChunk } from 'claude-code'
3
4import type { KeystrokeMode } from '../types'
5
6// Keystroke mode: the model composes its reply through three tools (type, backspace,
7// send) instead of writing text directly, at most `chunk` characters per `type` call.
8// The per-keystroke tool rows are hidden, the draft is drawn live in the band above the
9// prompt, and once `send` runs the next model step is answered by this module: the
10// draft is yielded as the step's text and the turn ends, so the transcript holds a
11// normal assistant message that no model call produced.
12
13const PLUGIN = 'keystroke'
14const COMMAND = 'keystroke'
15const TOOLS = ['type', 'backspace', 'send'] as const
16type ToolName = (typeof TOOLS)[number]
17const fullName = (t: ToolName) => `mcp__${PLUGIN}__${t}`
18const isOurs = (tool: unknown) => typeof tool === 'string' && tool.startsWith(`mcp__${PLUGIN}__`)
19
20const OFF: KeystrokeMode = { on: false, chunk: 1 }
21const MAX_BLOCKS_PER_TURN = 2
22const VIEW_CHARS = 60
23
24const mode = atom({ plugin: 'keystroke', key: 'mode' } as const, OFF)
25const draft = atom({ plugin: 'keystroke', key: 'draft' } as const, '')
26
27// Per-turn bookkeeping. Module variables are fine here: a hot reload mid-turn loses
28// them, and the worst case is one unblocked direct-text turn.
29let pendingSend: string | null = null
30let synthesizedThisTurn = false
31let blocksThisTurn = 0
32// The keystroke calls of the latest main-loop response, in order. Only the first is
33// typed: a response holding a whole message as 50 calls is the message written in one
34// go, which is what the mode exists to prevent.
35let stepKeystrokes: string[] = []
36const ONE_PER_RESPONSE = 'one keystroke per response: this call was not typed. Make one keystroke call, read its result, then the next.'
37
38const chars = (s: string) => [...s]
39const count = (s: string) => chars(s).length
40const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`
41
42export function parseArgs(args: string): KeystrokeMode | 'status' | undefined {
43 const a = args.trim().toLowerCase()
44 if (a === '' || a === 'status') return 'status'
45 if (a === 'off') return OFF
46 if (a === 'on') return { on: true, chunk: 1 }
47 const m = /^(\d+)$/.exec(a)
48 if (!m) return undefined
49 const n = Number(m[1])
50 return n >= 1 && n <= 10000 ? { on: true, chunk: n } : undefined
51}
52
53// What `type` and `backspace` answer: the end of the current line with a cursor, the
54// glance a typist takes at the field. The full draft is already in the model's context
55// as its own calls.
56export function view(d: string): string {
57 const line = d.slice(d.lastIndexOf('\n') + 1)
58 const cs = chars(line)
59 const tail = cs.slice(-VIEW_CHARS).join('')
60 return `${cs.length > VIEW_CHARS ? '…' : ''}${tail}▍`
61}
62
63export function section(m: KeystrokeMode): string {
64 return [
65 '# Keystroke mode',
66 '',
67 'Your replies to the user are composed through three tools instead of written directly:',
68 `- \`${fullName('type')}\` appends text to your draft, at most ${plural(m.chunk, 'character')} per call.`,
69 `- \`${fullName('backspace')}\` deletes the last characters of the draft.`,
70 `- \`${fullName('send')}\` delivers the draft as your reply and ends your turn.`,
71 '',
72 'One keystroke per response: each call answers with the draft so far before you choose the next one; of several keystroke calls in one response only the first is typed. Text written outside these tools is not a reply: a turn that ends with direct text, or with an unsent draft, is handed back to you to finish through the tools. Other tools work as usual. Only the main conversation composes this way; subagents reply normally.',
73 ].join('\n')
74}
75
76const storeKey = async ($: EngineInterface) => `mode:${await $.session.id()}`
77
78async function loadMode($: EngineInterface): Promise<KeystrokeMode> {
79 const v = (await $.store.get(await storeKey($))) as Partial<KeystrokeMode> | undefined
80 return v && typeof v.on === 'boolean' && typeof v.chunk === 'number' ? { on: v.on, chunk: v.chunk } : OFF
81}
82
83async function setMode($: EngineInterface, m: KeystrokeMode): Promise<void> {
84 await $.store.set(await storeKey($), m)
85 await update($, mode, () => m)
86 $.ui.status(m.on ? `keystroke ${m.chunk}/call` : undefined)
87 // The system prompt section and the tools' deferral read the mode; both are cached.
88 $.ui.invalidate('tool.describe')
89 $.ui.invalidate('prompt.section')
90}
91
92function describe(t: ToolName, m: KeystrokeMode): string {
93 switch (t) {
94 case 'type':
95 return `Append text to your draft reply: at most ${plural(m.chunk, 'character')} per call, one call per response. Answers with the end of the current line and a cursor.`
96 case 'backspace':
97 return 'Delete the last `count` characters of your draft reply (default 1). Answers with the end of the current line and a cursor.'
98 case 'send':
99 return 'Deliver your draft as the reply to the user and end your turn.'
100 }
101}
102
103export const register: Register = on => {
104 on('session.start', async ($, e, next) => {
105 const m = await loadMode($)
106 await update($, mode, () => m)
107 await $.tool.register({
108 name: 'type',
109 description: describe('type', m),
110 inputSchema: {
111 type: 'object',
112 properties: { text: { type: 'string', description: 'The characters to append.' } },
113 required: ['text'],
114 },
115 isDeferred: !m.on,
116 })
117 await $.tool.register({
118 name: 'backspace',
119 description: describe('backspace', m),
120 inputSchema: {
121 type: 'object',
122 properties: { count: { type: 'integer', minimum: 1, description: 'How many characters to delete (default 1).' } },
123 },
124 isDeferred: !m.on,
125 })
126 await $.tool.register({
127 name: 'send',
128 description: describe('send', m),
129 inputSchema: { type: 'object', properties: {} },
130 isDeferred: !m.on,
131 })
132 await $.command.register({
133 name: COMMAND,
134 description: 'Compose replies through keystroke tools, a few characters per call',
135 argumentHint: 'on | off | <characters per call> | status',
136 })
137 $.ui.status(m.on ? `keystroke ${m.chunk}/call` : undefined)
138 return next(e)
139 })
140
141 on('command.run', { command: COMMAND }, async ($, e) => {
142 const parsed = parseArgs(e.args)
143 const usage = `/${COMMAND} on (1 character per call) | <n> (n characters per call) | off | status`
144 if (parsed === undefined) return { text: `Could not read "${e.args.trim()}". ${usage}` }
145 if (parsed === 'status') {
146 const m = await read($, mode)
147 return { text: m.on ? `keystroke is on: ${plural(m.chunk, 'character')} per type() call. ${usage}` : `keystroke is off. ${usage}` }
148 }
149 await setMode($, parsed)
150 pendingSend = null
151 await update($, draft, () => '')
152 return {
153 text: parsed.on
154 ? `keystroke on: replies are now composed through type (${plural(parsed.chunk, 'character')} per call), backspace and send.`
155 : 'keystroke off: replies are written directly again.',
156 }
157 })
158
159 for (const t of TOOLS) {
160 on('tool.describe', { tool: fullName(t) }, async ($, e, next) => {
161 const r = await next(e)
162 const m = await read($, mode)
163 return { ...r, description: describe(t, m), isDeferred: !m.on }
164 })
165 // Our own tools never need the person's approval.
166 on('tool.check', { tool: fullName(t) }, () => ({ decision: 'allow' }))
167 }
168
169 on('prompt.compose', async ($, e, next) => {
170 const r = await next(e)
171 const m = await read($, mode)
172 if (!m.on || e.traits.includes('teammate')) return r
173 return { sections: [...r.sections, { id: `${PLUGIN}:mode`, text: section(m), scope: 'session' }] }
174 })
175
176 const notFirst = (id: string) => stepKeystrokes.length > 1 && stepKeystrokes[0] !== id
177
178 on('tool.call', { tool: fullName('type') }, async ($, e) => {
179 if (e.agentId !== undefined) return { deny: 'keystroke tools serve the main conversation only; reply normally.' }
180 const m = await read($, mode)
181 if (!m.on) return { deny: 'keystroke mode is off; write your reply directly.' }
182 if (notFirst(e.tool_use_id)) return { deny: ONE_PER_RESPONSE }
183 const text = String((e as { text?: unknown }).text ?? '')
184 const n = count(text)
185 if (n === 0) return { deny: 'nothing to type: pass text.' }
186 if (n > m.chunk) {
187 return { deny: `one type() call takes at most ${plural(m.chunk, 'character')}; this one had ${n}. Nothing was typed; resend in smaller pieces.` }
188 }
189 await update($, draft, d => d + text)
190 return { result: view(await read($, draft)) }
191 })
192
193 on('tool.call', { tool: fullName('backspace') }, async ($, e) => {
194 if (e.agentId !== undefined) return { deny: 'keystroke tools serve the main conversation only; reply normally.' }
195 const m = await read($, mode)
196 if (!m.on) return { deny: 'keystroke mode is off; write your reply directly.' }
197 if (notFirst(e.tool_use_id)) return { deny: ONE_PER_RESPONSE }
198 const raw = Number((e as { count?: unknown }).count ?? 1)
199 const k = Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 1
200 await update($, draft, d => chars(d).slice(0, Math.max(0, count(d) - k)).join(''))
201 return { result: view(await read($, draft)) }
202 })
203
204 on('tool.call', { tool: fullName('send') }, async ($, e) => {
205 if (e.agentId !== undefined) return { deny: 'keystroke tools serve the main conversation only; reply normally.' }
206 const m = await read($, mode)
207 if (!m.on) return { deny: 'keystroke mode is off; write your reply directly.' }
208 if (notFirst(e.tool_use_id)) return { deny: ONE_PER_RESPONSE }
209 const d = await read($, draft)
210 if (d.trim() === '') return { deny: 'the draft is empty; type the reply first.' }
211 pendingSend = d
212 return { result: `sent (${plural(count(d), 'character')}). Your turn ends here.` }
213 })
214
215 on('turn.start', async ($, e, next) => {
216 pendingSend = null
217 synthesizedThisTurn = false
218 blocksThisTurn = 0
219 await update($, draft, () => '')
220 return next(e)
221 })
222
223 // The step after `send`: no model call; the draft becomes the step's text.
224 on('turn.step', async function* ($, e, next) {
225 if (e.agentId !== undefined) return yield* next(e)
226 if (pendingSend === null) {
227 stepKeystrokes = []
228 const m = await read($, mode)
229 if (!m.on) return yield* next(e)
230 // Text in a response that also makes a keystroke call is narration ("still
231 // typing..."), not a reply: it is held back and dropped. Text in a response with
232 // no keystroke call is shown, and the stop hook decides what to do about it.
233 const held: TurnStepChunk[] = []
234 for await (const c of next(e)) {
235 if (c.kind === 'tool' && isOurs(c.name)) stepKeystrokes.push(c.id)
236 if (c.kind === 'text') {
237 held.push(c)
238 continue
239 }
240 if (c.kind === 'stop' && stepKeystrokes.length === 0) yield* held
241 yield c
242 }
243 return
244 }
245 const text = pendingSend
246 pendingSend = null
247 synthesizedThisTurn = true
248 await update($, draft, () => '')
249 yield { kind: 'text', index: 0, text }
250 yield { kind: 'stop', stopReason: 'end_turn', usage: null }
251 return { turnId: e.turnId, index: e.index, answer: text, toolUses: [], stopReason: 'end_turn', usage: null }
252 })
253
254 on('classic.Stop', async ($, e, next) => {
255 const m = await read($, mode)
256 if (!m.on || synthesizedThisTurn || blocksThisTurn >= MAX_BLOCKS_PER_TURN) return next(e)
257 const d = await read($, draft)
258 const direct = (e.last_assistant_message ?? '').trim()
259 if (d.trim() !== '') {
260 blocksThisTurn += 1
261 return { block: `keystroke mode: your draft (${plural(count(d), 'character')}) was not sent. Keep typing with ${fullName('type')} or deliver it with ${fullName('send')}.` }
262 }
263 if (direct !== '') {
264 blocksThisTurn += 1
265 return {
266 block: `keystroke mode is on: the ${plural(count(direct), 'character')} you wrote directly are not a reply. Compose it with ${fullName('type')} (at most ${plural(m.chunk, 'character')} per call) and finish with ${fullName('send')}.`,
267 }
268 }
269 return next(e)
270 })
271
272 // Keystroke rows are noise in the transcript: the draft is drawn in the band instead,
273 // and the sent reply arrives as an assistant message.
274 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
275 if (!isOurs(e.props.tool)) return next(e)
276 const { Box } = $.ui.resolve(e)
277 return <Box />
278 })
279 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
280 if (!isOurs(e.props.tool)) return next(e)
281 const { Box } = $.ui.resolve(e)
282 return <Box />
283 })
284 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
285 if (!e.props.calls.every(c => isOurs(c.tool))) return next(e)
286 const { Box } = $.ui.resolve(e)
287 return <Box />
288 })
289
290 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
291 const m = await read($, mode)
292 const d = await read($, draft)
293 if (!m.on || d === '' || e.props.hasSurvey) return next(e)
294 const { Box, Text } = $.ui.resolve(e)
295 return (
296 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
297 <Text dimColor>
298 draft · {plural(count(d), 'character')} · {m.chunk}/call
299 </Text>
300 <Text wrap="wrap">{d}▍</Text>
301 </Box>
302 )
303 })
304}
305types/index.d.ts 8 lines1export type KeystrokeMode = { on: boolean; chunk: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 keystroke: { mode: KeystrokeMode; draft: string }
6 }
7}
8