Watches subreddits you choose, has Claude's small model (Haiku) pick out posts that could improve your Claude Code use, and lists them with links in a side…

A Claude Code mod (an add-on that changes how Claude Code behaves) that watches subreddits you pick and shows the posts that could improve how you use Claude Code, in a side pane with links.
Good Claude Code tricks (a way to save notes between sessions, a way to use fewer tokens) get posted on Reddit all day, and you will not read them all. Tokens are the units AI usage is measured in, roughly pieces of words. By default this watches r/ClaudeCode posts with the "Built with Claude" flair. Claude's small model (Haiku) reads each new post once and keeps only the ones that give you something specific to try. Example of a post it would show: "r/ClaudeCode: saves your notes between sessions".
Type these two lines in a terminal (not inside Claude Code):
claude plugin marketplace add varunmoka7/reddit-watch
claude plugin install reddit-watch@reddit-watch
Needs Claude Code 2.1.287 or newer (mods are on by default from that version). It uses only Claude Code's own features and plain web requests, so it should work on any system Claude Code runs on. It has only been tried on macOS.
Type these inside Claude Code. It is off after install. Nothing is fetched and no model is called until you type /reddit on.
/reddit on turns it on and scans the latest posts now. It then checks again every hour while Claude Code is open./reddit off turns it off. A check already under way stops before its next step; a request already sent to Haiku may still finish./reddit scan checks for new posts now. It only works while watching is on./reddit opens the side pane./reddit add <subreddit> [flair] watches another subreddit, for example /reddit add ClaudeAI Built with Claude. Replace <subreddit> with the subreddit's name. [flair] is an optional label; type it without the brackets. It limits the watch to posts with that label./reddit remove <subreddit> stops watching one./reddit list shows what is watched, whether it is on, and how many tokens Haiku has used ("in" is the text it read, "out" is the text it wrote)./reddit clear empties the list of posts shown in the pane./reddit reset forgets which posts were already read and starts over. If watching is on, it reads them all again right away, which costs tokens. If watching is off, nothing is read until you run /reddit on.www.reddit.com (its public RSS feeds, which are Reddit's lists of new posts, no login), plus Claude's own model call. Nothing else./reddit on./reddit off stops all fetching and model calls. To remove it:
claude plugin uninstall reddit-watch@reddit-watch
/reddit reset.MIT. See LICENSE.
hooks/register.ts 258 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { RedditHit } from '../types'
5import { feedUrl, isYes, parse, parseSub, reasonOf, type Sub } from './lib'
6
7const PANE = 'reddit-watch'
8const HOUR = 3600_000
9const DEFAULT_SUBS: Sub[] = [{ sub: 'ClaudeCode', flair: 'Built with Claude' }]
10const items = atom({ plugin: 'reddit-watch', key: 'hits' } as const, [])
11
12const JUDGE =
13 'You filter subreddit posts for a Claude Code user who wants techniques or tools to adopt. ' +
14 'Answer yes only if the post gives a reusable, specific idea for how to use Claude Code ' +
15 'better: a hook, skill, MCP server, plugin or mod, a memory or context method, orchestration or multi-agent setup, ' +
16 'cost or token saving, account or session management, a review or testing workflow. Answer no for games, toys, ' +
17 'unrelated apps, jokes, UI cosmetics, questions without answers, and self-promotion with no reusable idea. ' +
18 'Reply "yes: <what to learn, max 12 words>" or "no".'
19
20async function subsOf($: any) {
21 return ((await $.store.get('subs')) ?? DEFAULT_SUBS) as Sub[]
22}
23
24type Refreshed = { added: RedditHit[]; errors: string[]; cancelled: boolean }
25let running: Promise<Refreshed> | null = null
26let runGen = -1
27// Bumped by /reddit off, clear and reset. A scan that sees it change stops; one that began before a wipe never writes.
28let gen = 0
29let wipedAt = 0
30
31const MAX_TRIES = 3
32const MAX_FAILS = 200
33
34// The hourly poll and a typed command can start together; they share one run so no post is judged twice.
35// A run that was cancelled or wiped is let finish first, then a fresh one starts.
36async function refresh($: any): Promise<Refreshed> {
37 while (running && runGen !== gen) await running.catch(() => {})
38 if (running) return running
39 runGen = gen
40 const p: Promise<Refreshed> = scan($, gen).finally(() => {
41 if (running === p) running = null
42 })
43 return (running = p)
44}
45
46// Stops any scan and waits for it, so what clear or reset writes next cannot be overwritten by it.
47async function wipe() {
48 wipedAt = ++gen
49 await running?.catch(() => {})
50}
51
52// One Haiku call per post never judged before; steady-state a poll only pays for new posts.
53async function scan($: any, my: number): Promise<Refreshed> {
54 const judged = new Set(((await $.store.get('judged')) ?? []) as string[])
55 const hits = ((await $.store.get('hits')) ?? []) as RedditHit[]
56 const fails = ((await $.store.get('fails')) ?? {}) as Record<string, number>
57 let tokens = ((await $.store.get('tokens')) ?? { in: 0, out: 0 }) as { in: number; out: number }
58 const added: RedditHit[] = []
59 const errors: string[] = []
60 const stopped = () => gen !== my
61 // Retried on later polls, but not forever: a post the model never answers is given up on.
62 const fail = (id: string) => {
63 fails[id] = (fails[id] ?? 0) + 1
64 if (fails[id]! >= MAX_TRIES) {
65 delete fails[id]
66 judged.add(id)
67 }
68 }
69 for (const s of await subsOf($)) {
70 if (stopped()) break
71 try {
72 const res = await $.http.fetch(feedUrl(s), { headers: { 'User-Agent': 'Mozilla/5.0 reddit-watch/0.2' } })
73 if (!res.ok) {
74 errors.push(`r/${s.sub}: reddit returned ${res.status}${res.status === 429 ? ' (too many requests, will retry later)' : ''}`)
75 continue
76 }
77 for (const p of parse(res.text).filter(p => p.id && !judged.has(p.id))) {
78 if (stopped()) break
79 let r
80 try {
81 r = await $.model.complete({
82 model: 'haiku',
83 system: JUDGE,
84 prompt: `Title: ${p.title}\n\n${p.body}`,
85 maxTokens: 40,
86 effort: 'low',
87 })
88 } catch (err) {
89 errors.push(`r/${s.sub}: ${(err as Error).message}`)
90 fail(p.id)
91 continue
92 }
93 if (stopped()) break
94 if (!r.isAnswered) {
95 fail(p.id)
96 continue
97 }
98 delete fails[p.id]
99 tokens = { in: tokens.in + (r.usage.input_tokens ?? 0), out: tokens.out + (r.usage.output_tokens ?? 0) }
100 judged.add(p.id)
101 if (isYes(r.text)) added.push({ id: p.id, sub: s.sub, title: p.title, link: p.link, reason: reasonOf(r.text) })
102 }
103 } catch (err) {
104 errors.push(`r/${s.sub}: ${(err as Error).message}`) // keep what earlier posts already cost
105 }
106 }
107 // After a clear or reset this scan's picture of the store is out of date, so it writes nothing.
108 if (my < wipedAt) return { added: [], errors, cancelled: true }
109 const all = [...added, ...hits].slice(0, 200)
110 await $.store.set('judged', [...judged].slice(-1500))
111 await $.store.set('fails', Object.fromEntries(Object.entries(fails).slice(-MAX_FAILS)))
112 await $.store.set('hits', all)
113 await $.store.set('tokens', tokens)
114 await update($, items, () => all)
115 return { added, errors, cancelled: stopped() }
116}
117
118async function openPane($: any) {
119 await $.ui.open({ id: PANE, title: 'Reddit watch' })
120}
121
122async function reloadHits($: any) {
123 const stored = ((await $.store.get('hits')) ?? []) as RedditHit[]
124 await update($, items, () => stored)
125}
126
127export const register: Register = on => {
128 on('session.start', async ($, e, next) => {
129 await $.command.register({
130 name: 'reddit',
131 description: 'Subreddit watch',
132 argumentHint: '[reset | scan | on | off | add <sub> [flair] | remove <sub> | list | clear]',
133 })
134 await reloadHits($)
135 // Never rejects: a failure is shown to the user instead.
136 const poll = async () => {
137 try {
138 if (!(await $.store.get('enabled'))) return
139 const { added, errors, cancelled } = await refresh($)
140 if (cancelled) return
141 if (errors.length) $.ui.toast(`Reddit watch: ${errors.join('; ')}`)
142 if (!added.length) return
143 $.ui.toast(`Reddit: ${added.length} new post(s) may help your Claude Code use`)
144 await openPane($) // seats only on a wide terminal; the toast covers the rest
145 } catch (err) {
146 try {
147 $.ui.toast(`Reddit watch failed: ${(err as Error).message}`)
148 } catch {}
149 }
150 }
151 void poll() // on at session start: scan the latest posts now
152 $.clock.every(HOUR, poll)
153 return next(e)
154 })
155
156 // /clear, /resume and /branch empty the pane's list without a new session.start; load the saved hits again.
157 on('classic.SessionStart', async ($, e, next) => {
158 if (e.source === 'clear' || e.source === 'resume' || e.source === 'fork') await reloadHits($)
159 return next(e)
160 })
161
162 on('command.run', { command: 'reddit' }, async ($, e) => {
163 const [verb, ...rest] = e.args.trim().split(/\s+/)
164 const arg = rest.join(' ')
165 try {
166 if (verb === 'on' || verb === 'off') {
167 await $.store.set('enabled', verb === 'on')
168 if (verb === 'off') {
169 gen++ // a running scan stops before its next fetch, model call or notice
170 return { text: 'Reddit watch off. No polling, no Haiku calls.' }
171 }
172 const { added, errors, cancelled } = await refresh($)
173 if (cancelled) return { text: 'Reddit watch scan stopped.' }
174 await openPane($)
175 return { text: `Reddit watch on. Scanned the latest posts: ${added.length} relevant.${errors.length ? `\n${errors.join('\n')}` : ''}` }
176 }
177 if (verb === 'add' || verb === 'remove') {
178 const s = parseSub(arg)
179 if (!s) return { text: `Usage: /reddit ${verb} <subreddit> ${verb === 'add' ? '[flair]' : ''}`.trim() }
180 const subs = (await subsOf($)).filter(x => x.sub.toLowerCase() !== s.sub.toLowerCase())
181 await $.store.set('subs', verb === 'add' ? [...subs, s] : subs)
182 return { text: `${verb === 'add' ? 'Watching' : 'Stopped watching'} r/${s.sub}${s.flair ? ` (flair: ${s.flair})` : ''}. Next /reddit picks it up.` }
183 }
184 if (verb === 'list') {
185 const status = (await $.store.get('enabled')) ? 'on' : 'off'
186 const t = ((await $.store.get('tokens')) ?? { in: 0, out: 0 }) as { in: number; out: number }
187 return {
188 text:
189 `Watch is ${status}\n` +
190 (await subsOf($)).map(s => `r/${s.sub}${s.flair ? ` (flair: ${s.flair})` : ''}`).join('\n') +
191 `\n\nHaiku so far: ${t.in} in / ${t.out} out tokens`,
192 }
193 }
194 if (verb === 'reset') {
195 await wipe()
196 await $.store.set('judged', [])
197 await $.store.set('fails', {})
198 await $.store.set('hits', [])
199 await update($, items, () => [])
200 if (!(await $.store.get('enabled'))) return { text: 'Judged posts cleared. /reddit on to rescan.' }
201 const { added, errors, cancelled } = await refresh($)
202 if (cancelled) return { text: 'Reddit watch scan stopped.' }
203 return { text: `Re-judged everything: ${added.length} relevant.${errors.length ? `\n${errors.join('\n')}` : ''}` }
204 }
205 if (verb === 'clear') {
206 await wipe()
207 await $.store.set('hits', [])
208 await update($, items, () => [])
209 return { text: 'Cleared.' }
210 }
211 await openPane($)
212 if (verb !== 'scan') return { text: 'Pane opened. /reddit scan fetches new posts.' }
213 if (!(await $.store.get('enabled'))) return { text: 'Reddit watch is off. /reddit on to start.' }
214 const { added, errors, cancelled } = await refresh($)
215 if (cancelled) return { text: 'Reddit watch scan stopped.' }
216 return { text: `${added.length} new relevant post(s).${errors.length ? `\n${errors.join('\n')}` : ''}` }
217 } catch (err) {
218 return { text: `reddit-watch failed: ${(err as Error).message}` }
219 }
220 })
221
222 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
223 const { Box, Text, Link } = $.ui.resolve(e)
224 const hits = await read($, items)
225
226 // Plain element calls, no JSX: the JSX factory `h` is not in scope in a module that imports ./lib.
227 const ORANGE = '#FF4500' // Reddit orange-red
228 const BLUE = '#5f99cf'
229 const header = Text({ bold: true, color: ORANGE, children: `● reddit watch ${hits.length} post${hits.length === 1 ? '' : 's'}` })
230 if (hits.length === 0)
231 return Box({
232 flexDirection: 'column',
233 children: [header, Text({ dimColor: true, children: '/reddit scan fetches, /reddit add <sub> adds one.' })],
234 })
235
236 return Box({
237 flexDirection: 'column',
238 children: [
239 header,
240 ...hits.map(hit =>
241 Box({
242 flexDirection: 'column',
243 borderStyle: 'round',
244 borderColor: '#3a3a3c',
245 paddingX: 1,
246 children: [
247 Text({ children: [Text({ bold: true, color: BLUE, children: `r/${hit.sub}` }), Text({ dimColor: true, children: ' ▲ worth a look' })] }),
248 Text({ bold: true, children: hit.title }),
249 hit.reason ? Text({ dimColor: true, italic: true, children: hit.reason }) : null,
250 Text({ children: Link({ href: hit.link, children: '↗ open thread' }) }),
251 ],
252 }),
253 ),
254 ],
255 })
256 })
257}
258hooks/lib.ts 45 lines1export type Sub = { sub: string; flair?: string }
2export type Post = { id: string; title: string; link: string }
3
4// Reddit's JSON endpoint 403s non-browser clients; the Atom feed is open (no scores).
5export const feedUrl = ({ sub, flair }: Sub) =>
6 flair
7 ? `https://www.reddit.com/r/${sub}/search.rss?q=${encodeURIComponent(`flair_name:"${flair}"`)}&restrict_sr=1&sort=new&limit=50`
8 : `https://www.reddit.com/r/${sub}/new.rss?limit=50`
9
10export const isYes = (reply: string) => /^\s*yes/i.test(reply)
11
12// "Yes: persists memory across sessions" -> "persists memory across sessions"
13export const reasonOf = (reply: string) => reply.replace(/^\s*yes\W*/i, '').trim().slice(0, 100)
14
15// "add r/ClaudeAI Built with Claude" -> { sub: 'ClaudeAI', flair: 'Built with Claude' }; null when the name is not a subreddit
16export function parseSub(args: string): Sub | null {
17 const [name = '', ...flair] = args.trim().split(/\s+/)
18 const sub = name.replace(/^\/?r\//i, '')
19 return /^[A-Za-z0-9_]{2,21}$/.test(sub) ? { sub, ...(flair.length && { flair: flair.join(' ') }) } : null
20}
21
22const NAMED: Record<string, string> = {
23 amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', hellip: '…', mdash: '—', ndash: '–',
24 lsquo: '‘', rsquo: '’', ldquo: '“', rdquo: '”', copy: '©',
25}
26
27// "AT&T", "'", "'" -> the characters they stand for. One pass, so "&lt;" becomes "<", not "<".
28export const decode = (s: string) =>
29 s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, c: string) => {
30 if (c[0] !== '#') return NAMED[c.toLowerCase()] ?? m
31 const n = c[1]?.toLowerCase() === 'x' ? parseInt(c.slice(2), 16) : parseInt(c.slice(1), 10)
32 return n > 0 && n <= 0x10ffff && !(n >= 0xd800 && n <= 0xdfff) ? String.fromCodePoint(n) : m
33 })
34
35export function parse(xml: string): (Post & { body: string })[] {
36 const tag = (s: string, t: string) => s.match(new RegExp(`<${t}[^>]*>([\\s\\S]*?)</${t}>`))?.[1] ?? ''
37 return [...xml.matchAll(/<entry>([\s\S]*?)<\/entry>/g)].map(([, e = '']) => ({
38 id: tag(e, 'id'),
39 title: decode(tag(e, 'title')),
40 link: decode(e.match(/<link[^>]*href="([^"]+)"/)?.[1] ?? ''),
41 // The content is HTML written as XML text: decode it to real HTML, drop the tags, then decode the page's own entities.
42 body: decode(decode(tag(e, 'content')).replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim().slice(0, 600),
43 }))
44}
45types/index.d.ts 8 lines1export type RedditHit = { id: string; sub: string; title: string; link: string; reason: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'reddit-watch': { hits: RedditHit[] }
6 }
7}
8