End-to-end AI pipeline that researches a topic, writes a two-host script, synthesizes voices, and exports a broadcast-ready podcast episode.
PodCraft AI runs four sequential stages to produce a complete podcast episode from a single topic prompt:
Topic (string)
│
▼
Stage 1 — Research Agent
Searches the web via Tavily, synthesizes findings into a structured brief
│ episode_N_brief.json
▼
Stage 2 — Script Generator
Sends the brief to Claude, receives a two-host JSON script
│ episode_N_script.json
▼
Stage 3 — Audio Assembler
Calls ElevenLabs TTS for each speaker turn, overlays background music,
assembles and normalizes the final mix
│ episode_N.mp3
▼
Stage 4 — Metadata Packager
Generates show notes and tags via Claude, writes RSS sidecar JSON,
optionally uploads to Buzzsprout
│ episode_N_meta.json
▼
episodes/
Each stage saves its output to episodes/ so any stage can be re-run independently using --from-stage.
| Layer | Tool / Service |
|---|---|
| LLM | Anthropic Claude (claude-sonnet-4-5) |
| Agent orchestration | LangChain ReAct agent |
| Web search | Tavily API |
| Text-to-speech | ElevenLabs (eleven_turbo_v2) |
| Audio assembly | pydub + ffmpeg |
| Backend API | FastAPI + uvicorn |
| Frontend | React 18 + TypeScript + Vite |
| RSS feed | RSS 2.0 + iTunes namespace (built-in, no library) |
| Env management | python-dotenv |
| HTTP client | requests |
| Testing | pytest |
| Linting | ruff |
podcraft-ai/
├── src/
│ ├── pipeline.py # Entry point — orchestrates all four stages
│ ├── research_agent.py # Stage 1 — LangChain ReAct + Tavily search
│ ├── script_generator.py # Stage 2 — Claude → two-host JSON script
│ ├── audio_assembler.py # Stage 3 — ElevenLabs TTS + pydub assembly
│ ├── metadata_packager.py # Stage 4 — Claude show notes + sidecar JSON
│ └── utils/
│ ├── llm.py # Shared Claude API client
│ ├── logger.py # Structured logging
│ └── validators.py # JSON schema validators
├── prompts/
│ ├── research_prompt.txt # Stage 1 system prompt
│ └── script_prompt.txt # Stage 2 host personas + segment structure
├── web/ # React frontend
│ └── src/
│ ├── App.tsx
│ ├── api.ts # Fetch wrappers for all API routes
│ ├── components/
│ │ ├── EpisodeList.tsx
│ │ └── RunPipeline.tsx
│ └── index.css
├── episodes/ # Output directory (mp3 + JSON sidecars)
├── music/ # Royalty-free background tracks
├── server.py # FastAPI server + SSE log streaming
├── requirements.txt
└── .env.example
| Method | Route | Description |
|---|---|---|
GET |
/api/episodes |
List all episodes that have a sidecar _meta.json |
DELETE |
/api/episodes/{episode_num} |
Delete the mp3, meta, brief, and script files for an episode |
GET |
/episodes/{filename} |
Stream an episode mp3 (static mount) |
| Method | Route | Description |
|---|---|---|
POST |
/api/pipeline/run |
Start a pipeline run; returns { run_id } |
GET |
/api/pipeline/run/{run_id}/stream |
SSE stream of log lines for a run |
GET |
/api/pipeline/run/{run_id}/status |
Poll the current status of a run |
POST /api/pipeline/run request body:
{
"topic": "string",
"episode_num": 1,
"dev_mode": false
}| Method | Route | Description |
|---|---|---|
GET |
/feed.xml |
RSS 2.0 + iTunes podcast feed generated from all episode metadata |
FastAPI serves both the REST API and static episode files. Pipeline runs execute in background threads. Log lines are forwarded from Python's logging module into a queue.Queue per run, then streamed to the browser via Server-Sent Events (SSE).
Browser
│ POST /api/pipeline/run
▼
FastAPI → spawns Thread → run_pipeline()
│ logger.info(...)
▼
_QueueHandler → Queue
│
Browser ←── SSE stream ─────┘
GET /api/pipeline/run/{id}/stream
React SPA built with Vite. In development, Vite proxies /api, /episodes, and /feed.xml to the FastAPI server at localhost:8000.
- EpisodeList — fetches episodes on mount and after each pipeline run; supports in-browser playback with a seekable progress bar
- RunPipeline — submits the pipeline form and opens an
EventSourceto stream live log output into the console panel
Each speaker turn is synthesized individually and written to a temp file, loaded into pydub, then deleted. Segments are assembled with configurable silence gaps between turns (300ms) and segments (800ms). Background music runs only under the intro and wrap segments — the main segment is dialogue-only. Music is sliced to exactly match the segment length and faded within that window (fade-out on intro, fade-in on wrap). The final mix is normalized before export at 192k bitrate.
The --from-stage flag (1–4) allows resuming a failed run without re-calling upstream APIs. Stages before from_stage load their output from saved JSON files in episodes/ rather than making API calls.
| Host | Voice | Persona |
|---|---|---|
Aria (HOST_A) |
ElevenLabs "Aria" | Warm, curious, drives the narrative, accessible language, audience surrogate |
Alex (HOST_B) |
ElevenLabs "Alex - Professional" | Analytical, slightly skeptical, adds context and data, grounds enthusiasm with evidence |
Script segments follow a fixed structure: intro → main → wrap.
- Python 3.11+
- Node.js 18+
- ffmpeg on PATH
Copy .env.example to .env and fill in:
ANTHROPIC_API_KEY=
ELEVENLABS_API_KEY=
TAVILY_API_KEY=
ELEVENLABS_VOICE_ID_HOST_A= # Aria voice ID from ElevenLabs VoiceLab
ELEVENLABS_VOICE_ID_HOST_B= # Alex voice ID from ElevenLabs VoiceLab
PODCAST_BASE_URL= # Public URL for RSS feed enclosure links
PODCAST_TITLE=
PODCAST_AUTHOR=
PODCAST_EMAIL=
PODCAST_DESCRIPTION=
BUZZSPROUT_API_KEY= # Optional — enables automatic upload
BUZZSPROUT_PODCAST_ID= # Optional
pip install -r requirements.txt
cd web && npm install# Terminal 1 — FastAPI backend
python server.py
# Terminal 2 — Vite frontend
cd web && npm run devUI → http://localhost:5173 | API → http://localhost:8000 | RSS → http://localhost:8000/feed.xml
# Full pipeline run
python -m src.pipeline --topic "AI in journalism" --episode 1
# Dev mode (only 2 TTS turns — conserves ElevenLabs quota)
python -m src.pipeline --topic "AI in journalism" --episode 1 --dev
# Resume from a specific stage (skips earlier stages, loads saved JSON)
python -m src.pipeline --topic "AI in journalism" --episode 1 --from-stage 3- Always use
--devfor test runs to avoid burning ElevenLabs quota (10k chars/month on free tier) - Voice IDs must be added to your ElevenLabs VoiceLab before the API can use them — copy the ID from the three-dot menu on the VoiceLab entry
server.pyonly callsload_dotenv()at startup — restart the server after editing.env- Run the linter:
ruff check src/ - Run tests:
pytest tests/ -v
Private — © 2026 Nathan Curtis. All rights reserved.
This repository is public for viewing purposes only. No permission is granted to copy, modify, or redistribute without explicit written permission.

