Build guide

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.

Claude CodeElevenLabsGroqGitHub
Oct 5, 2026
Optimized for

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


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:

  1. What it is: one or two sentences, plain words
  2. Why it got traction: what changed versus what existed before
  3. What it's good for: the concrete use case
  4. 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:

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:

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:

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:

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

Good luck and happy building.

Some links in this guide are affiliate links. They cost you nothing and support the free guides.

← Back to the library Good luck and happy building