# Broadcast

> Produces a daily healthcare AI news audio briefing — ingests from eleven registered sources (PubMed, arXiv, medRxiv, FDA guidance, regulations.gov, FDA MAUDE device adverse events, ONC/ASTP, CMS, and industry RSS feeds), dedupes and ranks stories, pins every claim to its source via a real evidence-pinning MCP server, generates a spoken script, rewrites each story with AI narration (grounded, with an automatic fallback to plain text if the narration can't be verified against its source), runs a QA gate, synthesizes audio via Gemini TTS, and produces GitHub-Pages-ready distribution artifacts (podcast RSS feed + Obsidian vault note). Use this skill when the user wants to run, generate, produce, or publish a healthcare AI briefing episode or podcast; asks about the status of a broadcast episode or run; wants to debug a failed or partial episode (QA failures, narration failures, TTS rate limits); wants to add/remove/tune an ingest source; or asks how the broadcast pipeline works. Requires GEMINI_API_KEY and a loca

- Skill: `hefrock/broadcast` (Agent Skill, multi-file: 35 files)
- Install (CLI): `npx skillmds@latest add hefrock/broadcast`
- Raw SKILL.md: https://api.skillmd.com/api/skills/hefrock/broadcast/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: Hefrock (https://skillmd.com/u/hefrock)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/hefrock/broadcast

---


# Broadcast — Healthcare AI News Briefing

An automated pipeline that turns the day's healthcare AI news into one spoken-word audio episode, end to end: ingest → dedup/rank → evidence-pinning → script generation → AI narration → QA gate → audio synthesis → distribution artifacts. Every stage is a standalone, independently-tested Python module in `scripts/`; `orchestrate.py` wires them into one real run.

Read this whole file before running anything live — several of the operational risks below were only discovered by actually running the pipeline against real APIs, and repeating those mistakes wastes real (rate-limited, sometimes shared) API quota.

## Prerequisites

- Python 3.12 (stdlib only — no `pip install` needed for any broadcast script).
- Node 22, and the evidence-pinning-mcp server built once (`orchestrate.py` spawns it as a subprocess automatically — it does not need to already be running, just built):
  ```bash
  cd mcp/evidence-pinning && npm ci && npm run build
  ```
- `GEMINI_API_KEY` set in the environment. Used for embeddings (dedup/ranking), AI narration, and text-to-speech — all three stages fail without it.
- `REGULATIONS_GOV_API_KEY` (optional). Without it, `regulations_gov` ingest uses the shared `DEMO_KEY` (10 req/hr, no registration) — that quota is shared with every other `DEMO_KEY` caller globally, not scoped to this pipeline, so it can be exhausted by unrelated traffic regardless of how often this pipeline actually runs. A free key from [api.data.gov](https://api.data.gov/signup/) raises that to 1000 req/hr, scoped to just this pipeline. Get one and set this env var whenever `regulations_gov` — a `regulatory`-category, highest-authority-floor source — matters enough that you don't want its reliability depending on an unrelated party's request volume.

## Running one episode

```bash
python skills/broadcast/scripts/orchestrate.py --data-dir ~/.broadcast-data \
  [--date YYYY-MM-DD]                    # default: today
  [--max-results-per-source N]           # default: 10 — keep low for a quick/cheap test run
  [--synth-delay-seconds N]              # default: 6.0 — pacing between TTS calls, see risks below
  [--no-narration]                       # skip AI narration, ship plain mechanical text
  [--narration-success-threshold N]      # default: 0.7 — episode-level narration fallback threshold
  [--no-audio-normalize]                 # skip per-segment peak normalization in the assembled episode
  [--inter-segment-silence-ms N]         # default: 400.0 — silence gap between segments (0 to disable)
  [--dry-run]                            # ingest only, print a Gemini call estimate, spend zero quota — see below
  [--no-audio-cache]                     # disable the per-segment synthesized-audio cache — see below
  [--top-three-count N]                  # default: 3 — shrink to reduce a real episode's TTS call count
  [--quick-hits-count N]                 # default: 7 — see --top-three-count
```

**Use `~/.broadcast-data` as the default `--data-dir` unless the user asks for somewhere else.** It holds persistent state (`dedup_store.json`, `evidence_store/`, `audio_cache/`) and every run's output (`episodes/<date>/{report.json,script.json,episode.wav}`) — deduplication and evidence provenance only accumulate meaningfully if the *same* directory is reused run over run, and nothing else in this repo establishes a canonical location. Don't invent a different path per session; that silently defeats the whole story-continuity design. `orchestrate.py` creates the directory itself if it doesn't exist yet — no setup needed beforehand.

**The per-segment audio cache (`<data-dir>/audio_cache/`, enabled by default)** exists because a real run has seen 11 of 14 segments fail to a Gemini TTS rate limit — twice. Each segment's synthesized audio is cached by a hash of its own exact text, so a retry after waiting for quota to recover only re-synthesizes segments that actually failed last time, not the whole episode from scratch — and a truly fixed segment (like the disclosure text, identical every day) only ever needs synthesizing once, ever, shared across every future episode. It's content-addressed by text only, not text+voice+model — if the pinned voice or TTS model ever changes, clear `<data-dir>/audio_cache/` manually first, or a retried episode could end up with an inconsistent voice across segments. Not auto-pruned — same status `episodes/<date>/` was in before `prune_episodes.py` existed.

There's also a manually-triggered GitHub Actions workflow, `.github/workflows/broadcast-live-smoke-test.yml` (`workflow_dispatch` only, never on push/PR — it makes real external API calls). It has two jobs: a smoke test of the ingest adapters + embeddings, and a full real episode run via `orchestrate.py`. Triggering it always runs *both* jobs — there is no way to trigger just one.

**Prefer running `orchestrate.py` directly in-session over triggering that workflow**, whenever the session can (i.e. it has `GEMINI_API_KEY` and can reach the network directly): direct invocation lets you run exactly one thing — e.g. just `live_smoke_test.py` to check ingest health, or one `orchestrate.py` call with a low `--max-results-per-source` — while `workflow_dispatch` always fires *both* jobs together, including the TTS-quota-heavy full episode run, whether you wanted that or not. That mismatch is exactly how this project has burned Gemini TTS quota before: a reconnaissance trigger meant to check one small thing also silently re-ran a full episode in the background. Reach for `workflow_dispatch` only when the session itself can't execute Python/reach the network, or when you specifically need to confirm behavior in the CI environment rather than locally.

## Estimating cost before a real run

`--dry-run` runs real ingestion (free — no `fetch_fn` call ever touches Gemini) and then stops, printing an estimate of how many Gemini calls a real run with this exact config would make, instead of spending any of that quota to find out. **`GEMINI_API_KEY` does not need to be set to use `--dry-run`** — this is the one CLI mode that doesn't require it, precisely so it can answer "is this worth it" before you've committed to getting a key at all:

```json
{"run_date": "2026-09-03", "dry_run": true, "ingest_failed": [...], "items_ingested": 23, "embed_calls_estimate": 23, "narration_calls_estimate_max": 10, "synth_calls_estimate_max": 14}
```

`embed_calls_estimate` is exact — every ingested item gets exactly one embedding call. `narration_calls_estimate_max`/`synth_calls_estimate_max` are genuine upper bounds, not predictions: computing the real, smaller number requires ranking, which itself needs embeddings (the one cost `--dry-run` exists to avoid paying for) — so these are capped at this pipeline's real, fixed selection ceiling (`rank.DEFAULT_TOP_THREE_COUNT + rank.DEFAULT_QUICK_HITS_COUNT` story segments, plus intro/disclosure/quick_hits_transition/outro), which same-day/rolling-window dedup can only ever shrink, never exceed. No `evidence-pinning-mcp` server is spawned and nothing is persisted to `--data-dir` in this mode.

Run this before a real episode whenever you're unsure how expensive it would be, or whenever a source's `feed_url` has recently changed (an RSS feed's real item count is uncapped by `--max-results-per-source` — see "Adding, removing, or tuning an ingest source" below — so its true embedding-call cost can only be known by actually checking, not by reading config).

## Scheduling

`.github/workflows/broadcast-scheduled.yml` runs `orchestrate.py` on a cron cadence (daily at 12:00 UTC by default — a placeholder, change the cron expression to whatever cadence gets decided). **It's disabled by default on purpose** — a `schedule:` trigger has no native "off" switch in GitHub Actions, so the job itself is gated on a repository variable:

```
Settings -> Secrets and variables -> Actions -> Variables -> New repository variable:
BROADCAST_SCHEDULE_ENABLED = true
```

Until that variable is set, every scheduled firing shows as **skipped** in the Actions UI (visibly, not silently absent) and spends zero Gemini quota. Run `--dry-run` (see above) with a realistic `--max-results-per-source` first to know the real per-run cost before setting this variable — that's the whole reason `--dry-run` exists.

`workflow_dispatch` (manual trigger) always runs regardless of the variable — a human manually triggering this is already an explicit decision; the gate exists specifically to keep the *unattended* cron trigger inert until opted into.

**`--data-dir` persistence in this workflow is best-effort, not durable.** GitHub Actions runners are ephemeral, so `dedup_store.json`/`evidence_store` continuity across scheduled runs depends on `actions/cache` (a rolling per-run-id cache key with a prefix restore, since a cache entry can't be overwritten once created) — GitHub evicts unused cache entries after roughly 7 days and caps total repo cache size at 10GB. A real durable `--data-dir` most likely belongs in whatever repo ends up hosting distribution (see "No real hosting" below) once that exists.

This workflow **does not run `distribute.py`** — publishing needs a real `--base-url` a hosting target actually serves, which doesn't exist yet. It only produces the episode; wiring in real publishing is a follow-up once hosting is decided.

## Reading the result — what "success" actually looks like

`report.json` (also printed to stdout) is the thing to read, not just the process exit code (which is 1 whenever `episode_produced` is `false`, even though everything upstream may have worked correctly):

- **`qa_passed`** — structural + grounding checks on the script itself (intro/outro present, every story traces to a real pinned claim, no leaked excluded stories). Nothing to do with audio.
- **`episode_produced`** — `true` only if *every* segment's audio synthesized. One segment failing withholds the whole episode's audio (deliberate — a partial episode isn't assembled with a silent gap). Check `synth_failed` for which segments and why.
- **`narration_attempted` / `narration_succeeded` / `narration_success_rate` / `narration_episode_level_fallback` / `narration_failures`** — the AI narration layer's own results. A narration failure is **not a pipeline failure** — it's a best-effort enhancement with an automatic two-tier fallback (per-segment, then whole-episode) to the original mechanical text, by design. A low success rate or `episode_level_fallback: true` means that day's episode shipped with plainer prose, not that anything is broken. These fields are all `null` when `--no-narration` was passed (distinguishable from a real 0/0 result).
- **`ingest_failed`** — per-source ingest failures. A source documented in `config/sources.json` as `feed_url_verified: false` failing is expected every run (see below), not a bug to chase — check the registry entry first before investigating.
- **`source_utilization`** — per-source breakdown of what happened to that run's candidates: `candidates` (survived same-day-dedup and got scored), `selected_top_three` / `selected_quick_hits` / `selected_total`, `not_selected` (considered, lost on relevance score), `dropped_duplicates` (same-day duplicate, never independently considered), and `selection_rate` (`selected_total / candidates`, `null` when `candidates` is 0). Pure observability, not a QA gate — nothing here fails a run, and a source with zero candidates today might just mean nothing newsworthy happened, not that it's being starved. A single run's numbers are noisy on their own; the useful read is comparing this field across several days' `episodes/<date>/report.json` files to spot a source that's *never* winning a slot, not any one day's snapshot. Sources with zero candidates this run don't appear in this dict at all, rather than a fabricated zero-row.

That rolling, multi-day view is `source_health_report.py`:
```bash
python skills/broadcast/scripts/source_health_report.py --data-dir ~/.broadcast-data [--days N]  # default: 30
```
Sums `source_utilization` across every `episodes/<date>/report.json` in the window, prints a table sorted by pooled `selection_rate` (most-starved first), and separately flags any registered source with *zero* candidates anywhere in the whole window — the clearest "worth asking a human about" signal it can produce. Same discipline as the field it aggregates: nothing here is a QA gate, and it can't tell a genuinely-starved source apart from one that's just legitimately quiet that month — it's a diagnostic to prompt a question, not a verdict. Read-only, standalone, never called automatically.

`qa_checks` has the same rolling-view gap `source_utilization` had before `source_health_report.py` — one run's pass/fail is a snapshot, not a trend. `qa_gate_history.py` closes it differently: instead of reimplementing pass-rate/regression logic a third time, it flattens `qa_checks` across a window into [`skills/agent-eval/`](../agent-eval/SKILL.md)'s own JSONL schema (one row per episode/check pair, `category` = check name) and hands off to that skill's already-tested `score_eval.py` for the actual aggregation, regressions, and CI gate:
```bash
python skills/broadcast/scripts/qa_gate_history.py --data-dir ~/.broadcast-data --out qa_results.jsonl [--days N]
python skills/agent-eval/scripts/score_eval.py qa_results.jsonl --fail-under 0.9
python skills/agent-eval/scripts/score_eval.py qa_results.jsonl --baseline last_weeks_qa_results.jsonl --fail-on-regression
```
A documented file-format handoff, not a cross-skill import — `qa_gate_history.py` has no dependency on `agent-eval` being present to run; the second command is only useful when it is.

A real, unedited (trimmed for length) example from an actual live run, captured before `healthcare_it_news` was removed from the registry (permanently WAF-blocked, no viable RSS alternative found — see "Adding, removing, or tuning an ingest source" below) and before `source_utilization` existed — `qa_passed: true` and narration mostly succeeded, but `episode_produced` is `false` purely because of a TTS rate limit, not a script problem:

```json
{
  "run_date": "2026-09-02",
  "ingest_failed": [{"source_key": "healthcare_it_news", "error": "HTTPError: HTTP Error 403: Forbidden"}],
  "top_three_count": 3,
  "quick_hits_count": 7,
  "pinned_count": 10,
  "segment_count": 14,
  "narration_attempted": 10,
  "narration_succeeded": 9,
  "narration_success_rate": 0.9,
  "narration_episode_level_fallback": false,
  "narration_failures": [
    {"canonical_id": "pmid:42644472", "reasons": ["narration length ratio 0.19 outside [0.4, 2.0]"]}
  ],
  "qa_passed": true,
  "qa_checks": [
    {"check": "has_intro", "passed": true, "detail": ""},
    {"check": "story_segments_grounded", "passed": true, "detail": ""}
  ],
  "synth_failed": [
    {"segment_type": "top_three_item", "canonical_id": "pmid:42644472", "error": "HTTPError: HTTP Error 429: Too Many Requests"}
  ],
  "episode_produced": false
}
```

## Debugging a failed or partial episode

Work through `report.json` in this order — nearly every failure traces to one of these, not a new regression:

1. **`qa_passed: false`** — look at `qa_checks` for entries with `passed: false`; each one's `detail` names the exact structural problem (a missing intro/outro, a story segment missing `claim_id`, etc.). This should essentially never happen from an unmodified `orchestrate.py` run — `script_gen.py` already enforces these invariants when it builds the script, and `qa_gate.py` only re-checks them as defense in depth. A failure here points to a real upstream bug, not something to route around or re-run past.
2. **`episode_produced: false` but `qa_passed: true`** — the script was fine; audio synthesis failed for at least one segment. Check `synth_failed`: if every entry reads `HTTP Error 429: Too Many Requests`, that's the known Gemini TTS rate limit (see Known operational risks below) — wait for quota to recover before doing anything else, don't immediately re-run. Any *other* error string is a real, new failure worth investigating on its own terms.
3. **`ingest_failed` non-empty** — a source documented in `config/sources.json` as `feed_url_verified: false` failing is expected every run; check the registry entry's `feed_url_verified_note` first. Any *other* source failing is new: check the error string — a previously-working source returning a *different* error than before usually means the feed URL or endpoint changed upstream, not a bug in this pipeline.
4. **Low `narration_success_rate` or `narration_episode_level_fallback: true`** — not a failure at all. This is the narration layer's own designed circuit breaker, working as intended; `narration_failures` lists which stories fell back and the specific grounding-check reason (an ungrounded span, a dropped hedge, an out-of-range length ratio). The episode still ships — just with plainer prose for that story or that day.

## Publishing an episode

```bash
python skills/broadcast/scripts/distribute.py --data-dir ~/.broadcast-data --date YYYY-MM-DD \
  --publish-dir <dir> --base-url <public-url> --feed-link <url> \
  [--feed-title "..."] [--feed-description "..."] [--feed-author "..."] \
  [--itunes-category "..."]        # default: Technology
  [--itunes-explicit true|false]   # default: false
  [--itunes-type episodic|serial]  # default: episodic
  [--itunes-author "..."]          # overrides --feed-author for itunes:author specifically
  [--itunes-subtitle "..."]
  [--itunes-image-url <url>]       # square, 1400-3000px, JPEG/PNG, no transparency — see below
  [--itunes-owner-name "..."] [--itunes-owner-email "..."]  # <itunes:owner> needs BOTH to appear
```

This reads the episode `orchestrate.py` already produced and writes GitHub-Pages-ready files to `--publish-dir`: the audio file, an RSS `feed.xml` (RSS 2.0 plus the `itunes:` namespace — category/explicit/type/author/duration/etc.), and a matching Obsidian vault-note markdown file. **It does not push, host, or deploy anything** — that's a deliberate, separate, human-driven step. The RSS feed's URLs are only real once `--publish-dir`'s contents are actually deployed to `--base-url`.

**`--itunes-image-url` (cover art) is the one thing this can't produce itself** — this pipeline has no way to generate real artwork, and Apple Podcasts won't list a show without one (square, 1400–3000px, JPEG/PNG, no transparency). Every other `itunes:` tag works and is emitted correctly with sensible defaults even with no image set; add `--itunes-image-url` once real art exists, no other change needed.

The vault note is markdown output only, not a vault write — landing it in a real Obsidian vault requires a live session with the `wiki-operator` skill's `/source` command and a connected `obsidian-vault` MCP server. `distribute.py` deliberately never writes to a vault directly.

## Adding, removing, or tuning an ingest source

Every source lives in `config/sources.json`, validated at load time by `source_registry.validate_registry()` — a malformed entry raises a specific `RegistryValidationError` there rather than surfacing as a confusing `KeyError` three stages downstream.

- **Adding an RSS-backed source (no code changes needed):** add an entry with `"key"`, `"name"`, `"category"` (an existing category, or a new one under `"categories"` — see "tuning" below), and `"feed_url"`. Any source with a `feed_url` field is automatically dispatched by `orchestrate.py`'s `_fetch_for_source()` to `ingest.fetch_rss()`, the same generic parser already handling four different feeds' real-world quirks (non-RFC-822 `pubDate` formats, a corrupted `<link>` field, etc. — see `ingest.py`'s `_parse_rss_pubdate()` if a new feed's dates come back wrong). Leave `"feed_url_verified": false` until you've actually confirmed it live with `live_smoke_test.py` (see below) — every currently-verified source in this file was confirmed that way, never assumed correct from the URL alone. Don't trust a guessed URL, even from a search result: `healthcare_it_news` (Healthcare IT News) was registered this way, found genuinely, permanently WAF/Cloudflare-blocked (HTTP 403 on the real URL and three guessed variants, even with a full browser-like header set), and was removed entirely rather than left as a documented-broken source indefinitely. Its replacement, `hit_consultant`, is currently registered with `"feed_url_verified": false` for exactly this reason — its URL came from a web search and a SWOT comparison against Healthcare Dive (picked partly because Healthcare Dive is named in third-party scraping guides as an example of a Cloudflare-protected site — likely the same failure mode that killed `healthcare_it_news`), not a live fetch. It needs a real `live_smoke_test.py` run before that flag flips to `true`.
- **Adding a query-based or fixed-endpoint source** (like `pubmed`/`arxiv`/`regulations_gov`/`fda_maude`, or `medrxiv`/`fda_guidance`) needs real code, not just config: a new `fetch_*()` function in `ingest.py` (see those seven functions for the two existing patterns) *and* a new dispatch branch in `orchestrate.py`'s `_fetch_for_source()` keyed on the source's `"key"`. Without both, that source raises `ValueError` at ingest time. `fda_maude` is a good example of the discipline this section otherwise only describes for RSS sources: it's registered but its `query_note` says plainly it's NOT yet live-verified — documented field names aren't the same as a confirmed real response, the same lesson `healthcare_it_news` taught the hard way.
- **Removing a source:** delete its entry from `"sources"` — nothing else references sources by a fixed list, `ingest_all()` just iterates whatever's currently in the registry.
- **Tuning relevance scoring:** `authority_floor` (0–1) and `half_life_days` (>0) are set per-*category*, not per-source (see `source_registry.py`'s docstring for what they control — a higher floor and longer half-life age a story more slowly). Every category and query in `config/sources.json` already carries a `*_note` field marking it as "a working default, not a validated measurement" — change these against real episode output, not intuition, and update the note to record why.
- **After any change**, run `python skills/broadcast/scripts/live_smoke_test.py` — it's cheap (real ingest + embedding calls, no TTS or narration spend) and will confirm the new or changed source actually returns real items before it's trusted in a full, expensive episode run.

## Verifying a code change to this pipeline

Every module in `scripts/` has a matching `test_*.py`, all stdlib `unittest`, no network calls (network-touching functions like `synthesize_text()` or `generate_narration()` are injectable and faked in tests — see any `test_orchestrate.py` `RunEpisodeWiring` test for the pattern). Run the full suite before trusting any change:

```bash
for f in skills/broadcast/scripts/test_*.py; do python3 "$f"; done
```

`test_evidence.py`, `test_evidence_pinning_client.py`, and `test_qa_gate.py` will report their evidence-pinning-mcp-server-dependent tests as **skipped**, not failed, if that server isn't built yet — a suite full of `OK (skipped=N)` can look deceptively clean. Build it first to actually exercise those tests for real:

```bash
cd mcp/evidence-pinning && npm ci && npm run build
```

`ci.yml` already runs the full suite (server built) on every push/PR — this is what to run locally *before* pushing, to catch a failure before CI does, not a substitute for it.

## Retention

Four different stores live under `--data-dir`, each retained (or deliberately not) on purpose — check this before assuming any of them just grows forever:

- **`dedup_store.json`**'s rolling-window entries are already pruned automatically on every `orchestrate.py` run (`dedup_store.prune_old_entries()`, wired in via `rank.py`, default 14-day window) — no action needed here, this one never grows unbounded.
- **`episodes/<date>/`** (the actual per-episode script/audio output) is *not* pruned automatically — a human may not have run `distribute.py` against a given episode yet, may want it kept as their own archive, or may be actively debugging it, so silent automatic deletion felt like the wrong default here. Use `prune_episodes.py` explicitly instead:
  ```bash
  python skills/broadcast/scripts/prune_episodes.py --data-dir ~/.broadcast-data [--retention-days N]  # default: 90
  ```
  Dry-run by default — it only *prints* what would be deleted and changes nothing on disk. Pass `--apply` to actually remove stale episode directories.
- **`evidence_store/`** (evidence-pinning-mcp's own state) is deliberately never pruned by anything in this pipeline — it's an append-only provenance log by design (see `mcp/evidence-pinning/README.md`), meant to keep a claim's full history queryable indefinitely. Pruning it would defeat its actual purpose, not just free disk space.
- **`audio_cache/`** (per-segment synthesized-audio cache, see "Running one episode" above) is *not* pruned automatically — no tool exists for this yet, an accepted gap for now rather than something silently ignored. It grows slowly: most of it is genuinely per-day (each story's own text is new), but fixed boilerplate (the disclosure segment) permanently dedupes to a single entry across every episode ever run.

## Known operational risks — read before running live

- **Gemini TTS rate-limits under realistic call volume**, confirmed live more than once — most recently 11 of 14 segments failing to `HTTP 429`, twice on separate real runs, and a third real run (the default-sized 10-story episode) failing outright — every segment after the first few hit `HTTP 429`, `episode_produced: false` — against an unbilled (free-tier) `GEMINI_API_KEY`. Never retrigger a failed or in-flight run back-to-back — wait for it to fully finish first. A blind retry is itself another request competing for the same already-exhausted quota; this made a real rate-limit situation *worse* in this project's own history, not better. `synthesize_text()`'s retry policy (`gemini_retry.py`) already honors the server's `Retry-After` header; `--synth-delay-seconds` adds further pacing; the per-segment `audio_cache/` (see "Running one episode" above) means a retry, once you do run one, only re-synthesizes segments that actually failed last time, not the whole episode. If you see a wall of `HTTP 429` in `synth_failed`, wait — don't immediately re-run. If runs keep failing outright on a free-tier key, `--top-three-count`/`--quick-hits-count` (see "Running one episode" above) shrink the real TTS call count for the next attempt — the free lever to pull before paid Gemini quota.
- **`narrate.py`'s pinned model was chosen from live reconnaissance**, not assumed — see its module docstring for the full account (a deprecated model, two overloaded newer ones, then a working one). If narration starts failing broadly, check whether that model id is still available/healthy before assuming a code regression.

## What this skill does not do yet

- **Scheduling exists but is off by default.** `.github/workflows/broadcast-scheduled.yml` (see "Scheduling" above) will run `orchestrate.py` on a cron cadence once `BROADCAST_SCHEDULE_ENABLED` is set — until then, every firing is a visible no-op. It doesn't publish (`distribute.py`) yet either way, pending a hosting decision.
- **No real hosting.** `distribute.py`'s output must be manually deployed (e.g. to GitHub Pages) for its feed to be reachable at `--base-url`.
- **No cover art.** `distribute.py` now emits the full `itunes:` namespace (category, explicit, type, author, subtitle, owner, per-episode duration) — see "Publishing an episode" above — but `itunes:image` is left for a human to supply once real artwork exists; this pipeline can't generate it. Apple Podcasts specifically won't list a show without one; every other podcast app and every other tag works fine in the meantime.
- **No cross-story "digest" synthesis.** Narration is strictly per-story, isolated to that story's own text — a deliberate scope decision made after research into hallucination risk in broader-context AI summarization, not an oversight. See `narrate.py`'s docstring for the reasoning.
- **No dollar-cost tracking.** `--dry-run` (see above) gives a pre-flight *call-count* estimate (exact for embeddings, a safe upper bound for narration/TTS), which is what actually would have prevented this project's own real quota-exhaustion incidents — but it doesn't know or report actual pricing, and there's still no cumulative spend tracking across runs.
- **Retention for `episodes/<date>/` is opt-in, not automatic** — see the Retention section above; `prune_episodes.py` exists but has to be run deliberately, nothing calls it on a schedule.

## Output discipline

- Always report `qa_passed`, `episode_produced`, and the narration/synth failure buckets together when summarizing a run — a summary that omits them can make a partially-failed episode look clean.
- Never say an episode was "published" unless `distribute.py` has run *and* its `--publish-dir` output has actually been deployed somewhere reachable at `--base-url`. Writing `feed.xml` locally is not publishing.
- A low narration success rate is informational, not an error — don't treat it with the same urgency as a QA or synthesis failure.
- If `synth_failed` shows `HTTP 429`, say so plainly and recommend waiting — don't immediately re-run the episode (see Known operational risks).

