原始内容
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:
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:
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:
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:
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:
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
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.
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.
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
boto3is used for R2 uploads. If missing, install withpip3 install --user boto3.youtube-transcript-apiis installed via pip3. If missing, install withpip3 install --user youtube-transcript-api.yt-dlpis used to check video duration. If missing, install withpip3 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.