---
name: glanceable-podcast
description: Turn a YouTube video or a web article into a "glanceable" podcast episode and add it to the user's private podcast feed. Videos keep their audio, with a frame every few seconds as chapter artwork. Articles are read aloud with open-source TTS, and their images become chapter artwork, with a chime and a spoken caption as each one appears. Use when the user shares a video or article link and asks to make it glanceable, turn it into a podcast episode, add it to their glanceable podcast feed, read it later, or listen to it as a podcast. Also use for managing that feed (list, remove, subscribe, rotate the URL). Runs in Claude Code on the user's Mac only.
---

# Glanceable podcast

Setup and background: https://tools.jamesking.io/glanceable-podcast/. Video episodes reimplement Alex Chan's glancecast technique (https://alexwlchan.net/2026/glancecast/, original tool at https://alexwlchan.net/projects/glancecast/).

The scripts in `scripts/` (next to this file) do all the mechanical work. They are self-contained `uv` scripts, so run them directly; their Python dependencies install themselves on first run.

| Script | Does |
|---|---|
| `scripts/video_episode.py` | video → `episode.mp3` + `episode.json` (+ optional `transcript.txt`) |
| `scripts/fetch_article.py` | article URL → `article.json` + images, for you to review |
| `scripts/article_episode.py` | `article.json` → `episode.mp3` + `episode.json`, spoken with Kokoro |
| `scripts/publish.py` | uploads to Cloudflare R2 and rebuilds the feed |
| `scripts/glance.py` | shared helpers; not run directly |

Don't recreate their steps with ad-hoc `yt-dlp`/`ffmpeg`/TTS commands. If a script fails, fix the cause and re-run it.

## Secrets: hard rules

- The R2 credentials and the feed's secret token live in the user's secret store: the macOS Keychain or 1Password, as set in the config. The scripts read them. **Never** read, print, `echo` or `cat` them. Never run `security find-generic-password`, `op read`, `op item get` or anything similar yourself, and never ask the user to paste a secret into the chat.
- **Never** write the feed URL, or any bucket object URL, into the conversation, a file or a commit. If the user wants the feed URL, run `publish.py feed-url`, which copies it to their clipboard without printing it.
- If a secret is missing, the scripts say where and how to add it. Pass that on for the user to do themselves; don't add secrets on their behalf.
- `~/.config/glanceable-podcast/config.json` holds only non-secret settings (account ID, bucket, public base URL, feed title, which secret store). Reading it is fine.

## Before either kind of episode

**First run in a session, or after any failure:** run `scripts/publish.py check`. If it isn't set up, point the user at the tool page above. Don't try to set up R2 yourself.

YouTube (and other video sites yt-dlp supports) → **Adding a video**. Anything else that's a page of text → **Adding an article**.

## Adding a video

1. **Pick the frame mode.** Follow the user's preference if they give one; otherwise:
   - Talks, lectures, slide decks, screen recordings: `--scene 0.3`. One picture per slide change gives fewer, more meaningful images and a smaller file. Use 0.4 if there are too many frames, 0.2 if slide changes were missed.
   - Anything else: the default (`--interval 5`).
   - Videos over ~90 minutes in interval mode: `--interval 10`, to keep the file a sensible size.

2. **Make the episode:**
   ```
   scripts/video_episode.py "<URL>" --transcript [--scene 0.3] [--remove-sponsors]
   ```
   This also handles Substack video posts (yt-dlp alone only sees their podcast audio): the script finds the post's uploaded video itself. Add `--remove-sponsors` only if the user asks to skip sponsor segments. It cuts the audio, so transcript timestamps no longer line up; don't derive section times from the transcript in that case. Expect about a minute plus download time for an hour-long video. YouTube often rate-limits captions; the episode is still made without a transcript.

3. **Improve the metadata** by editing `episode.json`:
   - `title`: keep the video title unless it's clickbait or unclear without the thumbnail. Then make it plain and descriptive. Drop a trailing " - Channel name".
   - `notes`: 2–4 short plain-text paragraphs (blank line between them) summarising what the video covers, written from `transcript.txt`. Be factual, with no hype. Without a transcript, summarise only what the `description` actually says.
   - `sections`: if the post or description lists timestamped topics ("[4:31] …"), use those. Otherwise, if empty and there's a transcript, add 4–10 entries `{"start": <seconds>, "title": "..."}` at real topic changes, using the transcript timestamps. Leave existing YouTube chapters alone. These become tappable timestamps in the show notes.
   - Don't change `mp3`, `cover`, `source_id` or `duration_seconds`.

4. **Publish** (below), then report: title, length, file size, number of frames and the frame mode.

## Adding an article

1. **Fetch it:**
   ```
   scripts/fetch_article.py "<URL>"
   ```
   It prints a summary: words and estimated minutes, headings, image groups, any images with no caption or useful alt text, and any maths. If it warns about a paywall, finds no text, or the site blocks scripts (e.g. a Cloudflare "Just a moment" page, as on pnas.org), ask the user to save the page from their browser (File → Save Page As…, "Webpage, Complete" so images come too) and pass the `.html` file instead; unzip it first if they send a zip. Never try to get past a bot check or CAPTCHA yourself.

