Skip to content

Podcast Curation & Daily Brief

The podcast system has three pipelines that share infrastructure and publish to a common private RSS feed:

  1. Podcast curation β€” finding and queuing identity-relevant episodes in Pocket Casts.
  2. Daily brief generation β€” synthesizing a short spoken podcast from VDB context.
  3. Deep produced episodes β€” a Stepwise flow (podcast-deep) that researches, writes, critiques, and TTS-produces 15-40 minute episodes.

Pipelines 1 and 2 use the media signal refinery for episode discovery and the scheduler for automation. A fourth, smaller path β€” the listened-episode digest β€” bridges what bio-zack actually listened to back into the transcription/summary pipeline.

System Architecture

PODCAST CURATION PIPELINE                     DAILY BRIEF PIPELINE
(every 12 hours)                              (after VDB send, ~5-6:30 AM PT)

+------------------+                          +------------------+
| OPML Subscriptions|                         | gather_context() |
| + Pocket Casts    |                         | (VDB data)       |
+--------+---------+                          +--------+---------+
         |                                             |
         v                                             v
+------------------+                          +------------------+
| Media Pipeline   |                          | Claude (Sonnet   |
| sources/podcasts |                          | 4.6) via         |
| (4h poll)        |                          | OpenRouter API   |
+--------+---------+                          +--------+---------+
         |                                             |
         v                                             v
+------------------+                          +------------------+
| Triage + Score   |                          | Podcast          |
| (media pipeline) |                          | Transcript       |
+--------+---------+                          | (.txt)           |
         |                                    +--------+---------+
         v                                             |
+------------------+                                   v
| podcast_curator  |                          +------------------+
| Rubric Scoring   |                          | ElevenLabs TTS   |
| (6 dimensions)   |                          | generate.py      |
+--------+---------+                          +--------+---------+
         |                                             |
         v                                             v
+------------------+                          +------------------+
| Queue Selection  |                          | RSS Feed         |
| (diversity,      |                          | /api/v1/podcast/ |
| dedup, capacity) |                          | {token}/feed.xml |
+--------+---------+                          +--------+---------+
         |                                             |
         v                                             v
+------------------+                          +------------------+
| Pocket Casts     |                          | Pocket Casts     |
| Up Next Queue    |                          | (subscribed)     |
+------------------+                          +------------------+


DEEP PRODUCED EPISODES (podcast-deep Stepwise flow, on demand)

topic -> research -> scratchpad -> outline -> fill-transcript
      -> rubric-critique -> targeted-refine -> text-quality-gate (loops up to 3x)
      -> chunk+tag (ElevenLabs v3 tags) -> per-chunk TTS
      -> ffmpeg assembly (crossfade + LUFS normalize) -> publish to RSS feed

Why This Architecture?

  1. Two-stage scoring: The media pipeline does cheap triage (Gemini Flash) to filter the firehose. The curation engine then applies an expensive identity-aware rubric (Gemini 2.5 Flash) to the survivors. Cost-effective filtering without sacrificing personalization.

  2. Identity-driven curation: The scoring prompt includes active goals, projects, identity observations, heartbeat topics, and listening history. Episodes are scored against who bio-zack is right now, not a static interest profile.

  3. Feedback loop: Listening signals (completed, abandoned, skipped) are synced from Pocket Casts history and fed back into the scoring prompt. The system learns from what bio-zack actually listens to.

  4. Cloned voice for spoken episodes: ElevenLabs synthesizes the daily brief and deep episodes with a cloned voice, making the podcast feel like silicon-zack talking directly to bio-zack.

Pocket Casts Integration

Client: scripts/integrations/pocketcasts.py

API reverse-engineered from the open source Pocket Casts iOS/Android apps (Automattic). Authentication via email/password login, with token refresh.

Credentials: ~/.config/vita/pocketcasts.json

{ "email": "user@example.com", "password": "REDACTED" }

Setup:

uv run python scripts/media/curate_podcasts_setup.py user@example.com yourpassword

API operations used:

