broadcast
Skill0 starsChanged 4 months agoMITView on GitHub
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 locally-built evidence-pinning-mcp server (see Prerequisites).
- Reads credentials
- Installs packages
In this repository19 other files it gives its agents.
- agent-evalSkill
- agent-redteamSkill
- deid-reid-harnessSkill
- fiction-workshopSkill
- issue-reconcilerSkill
- privacy-linterSkill
- privacy-threat-oracleSkill
- repo-pincerSkill
- research-ledgerSkill
- review-and-planSkill
- style-obfuscatorSkill
- wiki-governorSkill
- wiki-librarianSkill
- wiki-operatorSkill
- wiki-privacy-auditSkill
- wiki-synthesizerSkill
- wiki-teacherSkill
- wiki-warehouseSkill
- my-skill-nameSkill
The file
We split this skill into the rules it gives an agent
7 rules. About 0 are common, 1 is distinctive, and 6 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
6
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 10
Read this whole file before running anything live — several of the operational risks below were only discovered by actually running the …
- Very specific: line 178
Every source lives in config/sources.json, validated at load time by source_registry.validate_registry() — a malformed entry raises a …
- Very specific: line 182
Removing a source: delete its entry from "sources" — nothing else references sources by a fixed list, ingest_all() just iterates whatever's …
- Very specific: line 188
Every module in scripts/ has a matching test_*.py, all stdlib unittest, no network calls (network-touching functions like synthesize_text() …
- Very specific: line 231
Always report qa_passed, episode_produced, and the narration/synth failure buckets together when summarizing a run — a summary that omits …
- Very specific: line 232
Never say an episode was "published" unless distribute.py has run *and* its --publish-dir output has actually been deployed somewhere …
- Very specific: line 234
If synth_failed shows HTTP 429, say so plainly and recommend waiting — don't immediately re-run the episode (see Known operational risks).
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
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 installneeded for any broadcast script). - Node 22, and the evidence-pinning-mcp server built once (
orchestrate.pyspawns it as a subprocess automatically — it does not need to already be running, just built):cd mcp/evidence-pinning && npm ci && npm run build GEMINI_API_KEYset 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_govingest uses the sharedDEMO_KEY(10 req/hr, no registration) — that quota is shared with every otherDEMO_KEYcaller 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 raises that to 1000 req/hr, scoped to just this pipeline. Get one and set this env var wheneverregulations_gov— aregulatory-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
python ${CLAUDE_PLUGIN_ROOT}/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
[--host-style single|dialogue] # default: single — see below
[--text-only] # archive only: no narration, no TTS (embeddings are the only Gemini calls) — see below--text-only is the daily run in the daily-text / weekly-audio plan (references/weekly-show-design.md). It still ingests, ranks, pins evidence, builds the script and runs the QA gate, but skips narration and audio synthesis, so the only Gemini calls are one embedding per ingested item. Exit code is 0 when qa_passed is true (no audio is expected). --dry-run --text-only reports 0 narration and 0 TTS calls.
The text archive (archive.py) is written on every real run, with or without --text-only, to <data-dir>/archive/<date>.json and <date>.md. One entry per selected story that has a pinned claim: the source's own headline and summary (verbatim, never narrated text, which is why it is not built from script.json), link, DOI/PMID when there is one, category, claim_id, canonical_id and relevance_score. Entries are self-contained so a weekly pack can be built from the archive files alone. A selected story with no pinned claim is left out.
--host-style dialogue adds a second voice, Host B, reacting to each story alongside Host A's existing grounded narration — closer to a two-host conversational feel (à la NotebookLM's "Deep Dive") than today's single-narrator default. The key design choice: Host B's words are never Gemini-generated. Every reactive turn is picked from a small, fixed, hand-authored pool (narrate.REACTIVE_TEMPLATES) — zero network calls, zero hallucination surface — so this mode adds no new claim that needs grounding; it's a presentation layer over the exact same single grounded narration --host-style single already produces, not a second source of prose to verify. qa_gate.py's reactive_turns_within_pool check mechanically confirms every reactive turn actually came from that pool, not trusted by its own label. Cost impact is small — a few extra spoken seconds per story via audio_synth.synthesize_dialogue()'s two-voice TTS call, not a new class of Gemini call. A story that fails narration's grounding check (or when --host-style single) never gets a second voice at all — always exactly today's single mechanical/narrated text, never a partial dialogue.
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.
There is a third, text-only workflow, .github/workflows/broadcast-live-text-only.yml (workflow_dispatch only), for checking the pipeline against the real network without touching TTS or narration. It runs a --dry-run (live ingest, no Gemini calls), then --text-only (embeddings are the only Gemini calls) if the GEMINI_API_KEY repository secret exists, and optionally checks the evidence server's Crossref classifier against a known-retracted paper. Results, including call_counts, are written to the run summary and an artifact. It cannot reach TTS, so prefer it over the two workflows above for reconnaissance.
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:
{"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 = trueUntil 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 also does not upload or retain <data-dir>/archive/: only episodes/** is uploaded as an artifact, so the text archive survives only as long as the best-effort --data-dir cache does. It does not pass --text-only either, so it still spends narration and TTS quota.
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—trueonly 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). Checksynth_failedfor 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 orepisode_level_fallback: truemeans that day's episode shipped with plainer prose, not that anything is broken. These fields are allnullwhen--no-narrationwas passed (distinguishable from a real 0/0 result).call_counts— logical Gemini calls this run made:embed(one per ingested item),narrate,synth, andsynth_cache_hits(segments served fromaudio_cache/instead). These are calls the pipeline made, not HTTP attempts: a 429 retry insidegemini_retryis not counted, so treat them as a lower bound on quota actually used. They exist because the key's real limits were never measured; compare them across runs.text_onlyandarchive_entry_countsit alongside.ingest_failed— per-source ingest failures. A source documented inconfig/sources.jsonasfeed_url_verified: falsefailing 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), andselection_rate(selected_total / candidates,nullwhencandidatesis 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.jsonfiles 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:
python ${CLAUDE_PLUGIN_ROOT}/scripts/source_health_report.py --data-dir ~/.broadcast-data [--days N] # default: 30Sums 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/'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:
python ${CLAUDE_PLUGIN_ROOT}/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-regressionA 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:
{
"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:
qa_passed: false— look atqa_checksfor entries withpassed: false; each one'sdetailnames the exact structural problem (a missing intro/outro, a story segment missingclaim_id, etc.). This should essentially never happen from an unmodifiedorchestrate.pyrun —script_gen.pyalready enforces these invariants when it builds the script, andqa_gate.pyonly 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.episode_produced: falsebutqa_passed: true— the script was fine; audio synthesis failed for at least one segment. Checksynth_failed: if every entry readsHTTP 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.ingest_failednon-empty — a source documented inconfig/sources.jsonasfeed_url_verified: falsefailing is expected every run; check the registry entry'sfeed_url_verified_notefirst. 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.- Low
narration_success_rateornarration_episode_level_fallback: true— not a failure at all. This is the narration layer's own designed circuit breaker, working as intended;narration_failureslists 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.
Weekly show (designed, partly built)
A 10-12 minute two-host weekly show built from the week's archive entries is designed in references/weekly-show-design.md (decisions, the review that corrected the first draft, what is still unverified). What exists so far: the archive writer and --text-only above, and weekly_grounding.py, the per-turn grounding contract. A script is a list of turns; a claim turn must cite one claim_id from the week pack, quote a verbatim span from that story, and keep the story's hedges; reactive and link turns must come from fixed hand-written sets, so the only way a fact enters is through a claim turn. Each claim turn is checked against its one cited story: a 3+ word verbatim span, every number and every non-sentence-initial capitalized name present in that story, hedges kept, negation neither added nor dropped. That bounds where facts can come from; it does not prove wording is faithful (a reversed comparison using the story's own words, spelled-out numbers and lowercase entities all pass), so a passing script is not a verified one. The week-pack builder, the weekly generator and chunked two-voice synthesis are not built.
Publishing an episode
python ${CLAUDE_PLUGIN_ROOT}/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 appearThis 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 afeed_urlfield is automatically dispatched byorchestrate.py's_fetch_for_source()toingest.fetch_rss(), the same generic parser already handling four different feeds' real-world quirks (non-RFC-822pubDateformats, a corrupted<link>field, etc. — seeingest.py's_parse_rss_pubdate()if a new feed's dates come back wrong). Leave"feed_url_verified": falseuntil you've actually confirmed it live withlive_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 registered with"feed_url_verified": true: its real feed response (supplied by hand, 2026-09-03) parsed cleanly throughingest.parse_rss_xml(). That confirms the content is parsed correctly but not thatfetch_rss()'s own live HTTP call succeeds from this pipeline's runtime, so a reallive_smoke_test.pyrun is still the way to close that last gap (see the source'sfeed_url_verified_noteinconfig/sources.json). - Adding a query-based or fixed-endpoint source (like
pubmed/arxiv/regulations_gov/fda_maude, ormedrxiv/fda_guidance) needs real code, not just config: a newfetch_*()function iningest.py(see those existing functions for the two patterns) and a new dispatch branch inorchestrate.py's_fetch_for_source()keyed on the source's"key". Without both, that source raisesValueErrorat ingest time.fda_maudeis a good example of the discipline this section otherwise only describes for RSS sources: it's registered but itsquery_notesays plainly it's NOT yet live-verified — documented field names aren't the same as a confirmed real response, the same lessonhealthcare_it_newstaught 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) andhalf_life_days(>0) are set per-category, not per-source (seesource_registry.py's docstring for what they control — a higher floor and longer half-life age a story more slowly). Every category and query inconfig/sources.jsonalready carries a*_notefield 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 ${CLAUDE_PLUGIN_ROOT}/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:
for f in ${CLAUDE_PLUGIN_ROOT}/scripts/test_*.py; do python3 "$f"; donetest_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:
cd mcp/evidence-pinning && npm ci && npm run buildci.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 everyorchestrate.pyrun (dedup_store.prune_old_entries(), wired in viarank.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 rundistribute.pyagainst 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. Useprune_episodes.pyexplicitly instead:
Dry-run by default — it only prints what would be deleted and changes nothing on disk. Passpython ${CLAUDE_PLUGIN_ROOT}/scripts/prune_episodes.py --data-dir ~/.broadcast-data [--retention-days N] # default: 90--applyto 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 (seemcp/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 hitHTTP 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'sRetry-Afterheader;--synth-delay-secondsadds further pacing; the per-segmentaudio_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 ofHTTP 429insynth_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's NOT built here
- Scheduling exists but is off by default.
.github/workflows/broadcast-scheduled.yml(see "Scheduling" above) will runorchestrate.pyon a cron cadence onceBROADCAST_SCHEDULE_ENABLEDis set — until then, every firing is a visible no-op. It doesn't publish (distribute.py) yet either way, pending a hosting decision. - Hosting target chosen, publishing still manual. The intended host is the
AI_Styletrepo served by GitHub Pages (its README has the one-time Pages setup and the publish command).distribute.pyonly writes files; copying them there, committing and pushing is a deliberate human step, and nothing here has published an episode yet. - Cover art exists but must be passed explicitly.
AI_Stylet/docs/cover.jpg(2000x2000 JPEG) is the show art.distribute.pyemitsitunes:imageonly when given--itunes-image-url, so a publish without--itunes-image-url https://hefrock.github.io/AI_Stylet/cover.jpgships a feed with no artwork, which Apple Podcasts will not list. - 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.pyexists 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.pyhas run and its--publish-diroutput has actually been deployed somewhere reachable at--base-url. Writingfeed.xmllocally 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_failedshowsHTTP 429, say so plainly and recommend waiting — don't immediately re-run the episode (see Known operational risks).
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

