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

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 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.
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.
Claude Code 2.1.286 or later (mods). The card is drawn in the Claude desktop app and in the terminal.
In Claude Code:
/plugin marketplace add hacksurvivor/claude-mods
/plugin install bouncer@hacksurvivor
/reload-plugins
~/.claude/bouncer/projects.md.See PRIVACY.md.
Apache-2.0
hooks/register.tsx 176 lines1import { 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}
176hooks/bouncer.ts 159 lines1import 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}
159types/index.d.ts 32 lines1export 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