Operation Method Purpose
get_up_next() Up Next sync Read current queue
add_to_queue() Up Next sync (play_last) Add curated episodes
remove_from_queue() Up Next sync (remove) Clean finished episodes
get_listening_history() User history Feedback loop signals
get_subscriptions() User podcast list Subscription management
get_new_releases() New releases Fresh episodes from subs
search_episodes() Episode search Discovery + UUID resolution
search_podcasts() Discover search Podcast UUID lookup
get_podcast_episodes() Cache endpoint Episode listing for a show

Sync state (serverModified timestamp) is tracked in data/podcast/curation/pc_sync_state.json to enable incremental Up Next syncs.

Episode Scoring (6 Dimensions)

Config: config/podcast-curation.yaml

Each candidate is scored 0-10 on six dimensions, weighted to produce a 0-100 total:

Dimension Weight Description
topic_relevance 0.30 Match to current interests, projects, goals, and identity
guest_quality 0.20 Notable, expert, or someone bio-zack would respect/learn from
intellectual_depth 0.20 Substance over surface. Complex ideas, not hot takes
production_quality 0.10 Host engagement quality, conversational chemistry, humor
timeliness 0.10 Relevant NOW vs evergreen? Breaking or trending topic?
novelty 0.10 New perspective vs rehash? Unique angle or guest?

The total score is sum(dimension_score * weight * 10) across all dimensions.

Model: google/gemini-2.5-flash via OpenRouter. Candidates are batched (8 per call) with a system prompt that includes full identity context.

Curation Rubric

Positive Signals (Boosters)

The rubric defines seven booster categories (paraphrased β€” actual prompt is in config/podcast-curation.yaml):

  • Founder stories with specific tactical details
  • Technical deep dives with genuine expertise
  • Contrarian or counterintuitive arguments with evidence
  • Action-sports content matching personal interests
  • AI/LLM technical content or agent architecture
  • Dry humor, irreverent tone
  • System design or organizational architecture

Negative Signals (Penalties)

Six penalty categories:

  • Excessive emotional validation or self-help tone
  • Participation trophy mentality
  • Surface-level hot takes without depth
  • Corporate buzzwords (synergy, paradigm, ecosystem)
  • Overly promotional or advertorial content
  • Fluffy lifestyle or generic motivation content

Queue Rules

Rule Value Purpose
target_size 10 Target number of episodes in Up Next
min_score 65 Minimum curation score to queue
max_per_podcast 2 Max episodes from same podcast
max_same_category 3 Diversity cap per category
cleanup_remaining_threshold_seconds 120 Remove episodes with < 2min left
max_discovery_candidates 20 Discovery searches per cycle

Diversity Categories

Episodes are classified into one of six categories to enforce queue diversity:

  • politics_current_events
  • technology_ai
  • business_entrepreneurship
  • science_health
  • culture_philosophy
  • sports_action

Curation Pipeline Detail

Orchestrator: scripts/media/podcast_curator.py (PodcastCurator.run())

The full cycle runs every 12 hours:

  1. Sync listening signals - Cross-reference queue_log with Pocket Casts listening history. Classify outcomes: completed (>90%), partial (>50%), abandoned (<50% after 48h), skipped (never played after 72h).

  2. Gather identity context - Read active goals, projects, identity observations, heartbeat topics, current attention items, personality traits, and listening pattern history. Builds a rich text context for the scoring prompt.

  3. Gather candidates from pipeline - Pull podcast items from data/media/items/ and data/media/scores/ that scored >= 40 in media triage (last 7 days), via gather_candidates_from_pipeline(min_score=40).

  4. Gather discovery candidates - Search Pocket Casts for episodes matching goal/project keywords and hardcoded high-value terms. Up to max_discovery_candidates (20) discovery candidates per cycle.

  5. Score against rubric - Send all candidates to Gemini with the identity-aware prompt. Parse dimension scores and compute weighted total.

  6. Clean up Pocket Casts queue - Remove finished episodes (< 2min remaining) from Up Next.

  7. Select for queue - Apply diversity constraints, dedup against recently queued items, fill available slots up to target_size.

  8. Push to Pocket Casts - Resolve episode UUIDs (multi-strategy: existing UUID, podcast lookup + title match, global search fallback) and add to Up Next.

Listening Feedback Loop

After episodes are queued, the system tracks what happens to them:

Outcome Condition Signal
completed playingStatus == 3 or >= 90% played Strong positive
partial >= 50% played Mild positive
abandoned < 50% played, queued > 48h ago Negative
skipped Never appeared in history, queued > 72h ago Strong negative
starred User starred in Pocket Casts Very strong positive

