SLOPSHOPPER

prompt-bouncer

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

newcommandtoaststatuspromptmodel
★ 67v0.1.0MITupdated 2026-10-12ray-amjad/claude-code-mods/plugins/prompt-bouncer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-bouncer
› 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 › /bouncer ⎿ prompt-bouncer: mode quick, 0 bounced this session. Project docs: CLAUDE.md (74 chars) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ prompt-bouncer: quick mode
README

prompt-bouncer

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.

prompt-bouncer: on the left "the payments page shows the wrong price, fix it" is bounced with a toast saying this project calls that the Billing Panel; on the right the same request in the project's words goes straight through and Claude answers

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.

  • Quick mode (the default) uses no model and is instant. A short prompt that leans on "the thing", "stuff" or "that bug", or a bare "it" on the first prompt of a session, and names no file, feature or component, is bounced.
  • Project mode reads the project's own docs (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.

Quick mode bounces "fix the thing" with no model call; on the right, /force sends a prompt past the bouncer and Claude answers

How it works: you press Enter, the prompt.submit hook picks quick or project mode, project mode reads the docs with $.fs and asks Haiku, and the verdict either drops the prompt or lets Claude have it

Install

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.

Usage

  • Type as usual. A bounced prompt shows a toast, a 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.
  • In claude -p, a prompt starting with ! passes too (the ! is removed). In the interactive prompt box ! is shell mode, so use /force there.

Try project mode

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.

Settings

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

How it works

  • One hook on prompt.submit judges prompts a person typed. Notifications, scheduled prompts and messages from other sessions pass.
  • Quick mode is a few regular expressions: a vague noun phrase, or a bare pronoun on a session's first prompt, with nothing concrete named (a file, a path, code in backticks, a quoted name, CamelCase, snake_case, a Capitalized Name). Prompts over 15 words pass.
  • Project mode reads the docs fresh with $.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.
  • To bounce, the hook returns { drop: reason }, so the prompt never enters the session. Claude Code puts the prompt back in the box itself.
  • It fails open: if the hook throws, Haiku is down or its answer isn't JSON, the prompt goes through. A broken bouncer should never lock you out.
  • /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 }).

Limits

  • Project mode adds a few seconds to each prompt (one Haiku call). Quick mode adds nothing.
  • Haiku is judging, so expect the odd miss or the odd bounce you disagree with. /force is there for that.
  • Quick mode is English only, and its word lists are short.
  • docs patterns support * inside one folder (docs/decisions/*.md), not **.
  • /bouncer mode switches last for the session and reset if the mod reloads.

Develop

claude --plugin-dir ./prompt-bouncer
claude plugin validate .
claude plugin test .

Tested with Claude Code 2.1.296.

Source 1 files
hooks/register.js 167 lines
1// 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