The user has provided a media URL: $ARGUMENTS
Follow these steps exactly:
Step 0 — Bootstrap dependencies
Run the bootstrap script (scripts/bootstrap.sh relative to this skill's directory). It installs missing tools, verifies gh is authenticated, and skips subsequent runs via a marker file.
bash "<SKILL_DIR>/scripts/bootstrap.sh"If it exits non-zero, stop and tell the user what to fix before continuing.
Step 1 — Classify source and acquire transcript
Inspect the URL and dispatch to the matching leg. Each leg ends with /tmp/media_clean_transcript.txt written. Some legs also pre-set CONTENT_TYPE (consumed in Step 4b).
| Source | Detect | Leg | Pre-sets CONTENT_TYPE? | |||
|---|---|---|---|---|---|---|
| X/Twitter thread | `(x\ | twitter\ | fxtwitter\ | fixupx)\.com/.+/status/\d+` | 1a | yes → x-thread |
| Web article / HTML page | Any URL that is not video/audio/thread media | 1d (HTML ingestion) | yes → html-article | |||
instagram.com | 1b (yt-dlp native) | no | ||||
| YouTube | youtube.com, youtu.be | 1c (yt-dlp) | no | |||
| Podcast / talk / other | (everything else) | 1c — YouTube search, then audio-only detection | no |
Step 1a — X/Twitter thread
Run the thread fetcher. It calls fxtwitter's /2/thread/{id} endpoint and writes both the raw JSON and a stitched transcript:
uv run "<SKILL_DIR>/scripts/fetch_x_thread.py" "<URL>"The script prints a metadata JSON object to stdout — capture its fields (author_name, author_handle, author_url, published_date, tweet_count, title_guess, source_url, post_urls) for Steps 4c and 5.
Set CONTENT_TYPE=x-thread. Skip Steps 2 and 3 — proceed directly to Step 4.
If the script exits non-zero (empty thread, or a thread-opener that only returned one post because the public fxtwitter deployment lacks an account proxy), fall through to Step 1d (generic HTML ingestion) instead of stopping. The single post's text will be extracted as a web article.
Step 1b — Instagram
Keep the original URL and proceed to Step 2 with --cookies-from-browser (yt-dlp handles Instagram natively — see Step 2 for the flag).
Step 1c — YouTube / podcast / other
If already a YouTube URL (youtube.com or youtu.be), use it directly.
Otherwise (Apple Podcasts, Spotify, podcast pages, conference sites, direct audio URLs, etc.):
- Extract the episode title — Use
fetch_html.py(Tier 1) or the MCP browser snapshot to get the page title. For direct audio URLs (.mp3,.m4a,.wav,.aac,.ogg,.opus), derive a title from the URL slug (strip extension, replace hyphens/underscores with spaces, title-case). - Search YouTube — Use
mcp__MCP_DOCKER__brave_web_searchto search for"<title> <site>"(e.g."683 atp.fm"or"episode title podcast name"). If a YouTube result matches the episode title, use that YouTube URL and proceed to Step 2. - If no YouTube match — Check if the URL is a direct audio file:
- If the URL ends in .mp3, .m4a, .wav, .aac, .ogg, or .opus (or the Content-Type header from the page indicates audio), set AUDIO_ONLY=true and proceed to Step 2 with the original URL (yt-dlp will download the audio). - Otherwise, proceed to Step 2 with the original URL (yt-dlp will attempt to resolve it).
Step 1d — Web article / HTML page (generic HTML ingestion)
For URLs that point to text-based web content (LinkedIn posts, Medium articles, blog posts, Substack, news articles, etc.), ingest the page via a tiered extraction pipeline. This is also the fallback for fxtwitter failures (Step 1a) where only a single post was returned.
Tier 1 — Specialty domain handler (run fetch_html.py which includes domain-specific logic):
uv run --with "readability-lxml,lxml,beautifulsoup4" "<SKILL_DIR>/scripts/fetch_html.py" "<URL>"The script prints a JSON metadata object to stdout — capture its fields (title, author, published_date, description, site_name, source_url, content_length, needs_browser). It exits with code:
- 0 — content extracted successfully,
/tmp/media_clean_transcript.txtwritten. SetCONTENT_TYPE=html-article. Capture the metadata. Skip Steps 2 and 3 — proceed directly to Step 4. - 2 — content too thin (<200 chars), likely needs JavaScript rendering. Proceed to Tier 2.
- 1 — hard error. Proceed to Tier 2.
Tier 2 — MCP browser tools (for JS-heavy pages like LinkedIn):
Use the MCP browser tools to render the page and extract content:
- Navigate to the URL:
MCP_DOCKER_browser_navigate(url="<URL>")- Wait for content to load:
MCP_DOCKER_browser_wait_for(time=3)- Take an accessibility snapshot to extract the page text:
MCP_DOCKER_browser_snapshot()- Extract the meaningful text content from the snapshot. Look for the main article/post content area — skip navigation, sidebars, and footers.
- Write the extracted text to
/tmp/media_clean_transcript.txt. - If the snapshot yields substantive content (>200 chars), set
CONTENT_TYPE=html-article. Skip Steps 2 and 3 — proceed directly to Step 4.
If MCP browser tools still yield insufficient content, proceed to Tier 3.
Tier 3 — Puppeteer (full headless Chromium rendering):
Run the Puppeteer renderer:
node "<SKILL_DIR>/scripts/fetch_html_puppeteer.js" "<URL>"Requires puppeteer npm package — bootstrap.sh installs it on first use. The script:
- Launches headless Chromium
- Navigates to the URL, waits for
networkidle2 - Scrolls to trigger lazy-loaded content
- Tries article-specific selectors (article, main,.post-content, LinkedIn feed selectors, etc.)
- Falls back to
document.body.innerText
Exits with code 0 on success (transcript written), 2 if content is still insufficient, 1 on hard error.
On success, set CONTENT_TYPE=html-article. Skip Steps 2 and 3 — proceed directly to Step 4.
Tier 4 — MCP convert_to_markdown (last resort for any URL):
MCP_DOCKER_convert_to_markdown(uri="<URL>")This sends the URL to the MCP server which fetches and converts to markdown. Extract the main content from the returned markdown. Write to /tmp/media_clean_transcript.txt. Set CONTENT_TYPE=html-article. Skip Steps 2 and 3 — proceed directly to Step 4.
If all four tiers fail, report the error to the user and stop.
HTML metadata capture: Regardless of which tier succeeds, extract the following from the page for use in Steps 4c and 5: title, author, published_date, description, site_name, source_url. These come from the script stdout (Tier 1/3) or must be extracted manually from the MCP browser snapshot (Tier 2) or markdown output (Tier 4).
Step 2 — Download the transcript with yt-dlp
Run a single yt-dlp call to download both subtitles and metadata:
bash "<SKILL_DIR>/scripts/yt-dlp.sh" --write-auto-sub --sub-lang en --write-info-json --skip-download --sub-format vtt -o "/tmp/media_transcript" "<URL>"If AUDIO_ONLY=true (set in Step 1c for direct audio URLs) and yt-dlp fails to extract info, pass the original URL directly to Step 2c — transcribe_audio.py accepts URLs and handles audio extraction internally.
Note: For Instagram URLs, add --cookies-from-browser BROWSER, where BROWSER is the user's default browser. Detect it with:
defaults read ~/Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure LSHandlers 2>/dev/null | grep -B1 'https' | grep -o '"com\..*"' | head -1Map the bundle ID: com.google.Chrome → chrome, com.apple.Safari → safari, org.mozilla.firefox → firefox, com.brave.Browser → brave. Default to chrome if detection fails.
Check whether /tmp/media_transcript.en.vtt exists and is non-empty:
test -s /tmp/media_transcript.en.vtt && echo "VTT_OK" || echo "VTT_MISSING"- If
VTT_OK→ proceed to Step 3. - If
VTT_MISSING→ go to Step 2a (caption fallback).
Step 2a — Caption fallback
Read /tmp/media_transcript.info.json and extract the description field. Strip hashtags (#\w+) and leading/trailing whitespace. If the remaining text is >100 characters, write it to /tmp/media_clean_transcript.txt (one paragraph per line) and skip Step 3 — go directly to Step 4.
If the caption is insufficient (<=100 non-hashtag characters), check for audio-only content (Step 2c). If the content is not audio-only, go to Step 2b.
Step 2c — Audio-only detection and Whisper transcription
Read /tmp/media_transcript.info.json and check whether the media is audio-only:
- Parse the JSON. Look for:
- subtitles is empty ({}) or missing — no subtitle tracks - formats entries where vcodec is "none" — indicating audio-only streams - If all formats have vcodec == "none" and subtitles are empty → audio-only detected
- If audio-only: a. Transcribe with Whisper (local, no external API).
transcribe_audio.pybundles its own ffmpeg viaimageio-ffmpeg— no system ffmpeg needed. It accepts either a local file path or a URL:AUDIO_URL=$(python3 -c "import json; info=json.load(open('/tmp/media_transcript.info.json')); print(info.get('url',''))") if [-z "$AUDIO_URL"]; then AUDIO_URL="<URL>" fi uv run --with "faster-whisper,imageio-ffmpeg" python3 "<SKILL_DIR>/scripts/transcribe_audio.py" "$AUDIO_URL" baseThis extracts audio and transcribes in one step, writing/tmp/media_clean_transcript.txt(one sentence per line, no timestamps). If transcription fails, skip to graceful degradation (step 4 below). b. Verify/tmp/media_clean_transcript.txtexists and is non-empty. If so, skip Step 3 — go directly to Step 4. c. Skip Step 2b entirely — no frame extraction or OCR prompt for audio-only content. - If not audio-only (media has video streams), go to Step 2b (frame extraction fallback).
- Graceful degradation — If Whisper transcription fails:
- If Step 2a produced a description with >100 characters, use that (already written to /tmp/media_clean_transcript.txt). Skip Step 3 — go to Step 4. - If no description is available either, report to the user that the podcast could not be transcribed and suggest providing a YouTube link. Do not show the Step 2b OCR prompt — frame extraction is meaningless for audio-only content.
Step 2b — Frame extraction fallback (requires user approval)
Stop and ask the user:
No subtitles or usable caption found for this video. I can extract frames and read the on-screen text to build a transcript. This requires opencv-python-headless (~30MB, installed transiently via uv). Proceed?If the user declines, stop with a message explaining that the video can't be summarized without a transcript source.
If the user approves:
1. Download the video:
bash "<SKILL_DIR>/scripts/yt-dlp.sh" -o "/tmp/media_video.mp4" "<URL>"(For Instagram, add --cookies-from-browser BROWSER using the same browser detected above.)
2. Extract frames (scene-change detection):
uv run --with opencv-python-headless python3 "<SKILL_DIR>/scripts/extract_frames.py" /tmp/media_video.mp4This uses histogram comparison to detect scene changes and saves /tmp/media_frame_000.png, /tmp/media_frame_001.png, etc. The default threshold (0.85) works well for text-overlay videos. Pass a higher value (e.g. 0.92) to capture more frames if results seem sparse.
3. Probe vision capability: Use the Read tool on /tmp/media_frame_000.png. Then attempt to extract any visible text from the image. If you can identify readable text in the frame, vision works — continue with step 4 below. If you cannot read the image or extract meaningful text, fall back to step 5 (local OCR).
4. Vision OCR (preferred): Use the Read tool on each remaining frame. For each frame, extract all visible on-screen text. Collect all extracted text, deduplicate across frames (adjacent frames often repeat), and write the combined text to /tmp/media_clean_transcript.txt. Skip Step 3 — go directly to Step 4.
5. Local OCR fallback: If vision probing failed, inform the user:
Vision not available with this model. Falling back to local OCR via EasyOCR (~400MB first-run download). Proceed?
If approved, run:
uv run --with "easyocr,opencv-python-headless" python3 "<SKILL_DIR>/scripts/ocr_frames.py"Skip Step 3 — go directly to Step 4.
Step 3 — Parse the VTT into clean timestamped lines
Run the VTT parser script (scripts/parse_vtt.py relative to this skill's directory). It deduplicates overlapping caption windows and preserves timestamps for deep-linking:
uv run "<SKILL_DIR>/scripts/parse_vtt.py"Output format — one line per segment:
[00:00:00] I'm doing something absolutely insane right now.
[00:00:04] Artificial intelligence is a little bit perplexingStep 4 — Read the transcript in chunks, then generate the summary
4a — Check size and read in batches
First check how many lines the transcript has:
wc -l /tmp/media_clean_transcript.txtThen use the Read tool (not Bash) to read the file in batches of 400 lines using offset and limit. For a 1000-line file, make three Read calls: offset=1/limit=400, offset=401/limit=400, offset=801/limit=400. Read all batches before writing anything.
4b — Classify content type
If CONTENT_TYPE was already set in Step 1 (e.g. x-thread, html-article), skip classification and use that value.
Otherwise, classify the transcript into one of these types:
| Type | Signals |
|---|---|
| recipe | Cooking instructions, ingredient lists/amounts, food preparation steps, kitchen techniques, dish names, "add the…", "cook until…", "season with…" |
| general | Everything else — interviews, talks, lectures, panels, commentary, tutorials, reviews |
Set CONTENT_TYPE to recipe or general. This determines which template and summary structure to use in the following steps.
4c — Write the summary
Read the appropriate template (see Step 5 for template selection) and follow its section structure. Fill every section with comprehensive, substantive content drawn from the transcript. Use ## section headers, bullet points, and bold text for scannability. Aim for 800–1200 words of substance.
Timestamps (YouTube sources only): If the transcript came from a VTT file (Step 3) and a YouTube URL is available, include YouTube deep-links for each major topic or section. Convert [HH:MM:SS] to total seconds for the ?t= parameter (e.g. [01:05:30] → 3930 seconds). Format as a linked timestamp at the start of the relevant bullet or subheading:
### [[01:05:30]](https://youtu.be/VIDEO_ID?t=3930) Power Concentrationor inline for bullets:
- **[[00:14:00]](https://youtu.be/VIDEO_ID?t=840) Epistemic collapse** — We are entering...Use the YouTube URL from Step 1 as the base. Include timestamps for every major topic/section — aim for one timestamp per significant topic shift.
No timestamps available: If the transcript came from caption fallback (Step 2a), frame extraction (Step 2b), or Whisper transcription (Step 2c), the content has no timestamps. Omit timestamp links entirely — just use plain section headers and bullets.
Step 5 — Write the markdown file
Derive a slug from the title using only lowercase letters, numbers, and hyphens — strip all other characters (spaces become hyphens, consecutive hyphens collapse to one, leading/trailing hyphens removed). This sanitization is critical: shell metacharacters in the slug (;, $(), backticks, quotes) would be injected into file paths and gh commands below. Example: jenny-wen-design-process. Save the summary to:
~/Downloads/<slug>_summary.mdChoose the template based on CONTENT_TYPE:
| Content type | Template |
|---|---|
general | references/media-summary-template.md.j2 |
recipe | references/recipe-video-template.md.j2 |
x-thread | references/x-thread-template.md.j2 |
html-article | references/html-article-template.md.j2 |
Both paths are relative to this skill's directory. Key points:
- The metadata fields (Guest, Hosts, Podcast, Published) must be a bullet list, not bare lines — bare consecutive lines collapse into a single paragraph in CommonMark.
- No horizontal rules (
---) between sections. Use only one, directly before the italicised source attribution at the bottom. - Key Takeaways is the first section, before Guest Background.
gist_urlstarts as(to be filled after publishing)and is updated in Step 6.generated_by.modelmust be set to the runtime model identifier (e.g.claude-opus-4-6,claude-sonnet-4-6,claude-haiku-4-5). Use the exact model ID from the runtime environment, not a friendly name.- The source link at the bottom prefers YouTube or PocketCasts over Apple Podcasts. If you already have a YouTube URL from Step 1, use that. Otherwise check for a PocketCasts link (
pca.storpocketcasts.com). Fall back to the original URL only if neither is available.
X-thread-specific notes (when using x-thread-template.md.j2):
- Slug derivation: use
<handle>-<first-few-words>from the metadata printed byfetch_x_thread.py(e.g.schlickw-us-foreign-policy-anthropic-mythos). Same sanitization rules — lowercase, hyphens only. thread_urlandsource_urlare the root post's URL from the metadata.- Summary must be 2–4 sentences, strictly descriptive. State what the thread is about and the shape of its argument — nothing more. Do not infer author background, credentials, or biographical detail from outside the thread. Do not categorize or editorialize the content (e.g. "the list is loosely organized by…"). If you find yourself writing more than 4 sentences, the rest belongs in Context & Annotations.
- Full Thread: render every post verbatim as a numbered list. Format:
N. [[N/total]](post_url) <verbatim text>— the bracketed counter is the hyperlink back to that specific post on X. Preserve the author's wording, line breaks, and hashtags. Strip the leading auto-mention chain (consecutive@handlesat the start that X auto-prepends in reply threads), since those are artifacts of the threading mechanism, not the author's words. Hyperlink every@mentioninline as[@handle](https://x.com/handle)— both in post text and in any external link preview lines. Hyperlink hashtags as[#tag](https://x.com/hashtag/tag). - Cite referenced posts inline. When a thread post links to another X/Twitter status,
fetch_x_thread.pyresolves that tweet and includes it in thecited_postsmetadata field (keyed by URL). For each cited post that appears under a thread post, render it as an indented blockquote directly below that thread post, using the format:> **[@handle](https://x.com/handle)** ([date-link](tweet_url)): <verbatim cited text>. Do not just leave the bare URL — the reader should see what's being cited without leaving the summary. If a cited post is missing fromcited_posts(deletion, private account, API failure), leave only the bare URL and add a brief> _[cited post unavailable]_note. - Include substantive self-replies in the citation. The
self_repliesfield contains the cited author's follow-up posts to the target tweet (the script auto-walks the self-reply chain). If a self-reply is just a bare URL (it'll already be inexternal_links), skip it in the blockquote — it's redundant. If a self-reply adds substantive content (continues the thought, extends the argument, adds clarification), append its text to the blockquote as continuation (>\n> <self-reply text>), so the reader sees the full mini-thread the author is citing. Cap at the first 3 substantive self-replies per citation to keep the blockquote readable; link out (> _+N more posts in this thread — see [link](first_self_reply_url)_) if there are more. - Resolve external links inside cited posts. Cited tweets often link to longer-form content (X Article, Substack, blog post, paper) — sometimes the tweet body is just a teaser. The
cited_postsmetadata per citation exposes:article(an X-native long-form post, when present — hastitle,preview_text,body_excerpt,body_truncated),external_links(URLs from the cited tweet and its author's self-replies — the script fetches the thread chain to catch the common "teaser post + bare-URL self-reply" pattern),self_replies(the full self-reply chain under the target),photos,twitter_card, andauthor_website. Resolve in this priority (progressive disclosure — stop at the first tier that yields content): Skip resolution entirely for retweets, social-media-only links, or photos that are clearly not preview cards (selfies, memes, screenshots of other tweets).
1. article is present — the cited tweet *is* an X long-form Article; the body is already in article.body_excerpt (first ~6000 chars). Render the title as a link to the cited tweet URL and produce a 1–2 sentence synopsis from preview_text + body_excerpt. No WebFetch needed. If body_truncated is true, mention "(article continues on x.com)" so the reader knows there's more. 2. external_links non-empty — WebFetch the first substantive longform URL and add a 1–2 sentence synopsis as a sub-blockquote (> _Linked: [title](url)_ — <synopsis>). Self-reply URLs are already included here, so teaser-then-link pairs work out of the box. 3. twitter_card == "summary_large_image" and photos non-empty — download the first photo (curl -sL <photo_url> -o /tmp/cited_<handle>.jpg) and use the Read tool on it. Authors embed article titles and publication domains directly into preview images when X didn't generate a native link card. If the image reveals a title and domain, construct a likely article URL (e.g. <domain>/p/<slug-of-title>) and WebFetch; add the synopsis as a sub-blockquote. 4. author_website + tweet teases an external piece (text mentions "Substack", "blog", "post", "article") — note "Substack/blog index — see [website]" without fetching. 5. Else — skip; the cited tweet is self-contained.
- Context & Annotations (optional but recommended): everything that was inferred, looked up, or editorialized. Include author background pulled from the fxtwitter metadata (name, bio excerpt if useful) — and clearly label it as "from the author's X bio" or similar so the reader knows it's not from the thread. May also include per-post annotations on what's being linked, domain groupings, or observations on the thread's structure. Keep separate from the thread itself.
- No timestamps — X threads have no internal timeline to deep-link to.
Recipe-specific notes (when using recipe-video-template.md.j2):
- The metadata fields (Chef, Channel, Cuisine, Published, Servings, Prep/Cook Time) must be a bullet list.
- If the chef doesn't state exact servings or times, estimate from context and note it with "~" (e.g. "~4 servings").
- Ingredients should include quantities. If the chef eyeballs amounts, write "to taste" or approximate with "~".
- Instructions must be numbered steps, not bullets — order matters in a recipe.
html-article-specific notes (when using html-article-template.md.j2):
- Slug derivation: use
<author-or-site>-<first-few-words>from the metadata (e.g.johnpcutler-recently-at-a-conference). Same sanitization rules — lowercase, hyphens only. - The metadata fields (Author, Published, Site) must be a bullet list.
- If
authoris unavailable from metadata, infer from the URL or content (e.g. LinkedIn vanity URL → author handle). Label inferred metadata as *inferred*. - If
published_dateis unavailable, use the fetch date and note *date not available from source*. - Overview should be 2–3 sentences summarizing the article's scope and purpose.
- Main Arguments & Points is the core of the summary — structure as a numbered or bulleted list of the article's key arguments, claims, or observations, with supporting detail.
- Notable Details & Examples captures specific examples, data points, anecdotes, or quotes that illustrate the arguments.
- Context & Significance provides broader context: how this fits into the author's body of work, the field, or current discourse. May include author background if available from the page metadata — label it as "from the author's bio" or similar.
- No timestamps — HTML articles have no internal timeline.
Step 6 — Publish as a public GitHub Gist
Create a public GitHub Gist with the summary content. The gist filename must be prefixed with summary-, e.g. summary-jenny-wen-design-process.md.
Use the gh CLI:
gh gist create --public --filename "summary-<slug>.md" --desc "<Title> — Media Summary" "$HOME/Downloads/<slug>_summary.md"All arguments containing the slug or title must be double-quoted to prevent word-splitting and globbing. The --desc value is particularly important since the title may contain special characters even after slug sanitization (the description uses the original title, not the slug).
Once you have the Gist URL, update the gist_url field in the frontmatter of ~/Downloads/<slug>_summary.md, then run:
gh gist edit <gist-id> "$HOME/Downloads/<slug>_summary.md"so the published Gist also contains the self-referencing URL.
Print the resulting Gist URL to the user.
Step 7 — Open the file and notify
Open the summary in the background (so it doesn't steal focus) and post a macOS notification:
open -g "$HOME/Downloads/<slug>_summary.md" 2>/dev/null || xdg-open "$HOME/Downloads/<slug>_summary.md" 2>/dev/null || trueosascript -e 'display notification "Summary saved and Gist published" with title "Media Summary"' 2>/dev/null || trueFinal output to user
Tell the user:
- The local file path
- The public Gist URL
- A one-paragraph teaser of what the content is about