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

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.
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.
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.
| OS | Command |
|---|---|
| macOS | brew install yt-dlp |
| Windows | winget install yt-dlp |
| Linux / any Python | pipx install yt-dlp |
Keep it current with yt-dlp -U. YouTube changes often.
npm i yt-briefing
pnpm add yt-briefing
yarn add yt-briefing
bun add yt-briefing
.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.
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
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
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
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:
| Answer | What it does |
|---|---|
| OK | Neutral. The video is marked as seen, the next one loads. |
| Weak | Worthless. The title goes to the channel's skip examples, so the filter learns to drop titles like it. |
| Research | Ends the loop and hands this video to Claude, see below. |
| Stop | Ends 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).
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.
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.
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.
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.
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.
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.
MIT, see LICENSE.
hooks/register.tsx 187 lines1import 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}
187hooks/engine.ts 5 lines1// 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`]
5types/index.d.ts 8 lines1export type Pending = {
2 channel: string
3 videoId: string
4 title: string
5 type: string
6 publishedAt: string
7}
8