---
slug: "digest-skill"
source_type: "skill_md"
source_url: "https://cdn.jsdelivr.net/gh/jeremyhemsworth/digest-skill@main/SKILL.md"
repo: "https://github.com/jeremyhemsworth/digest-skill"
source_file: "SKILL.md"
branch: "main"
---
---
name: digest
description: Weekly digest of newsletter and podcast insights — novel, high-impact, contrarian ideas for business, marketing, and LinkedIn. Generates audio version via ElevenLabs.
---

# Weekly Digest

Scan the past week's newsletters and podcast episodes, extract what matters, and deliver a single digest of insights that are novel, high-impact, contrarian, or directly useful for building your business, driving marketing success, and sharing on LinkedIn. Then generate an audio version.

## Important: Writing Style

Follow the writing preferences in your project's `CLAUDE.md`. Direct. Specific. No filler.

## Setup

Before using this skill, create a `config.json` file in the `.claude/skills/digest/` directory. See `config.example.json` for the required structure. The skill reads API keys and service credentials from this file at runtime.

## Step 1: Gather Sources (run in parallel)

Run all of the following simultaneously.

### 1A: Newsletters

Search Gmail for newsletters from the last 7 days:

| Query | Purpose | maxResults |
|-------|---------|------------|
| `label:Substack newer_than:7d` | All Substack newsletters | 100 |
| `label:Newsletter newer_than:7d` | Non-Substack newsletters (beehiiv, etc.) | 100 |

Use your Gmail MCP's `gmail_search_messages` tool for each. (Load the schema first via ToolSearch if needed.)

**Priority senders** (always include if present) — customize this list with your own subscriptions:
- Product Growth (Aakash Gupta) — `aakashgupta@substack.com`
- Lenny's Newsletter — `lenny@substack.com`
- Growth Unhinged (Kyle Poyar) — beehiiv / `mail.beehiiv.com`
- Mostly Growth — substack
- a16z — `a16z@substack.com`
- Not Boring (Packy McCormick) — `notboring@substack.com`
- MKT1 (Emily Kramer) — `mkt1@substack.com`
- GTM Strategist (Maja Voje) — `gtmstrategist@substack.com`
- OnlyCFO — `onlycfo@substack.com`
- Wes Kao — `weskao@substack.com`
- Tech Brew — `techbrew@morningbrew.com`
- The Frontier (Product Hunt) — `hi@deeperlearning.producthunt.com`
- Alex / basicarts — `alex@basicarts.org`

### 1B: Podcast Episodes

Use a **hybrid approach** to find episodes published in the last 7 days.

#### Source 1: RSS Transcript Feeds (preferred — gives full transcripts directly)

These podcasts have transcripts embedded in their RSS feeds. Curl the feed and extract `<podcast:transcript>` tags from recent episodes:

| Podcast | RSS Feed URL | Transcript Format |
|---------|-------------|-------------------|
| Cheeky Pint | `https://feeds.transistor.fm/cheeky-pint-with-john-collison` | `text/plain` (.txt) |
| Think Fast, Talk Smart | `https://feeds.transistor.fm/think-fast-talk-smart-communication-techniques` | `text/vtt` (.vtt) |

For each feed:
```bash
python3 -c "
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone
import sys, json

xml_data = sys.stdin.read()
# Register namespaces
ns = {
    'itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd',
    'podcast': 'https://podcastindex.org/namespace/1.0',
}
root = ET.fromstring(xml_data)
channel = root.find('channel')
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
results = []
for item in channel.findall('item'):
    pub_date_str = item.find('pubDate').text if item.find('pubDate') is not None else ''
    try:
        from email.utils import parsedate_to_datetime
        pub_date = parsedate_to_datetime(pub_date_str)
    except:
        continue
    if pub_date >= cutoff:
        title = item.find('title').text if item.find('title') is not None else ''
        transcript_el = item.find('podcast:transcript', ns)
        transcript_url = transcript_el.get('url') if transcript_el is not None else None
        results.append({'title': title, 'published': pub_date_str, 'transcript_url': transcript_url})
print(json.dumps(results, indent=2))
"
```

