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 | |
facebook.com/.../videos/ |
1e (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 theContent-Typeheader from the page indicates audio), setAUDIO_ONLY=trueand 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).
- If the URL ends in
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.
Step 1e — Facebook video
Facebook videos are handled by yt-dlp natively. Treat them like YouTube/Instagram:
- Keep the original URL and proceed directly to Step 2.
- yt-dlp will extract the
en_USauto-caption set (if available) or the full transcript from thedescriptionfield. - No special flags needed beyond what's already in Step 2.
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 subtitles and metadata:
bash "<SKILL_DIR>/scripts/yt-dlp.sh" --write-auto-sub --sub-langs en,en_US --write-info-json --skip-download -o "/tmp/media_transcript" "<URL>"
yt-dlp writes subtitle files in whatever format the platform serves (VTT for YouTube, SRT for Facebook, etc.). The post-processing script in Step 2a discovers the file dynamically — no ffmpeg dependency.
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 -1
Map 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.
Run the post-processing script to check VTT/SRT, extract caption fallback, and detect audio-only content:
bash "<SKILL_DIR>/scripts/process_yt_output.sh"
The script prints a status token to stdout:
| Token | Meaning | Next step |
|---|---|---|
VTT_OK |
VTT file exists and is non-empty | Proceed to Step 3 |
CAPTION_OK |
No VTT, but description (>100 chars) written as fallback to /tmp/media_clean_transcript.txt |
Skip Step 3 → Step 4 |
AUDIO_ONLY |
No VTT, no caption; media is audio-only | Go to Step 2c below |
VIDEO_NO_SUBS |
No VTT, no caption; has video streams | Go to Step 2b |
CAPTION_THIN |
Description exists but ≤100 non-hashtag chars (also outputs AUDIO_ONLY or VIDEO_NO_SUBS) | Follow the second token |
INFO_MISSING |
info.json not found | Report error to user |
Step 2c — Whisper transcription (audio-only)
This step is reached only when process_yt_output.sh reported AUDIO_ONLY.
Transcribe with Whisper (local, no external API). transcribe_audio.py auto-detects the platform and uses Metal acceleration on Apple Silicon via mlx-whisper, falling back to CPU via faster-whisper on other platforms. It bundles its own ffmpeg via imageio-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 "mlx-whisper,imageio-ffmpeg" python3 "<SKILL_DIR>/scripts/transcribe_audio.py" "$AUDIO_URL" base
On non-Apple-Silicon machines, replace mlx-whisper with faster-whisper in the --with flag. If mlx-whisper fails on Apple Silicon, the script falls back to CPU automatically and warns the user.
This 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 below.
Verify /tmp/media_clean_transcript.txt exists and is non-empty. If so, skip Step 3 — go directly to Step 4.
Graceful degradation — If Whisper transcription fails:
- If
process_yt_output.shhad reportedCAPTION_THIN(description ≤100 chars), that text was not written. Manually check if a description exists in info.json and use it if >0 chars. - Otherwise, 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.mp4
This 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 subtitle file into clean timestamped lines
Run the subtitle parser script (scripts/parse_subs.py relative to this skill's directory). It discovers the subtitle file (VTT or SRT) written by process_yt_output.sh, deduplicates overlapping caption windows, and preserves timestamps for deep-linking:
uv run "<SKILL_DIR>/scripts/parse_subs.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 perplexing
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 perplexing
Step 3.5 — Hard gate: verify transcript and publish to Gist
This step is mandatory for all source types. Do not skip it.
Run the publish_transcript script, which verifies the transcript is present and substantial, then uploads it as a public GitHub Gist. Derive a title from the metadata captured in Step 1 (use media-transcript if no title is available yet):
bash "<SKILL_DIR>/scripts/publish_transcript.sh" "<title>"
The script prints TRANSCRIPT_GIST_URL=<url> on stdout. If it exits non-zero, STOP — do not proceed to Step 4. The exit code tells you why:
- 1 — transcript missing or too short (<200 chars)
- 2 — gist upload failed
Step 4 — Read the transcript, then generate the summary
The summary must be generated solely from the transcript content read in this step. Do not rely on prior knowledge of the episode, article, or thread. If a claim cannot be traced to a line in the transcript, omit it.
4a — Check size and read in batches
First check how many lines the transcript has:
wc -l /tmp/media_clean_transcript.txt
Then 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 Concentration
or 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.md
Choose 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.
transcript_urlmust be set toTRANSCRIPT_GIST_URLcaptured in Step 3.5. Never leave it blank or as a placeholder.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):articleis present — the cited tweet is an X long-form Article; the body is already inarticle.body_excerpt(first ~6000 chars). Render the title as a link to the cited tweet URL and produce a 1–2 sentence synopsis frompreview_text+body_excerpt. No WebFetch needed. Ifbody_truncatedis true, mention "(article continues on x.com)" so the reader knows there's more.external_linksnon-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.twitter_card == "summary_large_image"andphotosnon-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.author_website+ tweet teases an external piece (text mentions "Substack", "blog", "post", "article") — note "Substack/blog index — see [website]" without fetching.- Else — skip; the cited tweet is self-contained.
Skip resolution entirely for retweets, social-media-only links, or photos that are clearly not preview cards (selfies, memes, screenshots of other tweets).
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
Run the publish_summary script, which creates the gist, backfills the self-referencing gist_url, re-edits the gist, opens the file, and posts a macOS notification:
bash "<SKILL_DIR>/scripts/publish_summary.sh" "<slug>" "<title>"
Both arguments must be double-quoted to prevent word-splitting and globbing. The script prints the Gist URL to stdout on success. If it exits non-zero, report the error to the user.
Final output to user
Tell the user:
- The local file path
- The public Gist URL
- A one-paragraph teaser of what the content is about