A runbook for a future Claude session doing the thing this site actually needs
periodically: Rob Hawks (@robhawks) posts new videos to the same channel, and
they need to be added to this site — not a from-scratch rebuild, not a different
channel. Everything below assumes the site already exists at this path, with
_source/videoN/ folders and a VIDEOS list in build_site.py covering the videos
already processed.
The one-time setup (tools, model, initial 15 videos) is done. This doc is about the recurring, cheaper task: check the channel, find what's new, run the same per-video pipeline on only the new videos, and append them to the existing site without touching anything already built.
VIDEOS list in this file) and list the
channel's current videos to diff against it._source/videoN/ folder.Agent tool), same as the original run — see the
gotchas below, they still apply.VIDEOS list in this file (reuse the
existing section name if it's the same ongoing series), then rerun
python build_site.py. Existing video pages regenerate identically; only the
index page and the new video pages actually change.This machine already has everything installed from the initial run, but a fresh session doesn't know that — confirm rather than assume, and only install what's actually missing:
which yt-dlp && which ffmpeg && which whisper-cli
python -c "import markdown" 2>&1
If whisper-cli is missing its model, don't re-download blindly — the model used
for every video on this site so far is at:
/Users/peter.wills@equipmentshare.com/Library/Application Support/MacWhisper/models/ggml-model-whisper-small.bin
(Originally installed by MacWhisper, not by this pipeline — worth rechecking with
find ~ -iname "ggml-*.bin" in case that's moved or a better model has since been
added.) Use the same model for new videos that was used for the existing ones, so
transcription quality/quirks stay consistent across the site.
First, see what's already covered — every video already on the site has a url=
entry in the VIDEOS list below in this file:
grep -oE 'url="[^"]+"' build_site.py
Then list what's currently on the channel and diff by video ID:
yt-dlp --flat-playlist --print "%(id)s | %(title)s | %(duration_string)s" \
"https://www.youtube.com/@robhawks/videos"
Any video ID in the channel listing that isn't already in the grep output above is
new. Get its upload date too (cheap, skip-download):
yt-dlp --skip-download --print "%(id)s|||%(title)s|||%(upload_date)s|||%(duration_string)s" \
"https://www.youtube.com/watch?v=VIDEO_ID"
Sanity-check the title against the existing series naming (e.g. PBP 2027 Prep
Series #N: ...) — if Rob starts a genuinely new/different series, that's worth
flagging to the user rather than silently lumping it into an existing section.
Same as the original run, once per new video, in a fresh _source/videoN/ folder
(continue the numbering — if the last one is video15, the next is video16):
This is the core loop, run once per video in its own working directory:
mkdir -p "$WORKDIR" && cd "$WORKDIR"
# 1. audio only, as wav
yt-dlp -x --audio-format wav --audio-quality 0 -o "audio.%(ext)s" "$VIDEO_URL"
# 2. whisper.cpp needs 16kHz mono
ffmpeg -y -i audio.wav -ar 16000 -ac 1 -c:a pcm_s16le audio16k.wav
# 3. transcribe (plain text output, no timestamps)
whisper-cli -m "$MODEL_PATH" -f audio16k.wav -otxt -of transcript --print-progress
# 4. free disk space - a raw wav can be 400MB-1.5GB depending on length
rm -f audio.wav audio16k.wav
Then read transcript.txt and write a structured summary.md — see the exact
prompt template in section 4 below for the format that worked well.
For one new video, just run the pipeline yourself in the main conversation. For more
than a couple at once (e.g. catching up after being away for a month), running it
sequentially is slow and burns context on transcript text you don't need there.
Instead, launch one background subagent (Claude Code's Agent tool) per new video,
with a fully self-contained prompt: exact commands, the model path, the working
directory, and formatting instructions for the summary. The subagent does the whole
download → transcribe → summarize pipeline independently and returns only the
finished summary text.
Subagents kept launching whisper-cli with the Bash tool's run_in_background: true
option (or the Monitor tool), then ending their turn saying "I'll wait for the
completion notification" — but that notification pattern is for the main
session's background tasks, not something a subagent can meaningfully wait on inside
its own turn. The agent just stops, indefinitely, having done nothing after that
point.
Fix: tell the agent explicitly not to use run_in_background or Monitor for
this. Instead, background the process with plain shell job control, then poll with an
ordinary blocking foreground Bash call that returns real output when it's done:
# launch, detached, returns immediately
nohup whisper-cli -m "$MODEL_PATH" -f audio16k.wav -otxt -of transcript \
--print-progress > whisper.log 2>&1 & disown; echo launched
# then poll with a single blocking call (timeout: 590000 or so) -
# this actually blocks and returns "DONE" when the file appears
while [ ! -f transcript.txt ]; do sleep 20; done; echo DONE
If that poll call times out before the file exists (common for anything over ~30 minutes of audio), just call the exact same wait command again — never restart whisper-cli, it's still running from the first launch. Tell the agent explicitly to complete the read/summarize steps in the same turn immediately after the poll returns "DONE", rather than ending its turn again.
Even with the plain-shell-backgrounding approach above, a while polling loop that
runs past roughly 590 seconds can cause the sandbox to reap the backgrounded
whisper-cli process silently — no error, it just dies mid-transcription. This is
easy to miss: the poll loop just keeps timing out and you assume it's slow, when it's
actually dead.
Fix: if a video is long enough that transcription clearly should have progressed
further than it has, sanity-check with ps aux | grep whisper-cli and tail
whisper.log to see if the process is actually still alive and the log is still
growing. If it's dead, relaunch the exact same whisper-cli command but with
dangerouslyDisableSandbox: true set on that specific Bash call — this exempts
just that one long-running command from the reaping behavior. It only affects the
sandboxing of that command; it does not grant any additional capability beyond what
the agent already had (no network access, no new file permissions) — it just lets
a long-lived local process survive past the tool call's own scope. Worth noting
transparently to the user afterward, since instructing a subagent to disable
sandboxing is the kind of thing that (correctly) gets flagged for review.
Subagent Write calls to a file literally named summary.md get hard-blocked with
something like "subagents should return findings as text, not write report
files." This is a deliberate harness restriction on subagents self-reporting, and it
fires on the filename/context, not on user intent — even when the task explicitly
asks for a summary.md deliverable.
Fix: don't fight it. Tell agents to just return the full summary as their final
answer text if the write is blocked (some worked around it via a Bash heredoc or a
write-then-mv trick instead — that also works, but isn't necessary). Either way,
the coordinating session should always plan to persist the returned text to disk
itself, since you can't rely on every subagent's write succeeding.
Running many transcriptions in parallel on one machine means they all compete for the same CPU cores, so each one takes noticeably longer than it would running alone. This isn't really fixable — it's just worth setting expectations (and telling the user) that a batch of a dozen-plus long videos can take the better part of an hour of wall-clock time even though each individual transcription is fast in isolation.
Each per-video agent prompt should include, concretely:
run_in_background
or Monitor for this, instead...").While the batch runs:
TaskCreate/TaskUpdate to track one task per video plus a final "compile"
task, so progress is visible and nothing gets dropped.summary.md in that video's
directory yourself (see Gotcha 3) rather than assuming the agent already did.SendMessage reply is terse),
ask it to resend the full text rather than fabricating or skipping that video.This file (build_site.py) is the generator. It expects a _source/videoN/
directory per video (each containing summary.md and transcript.txt), a VIDEOS
metadata list describing title/URL/section/one-paragraph blurb per video, and
produces index.html + videos/*.html + style.css.
To add a new video:
summary.md and transcript.txt in a new _source/videoN/ folder
(continuing the numbering).dict(...) entry to the VIDEOS list, copying the shape of an
existing entry in the same section (reuse "PBP 2027 Prep Series" as-is if
that's still the ongoing series — don't invent a new section name for a video
that's just the next episode of the same one). Write a fresh one-paragraph
blurb for it — condense the new summary down to ~3-6 sentences, matching the
tone of the existing blurbs on the index page.cd /path/to/this/site && python build_site.py
Every video page is regenerated from its summary.md every time the script runs,
including ones that didn't change — that's expected and harmless (their output is
deterministic, so an unrelated re-run produces byte-identical files for untouched
videos). Only index.html and the brand-new video page(s) will actually have new
content.
Notable implementation details worth knowing before editing this script further:
**Header**\n- item with no blank line before the list, which most markdown
parsers (including Python's markdown package) won't recognize as a real list
without a preceding blank line. md_to_html() inserts one automatically rather
than trying to get 15 independently-written summaries to all follow strict
markdown list syntax.html.escape()'d. This bit us directly: a few VIDEOS blurb/title fields were
written with literal &, —, etc. (intending them as ready-made HTML),
and then a template call to html.escape() on that same field turned & into
& — which renders in the browser as the literal text & instead of
an ampersand. The fix (and the right way to do it from the start): keep the raw
Python strings as plain Unicode (&, — written as the actual character
—, "curly quotes" as actual curly-quote characters), and either escape
consistently exactly once wherever a field is inserted, or don't escape fields
that are meant to already be safe plain text. Don't mix both.<details>/<summary>
element for the full transcript rather than JS tabs — works with zero
JavaScript, and is closed by default so the page reads as summary-first.prefers-color-scheme plus :root[data-theme]
overrides so it looks reasonable in both light and dark, without needing any
external CSS framework — this is a fully offline, dependency-free static site.all.html exists for AI agents / search engines, not just humans. It
concatenates every video's full summary (not transcripts — that would be
~120K+ words) onto one URL. The per-video pages are the better UX for a human
browsing, but a tool that just fetches one URL (a chatbot's web-fetch feature, a
search crawler) won't follow 15 internal links on its own to reconstruct the same
coverage — all.html is the single URL to hand something like that instead of
the card-based index. It's regenerated automatically by all_page() every time
the script runs, no extra step needed when adding a new video.grep -rn "amp;amp;" . should return nothing. This is especially worth
doing after hand-writing a new blurb/title string — see the entity-escaping
note above, it's an easy mistake to reintroduce on every new entry.<ul> inside
an <li>, not a flat sibling list).ep field)._source/*/transcript.txt files (now including the new one) over
relying purely on the condensed summaries — summaries are lossy by design, and a
specific question often surfaces detail and exact quotes that only exist in the
full transcript.