text-to-podcast
Turn a web article or PDF into a .m4a podcast episode with proper metadata.
scripts/podcast.py is the deterministic spine of the pipeline. It owns input classification, slug derivation, file naming, the web2txt→web2pdf+pdf2txt fallback, chunking, the verify-expand content-preservation gate, multivoice tag building, TTS-source selection, date/description fixup, assembly, and cleanup. It never makes a judgment call about text — which expansions to apply is an LLM slot (llm-expand), gated by the deterministic verifier.
On next.action = llm-*, you fill the slot yourself — read next.prompt, apply it to next.inputs, write next.output, then run next.then. The spine never calls an LLM; it only emits llm-* actions. The envelope also carries a pre-rendered next.llm.argv — ignore next.llm, it is irrelevant to this skill.
Where This Skill's Files Live
All scripts/ and assets/ paths below are relative to this skill's own directory — wherever your agent harness installs it (e.g. ~/.agents/skills/text-to-podcast/) or where you clone the repo — not the current working directory. Prefix commands with that path: uv run --script <skill_dir>/scripts/podcast.py ….
Prerequisites
podcast.py probes for its external dependencies at the earliest moment — plan runs the gate before emitting anything actionable, and podcast.py check-prereqs [input] checks independently. uv, ffmpeg, and espeak-ng are required for every path; docker is only checked for URL inputs (the web2pdf fallback). If a required tool is missing, plan returns ok: false with next.action: human and a brew install … hint instead of proceeding.
The state-envelope contract
Every podcast.py subcommand prints one JSON state envelope to stdout whose next field tells you exactly what to run next:
{"slug": "...", "stage": "raw|clean|tts|tagged|wav|m4a|done",
"produced": ["<slug>.raw.txt"],
"next": {"action": "cli|llm-reformat|llm-expand|llm-tag-dialogue|llm-metadata|human|done",
"command": "...", "prompt": "assets/reformat-prompt.md",
"inputs": [...], "output": "...", "then": "...",
"llm": {"harness": "claude", "argv": ["claude", "-p", "..."]}}}
next.action is the contract:
cli→ runnext.commandyourself.llm-reformat→ applynext.prompttonext.inputs, writenext.output, then runnext.then.llm-expand→ applynext.prompt(assets/expand-prompt.md) tonext.inputs, writenext.output(the speech-friendly text), then runnext.then.verify-expandchecks it againstassets/expand-allowlist.json.llm-tag-dialogue→ fill everyspeaker: "?"slot innext.inputs(<slug>.tags.json) from the declared speakers (a constrained choice, not a free-form rewrite); write back tonext.output, then runnext.then. Only fires when--dialogueis set AND at least one paragraph has no leadingSpeaker:label.llm-metadata→ applynext.prompttonext.inputs→<slug>.meta.json(validated againstassets/metadata-schema.json), then runassembleonce the WAV exists.human→ ask the user (failure fallback only — e.g. a verifier gives up after a retry).done→ the.m4aexists.
The next.llm field is a pre-rendered argv for an unrelated CLI harness; ignore it — you fill the slot yourself.
Stage list
plan → fetch|ingest → llm-reformat → verify → llm-expand → verify-expand
→ [if --dialogue] tag-dialogue → [llm-tag-dialogue if holes] → merge-tags → verify-tags
→ tts (single) | tts_multivoice (dialogue)
→ llm-metadata
→ assemble → done
Multi-voice is declared upfront via plan --dialogue [--voice-map "HOST=female,GUEST=male"]. When --dialogue is set, the spine routes through tag-dialogue → tts_multivoice.py; otherwise straight to pick-tts-source → tts.py. There is no auto-detection and no human voice-map confirmation gate; if --voice-map is omitted the spine defaults (first→female, second→male) and prints a non-blocking notice.
Running a conversion
Start with plan, which also fail-fast checks prerequisites, then follow next:
uv run --script scripts/podcast.py plan <url-or-pdf-or-text> [--dialogue] [--voice-map "HOST=female,GUEST=male"]
# → prints the full stage list, a prerequisites block, and the first command to run.
If plan returns ok: false with next.action: human, install the missing prerequisites (hint gives the brew command) and re-run plan before going further. Then loop: run the cli/done actions directly; for the llm-* actions, do the text transform described below and write the named output file, then run next.then. The envelope always names the prompt, the input files, the output file, and the follow-up command — you never re-derive them.
Routing
Hand plan whatever the user gave you — URL, PDF, or pasted text. It classifies the input (classify_input) and picks the first subcommand: URLs go to fetch, PDFs and plain text both go to ingest. You never pick the entry point yourself.
Jumping in mid-pipeline
Normally you run plan and let it tell you where to start. The exception: if the user already has intermediate artifacts on disk (e.g. they reformatted <slug>.clean.txt by hand), you can skip plan and call the subcommand directly. Only do this when the state is known and complete; otherwise fall back to plan. The spine writes every envelope to <slug>.log.jsonl, which you may consult for resumability.
The LLM steps
These are the only places judgment is applied. Everything else is podcast.py.
1. Reformat — clean the raw text (llm-reformat)
Read assets/reformat-prompt.md and apply it to the raw text named in next.inputs. Write the result to next.output (typically <slug>.clean.txt, or <slug>.clean.NNN.txt per chunk when fetch/ingest reports needs_chunking: true).
The prompt's hard rules:
- Body content must be preserved character-for-character. No paraphrasing, summarizing, or reordering.
- Only inline page chrome (ads, nav, tag lists, copyright footers) may be removed.
- PDF column-wrap line breaks within a paragraph are joined into one line.
- Section headings go on their own line with blank lines above and below.
Chunking is deterministic. When the raw text is long, fetch/ingest sets needs_chunking: true and points next.command at podcast.py chunk. Run it: it splits at paragraph boundaries into <slug>.raw.NNN.txt chunks of ≤2500 words, and the envelope's inputs/outputs list each chunk pair. Reformat each chunk separately, then run podcast.py merge-chunks <slug> to concatenate into <slug>.clean.txt.
Then verify mechanically — never trust the reformat unchecked:
uv run --script scripts/podcast.py verify <slug>.raw.txt <slug>.clean.txt
This wraps scripts/checkclean.py, which fails (exit 1) if the clean text contains words not present in the raw (the model rewrote something) or retains less than half of the raw words (silent truncation). On failure the envelope re-emits llm-reformat with a reason and retry_count for the offending region; after one retry it emits next.action: human (never loops). On success it routes to expand.
2. Expand — speech-friendly text (llm-expand)
Read assets/expand-prompt.md and apply it to <slug>.clean.txt (or per-chunk <slug>.clean.NNN.txt). Write the result to next.output (<slug>.tts.txt or per-chunk <slug>.tts.NNN.txt). The prompt expands glued units (190GW→190 gigawatts), abbreviations where unambiguous (Q1 2028→the first quarter of 2028), symbols (&→and, ~50→approximately 50, ±→plus or minus), and does light prosody repair — while preserving meaning, ordering, and every informational fact. No summarizing, no commentary, no new claims, no introduced Speaker: labels. The permitted expansions are exactly those in assets/expand-allowlist.json; do not invent others.
Then verify mechanically:
uv run --script scripts/podcast.py expand <slug> # → emits the llm-expand plan
# (you fill the slot → <slug>.tts.txt)
uv run --script scripts/podcast.py verify-expand <slug> # → deterministic predicate
verify-expand checks (first failure wins): paragraph/sentence counts did not collapse (no silent summarization); every content token of clean is present in tts verbatim or explained by the allowlist (a from→to pair where a to form appears in tts); and no Speaker: labels were introduced. On failure it re-emits llm-expand for the failing chunk with a reason and retry_count; after one retry it emits human. The allowlist starts strict; loosen assets/expand-allowlist.json only if you observe false-failures.
<slug>.clean.txt is left untouched — it is the faithful text for metadata; expansions would fail checkclean, which is why this pass runs after verify.
3. Metadata extraction (llm-metadata)
Read assets/metadata-prompt.md and apply it to <slug>.clean.txt. The model returns a single JSON object validated against assets/metadata-schema.json:
{"title": "...", "artist": "...", "description": "...", "date": "YYYY-MM-DD"}
Write it to <slug>.meta.json. podcast.py assemble (and finalize-metadata) handle the rest deterministically: empty date → today, description >500 chars → written to <slug>.desc.txt with --description-file, missing title/artist → sensible defaults. You don't fix these up yourself. Run podcast.py finalize-metadata <slug> to preview the exact wav2m4a.py argv before assembly.
Multi-voice dialogues (--dialogue)
For interview Q&A, panels, screenplays — anything with repeated Speaker: labels — a single narrator is flat. Declare it upfront:
uv run --script scripts/podcast.py plan <input> --dialogue --voice-map "HOST=female,GUEST=male"
plan writes <slug>.voices.json ({dialogue, voice_map, defaulted}) and routes through tag-dialogue. After verify-expand succeeds:
uv run --script scripts/podcast.py tag-dialogue <slug> # → <slug>.tags.json
tag-dialogue requires <slug>.voices.json — it fails fast (human) if you forgot --dialogue. It splits <slug>.tts.txt (or .clean.txt) into blank-line paragraphs and writes <slug>.tags.json. Each entry is {index, speaker} for paragraphs whose leading Label: matches a declared speaker, or {index, speaker: "?", text_preview} for everything else. The envelope's holes lists the indices that need a speaker.
- No holes →
next.actioniscli→merge-tags. The LLM is not invoked. - Holes present →
next.actionisllm-tag-dialogue. Your only edit is to replace eachspeaker: "?"in<slug>.tags.jsonwith one of the declared speakers (usetext_previewfor context). You don't rewrite paragraphs, you don't touch<slug>.tagged.txt, you don't transcribe voice ids. Then run:
uv run --script scripts/podcast.py merge-tags <slug> # → <slug>.tagged.txt
uv run --script scripts/podcast.py verify-tags <slug> # → prints the tts_multivoice.py command
merge-tags validates that no ? remains, strips leading Speaker: labels, prepends [SPEAKER] per paragraph, and writes <slug>.tagged.txt. verify-tags asserts every paragraph has a known-speaker tag with no residual labels and emits the ready-to-run tts_multivoice.py argv — the voice map comes from <slug>.tags.json (copied from <slug>.voices.json at tag time), so you never transcribe voice ids.
Run the printed command (the output WAV is <slug>.wav). Input format:
[INTERVIEWER] Walk me through a time you set the strategy for a team you owned.
[CANDIDATE] The strategy was a clear build-versus-buy call. My team was tasked with…
When producing a multi-voice version alongside a single-voice file, write to a distinct name (e.g. <slug>-2voice.m4a). The output WAV then continues through finalize-metadata/assemble unchanged.
Text → WAV (speech synthesis)
Single-voice path (no --dialogue):
uv run --script scripts/podcast.py pick-tts-source <slug>
# → prints the exact tts.py command (synthesizes from <slug>.tts.txt, produced by llm-expand)
Multi-voice path: verify-tags already emits the tts_multivoice.py argv. You don't call pick-tts-source in this case.
Run the printed command. tts.py / tts_multivoice.py flags:
--voiceaccepts an alias (female→af_heartdefault,male→bm_george) or a full Kokoro id (af_bella,am_michael,bf_emma,bm_lewis, …). The id prefix encodes language+gender (a=American,b=British;f=female,m=male). Runuv run --script scripts/tts.py -hor readVOICESinscripts/tts.pyfor the full list.--speed(default1.0).- First run downloads the Kokoro-82M weights (~315 MB). The scripts split on blank lines, so very long inputs stream paragraph by paragraph.
Final assembly
uv run --script scripts/podcast.py assemble <slug> # deletes intermediates
uv run --script scripts/podcast.py assemble <slug> --keep # keep them for debugging
This runs scripts/wav2m4a.py with the finalized metadata. By default it masters the audio: a voice EQ/compression chain plus two-pass loudness normalization to −16 LUFS (Apple Podcasts / EBU R128) with a −1.5 dBTP true-peak ceiling, then a 48 kHz resample. See the module docstring in scripts/wav2m4a.py for the full chain. Pass --no-master to wav2m4a.py only if you bypass assemble and encode untouched. After producing the .m4a, intermediates (.pdf, .raw.txt, .clean.txt, .tts.txt, .voices.json, .tags.json, .tagged.txt, .wav, .desc.txt, .meta.json, .log.jsonl, and any chunk files) are deleted unless --keep is set.
File naming
All intermediate files are derived from a single <slug> that podcast.py owns — you don't name them. For reference:
| Stage | File |
|---|---|
| Raw text | <slug>.raw.txt (chunked: <slug>.raw.NNN.txt) |
| Clean text | <slug>.clean.txt (chunked: <slug>.clean.NNN.txt) |
| TTS text (post-expand) | <slug>.tts.txt (chunked: <slug>.tts.NNN.txt) |
| Declared voice map (dialogue) | <slug>.voices.json |
| Multi-voice tag slots (conditional) | <slug>.tags.json |
| Multi-voice tagged text (conditional) | <slug>.tagged.txt |
| WAV | <slug>.wav |
| Metadata JSON | <slug>.meta.json |
| Long description | <slug>.desc.txt |
| Resumability log | <slug>.log.jsonl |
| Final | <slug>.m4a |
| PDF (fallback only) | <slug>.pdf |
Worked example (single voice)
URL="https://example.com/posts/the-mythical-agent-month"
S="$HOME/.agents/skills/text-to-podcast" # or your clone path
uv run --script "$S/scripts/podcast.py" plan "$URL"
# → slug=example-com-posts-the-mythical-agent-month, first command = fetch
uv run --script "$S/scripts/podcast.py" fetch "$URL"
# → <slug>.raw.txt, next.action=llm-reformat
# (you) apply assets/reformat-prompt.md → <slug>.clean.txt
uv run --script "$S/scripts/podcast.py" verify "<slug>.raw.txt" "<slug>.clean.txt"
# → next.action=cli (expand)
uv run --script "$S/scripts/podcast.py" expand "<slug>"
# → next.action=llm-expand
# (you) apply assets/expand-prompt.md → <slug>.tts.txt
uv run --script "$S/scripts/podcast.py" verify-expand "<slug>"
# → next.action=cli (pick-tts-source)
uv run --script "$S/scripts/podcast.py" pick-tts-source "<slug>"
# → prints: uv run --script scripts/tts.py <slug>.tts.txt <slug>.wav [--voice female]
# (run it)
uv run --script "$S/scripts/podcast.py" metadata "<slug>"
# → next.action=llm-metadata
# (you) apply assets/metadata-prompt.md → <slug>.meta.json
uv run --script "$S/scripts/podcast.py" assemble "<slug>"
# → <slug>.m4a, intermediates deleted
Worked example (multi-voice)
uv run --script "$S/scripts/podcast.py" plan "$URL" --dialogue --voice-map "HOST=female,GUEST=male"
# ... fetch, reformat, verify, expand, verify-expand as above ...
uv run --script "$S/scripts/podcast.py" verify-expand "<slug>"
# → next.action=cli (tag-dialogue)
uv run --script "$S/scripts/podcast.py" tag-dialogue "<slug>" # → <slug>.tags.json
# (you fill `speaker: "?"` slots, IF any; else skip)
uv run --script "$S/scripts/podcast.py" merge-tags "<slug>" # → <slug>.tagged.txt
uv run --script "$S/scripts/podcast.py" verify-tags "<slug>" # → prints the tts_multivoice.py command
# (run it → <slug>.wav)
uv run --script "$S/scripts/podcast.py" metadata "<slug>"
# (you) apply assets/metadata-prompt.md → <slug>.meta.json
uv run --script "$S/scripts/podcast.py" assemble "<slug>"
Troubleshooting
For any ok: false envelope, read its reason and hint first — they name the problem and the exact next command. The notes below cover cases the envelope does not.
Fetch (JS-heavy pages) — fetch's fallback bumps Gotenberg's wait delay automatically; if a page still loads blank, retry fetch with WAIT_DELAY=15s in the env (up to 30s).
Expand allowlist too strict — a legitimate expansion flagged by verify-expand is a config gap, not a model error: loosen assets/expand-allowlist.json and document the addition.
TTS prosody / OOM — robotic prosody → different --voice (e.g. male, or bm_lewis) or --speed 0.95. --voice rejected → use an alias or full Kokoro id. OOM on a paragraph → break that paragraph up in the .clean.txt, re-run expand, then re-synthesize.
Multi-voice tag in wrong voice — if a tag comes out in the wrong voice despite verify-tags passing, the bracketed tag doesn't match the --voice TAG=VOICE arg verify-tags emits (tags are case-sensitive, must be first on the paragraph) — re-confirm the voice map with the user and re-run tag-dialogue.
Metadata defaults unwanted — assemble fills empty date with today and falls back to sensible defaults for missing title/artist. If those defaults are wrong, re-run the metadata prompt with a longer excerpt including the byline, or edit <slug>.meta.json manually before assemble. (Description over 500 chars → assemble routes it to <slug>.desc.txt automatically; no action needed.)
ffmpeg fails at assemble — check disk space, or lower --bitrate via wav2m4a.py, or pass --no-master to isolate the mastering chain.
General — assemble --keep retains intermediates (including <slug>.log.jsonl). You can start at any subcommand if you know the state; consult <slug>.log.jsonl to resume from the last logged envelope.