Bounces vague prompts before Claude sees them, and suggests the project's own term

A Claude Code mod that stops vague prompts before Claude sees them. When a prompt says "the thing" or "the payments page" and the project calls it something else, the mod drops it, tells you why, and suggests the project's own word.

A vague prompt costs a whole turn of Claude guessing, and a wrong guess stays in the context and sometimes in your files. This mod checks every prompt at prompt.submit, the moment you press Enter. A bounced prompt never reaches the model, costs no tokens, and goes back in the prompt box for you to fix.
CLAUDE.md, docs/glossary.md, decision records, specs) and asks Haiku whether the prompt is specific and uses the project's terms. If not, the toast suggests the right one: "This project calls that the Billing Panel. Is the wrong price in the plan display or an invoice?"/force <prompt> sends anything past it. Slash commands always pass.It never sends anything to Claude itself. The reason and the suggestion are for you only.


claude plugin marketplace add ray-amjad/claude-code-mods
claude plugin install prompt-bouncer@claude-code-mods
Or inside Claude Code: /plugin marketplace add ray-amjad/claude-code-mods, then /plugin install prompt-bouncer@claude-code-mods.
A line under the prompt shows the mode it's in: ⚠ prompt-bouncer: quick mode.
Prompt dropped by a hook line in the transcript, and comes back in the prompt box./force <prompt> sends the prompt as your own words, unchecked./bouncer shows the mode and which docs it found. /bouncer quick, /bouncer project or /bouncer off switch it for this session.claude -p, a prompt starting with ! passes too (the ! is removed). In the interactive prompt box ! is shell mode, so use /force there.The example/ folder is a tiny invoicing app with a glossary (Billing Panel, Plan Switcher, Dunning Run, Seat Ledger), each term with a "Not" column of loose words people use for it:
cd example
claude
/bouncer project
Then try the payments page shows the wrong price, fix it, add a CSV export to the members table or make the retry thing run more often, and compare with The Billing Panel shows the wrong plan price.
| Option | Default | What it does | | :- | :- | :- | | mode | quick | quick or project. Set it with /plugin configure prompt-bouncer@claude-code-mods. | | docs | CLAUDE.md,docs/glossary.md,docs/decisions/*.md,specs/*.md,docs/specs/*.md | The files project mode reads, comma-separated, from the folder Claude Code runs in. A * matches file names inside one folder. | | maxDocChars | 20000 | Docs past this many characters are cut, so the check stays cheap | | PROMPT_BOUNCER_MODE (env) | unset | Overrides mode for one session: PROMPT_BOUNCER_MODE=project claude |
prompt.submit judges prompts a person typed. Notifications, scheduled prompts and messages from other sessions pass.CamelCase, snake_case, a Capitalized Name). Prompts over 15 words pass.$.fs on every prompt, then makes one $.model.complete call to Haiku with the docs as a cached prefix and the prompt after them. Haiku answers JSON: { "pass": true } or a reason and a suggestion. A glossary with a "Not" column of loose words works best, because it tells Haiku exactly what to catch. Nothing is extracted or stored.{ drop: reason }, so the prompt never enters the session. Claude Code puts the prompt back in the box itself./force can't submit from inside its own command (it would wait on its own turn), so a timer submits it a moment later with $.prompt.submit({ asUser: true })./force is there for that.docs patterns support * inside one folder (docs/decisions/*.md), not **./bouncer mode switches last for the session and reset if the mod reloads.claude --plugin-dir ./prompt-bouncer
claude plugin validate .
claude plugin test .
Tested with Claude Code 2.1.296.
hooks/register.js 167 lines1// prompt-bouncer: guard what you can't undo, guarding against the user.
2// A vague prompt costs a whole turn of Claude guessing. This mod checks each
3// prompt at prompt.submit and drops the vague ones before Claude sees them.
4//
5// quick : instant rules, no model ("fix the thing", "it's broken")
6// project : a Haiku gate that reads the project's own docs and asks
7// "is this specific, and does it use this project's words?"
8//
9// Escape hatch: run /force <prompt> (or start a claude -p prompt with !). Slash commands always pass.
10
11// Words that point at something without naming it
12const VAGUE_NOUNS = /\b(the|that|this|some|those|these)\s+(thing|things|stuff|bug|bugs|issue|issues|problem|error|page|screen|button|function|file|part|bit|one)\b|\b(fix|do|change|update|clean)\s+(stuff|things|everything)\b|\bstuff\b|\bwhatever\b/i
13// Pronouns with nothing before them to point back at (first prompt only)
14const BARE_PRONOUN = /\b(it|that|this|them|those)\b/i
15// Something concrete: a file, a path, code, a name in quotes, CamelCase, snake_case,
16// or a Capitalized Name past the first word ("the Billing Panel")
17const ANCHOR = /[\w-]+\.[a-z]{1,5}\b|\/[\w.-]+|`[^`]+`|"[^"]+"|\b[A-Z][a-z]+[A-Z]\w*|\b\w+_\w+\b|\s[A-Z][a-z]{2,}/
18
19// Session overrides, set by /bouncer (they reset when the mod reloads)
20let modeOverride = null
21let bounces = 0
22let forceNext = null
23
24export function register(on, options) {
25 on('session.start', async ($, e, next) => {
26 $.ui.status((await currentMode($, options)) + ' mode')
27 try {
28 await $.command.register({ name: 'bouncer', description: 'Show prompt-bouncer, or switch it: /bouncer quick|project|off', argumentHint: '[quick|project|off]' })
29 await $.command.register({ name: 'force', description: 'Send a prompt past prompt-bouncer', argumentHint: '<prompt>' })
30 } catch (err) {
31 $.ui.log('could not register commands: ' + err.message)
32 }
33 return next(e)
34 })
35
36 // The guard. It returns { drop } to stop a prompt, or next(e) to let it in.
37 on('prompt.submit', async ($, e, next) => {
38 const text = e.text.trim()
39
40 // Only judge what a person typed, never a notification or a peer's message
41 const kind = e.origin && e.origin.kind
42 if (kind !== 'composer' && kind !== 'sdk' && kind !== 'bridge' && kind !== undefined) return next(e)
43
44 // Escape hatches: /force sent this one, or it starts with !
45 if (forceNext !== null && text === forceNext) { forceNext = null; return next(e) }
46 if (text.startsWith('!')) return next({ ...e, text: text.slice(1).trim() })
47 if (text.startsWith('/')) return next(e)
48
49 const m = await currentMode($, options)
50 if (m === 'off' || text === '') return next(e)
51
52 // Was there a conversation before this prompt? Then "it" can point back at something
53 const isFirst = (await $.session.messages()).length === 0
54
55 const verdict = m === 'project'
56 ? await projectGate($, text, isFirst, await loadDocs($, options))
57 : quickRules(text, isFirst)
58 if (verdict.pass) return next(e)
59
60 // Bounce: tell the user why (Claude Code puts the prompt back in the box to fix)
61 bounces += 1
62 // The toast shows the fix when there is one, else the reason
63 $.ui.toast('Bounced. ' + (verdict.suggestion || verdict.reason), { timeoutMs: 8000 })
64 // One line: a drop reason with newlines in it is mangled
65 return { drop: 'prompt-bouncer: ' + verdict.reason + (verdict.suggestion ? ' Try: ' + verdict.suggestion : '') + ' (Send it anyway: /force <prompt>)' }
66 }).catch(($, e, next) => next(e))
67 // A bouncer that breaks lets prompts through: it should never lock you out
68
69 // /bouncer shows the mode and the docs it reads; /bouncer quick|project|off switches
70 on('command.run', { command: 'bouncer' }, async ($, e) => {
71 const arg = e.args.trim()
72 if (arg === 'quick' || arg === 'project' || arg === 'off') {
73 modeOverride = arg
74 $.ui.status(arg + ' mode')
75 }
76 const docs = await loadDocs($, options)
77 return { text: 'mode ' + (await currentMode($, options)) + ', ' + bounces + ' bounced this session. Project docs: ' + (docs.files.join(', ') || 'none found') + ' (' + docs.text.length + ' chars)' }
78 })
79
80 // /force <prompt> sends the prompt as your own words, past the bouncer
81 on('command.run', { command: 'force' }, async ($, e) => {
82 const text = e.args.trim()
83 if (!text) return { text: 'Usage: /force <prompt>' }
84 forceNext = text
85 // A command can't submit a prompt itself (it would wait on its own turn),
86 // so a timer submits it a moment later, once the command has finished
87 $.clock.after(50, () => $.prompt.submit({ text, asUser: true }).catch((err) => $.ui.log('force failed: ' + err.message)))
88 return {}
89 })
90}
91
92// The mode: /bouncer override, then PROMPT_BOUNCER_MODE, then /config
93async function currentMode($, options) {
94 return modeOverride || (await $.env.get('PROMPT_BOUNCER_MODE')) || options.mode || 'quick'
95}
96
97// Quick mode: no model, just patterns. Short + vague + nothing named = bounce.
98function quickRules(text, isFirst) {
99 const words = text.split(/\s+/).length
100 if (words > 15 || ANCHOR.test(text)) return { pass: true }
101 const noun = text.match(VAGUE_NOUNS)
102 if (noun) {
103 return { pass: false, reason: '"' + noun[0] + '" doesn\'t say which one. Name the file, feature or component.' }
104 }
105 const pronoun = text.match(BARE_PRONOUN)
106 if (isFirst && pronoun) {
107 return { pass: false, reason: '"' + pronoun[0] + '" has nothing to point back to yet. Say what you mean.' }
108 }
109 return { pass: true }
110}
111
112// Project mode: Haiku reads the project's docs and judges the prompt
113async function projectGate($, text, isFirst, docs) {
114 const r = await $.model.complete({
115 model: 'haiku',
116 maxTokens: 300,
117 timeoutMs: 20000,
118 system: 'You are a gatekeeper for a coding agent. Reply with JSON only, no prose.',
119 prompt: [
120 // The docs are the same every prompt, so they form the cached prefix
121 { text: 'PROJECT DOCS (the project\'s own language):\n\n' + docs.text, cache: true },
122 {
123 text: 'A developer is about to send this prompt to a coding agent' +
124 (isFirst ? ' as the FIRST message of a new session (so "it" or "that" points at nothing)' : ' mid-conversation (short follow-ups like "yes" or "do it" are fine)') +
125 ':\n\n<prompt>\n' + text + '\n</prompt>\n\n' +
126 'Bounce it if EITHER is true: (1) it uses a loose word where the docs define a precise term ' +
127 '(any word in the glossary\'s "Not" column, or a close paraphrase of one, always bounces), or ' +
128 '(2) the agent would have to guess what it refers to, because it names no file, feature or component. ' +
129 'A prompt that already names a project term or a file passes, even if other words in it are casual. ' +
130 'Ordinary verbs and words like "switch", "change" or "update" are not loose terms.\n\n' +
131 'Reply {"pass": true} or {"pass": false, "reason": "<one short sentence>", "suggestion": "<under 25 words: the prompt rewritten with the project\'s term; if it could mean two things, ask which one, e.g. this project calls that the billing panel, which one did you mean?>"}',
132 },
133 ],
134 })
135 // If Haiku is down or answers nonsense, let the prompt through
136 if (!r.isAnswered) return { pass: true }
137 try {
138 const json = JSON.parse(r.text.slice(r.text.indexOf('{'), r.text.lastIndexOf('}') + 1))
139 return json.pass === false ? { pass: false, reason: json.reason || 'too vague for this project', suggestion: json.suggestion } : { pass: true }
140 } catch {
141 return { pass: true }
142 }
143}
144
145// Read the project's docs with $.fs: plain paths, or folder/*.md for every match in one folder
146async function loadDocs($, options) {
147 const root = await $.session.cwd()
148 const max = options.maxDocChars || 20000
149 const files = []
150 let text = ''
151 for (const pattern of String(options.docs || '').split(',').map((p) => p.trim()).filter(Boolean)) {
152 let paths = [pattern]
153 if (pattern.includes('*')) {
154 const dir = pattern.slice(0, pattern.lastIndexOf('/') + 1)
155 const re = new RegExp('^' + pattern.slice(dir.length).replace(/[.]/g, '\\.').replace(/\*/g, '.*') + '$')
156 if (!(await $.fs.exists(root + '/' + dir))) continue
157 paths = (await $.fs.list(root + '/' + dir)).filter((f) => f.kind === 'file' && re.test(f.name)).map((f) => dir + f.name).sort()
158 }
159 for (const path of paths) {
160 if (text.length >= max || !(await $.fs.exists(root + '/' + path))) continue
161 text += '\n--- ' + path + ' ---\n' + (await $.fs.read(root + '/' + path)).slice(0, max - text.length)
162 files.push(path)
163 }
164 }
165 return { files, text }
166}
167