SLOPSHOPPER

yt-briefing

The /yt briefing loop in the chat: summary, rating dialog, research hand-off.

newcommandtoastpromptprocesstimer
★ 2v1.3.0MITupdated 2026-10-07michal90r/yt-briefing/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · yt-briefing
› 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 › /yt ⎿ yt-briefing: The briefing runs in the chat. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

yt-briefing

npm license node mac ubuntu windows

Save hours on YouTube. yt-briefing watches the channels you follow so you don't have to. For each new video it gives you a short briefing in your own language — every point that matters, with only the filler cut, so nothing important is lost. Reading it takes a fraction of the time the video would, so you stay on top of everything and only watch what's actually worth it.

It also gets better the more you use it. You give each summary a quick rating, worth my time or not, and from that it learns what to keep showing you and what to drop. Over time the queue becomes yours: less noise, more of what you care about.

yt-briefing runs inside Claude Code. /yt runs the briefing in your chat, and the filtering and summaries run on your own Claude Code login. There is no separate model, provider or LLM key to set up.

First run vs later

On a channel's first sweep there is no history, so yt-briefing takes the latest video of each kind: the newest long-form, the newest short, and the newest live. That gives you a baseline without pulling the whole back catalog.

After that it works from history. Each rating moves a per-type cursor forward, so later runs only surface videos newer than the ones you already handled, and a session just continues where the last one left off.

Setup

You'll need Node 18+ or Bun, a YouTube Data API v3 key, and Claude Code installed and logged in, with claude on your PATH.

  1. Install yt-dlp (it pulls the subtitles):
OSCommand
macOSbrew install yt-dlp
Windowswinget install yt-dlp
Linux / any Pythonpipx install yt-dlp

Keep it current with yt-dlp -U. YouTube changes often.

  1. Add the package with any package manager:
npm  i   yt-briefing
pnpm add yt-briefing
yarn add yt-briefing
bun  add yt-briefing
  1. Put your YouTube key in a .env at your project root:
YT_BRIEFING_YOUTUBE_API_KEY=<key>    # console.cloud.google.com → enable "YouTube Data API v3"

