SLOPSHOPPER

english-coach

Haiku reviews each long prompt you send and shows English tips above the prompt

newbandcommandtoastpromptmodel
★ 1v0.3.2no licenseupdated 2026-10-14franc1sc0sv/english-coach
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · english-coach
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ 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 › /english-coach ⎿ english-coach: English tips off. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

english-coach

A Claude Code mod that reviews the English of each long prompt you send. Haiku 5.5 checks the prompt and shows the fixes and a better version above the prompt box. You can copy the better version.

English tips above the prompt

Install

Type this line at the Claude Code prompt in a terminal:

/plugin install english-coach --marketplace franc1sc0sv/english-coach

Type y to add the marketplace, then select the user scope. The mod starts immediately.

Ask Claude to install it

Tell Claude Code: "Install the english-coach plugin from github.com/franc1sc0sv/english-coach". Claude runs:

claude plugin marketplace add franc1sc0sv/english-coach
claude plugin install english-coach@english-coach --scope user

Then run /reload-plugins, or start a new session.

Use

  • /english-coach turns the tips on or off.
  • Each review is one small Haiku 5.5 call on your own usage. The full rewrite is a second call, made only when you press Copy.

Update

claude plugin update english-coach

Then run /reload-plugins in an open session.

Source 3 files
hooks/register.tsx 176 lines
1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import { REWRITE_SYSTEM, SYSTEM, isWorthReview, parse, prose } from "../lib/coach.ts";
5import type { TipKind } from "../types";
6
7const COMMAND = "english-coach";
8
9const ENABLED_KEY = "enabled";
10
11const MODEL = "claude-haiku-5-5";
12
13const REVIEW_MAX_TOKENS = 500;
14
15const REWRITE_MAX_TOKENS = 4000;
16
17const KIND_LABELS: Record<TipKind, string> = {
18  grammar: "Grammar",
19  word: "Word",
20  pronunciation: "Misheard",
21  clarity: "Clearer",
22};
23
24const LABEL_COLUMNS = 10;
25
26const review = atom({ plugin: "english-coach", key: "review" } as const, null);
27
28let isInteractive = false;
29
30let epoch = 0;
31
32async function isEnabled($: EngineInterface): Promise<boolean> {
33  return (await $.store.get(ENABLED_KEY)) !== false;
34}
35
36async function coach($: EngineInterface, text: string, at: number): Promise<void> {
37  const prompt = prose(text);
38  const reply = await $.model.complete({
39    model: MODEL,
40    system: SYSTEM,
41    prompt,
42    maxTokens: REVIEW_MAX_TOKENS,
43    effort: "low",
44    timeoutMs: 30000,
45  });
46
47  if (!reply.isAnswered || at !== epoch) return;
48
49  const tips = parse(reply.text);
50
51  if (tips !== undefined) await update($, review, () => ({ tips, prompt }));
52}
53
54async function rewrite($: EngineInterface, prompt: string): Promise<string | undefined> {
55  const reply = await $.model.complete({
56    model: MODEL,
57    system: REWRITE_SYSTEM,
58    prompt,
59    maxTokens: REWRITE_MAX_TOKENS,
60    effort: "low",
61    timeoutMs: 30000,
62  });
63
64  return reply.isAnswered ? reply.text.trim() : undefined;
65}
66
67export const register: Register = (on) => {
68  isInteractive = false;
69  epoch = 0;
70
71  on("session.start", async ($, e, next) => {
72    isInteractive = e.isInteractive;
73    await $.command.register({
74      name: COMMAND,
75      description: "Turn English tips on or off",
76    });
77
78    return next(e);
79  });
80
81  on("command.run", { command: COMMAND }, async ($) => {
82    const isOn = !(await isEnabled($));
83    await $.store.set(ENABLED_KEY, isOn);
84
85    if (!isOn) await update($, review, () => null);
86
87    return { text: `English tips ${isOn ? "on" : "off"}.` };
88  });
89
90  on("prompt.submit", async ($, e, next) => {
91    epoch += 1;
92    const at = epoch;
93    await update($, review, () => null);
94
95    const isOwnWords = e.origin.kind === "composer";
96
97    if (isInteractive && isOwnWords && isWorthReview(e.text) && (await isEnabled($))) {
98      $.clock.after(0, () => {
99        void coach($, e.text, at).catch(() => undefined);
100      });
101    }
102
103    return next(e);
104  });
105
106  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
107    const shown = await read($, review);
108
109    if (shown === null || e.props.hasSurvey) return next(e);
110
111    const beneath = await next(e);
112    const { Box, Button, Text } = $.ui.resolve(e);
113
114    const dismiss = () => update($, review, () => null);
115
116    const copy = async () => {
117      const text = await rewrite($, shown.prompt);
118
119      if (text === undefined || text === "") {
120        $.ui.toast("English coach could not write the rewrite.");
121
122        return;
123      }
124
125      await $.ui.copy({ text, surface: e.surface });
126      await update($, review, () => null);
127    };
128
129    return (
130      <Box flexDirection="column">
131        {beneath}
132        <Box flexDirection="column" paddingX={1} paddingTop={1}>
133          <Text>
134            <Text color="suggestion" bold>
135              English tip
136            </Text>
137            <Text dimColor>{`  ${shown.tips.length} ${shown.tips.length === 1 ? "fix" : "fixes"}`}</Text>
138          </Text>
139          <Box flexDirection="column" paddingTop={1}>
140            {shown.tips.map((tip, index) => (
141              <Box key={`tip-${index}`} paddingLeft={2}>
142                <Box width={LABEL_COLUMNS} flexShrink={0}>
143                  <Text color="warning">{KIND_LABELS[tip.kind]}</Text>
144                </Box>
145                <Box flexDirection="column">
146                  <Text>
147                    <Text color="error" strikethrough>
148                      {tip.said}
149                    </Text>
150                    <Text dimColor>{"  ->  "}</Text>
151                    <Text color="success" bold>
152                      {tip.better}
153                    </Text>
154                  </Text>
155                  <Text dimColor>{tip.why}</Text>
156                </Box>
157              </Box>
158            ))}
159          </Box>
160          <Box paddingLeft={2} paddingTop={1} columnGap={2}>
161            <Button key="copy" label="Copy full rewrite" hotkey="1" plain onPress={copy} />
162            <Button
163              key="dismiss"
164              label="Dismiss"
165              hotkey="2"
166              plain
167              role="dismiss"
168              onPress={dismiss}
169            />
170          </Box>
171        </Box>
172      </Box>
173    );
174  });
175};
176
lib/coach.ts 92 lines
1import type { Tip, TipKind } from "../types";
2
3type Json = null | boolean | number | string | Json[] | JsonObject;
4
5type JsonObject = { [key: string]: Json };
6
7const MIN_WORDS = 8;
8
9const MAX_PROSE_CHARS = 8000;
10
11const MAX_TIPS = 3;
12
13const WHY_CHARS = 80;
14
15const KINDS: readonly TipKind[] = ["grammar", "word", "pronunciation", "clarity"];
16
17export const SYSTEM = `English coach for a non-native speaker who may dictate prompts by voice. Find up to 3 real errors in the prompt: grammar, wrong words or literal translations from their first language, words the dictation misheard, or unclear phrasing. Ignore code, paths, names, punctuation, capitalization, filler words and casual tone.
18Write "why" for a learner who knows no grammar words: never say singular, plural, participle, tense, subject, verb, noun or preposition. Say "After 'this', use 'takes'", not "Third-person singular needs an s".
19Reply with JSON only:
20{"tips":[{"kind":"grammar|word|pronunciation|clarity","said":"2-5 exact words","better":"the fix","why":"plain reason under 60 chars"}]}
21No errors: {"tips":[]}`;
22
23export const REWRITE_SYSTEM =
24  "Rewrite the prompt in clear, natural English. Keep every request, detail, code, path and name. Reply with the rewrite only.";
25
26export function prose(text: string): string {
27  return text
28    .replace(/```[\s\S]*?```/g, " ")
29    .replace(/`[^`\n]*`/g, " ")
30    .trim()
31    .slice(0, MAX_PROSE_CHARS);
32}
33
34export function isWorthReview(text: string): boolean {
35  if (text.trimStart().startsWith("/")) return false;
36
37  return prose(text).split(/\s+/).filter(Boolean).length >= MIN_WORDS;
38}
39
40export function parse(reply: string): Tip[] | undefined {
41  const source = /\{[\s\S]*\}/.exec(reply)?.[0];
42
43  if (source === undefined) return undefined;
44
45  let value: Json;
46
47  try {
48    value = JSON.parse(source);
49  } catch {
50    return undefined;
51  }
52
53  if (!isJsonObject(value) || !Array.isArray(value.tips)) return undefined;
54
55  const tips = value.tips.flatMap(toTip).slice(0, MAX_TIPS);
56
57  return tips.length === 0 ? undefined : tips;
58}
59
60function toTip(value: Json): Tip[] {
61  if (!isJsonObject(value)) return [];
62
63  const { kind, said, better, why } = value;
64
65  if (!isString(said) || !isString(better) || !isString(why)) return [];
66
67  if (said.trim() === "" || better.trim() === "") return [];
68
69  if (said.trim() === better.trim()) return [];
70
71  return [
72    {
73      kind: KINDS.find((one) => one === kind) ?? "clarity",
74      said: said.trim(),
75      better: better.trim(),
76      why: clip(why.trim(), WHY_CHARS),
77    },
78  ];
79}
80
81function isJsonObject(value: Json | undefined): value is JsonObject {
82  return value === Object(value) && !Array.isArray(value);
83}
84
85function isString(value: Json | undefined): value is string {
86  return value === String(value);
87}
88
89function clip(text: string, limit: number): string {
90  return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
91}
92
types/index.d.ts 12 lines
1export type TipKind = "grammar" | "word" | "pronunciation" | "clarity";
2
3export type Tip = { kind: TipKind; said: string; better: string; why: string };
4
5export type Review = { tips: Tip[]; prompt: string };
6
7declare module "claude-code" {
8  interface PluginState {
9    "english-coach": { review: Review | null };
10  }
11}
12