Build a Personal AI Podcast with Claude Code
A daily AI news podcast only you can listen to, built with Claude Code and delivered straight to Apple Podcasts every morning.
What you'll build
A daily podcast that only you can listen to. It pulls today's AI news from real sources, picks the six stories that matter to you, writes a spoken script, renders it with ElevenLabs, and publishes to an RSS feed your phone follows, so a new 5-10 minute episode is waiting in Apple Podcasts every morning at 6 AM.
No podcast host. No directory submission. $0 to start, ~$6/month once you want daily full-length episodes.
What you'll need
- Python 3 with
feedparser,requests,beautifulsoup4,groq,pillow - A Groq API key (free tier is enough for the writing)
- An ElevenLabs account (free to test, $6 Starter to run daily)
ffmpegand the GitHub CLI (gh):brew install ffmpeg gh- A GitHub account (free; Pages hosts the feed)
Step 1: Pick 4-6 topics, then convert each into a real feed URL
Write down the topics you actually want to hear about. Then turn each one into a source that publishes on a schedule. That conversion is the whole trick: "AI news" is not a source, openai.com/news/rss.xml is a source.
| Topic | What it becomes |
|---|---|
| New models from any lab | OpenAI, DeepMind, Qwen feeds |
| Chinese labs | Qwen RSS; DeepSeek scraped |
| Consumer AI people adopt | TechCrunch AI, The Verge AI |
| Repos gaining traction | GitHub search API, sorted by stars |
| Tools you code with | GitHub releases for those repos |
| Creator side | YouTube blog, Social Media Today, Buffer |
Cut ruthlessly. My v1 had five sections (AI, markets, crypto, politics, local news) and it was low-key ass. Here's why it matters more in audio than in a newsletter: you skim past four sections in two seconds reading, but you can't skip anything when you're listening. A 10-minute episode is ~1,500 words total. Split five ways, each topic gets a headline. Depth in one topic beats coverage of five.
Step 2: Probe every feed before you write code against it
Most sites publish RSS at /rss.xml, /feed, /feed.xml, or /index.xml. About a third of what you'd guess is dead. Check first:
for u in \
"https://openai.com/news/rss.xml" \
"https://deepmind.google/blog/rss.xml" \
"https://qwenlm.github.io/blog/index.xml" \
"https://techcrunch.com/category/artificial-intelligence/feed/" \
"https://simonwillison.net/atom/everything/" \
"https://blog.youtube/rss/" ; do
printf "%-60s %s\n" "$u" "$(curl -s -o /dev/null -w '%{http_code} %{content_type}' "$u")"
done
Live as of writing: OpenAI, Google DeepMind, Google Research, Hugging Face, Qwen, Simon Willison, Import AI, Latent Space, MIT Tech Review, Ars Technica, TechCrunch, The Verge, YouTube blog, Social Media Today, Buffer, Cursor changelog.
Dead or non-XML: Anthropic publishes no RSS at all, Meta AI returns malformed XML, The Batch 404s, DeepSeek/Kimi/Zhipu have nothing usable.
For those, scrape the index page instead. Anthropic's news page is server-rendered, so a plain request plus a regex works:
html = requests.get("https://www.anthropic.com/news", headers=UA, timeout=20).text
slugs = re.findall(r'href="(/news/[a-z0-9\-]+)"', html)
Two sources that aren't RSS and are the highest-signal ones in the build:
# Repos that picked up stars fast. No aggregator covers these well. Free, no key.
"https://api.github.com/search/repositories"
params = {"q": f"created:>{since} topic:ai", "sort": "stars", "order": "desc"}
# A version bump in your own stack beats a funding round.
f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
Skip X/Twitter. Practitioner accounts would be the best source here, but the API is ~$200/month, more than eight times everything else in this build combined.
Step 3: Rank the items, then read the actual articles
Score cheaply before you spend a model call. Three signals do most of the work: source trust, how many independent sources cover the same story, and whether it touches your own stack.
Compare token overlap rather than exact titles when counting corroboration. "Gemini 4 Argon" and "Google announces Gemini 4 Argon AI model" are one story, and an exact-match key scores them as two.
Then cap how many of each type make the episode:
KIND_CAPS = {"tooling": 1, "repo": 2, "community": 2, "creator": 2,
"press": 3, "lab": 4, "practitioner": 2}
Without those caps, my first real run put four version bumps in the top four slots and left out the biggest model launch of the day. Tooling is capped at 1: a routine version bump makes boring radio.
Now the part that fixes the hallucinations. Fetch the article body, not the RSS blurb.
def extract_article(url, limit=4000):
r = requests.get(url, headers=UA, timeout=25)
soup = BeautifulSoup(r.text, "html.parser")
for tag in soup(["script", "style", "nav", "header", "footer", "aside", "form"]):
tag.decompose()
node = soup.find("article") or soup.find("main") or soup.body
paras = [p.get_text(" ", strip=True) for p in node.find_all("p")]
return re.sub(r"\s+", " ", " ".join(p for p in paras if len(p) > 60))[:limit]
My v1 fed the model a headline plus two lines of summary and asked for 100 words. It invented a gold price that appeared nowhere in the data. It wasn't lying. It was filling a gap. No prompt fixes that. Body text does.
Where the body genuinely isn't available, say so in the prompt rather than letting the model improvise:
SOURCE TEXT: (none available; do not invent any detail)
Step 4: Hand final selection to the model, not to regex
No regex separates "OpenAI shipped a model" from "OpenAI published a customer story about a bank." Both say OpenAI. My blocklists kept letting "Barclays scales Claude" and "Chatham scales its capital markets expertise with OpenAI" through.
One cheap model call with explicit DROP rules fixed it immediately:
Pick the 6 items that genuinely matter, most important first.
THE FIRST PICK IS A LEAD STORY. It must be something you would actually act on:
switch to, test, or make something about. Never lead with something whose honest
answer is "ignore this."
DROP, every time:
- customer stories and case studies ("<Company> uses <product> to do <thing>")
- funding rounds, valuations, acquisitions, lawsuits, executive hires
- conference and webinar announcements
- anything where the only fact is that a big company exists
A routine version bump of a tool you use is worth including ONLY if the release
notes say something actually changed for a user. Otherwise drop it.
Return ONLY valid JSON: {"picks": [{"index": <int>, "why": "<8 words max>"}]}
Model: Groq openai/gpt-oss-120b. Free tier handles it.
Step 5: Write the script with four beats per story
The voice is packaging. Every quality problem is a script problem. "Sounds like AI" is almost always the writing, not the model.
Every story gets four beats, in order:
- What it is: one or two sentences, plain words
- Why it got traction: what changed versus what existed before
- What it's good for: the concrete use case
- What it changes for you: name exactly ONE action. Switch, test, ignore, or make something about it.
Beat 4 is what makes it a brief instead of a feed reader. "Ignore it for now" is a legitimate answer.
The writing rules that actually mattered:
- Contractions. Short sentences. One idea per breath.
- Never state a number, version or price that isn't in the source text.
- Never assert anything about the listener's own setup. One episode said "when you upgrade from GPT-4" and I'm not on GPT-4. Guessing someone's stack is the same error as inventing a benchmark.
- For developer tools, say who touches it before what it does. A model that returns structured decisions got described as "an AI that answers yes or no" and I thought it was a chatbot asking me questions. "This is a part that goes inside software you build" would have prevented it. Lead with an analogy, then examples.
- Never narrate structure. No "today's top story was." The listener can hear the order.
- Keep a running glossary of terms you've already explained, so the brief gets denser over time instead of re-defining things every day.
Pauses go in as tags. They're what gives the listener room to think:
<break time="1.4s" /> between stories
<break time="0.6s" /> between beats
Step 6: Enforce a character floor with a retry loop
My first episode came out at 2.5 minutes against a 5-10 minute target. The model wrote one sentence per beat where the brief asked for four to six.
Asking for "more detail" doesn't work. Telling it the number it actually produced does:
f"That script is {chars:,} characters. It must be at least {floor:,}. "
f"You wrote roughly one sentence per beat; the brief calls for four to six. "
f"Rewrite it at full depth: same stories, same order, same voice, genuinely "
f"more substance per beat. Do not add stories and do not pad with filler."
One retry took it from 3,725 to 6,442 characters. Build it as an automatic loop against a floor (I use chars_per_minute * target_low * 0.85), not something you notice by listening.
One trap: openai/gpt-oss-120b reasons before answering, and those tokens count against max_tokens. At 800 it silently truncated mid-tag and still reported success. Always check finish_reason and treat "length" as a failure:
if r.choices[0].finish_reason == "length":
raise RuntimeError("truncated at max_tokens; raise it")
Step 7: Pick a voice and create a scoped ElevenLabs key
Go to elevenlabs.io → Voices → Voice Library. Preview until one sounds right, add it to your account, and copy the voice ID from the URL. It's the string in elevenlabs.io/voices/<VOICE_ID>.
Test the voice reading your actual script, not a "testing 1 2 3" line. Delivery on real sentences is what you're judging. I first tried a "Jarvis" voice from another project and rejected it once I heard it read a real episode.
Then Profile → API Keys → Create:
- Text to Speech → Access. Everything else stays No Access.
- Expire After: Never
- Auto-disable if leaked: On
- ⚠️ "Per credit refresh period" pre-fills with your plan's quota. Leave it at or above your real limit; capping it at 10,000 throttles the key below what you're paying for.
A key scoped this tightly returns 401 on /user/subscription and /voices. That's correct, not broken. Test it against the text-to-speech endpoint, the only one it needs.
| Plan | Price | Credits/mo | 5-min episodes |
|---|---|---|---|
| Free | $0 | 10,000 | ~2 (and no commercial licence) |
| Starter | $6 | 30,000 | ~6 |
| Creator | $22 | 121,000 | ~27 |
1 credit ≈ 1 character, and Flash v2.5 costs half, so roughly double those numbers. 5 minutes of speech ≈ 4,500 characters.
Start on Starter ($6, often $1 the first month). Free can't run a week of daily episodes (it's about two) and has no commercial licence, which matters the moment any of that audio shows up in a video.
Free alternative: Kokoro. Open-source, Apache-licensed, runs locally on an Apple Silicon Mac. Unlimited, zero cost, slightly less natural. The right call if you commit to daily and don't want a subscription.
Step 8: Render the audio, and don't trust the speed setting
This is the one that cost me the most time.
ElevenLabs' speed voice setting is silently ignored by eleven_flash_v2_5. I rendered the same script at speed: 0.88 and speed: 0.80 and got files 31.71s and 31.79s, 0.08 seconds apart. The parameter is accepted, returns 200, and does nothing. I only caught it by measuring the output files:
afinfo episode.mp3 | grep "estimated duration"
If you want slower, do it after the render, where atempo preserves pitch:
ffmpeg -y -i raw.mp3 -filter:a atempo=0.94 -b:a 128k episode.mp3
Then slower was worse. At 0.88 it was too robotic: time-stretching artifacts kill the engagement. My final setting is no stretch at all (1.0). The room to think comes entirely from the <break> tags, at zero quality cost. That was what I actually wanted when I said "slower."
Last gotcha: ElevenLabs caps request length. Split long scripts on story boundaries and concatenate, never mid-sentence:
MAX_CHARS_PER_REQUEST = 4800
parts = re.split(r"(?<=/>)\s*(?=\S)", script) # split at the break tags
# ...then: ffmpeg -f concat -safe 0 -i list.txt -c copy episode.mp3
Step 9: Generate cover art that survives being a thumbnail
Prompt it in GPT Image 2 or Nano Banana with a photo of yourself as reference. The structure that worked:
- Explicitly square 1:1, and say it's a podcast cover
- Subject: "the man from the reference image, matching his face and likeness exactly", chest-up, studio headphones, broadcast mic positioned low so it doesn't cover the face
- A colour theme stated plainly. Mine: "light blue water, pale sky blue through aqua to deeper ocean, airy and bright, explicitly not dark"
- Decorative elements carrying category labels. Mine used translucent bubbles reading MODELS / AGENTS / GITHUB / TOOLS / CREATORS / RESEARCH
- The title text, with "large, perfectly legible, and correctly spelled" stated explicitly
- "Everything important well inside the frame with generous margins, since this displays as a small thumbnail"
Two checks before you accept an image. Spelling: models still garble text, so regenerate rather than accept a wonky title. And shrink it to postage-stamp size, because that's how it will actually be seen.
⚠️ Apple requires square, 1400-3000 px, JPEG or PNG. Mine came out at 1254 and had to be upscaled. Ask for 3000×3000 up front.
Step 10: Host the feed on GitHub Pages
You do not need a podcast host, and you never submit to Apple's directory. Apple Podcasts will follow any RSS feed by URL.
gh repo create ai-brief-feed-1fd267 --public
Use an unguessable name suffix so it isn't stumbled on. It has to be public: Pages only serves private repos on a paid plan, and the audio is public content either way.
Repo contents:
cover-<hash>.png
feed.xml
episodes/2026-10-03.mp3
.nojekyll
Enable Pages on the default branch at root. Two clicks in Settings, or one call:
gh api -X POST repos/<user>/<repo>/pages -f 'source[branch]=main' -f 'source[path]=/'
Your feed URL is https://<user>.github.io/<repo>/feed.xml.
The feed is RSS 2.0 plus the iTunes namespace. Required at the channel level: title, link, language, description, itunes:author, itunes:category, itunes:image, itunes:explicit. Per episode: title, description, pubDate, a stable guid, itunes:duration, and an enclosure with the exact byte length:
<item>
<title>Friday, October 3</title>
<description>Plain-text bullets, one per story</description>
<content:encoded><![CDATA[<ul><li>...</li></ul>]]></content:encoded>
<pubDate>Fri, 03 Oct 2026 11:00:00 +0000</pubDate>
<guid isPermaLink="false">2026-10-03</guid>
<enclosure url="https://.../episodes/2026-10-03.mp3" length="4812345" type="audio/mpeg"/>
<itunes:duration>6:36</itunes:duration>
</item>
Write the episode descriptions with a second model call. My first version joined raw source titles with semicolons, which put amitshekhariitbhu/ai-system-design in the podcast app. One clean bullet per story, human names only, no repo slugs, no version numbers. "Gemini 4 Argon, Google's new frontier model," not a URL.
🔴 Name the cover file after a hash of its contents: cover-36bacd05.png. Apple caches show artwork by URL and ignores changes to the bytes behind it, so replacing cover.png in place leaves the old image on screen for hours or days. Restarting the app doesn't help. A content hash means a new image is automatically a new URL:
digest = hashlib.sha256(open(path, "rb").read()).hexdigest()[:8]
target = f"cover-{digest}.png"
Step 11: Subscribe in Apple Podcasts
Podcasts → File → Follow a Show by URL… → paste your feed URL → click Follow.
Two traps, both of which cost me time:
- Pasting the URL into the search box does nothing. That searches Apple's public directory, which your feed deliberately isn't in.
- It subscribes silently. No navigation, no confirmation. It looks like it failed when it worked. Go check Library → Shows.
Step 12: Schedule it, and verify through the scheduler
One script chains all five stages. On macOS, a launchd plist in ~/Library/LaunchAgents/:
<key>ProgramArguments</key>
<array>
<string>/Library/Developer/CommandLineTools/usr/bin/python3</string>
<string>/full/path/to/podcast_run.py</string>
</array>
<key>StartCalendarInterval</key>
<dict><key>Hour</key><integer>6</integer><key>Minute</key><integer>0</integer></dict>
<key>EnvironmentVariables</key>
<dict>
<key>GROQ_API_KEY</key><string>...</string>
<key>ELEVENLABS_API_KEY</key><string>...</string>
<key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.you.ai-podcast.plist
🔴 Trap 1: Full Disk Access. My first scheduled run died with:
can't open file 'podcast_run.py': [Errno 1] Operation not permitted
macOS blocks background jobs from reading ~/Downloads, ~/Documents and ~/Desktop. Grant Full Disk Access to the interpreter binary itself (System Settings → Privacy & Security → Full Disk Access → add the resolved python3 path), or keep the project outside those folders.
The grant is per binary, and symlinks don't inherit it. I had wrapped the job in zsh -i -c to pick up my env vars. Python had the grant, zsh didn't, so every single run failed. Calling the granted interpreter directly fixed it.
This is the same error, in the same folder, that had my email brief silently dead for months. Nine runs, zero deliveries, writing briefs into the void. It came back on the final step of this project, four months later.
🔴 Trap 2: scheduled jobs don't read your shell config. ~/.zshrc is sourced only by interactive shells. A launchd job gets a near-empty environment, so API keys have to be set in the job definition itself. Set PATH explicitly too. Homebrew binaries like ffmpeg and gh aren't on the default path, and without them the render and the publish both fail with "command not found."
Then verify properly. Running the script in your terminal proves nothing: your terminal has your environment and your permissions. Trigger it through the scheduler and read the log:
launchctl kickstart -k gui/$(id -u)/com.you.ai-podcast
tail -f ~/.jarvis/logs/podcast.log
That distinction is exactly what let my original failure hide for months. Log generously: every stage should print what it found, what it picked, how long the audio is, and what it cost.
The part most build guides skip
The failure mode for this project was never the code. My v1 ran for months and I still didn't find it useful.
So set a bar before you build it. Mine is 7 of the last 14 episodes actually listened to. If the pipeline runs perfectly and I listen to three, it failed: same outcome as v1, better infrastructure.
A guide that ends at "and now it runs automatically" teaches the easy half.
Resources
- ElevenLabs: https://elevenlabs.io
- Groq (free API tier): https://console.groq.com
- Kokoro TTS (free, local): https://github.com/hexgrad/kokoro
- Wispr Flow (voice-to-text, 1 free month): https://wisprflow.ai/r?AUSTIN16480
Good luck and happy building.
Some links in this guide are affiliate links. They cost you nothing and support the free guides.