Optional extras: YT_BRIEFING_MODEL picks the Claude model for filtering and summaries (default sonnet, any alias or model name claude --model accepts, haiku to go lighter on your plan's usage limits), and YT_BRIEFING_PROXY routes transcript fetches through a proxy on datacenter/VPS IPs.

  1. Onboard:
npx yt-briefing init      # or: bunx yt-briefing init

init asks for your language and the channels to follow, then installs /yt and the two skills into your project's .claude/skills/.

Add or remove channels anytime:

npx yt-briefing add @handle https://youtube.com/@another   # one or more, handle or URL
npx yt-briefing remove @handle                              # also deletes its learned profile
npx yt-briefing list                                        # show the current list

One-off: transcribe a single video

Just want one video summarized — no channels, no queue, no rating? Run /yt-transcribe and paste a URL or video ID. It pulls that video's transcript and writes a journalist-grade summary in the language you chose at setup (the same output_lang as /yt). Want a one-off in another language? Just say so when you run it (e.g. /yt-transcribe <url> in German) — it won't change your setup. --lang pl|en is separate — it picks which caption track to fetch, not the summary language.

For example:

/yt-transcribe https://www.youtube.com/watch?v=dQw4w9WgXcQ

Search within a channel

Mine one channel's videos for a topic and get a comparison. Run /yt-search with a channel and an intent — for example:

/yt-search @betterstack which terminal for AI coding

It covers the channel's whole history (not just recent uploads), re-ranks every upload against your intent, then lazily yields one matching video at a time to keep or skip — and synthesizes a comparison from everything you kept.

The one flag is --top N — how many of the top re-ranked matches to triage (default 10). Raise it to go deeper, lower it for a quicker pass:

/yt-search @betterstack which terminal
/yt-search @betterstack which terminal --top 5

Run it

Open your project in Claude Code and type /yt. Each summary arrives as Claude's message in the chat, and a short dialog under it asks for the rating:

AnswerWhat it does
OKNeutral. The video is marked as seen, the next one loads.
WeakWorthless. The title goes to the channel's skip examples, so the filter learns to drop titles like it.
ResearchEnds the loop and hands this video to Claude, see below.
StopEnds the loop. The next /yt resumes where you stopped.

The dialog's Other field takes anything else. Type what you think in your own words ("too many panel shows, skip those"): Claude turns it into a standing rule for that channel and infers the rating. ? your question starts research with that question, stop ends the loop. A prompt you type in the chat also ends it.

It works the same in the terminal, the desktop app, claude.ai/code and the phone app. Each summary is a turn of its own that ends with the summary, so the phone app shows it whole instead of folding it into a one-line digest. Claude pastes the summary the engine wrote, so a turn is short, but it still counts toward your Claude plan's usage, like the summaries themselves.

/yt is a Claude Code mod (a plugin in .claude/skills/yt-briefing/). Claude Code loads it on its own once you trust the project folder. If /yt is not listed, start a fresh session. To install again, after an upgrade or into another project, run npx yt-briefing install-skill (it installs /yt, /yt-transcribe and /yt-search).

Don't shelve it — research it

Tech channels announce something new every week, and the usual fate is "looks interesting" → to-do list → never. So next to OK/Weak there is a third key: Research. Press it, or type ? your question into the dialog's Other field, and the loop ends there: the video lands in your chat with its briefing and the command for its full transcript. Claude works your question with you, against your own codebase if you ask "would this fit my project", against the web if the claims need checking. A quick feedback loop instead of a shelf. The video is marked as seen, and the next /yt resumes the queue right where you broke off.

Upgrading from 0.x

1.0 runs on Claude Code only. The OpenAI-compatible provider and its three YT_BRIEFING_LLM_* keys are gone (delete them from .env), and the chat-driven /yt skill with its rating popup is replaced by the /yt mod. Run npx yt-briefing install-skill once in your project: it installs the mod, and removes the old /yt skill and the summary-gate hook from .claude/settings.json. Your channels, profiles and ratings in .yt-briefing/data/ carry over unchanged.

Why Claude Code, and nothing else

Filtering and summaries are a claude -p call from the engine: one prompt in, one answer out, with no tools, no project settings or hooks, no MCP servers and no saved session. It runs on the login you already have, so there is no second model to pay for or keep a key to. If ANTHROPIC_API_KEY is set in your environment, the engine removes it for that call, because Claude Code would otherwise bill it as API usage instead of using your login.

The engine still works ahead in the background. It expands channels in parallel and summarizes the next video while you rate the current one, so each step is usually ready with no wait. The mod asks for the rating and records it itself, so the model never has to remember the protocol.

Supporting every agent that reads SKILL.md meant leaving the rating to the model: it asked the question and recorded the answer, plus a hook to make sure the summary was really shown. A Claude Code mod drives the loop itself, which is why 1.0 drops the other agents.

Why one transcript at a time

yt-briefing pulls transcripts lazily. It fetches the one you are about to read, warms the next one in the background while you rate, and stops there. It never grabs the whole queue up front.

That pacing is deliberate. Pulling many transcripts in a quick burst looks like scraping to YouTube and gets your IP rate-limited or blocked, which is easy to hit on a server. Fetching them at the speed you actually work through the queue keeps you under the radar and the queue flowing.

Sync across machines

Your state is plain files in .yt-briefing/data/. Version that folder (or point YT_BRIEFING_DATA_DIR at a separate private repo) and commit after each rating: set "after_rate" in .yt-briefing/data/config.json to a script and the engine runs it after every rating. Recipe: docs/sync-across-machines.md.

Running on a VPS

Claude Code has to be installed and logged in on the server too, because the engine runs claude -p there. With no browser on the box, claude setup-token creates a long-lived login token for your subscription. An ANTHROPIC_API_KEY alone is not enough: the engine removes it for its calls (see above), so a server set up only with an API key fails with a login error.

YouTube blocks datacenter IPs, so transcript fetches fail on most servers. Route them through a free Cloudflare WARP proxy. See docs/warp-proxy.md.

License

MIT, see LICENSE.

Source 3 files
hooks/register.tsx 187 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Pending } from '../types'
4import { engine } from './engine.ts'
5
6// The /yt rating loop, in the chat on every surface. Each briefing is the model's whole answer to a
7// prompt this plugin submits, so the summary shows in full in the terminal, the desktop app and on
8// a phone; the rating dialog opens once that turn has ended. Ratings and comments are written by
9// the engine, never by this module.
10
11/** A sweep can expand channels, fetch transcripts and summarize; give it the engine's full budget. */
12const SWEEP_MS = 10 * 60_000
13/** A rating is a file write; a raw comment adds one `claude -p` call to distill it. */
14const RATE_MS = 3 * 60_000
15
16type SweepOut = {
17  status: string
18  summary?: string
19  pending?: Pending
20  lang?: string
21}
22
23/** The engine prints one JSON line on stdout; take the last non-empty line to be safe. */
24function parseOut(stdout: string): SweepOut {
25  const line = stdout.trim().split('\n').filter(Boolean).pop() ?? ''
26  return JSON.parse(line) as SweepOut
27}
28
29/** One engine sweep: the next ratable video, or the end state. Throws when the engine does not answer. */
30async function runSweep($: EngineInterface): Promise<SweepOut> {
31  const run = await $.process.run(engine('yt-sweep'), { timeoutMs: SWEEP_MS })
32  return parseOut(run.stdout)
33}
34
35/** Run the rating engine; on failure toast why and resolve null. */
36async function record($: EngineInterface, args: string[]): Promise<{ rule?: string } | null> {
37  const run = await $.process.run([...engine('yt-rating'), ...args], { timeoutMs: RATE_MS }).catch(err => ({
38    exitCode: 1, stdout: '', stderr: String(err),
39  }))
40  if (run.exitCode !== 0) {
41    $.ui.toast(`Rating not saved: ${run.stderr.trim().slice(0, 200) || 'engine error'}`)
42    return null
43  }
44  let result: { rule?: string } = {}
45  try { result = JSON.parse(run.stdout.trim().split('\n').pop() ?? '{}') } catch { /* rating is on disk */ }
46  return result
47}
48
49/** Give the video to the session as the person's prompt, to research it together. */
50async function handOff($: EngineInterface, p: Pending, summary: string, lang: string | undefined, question?: string) {
51  const transcript = [...engine('yt-transcript'), p.videoId, '--lang', 'auto'].join(' ')
52  const ask = question
53    ? `My question: ${question}`
54    : 'Ask me first what I want to dig into.'
55  await $.prompt.submit({
56    asUser: true,
57    text: [
58      `Let's research this video together: ${p.channel} — "${p.title}" (${p.videoId}, ${p.type}).`,
59      '',
60      'Its briefing:',
61      summary,
62      '',
63      `For anything beyond the briefing, pull the full transcript with \`${transcript}\` and work from the tool result; never paste the transcript into the chat, quote only short passages. Keep what the video claims separate from what you verify yourself, and use whatever the question needs (my project, the web). Answer in ${lang ?? 'English'}.`,
64      ask,
65      '',
66      'If it turns out to be hype, record it with `' + [...engine('yt-rating'), '--rating', '0'].join(' ') + '`; a lasting preference about this channel goes in with `' + [...engine('yt-rating'), '--raw-comment', '"<what to remember>"'].join(' ') + '`.',
67    ].join('\n'),
68  }).catch(err => $.ui.toast(`Could not hand the video to the session: ${String(err)}`))
69}
70
71// A phone or web client draws a plugin's command output and its dialogs, but not its log lines,
72// and it folds text written mid-turn into a one-line digest; only a turn's last message shows whole.
73
74/** True while the chat loop runs; a prompt the person types ends it. */
75let looping = false
76
77/** An answer this soon after its dialog opened is a tap meant for the dialog before it. */
78const STRAY_TAP_MS = 1500
79
80const CHOICES = ['OK', 'Weak', 'Research', 'Stop'] as const
81
82/** What a briefing turn must do, read from the system prompt while the loop runs. */
83function loopSection(reset: boolean): string {
84  const sweep = [...engine('yt-sweep'), ...(reset ? ['--reset'] : [])].join(' ')
85  return [
86    `The yt-briefing plugin is running the person's YouTube briefing. A user message reading exactly "${NEXT_TEXT}" is its request for the next video:`,
87    `run \`${sweep}\`; it prints one JSON line. If \`status\` is \`rating_needed\`, answer with \`summary\` verbatim as your whole message, Markdown kept: nothing before or after it, no question, no tool call after it (the plugin opens the rating dialog itself).`,
88    'Otherwise answer with one short line saying why there is nothing to rate. Write in the language of the summaries.',
89  ].join('\n')
90}
91
92/** What the chat shows of each briefing prompt; the instructions ride along as unseen context. */
93const NEXT_TEXT = 'yt: next video'
94
95/** Whether the next briefing prompt starts a fresh sweep. */
96let resetNext = false
97
98/** Ask for a briefing once the hook that wants it has answered: one submitted from inside would wait on it. */
99function submitLater($: EngineInterface, reset: boolean) {
100  resetNext = reset
101  $.clock.after(0, () => void $.prompt.submit({ text: NEXT_TEXT, asUser: true }).catch(err => {
102    looping = false
103    $.ui.toast(`Could not continue the briefing: ${String(err)}`)
104  }))
105}
106
107/** After a briefing turn: ask for the rating, record it and fetch the next one. */
108async function rateAfterTurn($: EngineInterface) {
109  let out: SweepOut
110  try {
111    out = await runSweep($)
112  } catch {
113    looping = false
114    return
115  }
116  if (out.status !== 'rating_needed' || !out.pending || !out.summary) {
117    looping = false
118    return
119  }
120  const title = out.pending.title.length > 80 ? `${out.pending.title.slice(0, 79)}…` : out.pending.title
121  let answer: string
122  for (;;) {
123    const opened = Date.now()
124    try {
125      answer = (await $.ui.ask(`«${title}» — rating?`, { header: 'yt-briefing', options: CHOICES })).trim()
126    } catch {
127      looping = false
128      return
129    }
130    if (!looping) return
131    if (Date.now() - opened >= STRAY_TAP_MS) break
132  }
133
134  if (answer === 'Stop' || answer.toLowerCase() === 'stop' || !answer) {
135    looping = false
136    return
137  }
138  if (answer === 'Research' || answer.startsWith('?')) {
139    looping = false
140    if (!(await record($, ['--rating', '1']))) return
141    const q = answer.startsWith('?') ? answer.slice(1).trim() || undefined : undefined
142    return handOff($, out.pending, out.summary, out.lang, q)
143  }
144  const args = answer === 'OK' ? ['--rating', '1'] : answer === 'Weak' ? ['--rating', '0'] : ['--raw-comment', answer]
145  if (!(await record($, args))) {
146    looping = false
147    return
148  }
149  submitLater($, false)
150}
151
152export const register: Register = on => {
153  on('session.start', async ($, e, next) => {
154    await $.command.register({
155      name: 'yt',
156      description: 'Rate the next videos from the YouTube channels you follow',
157    })
158
159    return next(e)
160  })
161
162  on('command.run', { command: 'yt' }, async ($) => {
163    looping = true
164    submitLater($, true)
165
166    return { text: 'The briefing runs in the chat.' }
167  })
168
169  // The engine shows a plugin none of its own prompts, so a prompt seen here is the person's.
170  on('prompt.submit', async ($, e, next) => {
171    if (looping && e.origin?.kind !== 'plugin') looping = false
172    return next(e)
173  })
174
175  // The loop's instructions ride in the system prompt, so the chat shows only NEXT_TEXT.
176  on('prompt.compose', async ($, e, next) => {
177    const composed = await next(e)
178    if (!looping) return composed
179    return { sections: [...composed.sections, { id: 'yt-briefing:loop', text: loopSection(resetNext), scope: 'session' as const }] }
180  })
181
182  on('turn.complete', async ($, e, next) => {
183    if (looping && !e.agentId && e.reason === 'answer') void rateAfterTurn($)
184    return next(e)
185  })
186}
187
hooks/engine.ts 5 lines
1// How the mod runs the engine, relative to the project root (the session's working directory).
2// This is the dev-clone form; `yt-briefing install-skill` / `init` rewrites it for the project it
3// installs into (the compiled dist/ under node_modules, with the runtime that ran the installer).
4export const engine = (name: string): string[] => ['bun', `src/${name}.ts`]
5
types/index.d.ts 8 lines
1export type Pending = {
2  channel: string
3  videoId: string
4  title: string
5  type: string
6  publishedAt: string
7}
8