If a transcript URL is found, fetch it directly with curl. For VTT files, strip timestamps and keep just the text.

#### Source 2: YouTube RSS Feeds (for remaining podcasts)

Fetch YouTube RSS feeds for episodes published in the last 7 days:

```bash
python3 -c "
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone
import sys, json

xml_data = sys.stdin.read()
ns = {'atom': 'http://www.w3.org/2005/Atom', 'yt': 'http://www.youtube.com/xml/schemas/2015', 'media': 'http://search.yahoo.com/mrss/'}
root = ET.fromstring(xml_data)
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
results = []
for entry in root.findall('atom:entry', ns):
    published = entry.find('atom:published', ns).text
    pub_date = datetime.fromisoformat(published.replace('Z', '+00:00'))
    if pub_date >= cutoff:
        vid_id = entry.find('yt:videoId', ns).text
        title = entry.find('atom:title', ns).text
        results.append({'id': vid_id, 'title': title, 'published': published})
print(json.dumps(results))
"
```

**YouTube feeds — customize this list with your preferred podcasts, in priority order:**

| Priority | Podcast | Feed URL |
|----------|---------|----------|
| 1 | Founders (David Senra) | `https://www.youtube.com/feeds/videos.xml?channel_id=UCy2FPslt0LLPsIV0iukvHpQ` |
| 2 | Lenny's Podcast | `https://www.youtube.com/feeds/videos.xml?channel_id=UC6t1O76G0jYXOAoYCm153dA` |
| 3 | 20VC | `https://www.youtube.com/feeds/videos.xml?channel_id=UCf0PBRjhf0rF8fWBIxTuoWA` |
| 4 | Mostly Growth | `https://www.youtube.com/feeds/videos.xml?channel_id=UC6WQpg2OxjHSpYRHnsM90bw` |
| 5 | a16z | `https://www.youtube.com/feeds/videos.xml?channel_id=UCQ1VQj-37kl2yS_VUhfQHsw` |
| 6 | Masters of Scale | `https://www.youtube.com/feeds/videos.xml?channel_id=UCiemDAS1bXMBTx3jIIOukFg` |
| 7 | This Week in Startups | `https://www.youtube.com/feeds/videos.xml?channel_id=UCkkhmBWfS7pILYIk0izkc3A` |

Curl all feeds in parallel (feed XML fetches are fine in parallel — rate limiting only happens during transcript fetches in Step 3).

**IMPORTANT — Filter out YouTube Shorts:** Before fetching transcripts, check video duration using `yt-dlp`:

```bash
yt-dlp --print duration VIDEO_ID 2>/dev/null
```

Skip any video shorter than 180 seconds (3 minutes) — these are Shorts/clips, not full episodes.

## Step 2: Read Newsletter Content

For each newsletter email from Step 1A, use your Gmail MCP's `gmail_read_message` tool to get the full content.

**Skip these — don't read or include:**
- Substack verification codes
- "Welcome" / subscription confirmation emails
- Substack "Weekly Stack" digest emails (from `no-reply@substack.com`)
- Newsletters clearly outside your business interests (geopolitics, poetry, macro econ, faith/devotional, general news)

**Keep and read everything about:** product, growth, marketing, GTM, startups, AI/tech, SaaS, PLG, building businesses, fundraising, leadership.

If there are more than 15 relevant newsletters, prioritize the priority senders listed in Step 1A.

## Step 3: Get Podcast Transcripts

### For RSS transcript podcasts:
Transcripts were already fetched in Step 1B. Use them directly.

### For YouTube-sourced podcasts:
For each new episode from Step 1B (that passed the duration filter), fetch the English transcript using `youtube-transcript-api`:

```bash
python3 -c "
from youtube_transcript_api import YouTubeTranscriptApi
api = YouTubeTranscriptApi()
transcript = api.fetch('VIDEO_ID')
text = ' '.join([t.text for t in transcript.snippets])
print(text)
" 2>&1
```

Replace `VIDEO_ID` with each video's ID. **Fetch in priority order, not all in parallel** — YouTube rate-limits after ~8 transcript requests in quick succession. If you hit a rate limit (`IpBlocked` error), stop fetching more YouTube transcripts and work with what you have.

