eos-podcast
Generate NotebookLM-style two-host podcasts via the EmptyOS Podcast app (port 9000). Use when the user asks to make a podcast, generate an episode, turn a topic or vault content into audio, or schedul
SKILL.md.agent-bus/skills/eos-podcast/SKILL.md
GitHubEmptyOS Podcast
Generate two-host conversational podcasts via the EmptyOS Podcast app on localhost:9000/podcast. Output MP3s land in the vault podcast folder (30_Resources/EmptyOS/podcast/) and are indexed automatically — playable at http://localhost:9000/podcast/.
When to Use
| User says | Mode |
|---|---|
| "make a podcast about X", "generate podcast on Y" | Topic — Mode 1 or Mode 2 |
| "podcast with this briefing…", "use this exact context" | Rich context — Mode 2 (two-step) |
| "daily podcast", "podcast from today's journal" | Vault-auto — Mode 3 |
| "list podcasts", "what episodes do I have" | GET /podcast/api/files |
Pre-flight
EmptyOS daemon must be running:
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:9000/podcast/
# Expect 200. If not, ask Kevin to run restart.bat — never start :9000 from a
# session (.claude/rules/daemon-handling.md).
Voice pairs, duration presets, and image styles are discoverable:
curl -s http://localhost:9000/podcast/api/voice-pairs | python -m json.tool
Current presets:
- Voice pairs:
en_mf,en_ff,en_mm,zh_mf,zh_ff,zh_mm,bi_mf(bilingual), plus custom XTTS clones (e.g.kevin_f) - Durations:
short(6 segments, ~3 min),medium(12 segments, ~6 min),long(20 segments, ~12 min)
Mode 1 — Topic Only (simple, one-shot)
Use when the LLM can freely research / invent the content from the topic string.
curl -s -X POST http://localhost:9000/podcast/api/generate \
-H "Content-Type: application/json" \
-d '{
"topic": "TOPIC HERE",
"voice_pair": "en_mf",
"duration": "medium",
"language": "en",
"cover": true,
"video": false
}'
Returns either {"job_id": "...", "mode": "async"} (worker pool queued) or {"mode": "sync", "result": {...}} (inline).
For async, poll: GET /podcast/api/job/{job_id} until status: "completed".
Mode 2 — Rich Context (two-step) — use for prep/briefing podcasts
Why two steps: /api/generate alone doesn't accept a context field (the web route strips it). If the LLM must be grounded in specific facts (interview prep, factual briefings, named people/projects, exact phrasings to reuse), generate the script first with /api/script which DOES accept context, then submit that script to /api/generate.
This also lets you (or the user) review and edit the script before burning TTS time.
Step A — Script with grounded context
curl -s -X POST http://localhost:9000/podcast/api/script \
-H "Content-Type: application/json" \
-d '{
"topic": "Short title, used for the filename slug",
"context": "FULL BRIEFING THE LLM MUST USE VERBATIM — names, facts, quotes, constraints, what NOT to say.",
"duration": "long",
"language": "en"
}' > /tmp/script.json
Response: {"topic": "...", "script": [{"speaker": "A", "text": "..."}, ...], "duration": "long", "language": "en"}
Step B — Generate audio from the script
SCRIPT=$(python -c "import json; d=json.load(open('/tmp/script.json')); print(json.dumps(d['script']))")
curl -s -X POST http://localhost:9000/podcast/api/generate \
-H "Content-Type: application/json" \
-d "{
\"topic\": \"Short title\",
\"script\": $SCRIPT,
\"voice_pair\": \"en_mf\",
\"duration\": \"long\",
\"cover\": true,
\"video\": false
}"
Context-field best practices (lessons from real failures)
- Name the domain explicitly and disambiguate homonyms. If the topic contains a word that collides with something famous, say so. Example: "This is about Endeavour Energy, the Australian electricity distributor — NOT Endeavor Global the entrepreneur network. NEVER talk about founders, scale 10x, or pitch decks."
- List proper nouns the script must use: people, projects, tools, standards. The LLM will anchor on them.
- Say what NOT to do. Negative examples are highest-leverage for keeping the LLM on-topic.
- Give verbatim phrasings when the user should be able to reuse lines from the podcast.
- End the context with a CLOSE line the hosts should say, so episodes thread together.
Splitting long content
long = ~12 min. For 30–60 min of content, split into N episodes × long rather than one "extra long" request. One episode per coherent theme. Fire sequentially — the Claude CLI script-gen has a global lock. Chain in a single bash script running in background.
Mode 3 — Vault Auto-Generate
curl -s -X POST http://localhost:9000/podcast/api/auto \
-H "Content-Type: application/json" \
-d '{
"source": "vault_daily",
"language": "en",
"duration": "short",
"cover": true,
"video": false
}'
Sources: vault_daily (today's journal + frogs + tasks), vault_random (discovery across vault), vault_dictionary (word-of-the-day deep dive).
Listing & Playback
| What | How |
|---|---|
| All episodes (JSON) | GET /podcast/api/files — {files: [...], dir, count} |
| Episode history (metadata only) | GET /podcast/api/history |
| Single episode metadata | GET /podcast/api/history/{id} |
| Stream MP3 | GET /podcast/api/audio/{filename} |
| Web UI | http://localhost:9000/podcast/ |
Audio directory resolves via:
podcast.audio_dirsetting if present and exists- Else
self.vault_dir— typically{vault}/30_Resources/EmptyOS/podcast/
After Generation
- Report filename, segment count, duration, and the UI URL:
http://localhost:9000/podcast/. - If the user asked for a standalone copy outside the vault podcast folder,
cpafter generation. - All episodes belong in the vault podcast folder — never scatter copies into ad-hoc directories.
Common Failure Modes
- HTTP 500 with a long
topicfield: the topic is slugged for the filename and hits length limits. Keeptopicshort (< 80 chars); put detail incontext. - LLM hallucinates a wrong interpretation of the domain: Mode 1 + an ambiguous topic = confident nonsense. Switch to Mode 2 with an explicit disambiguation line in
context. - Script comes back empty or JSON parse fails: retry. If repeated,
eos healthand check thethinkcapability (Claude CLI or OpenAI/Ollama). - Files written but not visible in UI: check
GET /podcast/api/files— the UI is a thin shell over that endpoint.
Legacy pre-EmptyOS episodes (retired 2026-07): one-time import via POST /podcast/api/import-hp (reads old JSON sidecars into EOS history), or copy MP3 + .json sidecar + _cover.png into the vault podcast folder.
Known Gaps
POST /api/generatedoes not currently forward a top-levelcontextfield to_full_generate— that is why Mode 2 requires the two-step flow. If you want a single-call rich-context generate, patchapps/personal/podcast/app.pyapi_generateto passcontext=data.get("context", "")into_full_generateand through to_generate_script.