# Handoff: build me a YouTube curation pipeline

You are an AI coding agent (Claude Code or similar) with shell access, file access,
and a way to run a job on a schedule. Your task: build a working YouTube curation
pipeline for your user, then prove it works end-to-end.

Everything you need is in this file. Work through the milestones in order and verify
each one before moving on.

## What you're building

Two ordinary YouTube playlists used as queues, with a nightly AI job between them:

```
[To Review]  ──►  nightly job  ──►  [To Watch]     score 4–5/5
  inbox            per video:
                   1. fetch transcript
                   2. rate 1–5 against
                      the user's interest
                      profile
                   3. apply the gate     ──►  removed      score ≤3
```

The user saves videos to **"To Review"** from any device — phone, TV, browser. Every
night, a scheduled job fetches each video's transcript, rates it 1–5 against a
personal interest profile, promotes 4–5/5 videos to **"To Watch"**, and removes the
rest. The user only ever opens "To Watch": a short queue where everything already
passed their own bar.

## Ground rules (non-negotiable)

1. **Only touch the two playlists this pipeline owns.** Never modify subscriptions,
   likes, watch history, or any other playlist.
2. **Removing = removing from the "To Review" playlist.** Nothing is ever deleted from
   YouTube itself.
3. **Conservative failure mode:** if you cannot rate a video (no transcript, fetch
   error), promote it to "To Watch" un-rated and say so in the log. Never silently
   discard anything.
4. **Secrets stay local.** OAuth credentials, tokens, and API keys live in local files
   with `chmod 600`, never in git, never echoed to the terminal.
5. **Verify every milestone** with a real command before telling the user it works.

## Milestone 0 — Interview your user (~5 minutes)

Ask one question at a time. Write the answers into `interests.md` using the template
in Appendix A.

1. **Topics:** "What do you actually want to watch? List topics and rank each HIGH /
   MEDIUM / LOW." Push for concrete anchors: favorite channels, 2–3 videos they loved.
2. **Quality bar:** "What makes a video worth your time — depth, novelty, hands-on
   demos? And what makes you close one — fluff, hype, recycled news?"
3. **Languages:** which languages they watch in (transcripts exist for most).
4. **Strictness:** "Should the gate be aggressive (when in doubt, cut) or lenient
   (when in doubt, keep)?" Map this to the rubric in Appendix A.
5. **Rating engine:** does the user (a) have a cheap LLM API key (Anthropic, Google,
   OpenAI — any works), or (b) prefer the nightly job to run as an agent session in
   their existing agent subscription? This picks Path A or B in Milestone 5.
   Path A is the default: cheaper, deterministic, easier to schedule.

## Milestone 1 — Prerequisites

- Python 3.10+ and a virtualenv:

```bash
mkdir -p ~/youtube-curator && cd ~/youtube-curator
python3 -m venv .venv
.venv/bin/pip install google-api-python-client google-auth-oauthlib youtube-transcript-api
```

- Pin the transcript library version in a `requirements.txt` (its API changed between
  major versions — write code against the version you actually installed).
- Optional but recommended fallback for transcripts: `yt-dlp`
  (`brew install yt-dlp` / `pipx install yt-dlp`). Pin it too, and re-check it after
  YouTube-side breakages (`yt-dlp -U`).
- A Google account with YouTube.

Verify: `.venv/bin/python3 -c "import googleapiclient, google_auth_oauthlib"` exits 0.

## Milestone 2 — YouTube API access (the one manual part)

Your user must click through Google Cloud Console themselves — you can't do it for
them, but you can give them the exact path. The console gets reorganized every few
years, so if a menu name below doesn't match, search for the feature name inside the
console. As of 2026:

1. Go to `console.cloud.google.com` → create project, name it `youtube-curator`.
2. **APIs & Services → Library** → enable **YouTube Data API v3**.
3. **APIs & Services → Google Auth Platform** (formerly "OAuth consent screen") →
   under **Audience**, set User type = **External** and add the user's own email under
   **Test users**; set the app name + support email under **Branding**.
4. **Clients → Create client → Desktop app** → download the JSON as
   `client_secret.json` into the project folder. `chmod 600` it.