If a transcript is very long (60+ minute episode), read just the first 15,000 words.

If the transcript fetch fails due to rate limiting, note the title as "transcript unavailable — rate limited" and move on. Do not retry.

## Step 4: Synthesize the Digest

Analyze all newsletter content and podcast transcripts. Extract and organize insights.

**What counts as signal:**
- Novel frameworks or mental models people haven't seen before
- Contrarian takes that challenge conventional wisdom
- Specific, tactical plays for growing a business (not generic advice)
- Marketing strategies, GTM plays, or positioning insights
- Data points or trends that reveal where markets are heading
- Ideas that would make strong LinkedIn content (provocative, specific, backed by reasoning)
- Concrete examples with names, numbers, and outcomes

**What to filter out:**
- Obvious takes everyone already agrees on
- Self-promotional content from the newsletter author
- Generic advice ("focus on your customer," "iterate fast," "build trust")
- Content outside your domain (pure engineering, policy, macro politics)
- Anything that sounds like a LinkedIn influencer cliche

**Also generate the episode title here.** Format:

```
[Mon DD–DD] | [Strongest claim] · [Topic 2] · [Topic 3]
```

Rules:
- Date range: abbreviated month + day range of the period covered (e.g., `Mar 12–18`)
- First segment: the single sharpest claim from The Signal — short declarative, 4–7 words, no hedging
- Two more topic slugs: specific and concrete, 3–5 words each
- Total title: under 80 characters
- No "weekly digest", no "episode", no generic filler — every word earns its place

Example: `Mar 12–18 | Growth is a trust problem · Kalshi vs. the CFTC · The 8% conversion floor`

This title is used in Step 7B for the RSS feed. Carry it forward.

## Step 5: Present the Written Digest

```
## Weekly Digest — [Date Range]

**Sources scanned:** X newsletters, X podcast episodes

---

### The Signal

[3-7 of the most important insights from this week. Each one gets 2-3 sentences max.
State the insight directly — don't build up to it. Attribute the source.]

---

### Contrarian / Provocative

[1-3 takes that challenge conventional wisdom. These are the ones worth
turning into LinkedIn posts. State why they're contrarian.]

---

### Tactical Takeaways

[3-5 specific, actionable ideas you could apply to your business or share.
The more concrete, the better. "Do X" not "consider X."]

---

### LinkedIn Angles

[2-3 specific content angles from this week's sources that could become a post or carousel.
For each: the hook line, the core argument, and which source it draws from.]

---

### Worth a Full Read

[Any newsletter or podcast episode that was especially dense with insight this week.
One-line reason why.]

---

### Skipped / Thin This Week

[List source names that had nothing relevant. No detail needed.]
```

Do NOT pad the digest. If there's only one contrarian take, list one. If no podcasts published, skip that section. Empty sections get cut entirely.

## Step 6: Generate Audio Digest

After presenting the written digest, generate an audio version covering **Signal + Contrarian + Tactical** sections only.

### 6A: Format for spoken delivery

Reformat those three sections into a spoken script. Rules:
- Remove markdown formatting (headers become spoken transitions like "Here's what mattered this week." / "Now, the contrarian takes." / "Tactical takeaways.")
- Remove attribution brackets — weave source names naturally ("Elena Verna on 20VC made the case that...")
- Keep sentences short and punchy — same voice as the written digest but optimized for ear, not eye
- No bullet points — convert to flowing paragraphs with natural transitions
- Total script should be under 10,000 characters (fits in one ElevenLabs Turbo v2 request)
- If over 10,000 characters, trim the least important tactical takeaway

### 6B: Generate audio via ElevenLabs

Read the config file to get the ElevenLabs API key and voice settings:

