SLOPSHOPPER

english-coach

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…

newrowstoastpromptmodelprocess
v0.2.1no licenseupdated 2026-10-10assert-not-singularity/english-coach
A shopper browsing a rack in a slop shop
README

english-coach

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.

Install

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

How it works

  • Fixes under your message. A background review starts when you send a message. A second or two later the fixes show under it: the original in red, the fix in green, the category, and a short explanation.
  • Only real language problems. Grammar, word choice, and transfer errors from your native language. Typos (including real-word typos such as "now" for "not"), letter case, punctuation, informal chat style, terse phrasing, jargon, and anything in code, quotes, or quote blocks are ignored. A phrase corrected in the last ten minutes is not corrected again, so pasting a tip back into a message does not produce a new one. Fixes that only add words around terse phrasing are dropped.
  • Careless mode. Messages that were clearly dashed off (many typos, fragments, swearing, and starting lowercase or without closing punctuation) get no tip and are excluded from the statistics. Start a message with ~ to skip analysis entirely.
  • Dictated messages. If your speech-to-text tool can add a marker to what it types (for example [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.
  • Weakness tracking. Findings are stored under a fixed set of categories (article, preposition, word order, …). Recurring areas are shown first and given extra attention by the reviewer. A progress note ("Btw, your … is getting better") appears rarely, only for an improved category that your latest message handled well.

Commands

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

Privacy

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

Requirements and limits

  • A Claude Code version with mods (function hooks); developed on 2.1.294. The mods API is early access and can change between releases, in which case an update of this plugin may be needed.
  • python3 on the PATH.
  • Each review is a small extra request that counts against your usage.
  • Review quality is that of a small model: it misses some errors, and now and then flags something a native would let pass.

Development

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.

Acknowledgments

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.

Source 2 files
hooks/register.tsx 133 lines
1import { 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}
133
types/index.d.ts 20 lines
1export 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