These signals are stored in data/podcast/curation/listening_signals.jsonl and included in the scoring prompt for future runs. The system reports per-podcast and per-category completion rates, top correct predictions, and mispredictions.

Listened-Episode Digest

Location: scripts/media/listened_digest.py (digest_listened_episodes())

The curation feedback loop only sees episodes the system itself queued. The digest closes that gap: it queries the full Pocket Casts listening history directly and pulls anything substantively listened to (default min_played_minutes=15) into the transcription/summary pipeline β€” so episodes bio-zack found on his own get captured too.

For each listened episode (up to max_episodes=5 per run):

  1. Classify outcome β€” completed (playingStatus 3 or β‰₯90%), partial (β‰₯50%), or sampled (below).
  2. Find or create transcript β€” reuse an existing AssemblyAI transcript (title fuzzy-match over a 14-day lookback) or download audio and transcribe it via PodcastTranscriber.
  3. Find or create summary β€” reuse an existing summary or generate one with PodcastSummarizer.
  4. Save a digest record to data/podcast/digests/YYYY-MM-DD.jsonl.
  5. Notify β€” for completed episodes with a summary, queue a Telegram message with the top takeaways (trigger="listened_digest", dedup keyed on episode UUID).

Already-digested UUIDs are tracked across the digest files so each episode is processed once. get_recent_digests(days) exposes records for surfaces like the /now dashboard.

# Full run
uv run python scripts/media/listened_digest.py
# Preview what would be digested
uv run python scripts/media/listened_digest.py --dry-run

Episode Source: Subscription Feed

Adapter: scripts/media/sources/podcasts.py (PodcastAdapter)

Podcast subscriptions are managed via OPML export from Pocket Casts, stored at data/podcasts/subscriptions.opml. The adapter parses this file and fetches RSS feeds using feedparser.

  • Poll interval: every 4 hours
  • Max episodes per feed: 3 (configurable)
  • Max age: 7 days (max_age_days=7)
  • Episodes enter the media pipeline as source: "podcast" items
  • Triage scoring happens at the media pipeline level
  • Higher-scoring episodes may get transcribed via AssemblyAI

Transcription Pipeline

Location: scripts/media/transcribe.py (PodcastTranscriber)

High-scoring podcast episodes (above the transcribe_score threshold) are transcribed via AssemblyAI:

  1. Download audio to temp directory (cached by URL hash)
  2. Upload to AssemblyAI with speaker labels enabled
  3. Store transcript in data/media/transcripts/YYYY-MM-DD.jsonl
  4. Cost: price_per_minute ($0.012/min), budget-capped at transcribe_daily_usd

Note β€” config overrides: the dataclass defaults in scripts/media/config.py are transcribe_score=75 and transcribe_daily_usd=1.50, but the live config/media-intel.yaml overrides them to a low transcribe threshold (25) and a higher daily budget ($3.00). The YAML values win at runtime, so in practice the system transcribes generously.

Podcast Summarization

Location: scripts/media/podcast_summary.py (PodcastSummarizer)

Transcribed episodes are summarized by LLM:

  • Executive summary (2-3 paragraphs)
  • Key topics (3-7)
  • Highlights with speaker attribution (3-5 notable quotes)
  • Actionable takeaways (3-5)

Summaries stored in data/media/podcast_summaries/YYYY-MM-DD.jsonl.

Daily Brief (VDB Podcast)

Location: scripts/podcast/generate_transcript.py

After the VDB email is sent (~5-6:30 AM PT), the scheduler generates a podcast episode:

  1. Gather context - Same gather_context() used for the VDB email, imported from scripts.vdb.gather_context (sleep, calendar, tasks, media signal, etc.). The daily brief is directly coupled to the VDB context builder.

  2. Generate transcript - Claude (Sonnet 4.6) via the OpenRouter HTTP API (anthropic/claude-sonnet-4-6, temperature=0.8, max_tokens=16000) generates a 600-1000 word conversational narrative. The prompt instructs silicon-zack voice: casual, direct, analytical. Content priority: sleep/body, day ahead, the one thing, action items, training, signal, overnight activity. (A find_claude_binary() helper exists in the module but is unused dead code β€” generation goes over HTTP, not the local CLI.)

  3. Synthesize audio - ElevenLabs TTS with cloned voice (generate.py). Text is cleaned (markdown stripped, unicode normalized), chunked at 4500 chars (MAX_CHUNK_CHARS), and synthesized with context-aware boundaries (previous/next ~500 chars passed for continuity). Engine eleven_multilingual_v2, output format mp3_44100_128. Falls back to Kokoro ONNX if no ElevenLabs key is configured.

  4. Publish - Episode metadata appended to data/podcast/episodes.json. MP3 saved to data/podcast/episodes/, transcript to data/podcast/transcripts/.

