SLOPSHOPPER

Bouncer

Paste a link with a short question and Claude's answer opens with a verdict for your projects: use it, later or skip, the effort, the cost and one line why

newrowscommandprompt
v0.1.0Apache-2.0updated 2026-10-04hacksurvivor/claude-mods/mods/bouncer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · 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 ⎿ bouncer: Nothing yet. Paste a link and ask what it is worth. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Bouncer

A Claude Code mod for the links you paste. Every one meets the bouncer: Claude reads it, then its answer opens with a card that says whether it's worth your time.

Bouncer in the Claude desktop app: a pasted link, the verdict card above Claude's answer with Start on it, Later and Skip, and the /bouncer list

  • Use it, Later or Skip, which of your projects it serves, how long it takes to try, what it costs, and one line why.
  • Start on it sends the prompt to begin; Later and Skip remember your call.
  • /bouncer lists every link judged so far, by verdict. /bouncer off pauses it for the session, /bouncer on resumes it.

It only steps in for a link with a short question (up to 400 characters besides the link). It leaves alone local addresses, .local and .test hosts, Gmail and Google Docs links, and claude.ai and ChatGPT chats.

Your projects

Bouncer judges each link against the projects in ~/.claude/bouncer/projects.md, one per line:

- shop-app: an online store for handmade furniture
- the blog: weekly posts about remote work

Without that file it judges against what you're working on in the conversation and folder.

Requirements

Claude Code 2.1.286 or later (mods). The card is drawn in the Claude desktop app and in the terminal.

Install

In Claude Code:

/plugin marketplace add hacksurvivor/claude-mods
/plugin install bouncer@hacksurvivor
/reload-plugins

What it runs, reads and sends

  • Reads your prompt, to spot a pasted link, and ~/.claude/bouncer/projects.md.
  • Sends nothing itself. It adds a short brief to your prompt (the link, your projects, and the shape of the verdict line), which Claude Code sends to Anthropic with your message under your Claude account.
  • Keeps up to 200 judged links in Claude Code's plugin store on your computer.
  • Claude reads the link with its own tools, as it would without Bouncer.

See PRIVACY.md.

License

Apache-2.0

