Three Claude Code podcast skills built on the same production stack. The flagship, daily-podcast, turns a list of saved articles (or RSS items) into a fully-produced, published episode in one pass:
- Pulls full content for each item
- Writes a segmented script using a deterministic template (intro + per-item + outro)
- Renders TTS via Qwen3-TTS with a locked house voice (
ref_audiocloning, no run-to-run drift) - Concatenates with auto-padded silences to satisfy the platform's chapter rules
- Builds a date-stamped cover, timeline, and HTML description
- Publishes audio, cover, and feed manifest to Cloudflare R2, then triggers the site rebuild
- Updates a per-user dedup log so the same URLs are not re-covered
All three shows are RSS-first ("ship_mode": "web"): the R2/RSS publish is the ship,
and save-to-spotify is never invoked. The daily show uploaded to a private
save-to-spotify show as well until #218;
that copy was retired because it was unlistenable to anyone but its owner and, at the
60-episode cap, cost a published episode every run.
Ships an executable render.py and a self-contained claude -p prompt so the whole thing can run unattended on a schedule.
The second show, frontier-commits (skills/frontier-commits/), reads the
frontier AI labs' public GitHub activity — new repos, releases, archivals, staleness, star
velocity — into a speculation-forward weekly episode (Mondays), shipped through the same
render.py in its web-only mode: this show is RSS-first, so the R2/RSS publish is the ship
and save-to-spotify is never involved. A daily launchd collector (pure Python + the gh CLI, no Claude credential)
accumulates per-org snapshots; a deterministic detector diffs them into typed, mention-once
story candidates; the weekly run researches each story in an isolated context and writes the
segments. The same snapshot data feeds labs.json, which powers a /labs/ dashboard on
cortech.online. Setup, story types, and the
unattended procedure live in skills/frontier-commits/SKILL.md.
The third show, surface-tension (skills/surface-tension/), is a
weekly call-in radio show in which a four-voice panel argues about personal independent
blog posts surfaced by community vote on bubbles.town — the only one of
the three whose beat is not tech. A two-source gather ranks candidates on real votes; a
week-seeded assignment layer hands out seats, stances, turn order and bit ownership before any
prose exists; each scene is then written by an isolated claude -p and rendered as a lines
multi-voice segment, so a whole panel scene is still one chapter. The four panelists are locked
ref_audio clones, and the show ships through the same render.py in its web-only mode onto
its own public feed — RSS-first, save-to-spotify is never involved. Cast, roles, content guards,
manifest keys, and the unattended procedure live in
skills/surface-tension/SKILL.md.
This skill ships a real show every morning: Cortech — Daily Digest.
Every episode on that page was produced end-to-end by daily-podcast on an unattended
schedule — same house voice, same template, same R2/RSS publish path described below.
It's the fastest way to hear what the renderer actually sounds like.
- 🎧 Listen / browse episodes: https://cortech.online/podcast/
▶️ Hear one now: Daily Digest — June 5, 2026 (mp3, 6:19) — one of the daily episodes- 📡 Subscribe (RSS): https://cortech.online/podcast/rss.xml
(That page and feed are generated by cortech.online from the Cloudflare R2 bucket each run publishes to — see Publish to a web feed below.)
/plugin marketplace add schmug/clodcast
/plugin install daily-podcast@clodcastVersions follow semver and are tagged vX.Y.Z. See the
Releases page for tagged
versions and the CHANGELOG for what changed in each. Both are
maintained automatically by release-please
from conventional commits: every push to main updates a standing "release PR" with
the next version and changelog; merging that PR cuts the tag and GitHub Release.
- Cloudflare R2 — a bucket, an API token, and a public base URL. This is where every episode ships; see Publish to a web feed.
- Apple Silicon Mac (Qwen3-TTS via MLX uses Metal). Swap the renderer if you want a different TTS provider.
- Python 3.10+ — runtime deps are declared in
pyproject.toml(mlx-audio>=0.5.1,soundfile,mutagen,Pillow,numpy,feedparser)pip install -r requirements.txt(orpip install -e .for an editable checkout)
ffmpegandffprobeghCLI onPATH, authenticated (gh auth login) — required by thefrontier-commitsskill (daily snapshot collector and per-story research)- ~4 GB free disk for the first model download (Qwen3-TTS Base 1.7B-8bit)
One-time config:
mkdir -p ~/.config/daily-podcast
cat > ~/.config/daily-podcast/config.json << 'EOF'
{
"show_name": "Your Show Name",
"host_name": "Your Name",
"opml_files": ["/path/to/your-feeds.opml"],
"lookback_hours": 24,
"target_item_count": 10,
"r2_bucket": "your-bucket",
"r2_public_base_url": "https://audio.example.com"
}
EOFr2_bucket and r2_public_base_url are required — they are the ship. Credentials go in
the environment or secrets.json, never here; see the next section.
A "show_id" (plus the auto_prune_episodes / max_prune_per_run / episode_cap /
poll_timeout_s keys) is only read by a legacy "ship_mode": "spotify" manifest, which
no show emits any more. Leaving them in an existing config is harmless; nothing reads them.
Off unless you ask for it. Add a music block to config.json and every assembled
episode gets one bar of theme before the host, the theme ducked under the intro,
clean speech under the stories, the theme back under the sign-off, and a two-bar
finish:
Every balance knob (duck_db, lead_seconds, output_lufs, …) is optional and
defaults to the reference mix; the full list is in
SKILL.md. A second mode,
"mode": "sting", plays a short signature at each end without ever overlapping speech —
that is what Frontier Commits uses for its one-bar Midnight Terminal. Rehearse a change with
--dry-run — it produces the real mixed mp3 without publishing.
The bundled theme is Pixel Window, composed in Strudel for this show. Its source
and its unresolved sample licensing are recorded in
skills/daily-podcast/assets/music/PROVENANCE.json —
read it before distributing this audio anywhere new.
Each finished episode is published to a Cloudflare R2 bucket, which
cortech.online turns into a /podcast/
page and an iTunes RSS feed at /podcast/rss.xml — the feed the public Spotify show
ingests. This is the distribution channel, not an extra: a failed publish fails the run
and leaves the dedup log untouched, so the next run re-selects those sources.
Add the bucket + public URL to config.json:
"r2_bucket": "clodcast",
"r2_public_base_url": "https://audio.cortech.online" // your R2 public domainProvide credentials via env (never in config.json or git) — or a 0600
~/.config/daily-podcast/secrets.json with the same keys:
export R2_ACCESS_KEY_ID=... # R2 API token
export R2_SECRET_ACCESS_KEY=...
export R2_ACCOUNT_ID=... # Cloudflare account ID
# optional:
export PAGES_DEPLOY_HOOK_URL=... # POSTed after publish so the site rebuilds in ~30sThe optional Pages deploy hook is POSTed after a successful publish so the site
rebuilds. It resolves the same cron-friendly way as the credentials — env first,
then secrets.json ("PAGES_DEPLOY_HOOK_URL"), then config.json
("pages_deploy_hook_url"). A scheduled run (launchd/cron) never inherits your
interactive shell env, so put the hook in secrets.json (0600) for unattended runs —
that's also its preferred home because the URL can trigger builds. config.json
support is a convenience for the shareable file; if all three are unset, no hook fires
(unchanged).
When all five resolve, a successful run publishes <slug>.mp3 + a manifest.json
entry (which carries both an HTML description and a clean plain-text summary for
web/RSS consumers) and prints "r2_status": "published". Under "ship_mode": "web" —
what every show ships — that is the only success value: anything else exits non-zero and
leaves covered.json untouched. On a legacy Spotify-mode manifest the publish is instead
additive and the 3-state "r2_status" can be "published", "skipped" (R2 not
configured), or "failed" without failing the run. See
SKILL.md for details.
Ask Claude to ship today's podcast. The skill activates automatically:
"ship today's daily digest"
Run the orchestrator, which gathers + curates deterministically and summarizes each item
in its own isolated claude -p subprocess — so a cyber-content classifier block drops
only that item instead of failing the whole run:
python3 skills/daily-podcast/orchestrate.pyFinal stdout is a single line: SHIPPED <episode_uri> ... or FAILED <reason>.
Hook it up to launchd, cron, or any scheduler.
Heads up — child
claude -pneeds its own credentials. Each item is summarized in a childclaude -psubprocess that authenticates from disk/env, not from the parent's in-memory login. A scheduler can start those children with no usable credential, in which case every item 401s and the run fails fast with an actionable line (instead of silently reporting "no viable items"). Give the job a durable credential — a persistent on-disk token, orANTHROPIC_API_KEYin the scheduler's own environment — and verify it from inside the scheduled context. See SKILL.md, "Unattended runs need durable credentials".
Every render gates itself before doing expensive work, and recovers from the failure modes this pipeline has actually hit in production:
- Pre-flight — ffmpeg/ffprobe, encoder profile, house voice, TTS module, and R2
credentials (required under
"ship_mode": "web", since the publish is the ship). A failure aborts before a single TTS segment.--skip-preflightbypasses it. A Spotify-mode manifest adds three more checks —show_id, save-to-spotify auth, and episode capacity (pre-pruning a slot at the 60-episode cap so a 429 never costs a wasted render). - Artifact gate — after render, before the ship: local conformance checks plus a refusal to re-publish bytes Spotify already rejected.
- Durable state — a deterministic per-date workdir plus
state.json, so an interrupted run resumes by re-running the same command. - Self-healing recovery — a server-side processing rejection no longer wedges
every future run; a transient upload flake retries once; the readiness poll
waits 30 minutes and distinguishes
PROCESSINGfromFAILED. - Incident reports — any non-clean exit writes a structured report to
~/.config/daily-podcast/incidents/new/.skills/daily-podcast/triage.py listshows what is still open (re-classified against today's signatures);triage.py resolve <report>archives a handled one toincidents/handled/, so the queue drains withoutrmand without losing the evidence.
Each failure mode has a write-up in incidents/ with its symptom,
root cause, automated remedy, and the test that guards it.
For unattended runs, pre-flight with render.py --selftest so a broken dependency or a
missing R2 credential fails loudly before the scheduled hour:
#!/usr/bin/env bash
set -euo pipefail
cd "$HOME/clodcast"
python3 skills/daily-podcast/render.py --selftest || { echo "selftest failed"; exit 1; }
python3 skills/daily-podcast/orchestrate.pyFor disk hygiene, render.py --prune-workdirs N is the mechanism — pass it when calling
render.py directly with --manifest. orchestrate.py does not forward this flag.
--selftest (mutually exclusive with --manifest) checks ffmpeg/ffprobe, config.json,
R2 credentials, and the house-voice clip — printing a pass/fail line per check and a JSON
summary — and exits non-zero on any failure. It runs in under 5 seconds. It probes the
web-only gate by default, matching what every show ships; --selftest --ship-mode spotify
probes the legacy CLI gate instead. On a successful real run the auto-created workdir is
deleted (pass --keep-workdir to retain it; a failed run always keeps it for debugging).
retitle.py rewrites the title field on entries already in the R2 manifest, so the
public show's back catalogue can carry topical titles instead of date stamps. It is a
maintenance tool — no run calls it — and it touches nothing else: not the audio, not the
slug, and therefore not the <guid> Spotify keys each episode on.
python3 skills/daily-podcast/retitle.py # review every title
python3 skills/daily-podcast/retitle.py --only <slug> --apply # canary: one episode
python3 skills/daily-podcast/retitle.py --apply # the restDry run is the default. --apply backs the current manifest up to
~/.config/daily-podcast/manifest-backups/, PUTs the rewrite, and fires the
Cloudflare Pages deploy hook so cortech.online rebuilds (it reads the manifest at build
time, so without the hook the change stays invisible until the next episode ships). A
second --apply writes nothing: the titles come from a fixed table
(skills/daily-podcast/backfill_topics.json) and are composed by the same
episode_title() new episodes use.
The orchestrator writes two additional state files alongside covered.json and runs.jsonl:
~/.config/daily-podcast/feed_usage.json— records the last date each feed contributed a segment; drives the variety penalty so the same feed doesn't dominate consecutive episodes. Updated only on a successful real run (--dry-runleaves it unchanged).~/.config/daily-podcast/dropped.jsonl— append-only log of every item that was blocked, refused, timed out, errored, or hit an auth failure during a run. One JSON line per dropped item:{timestamp, run_date, feed_name, url, reason, detail}(reason∈refused/blocked/auth/timeout/error). Useful for diagnosing feed-level issues or cyber-content policy patterns; an all-authnight means childclaude -pcould not authenticate (see SKILL.md).
Qwen3-TTS occasionally derails mid-segment into looping babble. The artifact gate
catches the gross cases and the documented recovery deletes the offending audio — so
~/.config/daily-podcast/bloopers/ archives it first. Clips land content-addressed
under clips/<sha16>.mp3 with one append-only row each in index.jsonl, tagged by
what tripped the capture: gate (about to be rejected), near-miss (slow but
shipped), run-failed (swept from a dead run's workdir), derailed (a take the
transcript check re-rolled, on engines that declare it), or manual.
Nothing in a run reads the bin back. Sift it with jq, and bank a clip you heard
yourself with:
python3 skills/daily-podcast/bloopers.py mark \
--from episode.mp3 --start 4:12 --end 4:58 --note "birdsbirdsbirds"Every run appends one JSON record to ~/.config/daily-podcast/runs.jsonl — on success,
--dry-run, and failure alike. It's append-only (one line per day), so grepping a single file
answers across-runs questions — which voice ran yesterday, which run failed and why, whether
loudness is drifting from Spotify's -16 LUFS target — without spelunking ephemeral workdirs.
Each record has a stable key set (timestamp, status, episode_uri, voice, voice_mode,
chapter_count, duration_s, error_message, git_sha, loudnorm, …; missing values are
null, never absent), so it parses cleanly line-by-line:
# Every failure and its error message
jq -r 'select(.status == "failed") | .error_message' ~/.config/daily-podcast/runs.jsonl
# Loudness (output_i, LUFS) per run — watch for drift
jq -r 'select(.loudnorm) | "\(.timestamp) \(.loudnorm.output_i)"' ~/.config/daily-podcast/runs.jsonlRetention is the operator's job (the file grows ~one line/day); rotate it manually if you like.
The default "house" voice is ref_audio cloning from a ~22 second reference clip. The Base 1.7B Qwen3-TTS model regenerates that voice's timbre and prosody for any new text, so the voice stays consistent across episodes.
On first run, the bundled default is copied to ~/.config/daily-podcast/voices/house.{wav,txt}. Anything you put there wins over the bundled copy and survives plugin updates.
To change the voice:
- Capture a new ~20-30 second reference clip (any TTS or human recording)
- Save it to
~/.config/daily-podcast/voices/house.wav(PCM_16, mono, 24 kHz preferred) - Update
~/.config/daily-podcast/voices/house.txtwith the exact transcript - Done — every subsequent
voice: "house"render uses the new clip
Other voice options (set in manifest):
"voice": "random"— preset rotation over[Ryan, Aiden, Ethan, Chelsie]"voice": "Ryan"(or any preset) — single fixed preset"voice_instruct": "..."— VoiceDesign mode, full natural-language override
Want to design your own voice from scratch? See docs/durable-voices.md — covers why ref_audio cloning beats VoiceDesign for long-running shows, the iteration workflow that produced the bundled house voice, common failure modes to avoid (over-enunciation, theatrical drift, noir weight), and how to verify a new clip is stable.
Install the dev tools (lint + tests). The runtime deps are Apple-Silicon-only, so for tooling alone just install the two tools directly:
pip install ruff pytest # tooling only (no MLX)
# or, for a full editable env on Apple Silicon:
pip install -e ".[dev]"Lint & format — both ruff check and ruff format --check are enforced (CI fails on a format diff). The renderer's once hand-tuned layout was reformatted to ruff format in one isolated commit; run ruff format . to fix any diff before committing:
ruff check .
ruff format --check . # enforced — fails on any diffThe reformat commit is listed in .git-blame-ignore-revs so the bulk-format churn doesn't pollute git blame. To skip it locally:
git config blame.ignoreRevsFile .git-blame-ignore-revs(GitHub honors this file automatically in its blame view.)
Tests:
pytestPre-commit hooks — run ruff check --fix plus whitespace/EOF/YAML/JSON hygiene on staged files (the reference clip in refs/ is excluded):
pip install pre-commit && pre-commit installAfter that, git commit runs the hooks automatically; pre-commit run --all-files checks the whole tree.
MIT