```bash
python3 -c "
import json, urllib.request, sys

config = json.load(open('CONFIG_PATH'))
api_key = config['elevenlabs_api_key']

# Read script from stdin
script = sys.stdin.read()

voice_id = config.get('elevenlabs_voice_id', 'NNl6r8mD7vthiJatiJt1')
url = f'https://api.elevenlabs.io/v1/text-to-speech/{voice_id}/stream'

payload = json.dumps({
    'text': script,
    'model_id': 'eleven_turbo_v2',
    'output_format': 'mp3_44100_128',
    'voice_settings': {
        'stability': 0.6,
        'similarity_boost': 0.8,
        'style': 0.15,
        'use_speaker_boost': True,
    },
}).encode()

req = urllib.request.Request(url, data=payload)
req.add_header('xi-api-key', api_key)
req.add_header('Content-Type', 'application/json')
req.add_header('Accept', 'audio/mpeg')

from datetime import date
output_dir = config.get('output_dir', './digests')
output_path = f'{output_dir}/digest-{date.today().isoformat()}.mp3'

import os
os.makedirs(os.path.dirname(output_path), exist_ok=True)

with urllib.request.urlopen(req) as resp:
    with open(output_path, 'wb') as f:
        while True:
            chunk = resp.read(1024)
            if not chunk:
                break
            f.write(chunk)

print(f'Audio saved: {output_path}')
char_count = len(script)
print(f'Characters used: {char_count} (~{char_count} credits)')
" <<'SCRIPT'
INSERT_SPOKEN_SCRIPT_HERE
SCRIPT
```

Replace `CONFIG_PATH` with the path to your `config.json` and `INSERT_SPOKEN_SCRIPT_HERE` with the actual spoken script from Step 6A.

After generating, report:
- File path of the MP3
- Character count / credits used
- Approximate duration (estimate ~150 words per minute)

## Step 7: Publish to Podcast RSS Feed

Upload the MP3 to Cloudflare R2 and update the podcast RSS feed so Spotify and Apple Podcasts auto-ingest the new episode.

### 7A: Upload MP3 to R2

```bash
python3 -c "
import boto3, json
from datetime import date

config = json.load(open('CONFIG_PATH'))
r2 = config['r2']

s3 = boto3.client('s3',
    endpoint_url=r2['endpoint'],
    aws_access_key_id=r2['access_key_id'],
    aws_secret_access_key=r2['secret_access_key'],
    region_name='auto'
)

output_dir = config.get('output_dir', './digests')
today = date.today().isoformat()
local_path = f'{output_dir}/digest-{today}.mp3'
r2_key = f'episodes/digest-{today}.mp3'

s3.upload_file(local_path, r2['bucket'], r2_key, ExtraArgs={'ContentType': 'audio/mpeg'})

import os
file_size = os.path.getsize(local_path)
print(f'Uploaded: {r2[\"public_url\"]}/{r2_key}')
print(f'Size: {file_size} bytes')
"
```

### 7B: Update RSS feed

Download the existing `feed.xml` from R2, add the new episode entry, and re-upload. The feed uses raw string templating (not `xml.etree`) to avoid the duplicate `xmlns:itunes` bug that breaks Spotify/Apple ingestion.