2. **Review `article.json`.** Read the whole thing. This is where you earn your place, but **only the spoken form changes, never the meaning**:
   - **Junk blocks:** set `"skip": true` on anything that isn't the article (cross-posting notes, "subscribe" lines, author bios, sponsor messages). Don't skip content you merely find less interesting.
   - **`pronounce`:** add entries for names and terms Kokoro is likely to get wrong: unusual surnames, brands, acronyms read as words, odd abbreviations. Prefer plain respellings (`"McKnight": "Mick-Nite"`, `"Meta.ai": "Meta A.I."`). Phonemes in slashes (`"/kˈɔfəɹ/"`) also work. Keys match whole words or phrases anywhere in the text, so they also fix things like `"1920s and 30s": "nineteen-twenties and thirties"`.
   - **`speak` on a block:** only to make text readable aloud: missing spaces, URLs (say "a link to …"), stray symbols, footnote numbers, emoji. Never paraphrase, shorten or "improve" the author's prose.
   - **`speak` on an image:** what's said when it appears, in the caption voice. Without it the script reads the caption tidied into a sentence, or else the alt text. Write `speak` when the caption is awkward aloud: "(left)" / "(right)" asides, typos, labels that don't parse as speech. Keep every fact in the caption. For an image with no caption or useful alt text, the default is the chime alone. Only describe it yourself (look at the file in `images/`) if the user has asked for descriptions, and say "An image of …" so it's clear the words aren't the author's.
   - **`speak_title`:** set it if the title has tags like "(essay)" or "| Site name" that sound odd aloud.
   - **Maths** (`math_say`, only when the summary reports maths): display equations are shown as pictures and never spoken; leave them alone. Inline formulas appear in the text as `⟦m001⟧` tokens and are spoken via `math_say`, keyed by the formula's text (see `maths` for each token's text). Short ones come with a guessed name; fix any that read badly (subscripts squashed together: `"xt,k": "x t, k"`; context: `"∂B": "the boundary of B"`; a lone `"a": "ay"`). Longer ones are `null`, which says "shown" while a card of the paragraph's formulas is on screen. Give a name to formulas the prose leans on, and to ones that contain words (`"Uhalts on⟨M,w⟩": "U halts on M, w"`), but don't read out long formulas: the user wants maths shown, not spoken.
   - **Academic papers:** use `speak` to drop numeric citations like "(1, 2)" or "(7–9)" and footnote markers (* † ‡); keep "see ref. 29" style references, with a `pronounce` entry `"ref.": "reference"`. Mention in the notes how maths is handled.
   - **`notes`:** 2–3 short plain-text paragraphs saying what the piece argues or covers, factually, with no hype. This goes in the show notes.
   - Leave `file`, `grid`, `id`, `link`, `cover` and the block order alone.

3. **Make the episode:**
   ```
   scripts/article_episode.py <path/to/article.json>
   ```
   About 10× faster than real time on an Apple Silicon Mac: a 20-minute article takes about 2 minutes. The defaults are the user's chosen voices (`af_heart` for the article, `am_puck` at 1.1× for captions); only change them if asked.

4. **Publish** (below), then report: title, length, file size, number of image chapters, and anything notable you changed (skipped blocks, pronunciations, rewritten captions).

## Publishing

```
scripts/publish.py add <path/to/episode.json> --cleanup
```

Say it will appear in their podcast app on its next refresh. No URLs. For several links, make and publish them one at a time.

## Managing the feed

| User wants to… | Run |
|---|---|
| See what's in the feed | `scripts/publish.py list` |
| Remove an episode | `scripts/publish.py remove <id>` (id from `list`, a YouTube video id, or an article URL) |
| Subscribe / get the feed URL | `scripts/publish.py feed-url` (copies to the clipboard) |
| Change feed title, description or artwork | `scripts/publish.py init --title "…" [--description "…"] [--cover image.jpg]` |
| Revoke a leaked feed URL | `scripts/publish.py rotate`, then `feed-url` and re-subscribe |

## Troubleshooting

- **"Sign in to confirm you're not a bot":** retry with `--cookies-from-browser safari`. Ask the user which browser they're signed into YouTube with (safari, chrome or firefox), and get their OK first, since it reads their browser cookies.
- **Extraction, format or signature errors:** yt-dlp is probably out of date. Re-run as `uv run --upgrade-package yt-dlp scripts/video_episode.py …`. yt-dlp also needs `deno` for YouTube (`brew install deno`).
- **An article comes out short or empty:** it's paywalled, rendered by JavaScript, or behind a bot check. Ask the user to save the full page as HTML and pass the file.
- **Maths won't render:** it needs Google Chrome installed (Playwright drives it) and, for the nicest typesetting, a network connection to load MathJax; offline it falls back to Chrome's own MathML rendering.
- **A word is still mispronounced:** add or adjust a `pronounce` entry and re-run `article_episode.py`; it's quick.
- **`security` or `op` errors inside a sandbox:** the command may need to run outside the sandbox. Ask the user.
- **1Password: "No accounts configured" or authorization errors:** ask the user to unlock 1Password and check Settings → Developer → Integrate with 1Password CLI is on. `op` then asks them for Touch ID.
- **`AccessDenied` from R2:** the API token needs *Object Read & Write* on this bucket.
- **"feed isn't publicly readable":** public access (r2.dev subdomain or a custom domain) isn't enabled on the bucket, or `public_url` in the settings is wrong.
- **Frames or images look wrong:** report it rather than hand-editing the MP3. Re-running the episode script with different options is cheap.