Credentials: OpenRouter API key at ~/.config/vita/openrouter.json ({"api_key": "..."}).

RSS Feed

The daily brief β€” and every other generated episode β€” is served as a single private podcast feed:

  • Feed URL: /api/v1/podcast/{token}/feed.xml
  • Episode serving (by filename): /api/v1/podcast/{token}/episodes/{filename}
  • Cover art: /api/v1/podcast/{token}/cover.jpg
  • Token: Random hex string stored in data/podcast/.feed_token (get_or_create_feed_token())
  • Discovery: GET /api/v1/podcast/feed-url returns the subscription URL + token

Two access-control schemes coexist:

  • Token-in-path for the feed and direct file serving. A wrong token returns 404, and the filename path is sanitized against traversal (.., /, \).
  • HMAC-signed play URLs for episode-by-ID access. /episodes returns each episode with a play_url of the form /play/{episode_id}?sig=..., where sig is the first 16 hex chars of HMAC-SHA256(feed_token, episode_id). GET /play/{episode_id} verifies the signature with hmac.compare_digest (403 on mismatch) before serving the MP3 β€” so a play link can be shared without exposing the feed token itself.

Subscribe in Pocket Casts or any podcast app via the feed URL. The token in the path prevents public discovery.

Episode Characteristics

Typical daily brief episode:

  • Duration: 4-6 minutes
  • Size: ~300-500 KB (MP3 128kbps 44.1kHz)
  • Character count: ~5000-5700
  • Engine: ElevenLabs eleven_multilingual_v2

Idempotency

generate_transcript.py checks episodes.json before generating. If an ElevenLabs "Daily Brief" episode already exists for today, it skips (unless --force or --preview).

Deep Produced Episodes (podcast-deep)

Location: flows/podcast-deep/ (Stepwise flow, FLOW.yaml version 4.0)

Beyond the short daily brief, podcast-deep is a full produced-podcast generator for 15-40 minute episodes. It publishes to the same RSS feed (data/podcast/episodes.json + .feed_token). The flow chains agent steps (Claude with web browsing), LLM steps (OpenRouter), and Python run steps.

Run:

stepwise run podcast-deep --name "my-episode" \
  --input topic="The future of autonomous agents" \
  --config host_name="Your Name" \
  --config host_perspective="AI, software engineering, philosophy"

Inputs: topic (required), guidance, audience (general|technical|expert), episode_type (deep_dive|briefing|explainer).

Pipeline steps (FLOW.yaml):

Step Executor Model / tool Purpose
research agent (claude) web browse + search Deep-dive research (cost cap $5, ≀25 min)
scratchpad llm anthropic/claude-sonnet-4.6 Creative angles, hooks, analogies (temp 0.8)
outline llm claude-sonnet-4.6 Section structure + target minutes (temp 0.6)
fill-transcript agent (claude) β€” Full transcript from outline (cost cap $3, ≀15 min)
rubric-critique llm claude-sonnet-4.6 Score draft + emit targeted edits (temp 0.3)
targeted-refine llm claude-sonnet-4.6 Apply edits surgically (temp 0.7)
text-quality-check / repair-transcript script + agent text_quality_check.py Validate length/format; repair loop (up to ~3x)
chunk-and-tag script chunk_and_tag.py Split into TTS chunks with ElevenLabs v3 expression tags
synthesize script synthesize.py (ElevenLabs eleven_v3) Per-chunk TTS
audio-assembly script audio_assembly.py (ffmpeg) Concatenate, crossfade, two-pass LUFS normalize (target -16 LUFS)
publish script publish.py Register metadata, save transcript, update RSS feed