```bash
python3 -c "
import boto3, json, os, re
from datetime import date, datetime, timezone
from email.utils import format_datetime

config = json.load(open('CONFIG_PATH'))
r2 = config['r2']
podcast = config['podcast']

s3 = boto3.client('s3',
    endpoint_url=r2['endpoint'],
    aws_access_key_id=r2['access_key_id'],
    aws_secret_access_key=r2['secret_access_key'],
    region_name='auto'
)

output_dir = config.get('output_dir', './digests')
today = date.today().isoformat()
mp3_key = f'episodes/digest-{today}.mp3'
pub_url = r2['public_url']
mp3_url = f'{pub_url}/{mp3_key}'
local_mp3 = f'{output_dir}/digest-{today}.mp3'
file_size = os.path.getsize(local_mp3)

duration_seconds = int(file_size / 16000)
minutes = duration_seconds // 60
seconds = duration_seconds % 60

pub_date = format_datetime(datetime.now(timezone.utc))

# Build new <item> block
new_item = f'''    <item>
      <title>INSERT_EPISODE_TITLE_HERE</title>
      <description>INSERT_EPISODE_DESCRIPTION_HERE</description>
      <pubDate>{pub_date}</pubDate>
      <guid isPermaLink=\"true\">{mp3_url}</guid>
      <enclosure url=\"{mp3_url}\" length=\"{file_size}\" type=\"audio/mpeg\"/>
      <itunes:duration>{minutes}:{seconds:02d}</itunes:duration>
      <itunes:explicit>false</itunes:explicit>
    </item>'''

# Download existing feed and insert new item after opening <channel> metadata
feed_path = '/tmp/feed.xml'
try:
    s3.download_file(r2['bucket'], 'feed.xml', feed_path)
    with open(feed_path, 'r') as f:
        feed = f.read()
    # Insert new item before the first existing <item>, or before </channel>
    if '<item>' in feed:
        feed = feed.replace('<item>', new_item + '\n    <item>', 1)
    else:
        feed = feed.replace('</channel>', new_item + '\n  </channel>')
except:
    # Create new feed from scratch
    feed = f'''<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<rss version=\"2.0\" xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\" xmlns:atom=\"http://www.w3.org/2005/Atom\">
  <channel>
    <title>{podcast['title']}</title>
    <link>{pub_url}</link>
    <atom:link href=\"https://pubsubhubbub.appspot.com\" rel=\"hub\"/>
    <atom:link href=\"{pub_url}/feed.xml\" rel=\"self\" type=\"application/rss+xml\"/>
    <description>{podcast['description']}</description>
    <language>en-us</language>
    <itunes:author>{podcast['author']}</itunes:author>
    <itunes:owner>
      <itunes:name>{podcast['author']}</itunes:name>
      <itunes:email>{podcast['email']}</itunes:email>
    </itunes:owner>
    <itunes:explicit>false</itunes:explicit>
    <itunes:image href=\"{pub_url}/cover.jpg\"/>
    <itunes:category text=\"Business\"/>
    <itunes:type>episodic</itunes:type>
{new_item}
  </channel>
</rss>'''

with open(feed_path, 'w') as f:
    f.write(feed)

s3.upload_file(feed_path, r2['bucket'], 'feed.xml', ExtraArgs={'ContentType': 'application/rss+xml'})
print(f'RSS feed updated: {pub_url}/feed.xml')
print(f'New episode: {today} ({minutes}:{seconds:02d})')
"
```

Replace `CONFIG_PATH` with the path to your `config.json`. Replace `INSERT_EPISODE_TITLE_HERE` with the episode title generated in Step 4. Replace `INSERT_EPISODE_DESCRIPTION_HERE` with a 1-2 sentence episode description from the Signal and Contrarian sections. Keep it under 250 characters.

### 7C: Ping WebSub hub

After uploading the feed, ping Google's PubSubHubbub hub so subscribers (Spotify, Apple, YouTube Music) get notified immediately instead of waiting for their next poll cycle.

```bash
curl -s -o /dev/null -w "HTTP %{http_code}" -X POST https://pubsubhubbub.appspot.com/ \
  -d "hub.mode=publish" \
  -d "hub.url=YOUR_FEED_URL"
```

Replace `YOUR_FEED_URL` with the public URL to your `feed.xml` on R2. A 204 response means the hub accepted the ping. Any other code means the ping failed — log it but don't block.

After publishing, report:
- RSS feed URL
- Episode MP3 URL
- WebSub ping status (204 = success)

## Notes

- `boto3` is used for R2 uploads. If missing, install with `pip3 install --user boto3`.
- `youtube-transcript-api` is installed via pip3. If missing, install with `pip3 install --user youtube-transcript-api`.
- `yt-dlp` is used to check video duration. If missing, install with `pip3 install --user yt-dlp`.
- Some podcast channels (especially 20VC and This Week in Startups) publish multiple times per week. Include all episodes but keep summaries tight.
- If a YouTube feed returns a 404 or empty result, the channel ID may have changed. Flag it and move on.
- For a weekly digest with more sources, focus synthesis on quality over quantity. A week with 20 newsletters should still produce the same tight digest format — just with better signal extracted.
- Audio generation costs ~11,000 ElevenLabs credits per episode. At once per week, that's ~44,000 credits/month on the Creator plan.
