SLOPSHOPPER

reddit-watch

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…

newpanecommandtoastmodelnetwork
v0.4.0MITupdated 2026-10-11varunmoka7/reddit-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · reddit-watch
│ ┃ Reddit watch ✕ › fix the failing auth test and add an audit log call │ ┃ ● reddit watch 0 posts │ ┃ /reddit scan fetches, /reddit add <sub> adds ⏺ Read(src/auth.ts) │ ┃ one. ⎿ 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 │ │ › /reddit │ ⎿ reddit-watch: Pane opened. /reddit scan fetches new posts. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Reddit watch
● reddit watch 0 posts /reddit scan fetches, /reddit add <sub> adds one.
README

reddit-watch

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.

Why you might want it

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

Install

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.

How to use

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.

What it touches

  • Network: only 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.
  • Files: it reads and writes only its own small saved state (settings, the posts already read, the posts shown) in the folder where Claude Code keeps its own settings. It does not touch your project files.
  • Model calls: Haiku, one short call per new post, each post read once. The author's real use was about 29,000 input tokens and 1,900 output tokens to read 100 posts, which is roughly 300 tokens per post. After the first scan, an hourly check only pays for posts that are new.
  • Default: off until you run /reddit on.

Turn it off or uninstall

/reddit off stops all fetching and model calls. To remove it:

claude plugin uninstall reddit-watch@reddit-watch

Known limits

  • It only knows the last 50 posts each feed returns. A busy subreddit can push a post out before the next check.
  • Reddit may refuse requests when it is busy (error 429). The mod says so and tries again at the next check.
  • If the model fails to answer for a post three times, that post is skipped. It stays skipped while the mod still remembers it (see the next item) or until you run /reddit reset.
  • It remembers the last 1500 posts it read. Watching more than about 30 subreddits at once can make it re-read old posts.
  • If you run several Claude Code windows at once, each one checks on its own and pays for its own calls. They also save to the same place, so the last window to finish can overwrite what another just saved (posts shown, which posts were read, token counts). Some posts may then be read twice.
  • Haiku's picks are a guess. Some good posts will be missed and some weak ones shown.

License

MIT. See LICENSE.

Source 3 files
hooks/register.ts 258 lines
1import { 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}
258
hooks/lib.ts 45 lines
1export 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&amp;T", "&#39;", "&#x27;" -> the characters they stand for. One pass, so "&amp;lt;" becomes "&lt;", 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}
45
types/index.d.ts 8 lines
1export 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