Episode types map to iTunes episode types in the feed (deep_dive β†’ full, briefing/quick β†’ bonus). The flow's cloned voice id defaults to the same 4NykfJgp4HqPHbp1OwTB as the daily brief, configurable via the voice_id config. ElevenLabs key resolution order: STEPWISE_VAR_ELEVENLABS_API_KEY β†’ ELEVENLABS_API_KEY β†’ secrets.toml in the flow directory.

Note: the text-quality gate is a single repair pass with verification rather than a true loop β€” Stepwise loop semantics don't reliably re-trigger upstream any_of check steps, so the flow uses initial-check β†’ repair-transcript β†’ text-quality-check with an escalate exit if still failing.

API Endpoints

Podcast Feed (api/src/routers/podcast.py)

Endpoint Method Auth Description
/api/v1/podcast/{token}/feed.xml GET Token in URL RSS feed for podcast apps
/api/v1/podcast/{token}/episodes/{filename} GET Token in URL Serve MP3 file (path-traversal guarded)
/api/v1/podcast/{token}/cover.jpg GET Token in URL Serve podcast cover art
/api/v1/podcast/play/{episode_id} GET HMAC sig query param Serve MP3 by episode ID (signature-verified)
/api/v1/podcast/episodes GET None List episodes with play_url + has_transcript flags
/api/v1/podcast/episodes/{episode_id}/transcript GET None Return the episode transcript text
/api/v1/podcast/feed-url GET None Get subscription URL + token

Podcast Curation (api/src/routers/podcast_curation.py)

Endpoint Method Description
/api/v1/podcast-curation/candidates GET Scored candidates (filterable by score, category, search)
/api/v1/podcast-curation/candidates/{item_id} GET Single candidate detail
/api/v1/podcast-curation/queue-log GET History of episodes pushed to Pocket Casts
/api/v1/podcast-curation/stats GET Aggregated stats (score distribution, top podcasts, costs)
/api/v1/podcast-curation/summaries GET Episode summaries with topics and highlights
/api/v1/podcast-curation/rubric GET Current rubric dimensions, penalties, boosters, queue rules

Candidate query params: sort (score|recent), min_score, category, search, limit, offset

The API runs on port 33800 (./run api). See Architecture for the full service map.

Scheduler Integration

Task Interval Script Purpose
media_podcast 4 hours (90s startup delay) scripts/media/run_pipeline.py podcast Fetch new episodes from subscriptions
podcast_curation 12 hours (300s delay) scripts/media/podcast_curator.py Score + queue to Pocket Casts
listened_digest 2 hours scripts/media/listened_digest.py Bridge listened episodes into transcription/summary
VDB podcast After VDB send (~5-6:30 AM) scripts/podcast/generate_transcript.py Daily brief audio

The podcast fetch runs with a 90-second startup delay; curation runs with a 300-second delay (to let the fetch run first). Interval constants live in api/src/scheduler.py (MEDIA_PODCAST_INTERVAL, PODCAST_CURATION_INTERVAL, LISTENED_DIGEST_INTERVAL).

CLI Usage

# Full curation run
uv run python scripts/media/podcast_curator.py

# Dry run (score but don't push)
uv run python scripts/media/podcast_curator.py --dry-run

# Show candidates only
uv run python scripts/media/podcast_curator.py --candidates

# Show identity context used for scoring
uv run python scripts/media/podcast_curator.py --context

# Clean up finished episodes only
uv run python scripts/media/podcast_curator.py --cleanup

# Show listening signal feedback
uv run python scripts/media/podcast_curator.py --signals

# Digest listened episodes
uv run python scripts/media/listened_digest.py
uv run python scripts/media/listened_digest.py --dry-run

# Generate daily brief (transcript + audio)
uv run python scripts/podcast/generate_transcript.py

# Preview transcript only (no audio)
uv run python scripts/podcast/generate_transcript.py --preview

# Force regeneration
uv run python scripts/podcast/generate_transcript.py --force

# List ElevenLabs voices
uv run python scripts/podcast/generate.py voices

# Produce a deep episode
stepwise run podcast-deep --name "my-episode" --input topic="..."

