Skip to content

Repository files navigation

clodcast

CI

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_audio cloning, 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.

Frontier Commits

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.

Surface Tension

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.

Live example

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.

(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.)

Install

/plugin marketplace add schmug/clodcast
/plugin install daily-podcast@clodcast

Releases

Versions 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.

Dependencies

  • 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 (or pip install -e . for an editable checkout)
  • ffmpeg and ffprobe
  • gh CLI on PATH, authenticated (gh auth login) — required by the frontier-commits skill (daily snapshot collector and per-story research)
  • ~4 GB free disk for the first model download (Qwen3-TTS Base 1.7B-8bit)

Setup

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"
}
EOF

r2_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.

Optional intro/outro music

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:

  "music": {
    "enabled": true,
    "asset": "skills/daily-podcast/assets/music/pixel-window.flac",   // relative to skills/daily-podcast/
    "asset_sha256": "6c643cec1c5bec9a901e2948feb274a315bfa79db1cabe840d0e65e3d2987e2b"
  }

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.

Publish to a web feed (Cloudflare R2)

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 domain

Provide 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 ~30s

The 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.

Usage

Interactive (one episode in a conversation)

Ask Claude to ship today's podcast. The skill activates automatically:

"ship today's daily digest"

Headless (unattended schedule)

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.py

Final stdout is a single line: SHIPPED <episode_uri> ... or FAILED <reason>.

Hook it up to launchd, cron, or any scheduler.

Heads up — child claude -p needs its own credentials. Each item is summarized in a child claude -p subprocess 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, or ANTHROPIC_API_KEY in the scheduler's own environment — and verify it from inside the scheduled context. See SKILL.md, "Unattended runs need durable credentials".

Reliability

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-preflight bypasses 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 PROCESSING from FAILED.
  • Incident reports — any non-clean exit writes a structured report to ~/.config/daily-podcast/incidents/new/. skills/daily-podcast/triage.py list shows what is still open (re-classified against today's signatures); triage.py resolve <report> archives a handled one to incidents/handled/, so the queue drains without rm and 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.

Scheduled runs (pre-flight + disk hygiene)

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.py

For 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).

Retitling published episodes

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 rest

Dry 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.

Config files

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-run leaves 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} (reasonrefused/blocked/auth/timeout/error). Useful for diagnosing feed-level issues or cyber-content policy patterns; an all-auth night means child claude -p could not authenticate (see SKILL.md).

Bloopers bin

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"

Run log

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.jsonl

Retention is the operator's job (the file grows ~one line/day); rotate it manually if you like.

Voice

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:

  1. Capture a new ~20-30 second reference clip (any TTS or human recording)
  2. Save it to ~/.config/daily-podcast/voices/house.wav (PCM_16, mono, 24 kHz preferred)
  3. Update ~/.config/daily-podcast/voices/house.txt with the exact transcript
  4. 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.

Development

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 diff

The 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:

pytest

Pre-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 install

After that, git commit runs the hooks automatically; pre-commit run --all-files checks the whole tree.

License

MIT

About

Daily-digest podcast skill — turns a list of saved articles into a fully-produced Spotify episode using Qwen3-TTS, on top of the save-to-spotify CLI.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages