Quiet English feedback on every prompt you type: a small model reviews it in the background and the fixes appear directly under your message. Recurring…

Quiet English feedback on every prompt you type, for non-native speakers who use Claude Code daily. A small model (Haiku) reviews each prompt in a separate background call, and the fixes appear directly under your message. Nothing is added to the main conversation's context.
/plugin marketplace add assert-not-singularity/english-coach
/plugin install english-coach@english-coach
/english-coach:setup
/english-coach:setup asks for your native language so the coach can spot transfer errors (false friends, calques, prepositions). Nothing is assumed: until you set it, the coach gives generic feedback and shows a one-time reminder. Settings live in ~/.config/english-coach/config.json; from a terminal, python3 <plugin-dir>/scripts/english-coach.py setup does the same interactively.
~ to skip analysis entirely.[dictated]), set it with config set dictation_marker "[dictated]" or in /english-coach:setup. A message containing the marker, anywhere and in any case, is reviewed in speech mode: likely mis-transcriptions (homophones such as their/there/they're, weather/whether, now/no), missing punctuation, filler words and run-ons are ignored, and only the grammar and word choice you produced is judged. The marker is removed before Claude reads your message (config set strip_marker off keeps it). Dictated messages are counted apart from typed ones: the main report covers written text only, followed by a short dictated section. The coach only sees the transcript, so pronunciation and fluency are out of its reach./english-coach:setup # native language, minimum words, tips per message
/english-coach:coach report # weekly sparkline per category, share of clean messages, habit phrases,
# and (if a native language is set) slips that copy its patterns
/english-coach:coach off # pause
/english-coach:coach on # resume
Settings (config set <key> <value>): native_language, dictation_marker (none by default), strip_marker (on), min_words (default 6), max_tips (default 2), icon_written (✎) and icon_dictated (🎙︎): the icon in front of each fix. Both defaults are text-style symbols, so the pair looks alike and follows the text color; set your own if you prefer, for example Nerd Font icons with config set icon_written $'\uf040' and config set icon_dictated $'\uf130'.
Prompts are sent to Claude (Haiku) through your own Claude Code session, the same destination as the prompt itself. Locally, only the corrected fragments, explanations, word counts, and whether a message was typed or dictated are stored, never whole prompts, in ~/.local/share/english-coach/log.db (override with ENGLISH_COACH_DIR).
python3 on the PATH.claude plugin validate . # manifests and the mod
claude plugin test . # the mod's tests (mocked engine)
python3 tests/eval.py # prompt regression eval: the real review pipeline on fixture
# messages, several runs each, pass rate per case
python3 tests/eval.py --script new=scripts/english-coach.py --script old=/path/to/old.py
# compare a candidate against another version
The eval calls Haiku through the claude CLI against throwaway data, never your real database. Run it before changing the prompt or the validation: the model's output is probabilistic, so it reports rates, and cases marked informational (known weak spots) do not fail the run.
This plugin adapts ideas from claude-language-coach by Sergio Alves Junior (MIT): reviewing each prompt out of band with a small model, drawing the fixes under the prompt through a ui.render hook on the message row, calibrating the reviewer with an explicit "do not flag" list, treating errors that copy the user's native language as their own kind, explanations that teach a reusable pattern, and discarding any fix whose original text does not occur verbatim in the prompt. The code here is an independent implementation.
hooks/register.tsx 133 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Fix, Review } from '../types'
5
6const MODEL = 'haiku'
7const KEEP = 50
8const OWN_WORDS = new Set(['composer', 'bridge'])
9
10const reviews = atom({ plugin: 'english-coach', key: 'reviews' } as const, {} as Record<string, Review>)
11
12const textKey = (text: string) => {
13 let hash = 0x811c9dc5
14 for (const char of text.trim().replace(/\s+/g, ' ')) {
15 hash = Math.imul(hash ^ char.codePointAt(0)!, 0x01000193)
16 }
17
18 return (hash >>> 0).toString(16)
19}
20
21const script = ($: EngineInterface, args: string[], payload: unknown) =>
22 $.process.run(['python3', '-I', `${$.plugin.root}/scripts/english-coach.py`, ...args], {
23 stdin: JSON.stringify(payload),
24 timeoutMs: 15_000,
25 })
26
27type Prepared = {
28 coach: boolean
29 text: string
30 mode: 'written' | 'dictated'
31 strip: boolean
32 system?: string
33 user?: string
34}
35
36const prepare = async ($: EngineInterface, text: string): Promise<Prepared> => {
37 try {
38 return JSON.parse((await script($, ['prepare'], { prompt: text })).stdout) as Prepared
39 } catch {
40 return { coach: false, text, mode: 'written', strip: false }
41 }
42}
43
44// `shown` is the text as the message row will draw it, which is the key the fixes are looked up by.
45const review = async ($: EngineInterface, prepared: Prepared, shown: string) => {
46 try {
47 const reply = await $.model.complete({
48 model: MODEL,
49 system: prepared.system,
50 prompt: prepared.user ?? '',
51 maxTokens: 700,
52 timeoutMs: 30_000,
53 })
54 if (!reply.isAnswered) return
55
56 const result = JSON.parse(
57 (await script($, ['ingest'], { prompt: prepared.text, mode: prepared.mode, reply: reply.text })).stdout,
58 )
59 if (!result.ok) return
60 if (result.hint) $.ui.toast(result.hint)
61 if (result.careless || (result.fixes.length === 0 && !result.btw)) return
62
63 const entry: Review = { fixes: result.fixes as Fix[], btw: result.btw ?? null, icon: result.icon ?? '✎' }
64 await update($, reviews, held => Object.fromEntries(Object.entries({ ...held, [textKey(shown)]: entry }).slice(-KEEP)))
65 } catch (error) {
66 $.ui.toast(`english-coach: review failed (${String(error).slice(0, 80)})`)
67 }
68}
69
70export const register: Register = on => {
71 let isInteractive = true // session.start always runs before the first prompt and sets it
72
73 on('session.start', async ($, e, next) => {
74 isInteractive = e.isInteractive
75
76 return next(e)
77 })
78
79 on('prompt.submit', async ($, e, next) => {
80 if (!isInteractive || !OWN_WORDS.has(e.origin?.kind ?? 'composer')) return next(e)
81
82 const prepared = await prepare($, e.text)
83 const isStripped = prepared.strip && prepared.text !== e.text.trim()
84 const entered = await next(isStripped ? { ...e, text: prepared.text } : e)
85 if (entered.drop !== undefined) return entered
86
87 if (prepared.coach) {
88 const shown = isStripped ? prepared.text : e.text.trim()
89 $.clock.after(0, () => void review($, prepared, shown))
90 }
91
92 return entered
93 }).catch(($, e, next) => next(e))
94
95 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
96 const drawn = await next(e)
97 const held = (await read($, reviews))[textKey(e.props.text)]
98 if (held === undefined) return drawn
99
100 const { Box, Text } = $.ui.resolve(e)
101 const mark = `${held.icon} ` // configurable: one icon for typed text, one for dictated text
102
103 return (
104 <Box flexDirection="column">
105 {drawn}
106 <Box flexDirection="column" paddingLeft={2}>
107 {held.fixes.map((fix, index) => (
108 <Box key={`fix-${index}`} flexDirection="column">
109 <Text wrap="wrap">
110 <Text dimColor>{mark}</Text>
111 <Text color="error">{fix.original}</Text>
112 <Text dimColor>{' → '}</Text>
113 <Text color="success" bold>
114 {fix.fix}
115 </Text>
116 <Text dimColor>{` ${fix.category}${fix.transfer ? ' · transfer' : ''}`}</Text>
117 </Text>
118 <Text dimColor italic wrap="wrap">
119 {` ${fix.explanation}`}
120 </Text>
121 </Box>
122 ))}
123 {held.btw === null ? null : (
124 <Text dimColor wrap="wrap">
125 {`✎ ${held.btw}`}
126 </Text>
127 )}
128 </Box>
129 </Box>
130 )
131 })
132}
133types/index.d.ts 20 lines1export type Fix = {
2 category: string
3 transfer: boolean
4 original: string
5 fix: string
6 explanation: string
7}
8
9export type Review = {
10 fixes: Fix[]
11 btw: string | null
12 icon: string
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'english-coach': { reviews: Record<string, Review> }
18 }
19}
20