Source 3 files
hooks/register.tsx 176 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Ruling, Seen } from '../types'
5import { MARK, PROJECTS_FILE, STATUS_LABEL, VERDICT_COLOR, VERDICT_LABEL, brief, findsIn, idOf, listing, meta, projectsIn, split } from './bouncer'
6
7// Paste a link with a short question, and Claude's answer opens with a card:
8// use it, later or skip, which of the person's projects it serves, the effort
9// and one line why. The prompt carries a hidden brief asking for a verdict
10// line; the card is drawn from that line and the line is hidden. /bouncer lists
11// every link judged so far.
12
13const SEEN_KEY = 'seen'
14const SEEN_KEPT = 200
15
16const isPaused = atom({ plugin: 'bouncer', key: 'isPaused' } as const, false)
17const choices = atom({ plugin: 'bouncer', key: 'choices' } as const, {} as Record<string, Seen['status']>)
18
19// The person's projects, from ~/.claude/bouncer/projects.md when it exists.
20async function projects($: EngineInterface): Promise<string[]> {
21  try {
22    return projectsIn(await $.fs.read(`${(await $.env.get('HOME')) ?? ''}/${PROJECTS_FILE}`))
23  } catch {
24    return []
25  }
26}
27
28async function allSeen($: EngineInterface): Promise<Seen[]> {
29  const kept = await $.store.get(SEEN_KEY)
30
31  return Array.isArray(kept) ? (kept as Seen[]) : []
32}
33
34async function remember($: EngineInterface, ruling: Ruling): Promise<void> {
35  const seen = await allSeen($)
36  const id = idOf(ruling)
37  const before = seen.find(entry => entry.id === id)
38  const entry: Seen = { ...ruling, id, at: await $.clock.now(), status: before?.status ?? 'new' }
39  await $.store.set(SEEN_KEY, [...seen.filter(item => item.id !== id), entry].slice(-SEEN_KEPT))
40}
41
42async function choose($: EngineInterface, ruling: Ruling, status: Seen['status']): Promise<void> {
43  const id = idOf(ruling)
44  const seen = await allSeen($)
45  const known = seen.some(entry => entry.id === id)
46  const next = known
47    ? seen.map(entry => (entry.id === id ? { ...entry, status } : entry))
48    : [...seen, { ...ruling, id, at: await $.clock.now(), status }]
49  await $.store.set(SEEN_KEY, next.slice(-SEEN_KEPT))
50  await update($, choices, now => ({ ...now, [id]: status }))
51}
52
53async function start($: EngineInterface, ruling: Ruling): Promise<void> {
54  await choose($, ruling, 'started')
55  await $.prompt.submit({ text: ruling.next || `Let's start on ${ruling.title}: ${ruling.url}`, asUser: true })
56}
57
58async function statusOf($: EngineInterface, ruling: Ruling): Promise<Seen['status']> {
59  const id = idOf(ruling)
60  const chosen = (await read($, choices))[id]
61
62  return chosen ?? (await allSeen($)).find(entry => entry.id === id)?.status ?? 'new'
63}
64
65export const register: Register = on => {
66  on('session.start', async ($, e, next) => {
67    await $.command.register({
68      name: 'bouncer',
69      description: 'List the links Bouncer judged, or turn it off or on',
70      argumentHint: '[off|on]',
71      immediate: true,
72    })
73
74    return next(e)
75  })
76
77  on('command.run', { command: 'bouncer' }, async ($, e) => {
78    const arg = e.args.trim().toLowerCase()
79
80    if (arg === 'off' || arg === 'on') {
81      await update($, isPaused, () => arg === 'off')
82
83      return { text: arg === 'off' ? 'Bouncer is off for this session. /bouncer on brings it back.' : 'Bouncer is on.' }
84    }
85
86    return { text: listing(await allSeen($), await $.clock.now()) }
87  })
88
89  // A link the person pasted gets the brief beside it.
90  on('prompt.submit', async ($, e, next) => {
91    // Typed by the person: at the terminal, through Remote Control, or in an SDK
92    // host such as the desktop app; not a task, a schedule or a plugin.
93    const isPerson = e.origin === undefined || ['composer', 'bridge', 'sdk'].includes(e.origin.kind)
94    const links = isPerson && !e.text.trimStart().startsWith('/') ? findsIn(e.text) : []
95
96    if (links.length === 0 || (await read($, isPaused))) {
97      return next(e)
98    }
99
100    return next({ ...e, context: [...(e.context ?? []), brief(links, await projects($))] })
101  })
102
103  on('turn.complete', async ($, e, next) => {
104    const done = await next(e)
105
106    if (e.agentId === undefined && e.reason === 'answer' && e.answer.includes(MARK)) {
107      const { ruling } = split(e.answer)
108
109      if (ruling !== undefined) {
110        await remember($, ruling)
111      }
112    }
113
114    return done
115  })
116
117  // The card above the answer the verdict line opens; the line itself hidden.
118  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
119    if (!e.props.text.includes(MARK.slice(0, 2))) {
120      return next(e)
121    }
122
123    const { ruling, text } = split(e.props.text)
124
125    if (text === e.props.text) {
126      return next(e)
127    }
128
129    const drawn = await next({ ...e, props: { ...e.props, text } })
130
131    if (ruling === undefined) {
132      return drawn
133    }
134
135    const status = await statusOf($, ruling)
136    const id = idOf(ruling)
137    const { Box, Button, Text } = $.ui.resolve(e)
138    const detail = meta(ruling)
139
140    return (
141      <Box flexDirection="column">
142        <Box key={`ruling-${id}`} flexDirection="column" borderStyle="round" borderDimColor paddingX={1} marginBottom={1}>
143          <Box flexDirection="row" columnGap={1} flexWrap="wrap">
144            <Text bold color={VERDICT_COLOR[ruling.verdict]} dimColor={ruling.verdict === 'skip'}>
145              {VERDICT_LABEL[ruling.verdict]}
146            </Text>
147            {ruling.for.map(project => (
148              <Text key={`for-${project}`} dimColor>
149                {`· ${project}`}
150              </Text>
151            ))}
152            {detail === '' ? null : (
153              <Box flexGrow={1} justifyContent="flex-end">
154                <Text dimColor>{detail}</Text>
155              </Box>
156            )}
157          </Box>
158          <Text wrap="wrap">{ruling.why}</Text>
159          {status === 'new' ? (
160            <Box flexDirection="row" columnGap={2} marginTop={1}>
161              <Button key={`start-${id}`} label="Start on it" variant="primary" onPress={() => start($, ruling)} />
162              <Button key={`later-${id}`} label="Later" plain onPress={() => choose($, ruling, 'later')} />
163              <Button key={`skip-${id}`} label="Skip" plain dimColor onPress={() => choose($, ruling, 'skipped')} />
164            </Box>
165          ) : (
166            <Text key={`status-${id}`} dimColor>
167              {STATUS_LABEL[status]}
168            </Text>
169          )}
170        </Box>
171        {drawn}
172      </Box>
173    )
174  })
175}
176
hooks/bouncer.ts 159 lines
1import type { Ruling, Seen, Verdict } from '../types'
2
3export const MARK = '[bouncer]'
4
5// Where the person lists what they work on, one project per line.
6export const PROJECTS_FILE = '.claude/bouncer/projects.md'
7
8// The projects in that file: its "- " lines, or its non-empty lines.
9export function projectsIn(text: string): string[] {
10  const lines = text
11    .split('\n')
12    .map(line => line.trim())
13    .filter(line => line !== '' && !line.startsWith('#'))
14
15  return lines.map(line => line.replace(/^[-*]\s+/, ''))
16}
17
18const URL_PATTERN = /https?:\/\/[^\s<>()"'`\]]+/g
19// Links that are work in progress, not something to weigh.
20const NOT_A_FIND = /^(localhost|127\.|0\.0\.0\.0|\[::1\]|[^/]*\.(local|test|localhost)\b|mail\.google\.com|docs\.google\.com|claude\.ai\/(code|chat|artifact)|chatgpt\.com\/(c|g)\/)/i
21// Past this much text beside the links, the prompt is a task, not a link to weigh.
22const MAX_ASIDE = 400
23
24export function linksIn(text: string): string[] {
25  return [...new Set((text.match(URL_PATTERN) ?? []).map(url => url.replace(/[.,;:!?]+$/, '')))]
26}
27
28// The links worth a verdict, when the prompt is mostly a link and a question.
29export function findsIn(text: string): string[] {
30  const links = linksIn(text)
31  const aside = links.reduce((rest, url) => rest.split(url).join(' '), text).trim()
32
33  if (aside.length > MAX_ASIDE) {
34    return []
35  }
36
37  return links.filter(url => !NOT_A_FIND.test(url.replace(/^https?:\/\//, '')))
38}
39
40// What rides beside the prompt, unseen, asking for the verdict line.
41export function brief(links: string[], projects: string[]): string {
42  return [
43    `The person pasted ${links.length === 1 ? 'a link' : 'links'}: ${links.join(' ')}`,
44    'Read what it is first (open the page, the repo or the video\'s transcript) and answer what they asked.',
45    ...(projects.length > 0
46      ? ['Judge it for their projects:', ...projects.map(project => `- ${project}`)]
47      : ['Judge it for what they are working on, from this conversation and folder.']),
48    '',
49    `Begin your final answer with one line of its own: ${MARK} followed by JSON, then a blank line, then your answer.`,
50    'The line is drawn as a card and hidden from the text; never mention it.',
51    `${MARK} {"verdict":"use|later|skip","url":"<the link>","title":"<2 to 5 words naming it>","for":["<project names it serves, empty for none>"],"effort":"<time to try it, e.g. Half a day>","cost":"<free, free tier, $20/mo, ...>","why":"<one sentence: what it would do for them, or why it isn't worth their time>","next":"<the prompt they'd send to start on it, in their voice>"}`,
52    'use: worth acting on now; later: real but not now; skip: hype, a course pitch, a scam, or nothing in it for them.',
53    'Keep effort and cost to four words each and why under 25 words; they sit on one small card.',
54    'Be blunt. With several links, judge the one they asked about most.',
55  ].join('\n')
56}
57
58const VERDICTS: readonly Verdict[] = ['use', 'later', 'skip']
59
60// The verdict line out of a reply block, and the block without it. A line still
61// streaming in is hidden too, before it parses.
62export function split(text: string): { ruling: Ruling | undefined; text: string } {
63  const lines = text.split('\n')
64  const last = lines.length - 1
65  const isStreamingIn = (line: string, index: number): boolean =>
66    index === last && line.trim() !== '' && MARK.startsWith(line.trim())
67  const at = lines.findIndex((line, index) => line.trimStart().startsWith(MARK) || isStreamingIn(line, index))
68
69  if (at === -1) {
70    return { ruling: undefined, text }
71  }
72
73  const rest = [...lines.slice(0, at), ...lines.slice(at + 1)].join('\n').replace(/^\s*\n/, '')
74
75  return { ruling: parseLine(lines[at] ?? ''), text: rest }
76}
77
78function parseLine(line: string): Ruling | undefined {
79  const json = line.trim().slice(MARK.length).trim()
80
81  try {
82    const found = JSON.parse(json) as Partial<Ruling>
83    const verdict = VERDICTS.find(entry => entry === found.verdict)
84
85    if (verdict === undefined || typeof found.why !== 'string') {
86      return undefined
87    }
88
89    return {
90      verdict,
91      url: String(found.url ?? ''),
92      title: String(found.title ?? found.url ?? 'This link'),
93      for: Array.isArray(found.for) ? found.for.map(String).filter(name => name.trim() !== '') : [],
94      effort: String(found.effort ?? ''),
95      cost: String(found.cost ?? ''),
96      why: found.why,
97      next: String(found.next ?? ''),
98    }
99  } catch {
100    return undefined
101  }
102}
103
104export const VERDICT_LABEL: Record<Verdict, string> = { use: 'Use it', later: 'Later', skip: 'Skip' }
105export const VERDICT_COLOR: Record<Verdict, string | undefined> = { use: 'success', later: 'warning', skip: undefined }
106export const STATUS_LABEL: Record<Seen['status'], string> = {
107  new: '',
108  started: 'Started',
109  later: 'Saved for later',
110  skipped: 'Skipped',
111}
112
113// A link's key across sessions.
114export function idOf(ruling: Ruling): string {
115  const key = (ruling.url || ruling.title).replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '')
116  let hash = 0
117
118  for (const char of key) {
119    hash = (hash * 31 + char.charCodeAt(0)) | 0
120  }
121
122  return `ruling-${(hash >>> 0).toString(36)}`
123}
124
125export function meta(ruling: Ruling): string {
126  return [ruling.effort, ruling.cost].filter(part => part.trim() !== '').join(' · ')
127}
128
129// /bouncer: what Bouncer has seen, by verdict, newest first.
130export function listing(seen: Seen[], now: number): string {
131  if (seen.length === 0) {
132    return 'Nothing yet. Paste a link and ask what it is worth.'
133  }
134
135  const day = (at: number): string => {
136    const days = Math.floor((now - at) / 86_400_000)
137
138    return days <= 0 ? 'today' : days === 1 ? 'yesterday' : `${days} days ago`
139  }
140
141  return VERDICTS.flatMap(verdict => {
142    const rows = seen.filter(entry => entry.verdict === verdict).sort((a, b) => b.at - a.at)
143
144    if (rows.length === 0) {
145      return []
146    }
147
148    return [
149      `${VERDICT_LABEL[verdict]}:`,
150      ...rows.map(entry => {
151        const status = STATUS_LABEL[entry.status]
152        const parts = [entry.title, entry.for.join(', '), day(entry.at), status].filter(part => part !== '')
153
154        return `  ${parts.join(' · ')}  ${entry.url}`
155      }),
156    ]
157  }).join('\n')
158}
159
types/index.d.ts 32 lines
1export type Verdict = 'use' | 'later' | 'skip'
2
3// What Claude says about a link, on the first line of its answer.
4export type Ruling = {
5  verdict: Verdict
6  url: string
7  title: string
8  for: string[]
9  effort: string
10  cost: string
11  why: string
12  // The prompt "Start on it" sends.
13  next: string
14}
15
16// A link Bouncer has looked at, kept across sessions for /bouncer.
17export type Seen = Ruling & {
18  id: string
19  at: number
20  status: 'new' | 'started' | 'later' | 'skipped'
21}
22
23declare module 'claude-code' {
24  interface PluginState {
25    'bouncer': {
26      isPaused: boolean
27      // Bumped when a card's choice changes, so the cards draw again.
28      choices: Record<string, Seen['status']>
29    }
30  }
31}
32