# Pocket Casts CLI
uv run python scripts/integrations/pocketcasts.py queue
uv run python scripts/integrations/pocketcasts.py subs
uv run python scripts/integrations/pocketcasts.py new
uv run python scripts/integrations/pocketcasts.py search "AI agents"

File Locations

File Purpose
scripts/media/podcast_curator.py Curation engine (scoring, selection, queue push)
scripts/media/listened_digest.py Listened-episode digest bridge
scripts/media/sources/podcasts.py OPML parser + RSS episode adapter
scripts/media/transcribe.py AssemblyAI transcription pipeline
scripts/media/podcast_summary.py LLM-based transcript summarization
scripts/media/curate_podcasts_setup.py One-shot setup + first curation run
scripts/integrations/pocketcasts.py Pocket Casts API client
scripts/podcast/generate.py ElevenLabs TTS episode generation
scripts/podcast/generate_transcript.py Daily brief transcript (Claude via OpenRouter)
scripts/podcast/generate_initial.py Initial episode generation helper
flows/podcast-deep/FLOW.yaml Deep produced-episode Stepwise flow
api/src/routers/podcast.py Feed + episode serving endpoints
api/src/routers/podcast_curation.py Curation API endpoints
api/src/schemas/podcast_curation.py Pydantic schemas for curation API
config/podcast-curation.yaml Rubric, penalties, boosters, queue rules
config/media-intel.yaml Live transcribe thresholds + budgets (overrides config.py defaults)
data/podcast/episodes.json Episode metadata (all generated episodes)
data/podcast/episodes/*.mp3 Generated audio files
data/podcast/transcripts/*.txt Generated episode transcripts
data/podcast/.feed_token RSS feed access token
data/podcast/cover.jpg RSS feed cover art
data/podcast/curation/candidates.jsonl All scored curation candidates
data/podcast/curation/queue_log.jsonl Episodes pushed to Pocket Casts
data/podcast/curation/listening_signals.jsonl Listening outcome feedback
data/podcast/curation/pc_sync_state.json Pocket Casts sync state
data/podcast/digests/*.jsonl Listened-episode digest records
data/podcasts/subscriptions.opml Podcast subscriptions (OPML export)
data/media/items/YYYY-MM-DD.jsonl Raw podcast items (media pipeline)
data/media/scores/YYYY-MM-DD.jsonl Triage scores (media pipeline)
data/media/transcripts/YYYY-MM-DD.jsonl AssemblyAI transcripts
data/media/podcast_summaries/YYYY-MM-DD.jsonl Episode summaries
~/.config/vita/pocketcasts.json Pocket Casts credentials
~/.config/vita/pocketcasts_tokens.json Cached auth tokens
~/.config/vita/openrouter.json OpenRouter API key (daily brief transcript)

External Integrations

Integration Purpose Auth Cost
Pocket Casts API Queue management, listening history Email/password login Free
OpenRouter (Gemini) Curation scoring, triage, summarization API key ~$0.01-0.05/run
OpenRouter (Claude Sonnet 4.6) Daily brief transcript generation API key ~per-episode tokens
ElevenLabs Daily brief + deep-episode TTS API key ~$0.15-0.30/brief, more per deep episode
AssemblyAI Podcast transcription API key $0.012/minute
Feedparser RSS episode fetching None Free

Known Limitations

  1. Pocket Casts API is unofficial - Reverse-engineered from mobile apps, may break on API changes.
  2. Discovery search is keyword-based - Limited to Pocket Casts search, no semantic discovery.
  3. Listening signals need time - Outcomes classified after 48-72h delay; cold start with < 5 signals skips listening context.
  4. UUID resolution is fuzzy - Title matching can occasionally match wrong episodes; the listened-digest also uses fuzzy title matching to reuse existing transcripts.
  5. Daily brief is English-only - ElevenLabs multilingual model used but transcript generated in English.
  6. OPML requires manual export - Subscription list updated via /sync-podcasts browser skill, not automatic.
  7. Deep-episode quality gate is single-pass - Stepwise loop semantics force a single repair pass with verification rather than a true retry loop.

Where to Go Next

  • VDB β€” the daily brief reuses the VDB context builder.
  • Media β€” the signal refinery that supplies podcast triage candidates.
  • Stepwise / Flows β€” how podcast-deep is orchestrated.
  • Model Guidance β€” current model roster and routing.