5. **Important:** while the app is in *Testing* mode, refresh tokens expire every
   7 days and the pipeline will silently stop. After the acceptance test passes, have
   the user publish the app (**Audience → Publish app**). Refresh tokens then stop
   expiring. The YouTube scope is a *sensitive* scope, so an unverified production app
   shows an "unverified app" warning during login (click *Advanced → continue* — 
   expected) and is capped at 100 users, which is a non-issue because your user is the
   only user. Full Google verification is only needed to serve strangers.

Quota: the free tier gives 10,000 units/day. This pipeline uses roughly 100–500/day
(playlist writes cost 50 units each) — nowhere near the limit.

**Headless machines:** the first OAuth run opens a browser
(`InstalledAppFlow.run_local_server`) — Google removed the copy-paste (OOB) flow in
2022, so there is no browserless first auth. If the pipeline will live on a headless
box (Raspberry Pi, NAS), run `yt.py auth` once on a machine with a display and copy
the resulting `token.json` over. Refreshes after that are automatic and headless.

## Milestone 3 — Playlists

The simplest path: have the user create two **private** playlists in the YouTube app
or website — `To Review` and `To Watch` — or reuse ones they already have. Get the
playlist IDs (the `list=PL...` parameter in each playlist's URL) and store them in
`config.json`:

```json
{ "to_review": "PL...", "to_watch": "PL..." }
```

## Milestone 4 — The helper script

Write `yt.py` — one file, argparse CLI, commands:

| Command | Behavior |
|---------|----------|
| `auth` | Run/refresh the OAuth flow (`google_auth_oauthlib.flow.InstalledAppFlow`, scope `https://www.googleapis.com/auth/youtube`), cache token to `token.json` (chmod 600) |
| `list <to_review\|to_watch>` | Print each video as `VIDEO_ID<TAB>title<TAB>channel` |
| `add <playlist> <video_id>` | `playlistItems.insert` |
| `remove <playlist> <video_id>` | Find the **playlist-item id** via `playlistItems.list(part="id", playlistId=<PL>, videoId=<VID>)`, then `playlistItems.delete(id=items[0].id)`. (Deletion needs the playlist-item id, not the video id — the classic trap.) |
| `transcript <video_id>` | Try `youtube-transcript-api` (any language from `interests.md`); on failure, fall back to `yt-dlp --write-auto-sub --skip-download` (prefer VTT/SRT formats) and parse the file; on total failure print `{"error": "..."}` |

Implementation notes:

- Read playlist IDs from `config.json`; token refresh should be automatic inside
  every command.
- Paginate `playlistItems.list` (`maxResults=50` + `pageToken`) — a first run
  against a years-old backlog will have more than 50 videos.
- Exit non-zero on failure so the nightly job can react.
- Test every command manually now: `auth`, `list`, `add` + `remove` a test video,
  `transcript` on a known video (e.g. any talk with captions).

## Milestone 5 — The nightly job (two paths — pick per Milestone 0, question 5)

Both paths implement the same loop and the same rules:

> For each video in "To Review": fetch transcript → rate 1–5 against `interests.md`
> (judge the transcript's CONTENT, not the title, in both directions) → score 4–5:
> add to "To Watch", then remove from "To Review"; score ≤3: remove. Transcript
> unavailable: promote un-rated. **Always add before remove** — never leave a video
> promoted and still in the inbox, and never removed-but-nowhere. Append one line to
> `curation-log.md`: date, counts (promoted / removed / un-rated), and a short
> per-video reason. If a command fails twice in a row, stop and leave the rest for
> tomorrow's run.

**Path A (default) — `rate.py` + one LLM API call per video.**
Write `rate.py` that runs the loop above, calling a cheap LLM API for the rating:
send `interests.md` as the system prompt and the transcript (plus title + channel) as
the user message; ask for `SCORE: <1-5>` and `REASON: <one line>` and parse them.
Any provider works — e.g. Claude Haiku costs roughly $1–2/month at 5 videos/day;
Gemini's free tier costs nothing if the user stays within its daily request limits.
Read the API key from an env var or a chmod-600 file. Deterministic, testable,
cron-friendly.

**Path B — agent session.** If the user prefers running it inside their agent
subscription: write the loop as a prompt file `nightly-job.md` ("Work in
~/youtube-curator. Run the YouTube curation pass: …" — the rules above, plus the
`yt.py` commands to use), and schedule an agent session that executes it. You (the
building agent) rate the videos directly in-session — no extra API key needed.

## Milestone 6 — Schedule it

1. **Default: the OS scheduler** — `cron` or `launchd` (macOS), `systemd` timer
   (Linux), Task Scheduler (Windows). Path A: run `rate.py` nightly. Path B: run a
   headless agent call, e.g. `claude -p "$(cat ~/youtube-curator/nightly-job.md)"`.
   Pick a time the machine is usually awake (e.g. 23:00).
2. **Click-free alternative** for non-terminal users: an agent app's built-in
   scheduled routine. Honest caveat: desktop-app schedulers are the least reliable
   option for unattended work (missed runs when the app is closed, occasional
   permission stalls) — prefer the OS scheduler when possible.

Two honest constraints to tell the user:

- The job only runs when this machine is awake. A missed night is harmless — the
  inbox just waits.
- **Don't move this to a cloud VM or CI runner.** YouTube blocks transcript fetching
  from datacenter IPs; the transcript step effectively requires a residential IP, so
  this pipeline belongs on a home machine (laptop, Mac mini, Raspberry Pi, NAS).

## Milestone 7 — Acceptance test (do not skip, do not fake)

1. Have the user drop **three** videos into "To Review": one clearly on-profile, one
   clearly off-profile, one borderline.
2. Run the job manually (Path A: `python3 rate.py`; Path B: execute `nightly-job.md`
   yourself, now).
3. Verify with `yt.py list` on both playlists: on-profile landed in "To Watch",
   off-profile is gone, and `curation-log.md` has the line with scores + reasons.
4. Walk the user through the reasons. If the borderline call feels wrong to them,
   tune `interests.md` (usually the strictness line), not the code.
5. Only after this passes, remind the user to publish the OAuth app
   (Milestone 2, step 5).

## Appendix A — interests.md template

```markdown
# My YouTube interest profile

## Topics
| Topic | Priority | Anchors (channels / example videos) |
|-------|----------|--------------------------------------|
| AI coding tools | HIGH | ... |
| Woodworking | MEDIUM | ... |
| ... | LOW | ... |

## What earns my time
Depth over news. Hands-on demos over opinion. Original analysis over recaps.

## What loses my time
Hype without substance, 20-minute videos with 3 minutes of content, reaction content.

## Languages
English, Czech.

## Strictness
When in doubt, cut. A missed gem costs less than a wasted evening.

## Rubric (scale the nightly job uses)
5 — squarely in a HIGH topic AND clears the quality bar; I'd watch it tonight.
4 — solid: on-topic with real substance, or exceptional content in a MEDIUM topic.
3 — fine but generic; I wouldn't miss it. (Gate: removed.)
2 — off-profile or thin content.
1 — clickbait, hype, or noise.
```

## Appendix B — Troubleshooting

| Symptom | Fix |
|---------|-----|
| OAuth works, then dies after a week | App still in Testing mode → publish it (Milestone 2, step 5) |
| `transcript` fails on everything | Transcript scraping is IP-blocked or the library broke after a YouTube change → update the pinned `yt-dlp` (`yt-dlp -U`) and the transcript library; if running on a cloud/datacenter IP, move to a home machine |
| A whole run promotes everything un-rated | That's the transcript layer being down, not an unusually good inbox — see the row above. The log's un-rated count is your health signal |
| `remove` says video not found | You're passing a video id where a playlist-item id is needed — see Milestone 4 |
| Job never runs | Machine asleep at schedule time → pick a time it's awake, or accept catch-up-next-day behavior |
| Everything gets removed / everything passes | Tune `interests.md` strictness line and topic priorities; re-run on 2–3 known videos |

## Appendix C — Zero-machine variant (Google Apps Script + Gemini)

If the user has no machine that's reliably awake, offer this variant instead of
Milestones 4–6: a **Google Apps Script** project (script.google.com) with the
**YouTube advanced service** enabled for playlist read/write, a **daily time-driven
trigger**, and a `UrlFetchApp` call to the **Gemini API** passing each video's
YouTube **URL** directly — Gemini analyzes the video itself, so there is no
transcript scraping at all. Store `interests.md`'s content as a constant in the
script; same rules, same gate, log to a Google Sheet or the script's log. Costs
nothing (Apps Script is free; Gemini's free tier covers a few videos a day — it
allows up to 8 hours of YouTube video per day as of 2026, public videos only).
Trade-offs: the GCP setup (Milestone 2) is still required, the user now owns ~100
lines of JavaScript instead of Python, and Gemini's URL-ingestion free allowance is
a preview feature that may change.
