From 8c71da543f8c5f2c8cc296b38d2918993a1712a7 Mon Sep 17 00:00:00 2001 From: tcpsyn Date: Fri, 14 Aug 2026 03:28:16 -0500 Subject: [PATCH] Add Big Bend location signal to homepage metadata and llms.txt The site carried no geographic signal at all: the homepage title, description and schema keywords never mentioned Alpine, Marfa or the Big Bend, and llms.txt still described the show as broadcasting from a fictional desert hermit's RV and being produced in New Mexico. - Homepage title, description and OG/Twitter tags now lead with Alpine, Texas and name the surrounding towns; keywords put the location terms first - PodcastSeries schema gains contentLocation with Alpine's real coordinates (from BIG_BEND_TOWNS), spatialCoverage for the region, and an about array covering Alpine, Marfa, Terlingua, Big Bend National Park and the Marfa Lights - llms.txt: corrects the setting throughout, adds the region to Key Facts, and answers "what places does the show talk about" - Fixes the stale "37+ episodes" in five places, using 50+ so it ages better - Corrects three claims that were also wrong on How It Works: 68 voice profiles (now 82), adaptive call shapes (deleted system), and 5 recording stems (now 7) Also adds the Tier 2 plan for crawlable per-episode pages. Co-Authored-By: Claude Opus 5 --- docs/plans/2026-08-14-seo-episode-pages.md | 500 +++++++++++++++++++++ website/index.html | 45 +- website/llms.txt | 26 +- 3 files changed, 552 insertions(+), 19 deletions(-) create mode 100644 docs/plans/2026-08-14-seo-episode-pages.md diff --git a/docs/plans/2026-08-14-seo-episode-pages.md b/docs/plans/2026-08-14-seo-episode-pages.md new file mode 100644 index 0000000..f92cb3b --- /dev/null +++ b/docs/plans/2026-08-14-seo-episode-pages.md @@ -0,0 +1,500 @@ +# Crawlable Episode Pages Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Turn 57 client-rendered `?slug=` episode pages into 57 static, crawlable HTML pages at `/episode//` containing the full transcript, so the Big Bend content the show already produces becomes indexable. + +**Architecture:** A build-time Python generator reads the Castopod RSS feed and the existing `website/transcripts/*.txt` files, and writes one fully static page per episode into `website/episode//index.html`. No runtime feed dependency, no JS required to see content. The Cloudflare worker gains a 301 from the legacy `?slug=` URL, and loses its user-agent gate. `publish_episode.py` calls the generator so new episodes get a page automatically. + +**Tech Stack:** Python 3.11 (stdlib `xml.etree`, `html`, `json`, `pathlib`), pytest, Cloudflare Pages + `_worker.js`, wrangler. + +--- + +## Why This Matters (context for the implementer) + +Current state, verified 2026-08-14: + +- `website/episode.html` is a JS shell. It has **zero** server-rendered episode content — it fetches `/feed`, finds the matching `` client-side, then fetches `/transcripts/.txt`. +- URLs are query params: `/episode.html?slug=episode-58-...`. 54 of the 64 sitemap entries are this shape. +- `website/transcripts/` holds **58 `.txt` files, 3.0 MB total**, none of which appear in `sitemap.xml` and which are linked only from JavaScript. +- `_worker.js:107-165` injects real ``/OG tags — but **only when the User-Agent matches `facebookexternalhit|twitterbot|linkedinbot|slackbot|discordbot|telegrambot|whatsapp|pinterest|redditbot`**. Googlebot, Bingbot, GPTBot, ClaudeBot and PerplexityBot are all absent, so search and answer engines get the generic shell. + +That UA gate is also a **cloaking risk**: serving different HTML to crawlers than to users is against Google's guidelines. Google deprecated "dynamic rendering" as a workaround. Task 6 removes the gate rather than extending it — once pages are static, nothing needs UA sniffing. + +### Data facts confirmed before writing this plan + +- RSS feed: `https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml`, HTTP 200, 124 KB, **57 `<item>` elements**. +- Each item has: `<title>`, `<link>`, `<pubDate>`, `<guid>`, `<description>` (CDATA-wrapped HTML), `<enclosure url=...>`, `<itunes:duration>` (seconds, e.g. `4770`), `<itunes:episode>` (e.g. `58`). +- Slug is derived from `<link>`: everything after `/episodes/`, trailing slash stripped. +- **All 57 feed episodes have a matching transcript file.** Zero missing. +- One orphan transcript, `episode-32-tacos-taxes-and-tall-tales.txt`, has no feed item — ep 32 never finished publishing (`data/publish_state.json` shows a castopod step and nothing else). **The generator must skip orphans, not crash on them.** +- Transcript format is plain text, blank-line separated, `SPEAKER: text` per paragraph — e.g. `LUKE: Alright, welcome back...` / `SLIM: Hey Luke, yeah thanks...`. + +--- + +## Task 1: Transcript parser + +**Files:** +- Create: `website_gen/transcript.py` +- Test: `tests/test_transcript_parser.py` + +**Step 1: Write the failing test** + +```python +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from website_gen.transcript import parse_transcript + + +def test_splits_speaker_turns(): + raw = "LUKE: Welcome back.\n\nSLIM: Hey Luke, thanks for taking my call." + turns = parse_transcript(raw) + assert turns == [("LUKE", "Welcome back."), + ("SLIM", "Hey Luke, thanks for taking my call.")] + + +def test_unlabeled_paragraph_carries_previous_speaker(): + raw = "LUKE: First thing.\n\nStill Luke talking." + assert parse_transcript(raw) == [("LUKE", "First thing."), + ("LUKE", "Still Luke talking.")] + + +def test_ignores_blank_and_whitespace_paragraphs(): + raw = "LUKE: One.\n\n \n\nSLIM: Two." + assert len(parse_transcript(raw)) == 2 + + +def test_speaker_name_with_spaces_is_not_treated_as_label(): + """A colon mid-sentence must not be mistaken for a speaker label.""" + raw = "LUKE: Here's the thing: it was the alternator." + turns = parse_transcript(raw) + assert len(turns) == 1 + assert turns[0][0] == "LUKE" + assert "the thing: it was" in turns[0][1] + + +def test_empty_input_returns_empty_list(): + assert parse_transcript("") == [] + assert parse_transcript(" \n\n ") == [] +``` + +**Step 2: Run test to verify it fails** + +Run: `./venv/bin/python -m pytest tests/test_transcript_parser.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'website_gen'` + +**Step 3: Write minimal implementation** + +```python +import re + +SPEAKER_RE = re.compile(r"^([A-Z][A-Z0-9 .'-]{0,30}):\s*(.*)$", re.DOTALL) + + +def parse_transcript(raw: str) -> list[tuple[str, str]]: + """Split a transcript into (speaker, text) turns. + + Paragraphs are blank-line separated. A paragraph that does not open with a + SPEAKER: label is attributed to whoever spoke last, which is how the + transcriber emits long turns that wrap. + """ + turns: list[tuple[str, str]] = [] + current = None + for para in re.split(r"\n\s*\n", raw or ""): + para = para.strip() + if not para: + continue + m = SPEAKER_RE.match(para) + if m: + current = m.group(1).strip() + text = m.group(2).strip() + else: + text = para + if current is None: + current = "LUKE" + if text: + turns.append((current, text)) + return turns +``` + +**Step 4: Run test to verify it passes** + +Run: `./venv/bin/python -m pytest tests/test_transcript_parser.py -v` +Expected: PASS, 5 tests + +**Step 5: Commit** + +```bash +git add website_gen/transcript.py tests/test_transcript_parser.py +git commit -m "Add transcript parser for episode page generation" +``` + +--- + +## Task 2: RSS feed loader + +**Files:** +- Create: `website_gen/feed.py` +- Test: `tests/test_feed_loader.py` +- Fixture: `tests/fixtures/feed_sample.xml` (hand-trim two `<item>` blocks out of the live feed) + +**Step 1: Write the failing test** + +```python +from website_gen.feed import parse_feed, Episode + + +def test_parses_core_fields(feed_xml): + eps = parse_feed(feed_xml) + ep = next(e for e in eps if e.number == 58) + assert ep.slug == "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho" + assert ep.title.startswith("Episode 58: Rayfield's Nephew") + assert ep.duration_seconds == 4770 + assert ep.audio_url.startswith("https://") + + +def test_description_is_stripped_of_cdata_and_html(feed_xml): + ep = parse_feed(feed_xml)[0] + assert "<![CDATA[" not in ep.description + assert "<p>" not in ep.description + + +def test_slug_comes_from_link_and_drops_trailing_slash(feed_xml): + for ep in parse_feed(feed_xml): + assert not ep.slug.endswith("/") + assert "/" not in ep.slug + + +def test_pubdate_parses_to_iso_date(feed_xml): + ep = next(e for e in parse_feed(feed_xml) if e.number == 58) + assert ep.published_iso.startswith("2026-08-04") +``` + +**Step 2: Run to verify it fails** + +Run: `./venv/bin/python -m pytest tests/test_feed_loader.py -v` +Expected: FAIL — module missing + +**Step 3: Implement** + +Use `xml.etree.ElementTree` with the itunes namespace `http://www.itunes.com/dtds/podcast-1.0.dtd`. Dataclass: + +```python +@dataclass +class Episode: + number: int | None + slug: str + title: str + description: str + published_iso: str + duration_seconds: int | None + audio_url: str +``` + +Parse `pubDate` with `email.utils.parsedate_to_datetime`. Strip CDATA and tags from `<description>` with a regex, then `html.unescape`. + +**Step 4: Verify passes.** **Step 5: Commit.** + +```bash +git commit -m "Add RSS feed loader for episode page generation" +``` + +--- + +## Task 3: Page renderer + +**Files:** +- Create: `website_gen/render.py`, `website_gen/templates/episode.html` +- Test: `tests/test_episode_render.py` + +The template is a full standalone page matching the existing site chrome (copy the `<nav>`, footer markup and `css/style.css?v=6` link from `website/how-it-works.html` so it looks native). + +**Step 1: Write the failing test** + +```python +import json, re +from website_gen.render import render_episode_page + + +def test_title_and_canonical_are_episode_specific(sample_episode): + html = render_episode_page(sample_episode, turns=[("LUKE", "Hello.")]) + assert "<title>Episode 58: Rayfield" in html + assert '<link rel="canonical" href="https://lukeattheroost.com/episode/episode-58-' in html + + +def test_transcript_is_in_the_html_not_fetched_by_js(sample_episode): + html = render_episode_page(sample_episode, turns=[("LUKE", "The Marfa Lights are real.")]) + assert "The Marfa Lights are real." in html + assert "fetch(" not in html + + +def test_emits_valid_podcastepisode_schema(sample_episode): + html = render_episode_page(sample_episode, turns=[("LUKE", "Hi.")]) + block = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S).group(1) + data = json.loads(block) + types = {o["@type"] for o in (data if isinstance(data, list) else [data])} + assert "PodcastEpisode" in types + + +def test_escapes_html_in_transcript_text(sample_episode): + html = render_episode_page(sample_episode, turns=[("LUKE", "5 < 6 & <script>alert(1)</script>")]) + assert "<script>alert(1)</script>" not in html + assert "<script>" in html + + +def test_escapes_quotes_in_title_meta(sample_episode): + sample_episode.title = 'Episode 1: The "Best" Show' + html = render_episode_page(sample_episode, turns=[]) + assert 'content="Episode 1: The "Best"' not in html # must be escaped + + +def test_speaker_labels_get_semantic_markup(sample_episode): + html = render_episode_page(sample_episode, turns=[("LUKE", "Hi."), ("SLIM", "Hey.")]) + assert html.count('class="transcript-turn"') == 2 + assert "LUKE" in html and "SLIM" in html +``` + +**Step 3: Implementation notes** + +- Escape every interpolated value with `html.escape(value, quote=True)`. The transcript is user-facing text from Whisper; treat it as untrusted. +- Schema block is a JSON array containing `PodcastEpisode` and `BreadcrumbList`: + +```python +{ + "@context": "https://schema.org", + "@type": "PodcastEpisode", + "url": f"https://lukeattheroost.com/episode/{ep.slug}/", + "name": ep.title, + "description": ep.description, + "datePublished": ep.published_iso, + "timeRequired": f"PT{ep.duration_seconds}S", + "associatedMedia": {"@type": "MediaObject", "contentUrl": ep.audio_url}, + "partOfSeries": { + "@type": "PodcastSeries", + "name": "Luke at the Roost", + "url": "https://lukeattheroost.com", + }, + "contentLocation": { + "@type": "Place", + "name": "Big Bend, West Texas", + "address": {"@type": "PostalAddress", "addressLocality": "Alpine", + "addressRegion": "TX", "addressCountry": "US"}, + }, +} +``` + +The `contentLocation` on every episode is the point of the whole exercise — it is what ties 57 pages of West Texas conversation to the region geographically. + +- Include a native `<audio controls preload="none" src="{audio_url}">` so the page is useful without JS. +- Add prev/next episode links. Internal linking is what gets 57 pages crawled instead of 3. + +**Step 5: Commit** + +```bash +git commit -m "Add episode page renderer with PodcastEpisode schema" +``` + +--- + +## Task 4: Generator CLI + +**Files:** +- Create: `generate_episode_pages.py` (repo root, matching the existing script convention) +- Test: `tests/test_episode_generator.py` + +**Behaviour:** + +``` +python generate_episode_pages.py # fetch live feed, write all pages +python generate_episode_pages.py --feed FILE # use a local feed (tests, offline) +python generate_episode_pages.py --dry-run # report what would be written +``` + +**Step 1: Failing tests** + +```python +def test_writes_one_index_html_per_feed_episode(tmp_path, feed_file, transcripts_dir): + n = generate(feed_file, transcripts_dir, tmp_path) + assert n == 2 + assert (tmp_path / "episode" / "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho" / "index.html").exists() + + +def test_orphan_transcript_without_feed_item_is_skipped(tmp_path, feed_file, transcripts_dir): + """episode-32 has a transcript but was never published to the feed.""" + generate(feed_file, transcripts_dir, tmp_path) + assert not (tmp_path / "episode" / "episode-32-tacos-taxes-and-tall-tales").exists() + + +def test_missing_transcript_still_produces_a_page(tmp_path, feed_file, transcripts_dir): + """An episode published before its transcript lands must not break the build.""" + (transcripts_dir / "episode-58-....txt").unlink() + generate(feed_file, transcripts_dir, tmp_path) + html = (tmp_path / "episode" / "episode-58-..." / "index.html").read_text() + assert "Transcript not yet available" in html + + +def test_dry_run_writes_nothing(tmp_path, feed_file, transcripts_dir): + generate(feed_file, transcripts_dir, tmp_path, dry_run=True) + assert not (tmp_path / "episode").exists() +``` + +**Step 5: Commit** + +```bash +git commit -m "Add episode page generator CLI" +``` + +--- + +## Task 5: Sitemap regeneration + +**Files:** +- Modify: `generate_episode_pages.py` (add `--sitemap`) +- Modify: `website/sitemap.xml` (regenerated output) +- Test: `tests/test_sitemap.py` + +The current sitemap has 64 `<url>` entries, 54 of them `?slug=` URLs. Those must be **replaced**, not supplemented — leaving both forms invites duplicate-content dilution even with a canonical. + +**Tests:** + +```python +def test_sitemap_contains_no_query_param_urls(generated_sitemap): + assert "episode.html?slug=" not in generated_sitemap + + +def test_sitemap_has_one_entry_per_episode(generated_sitemap): + assert generated_sitemap.count("<loc>https://lukeattheroost.com/episode/") == 57 + + +def test_static_pages_survive_regeneration(generated_sitemap): + for path in ["", "/how-it-works", "/clips", "/stats", "/privacy", "/terms", "/llms.txt"]: + assert f"<loc>https://lukeattheroost.com{path}</loc>" in generated_sitemap + + +def test_lastmod_matches_episode_publish_date(generated_sitemap): + assert "<lastmod>2026-08-04</lastmod>" in generated_sitemap +``` + +**Commit:** `git commit -m "Generate sitemap from feed with clean episode URLs"` + +--- + +## Task 6: Worker — 301 the old URL, remove the UA gate + +**Files:** +- Modify: `website/_worker.js:107-165` + +Replace the entire social-crawler injection block with a redirect. Once pages are static, the injection is dead code and the UA gate is a liability. + +```javascript + // Legacy query-param episode URLs -> clean paths. + // Published social posts and YouTube descriptions still point at the old + // form, so this 301 has to stay indefinitely. + if (url.pathname === "/episode.html" && url.searchParams.get("slug")) { + const slug = url.searchParams.get("slug").replace(/[^a-z0-9-]/gi, ""); + if (slug) { + return Response.redirect(`https://lukeattheroost.com/episode/${slug}/`, 301); + } + } +``` + +**Why the slug is sanitised:** it lands in a `Location:` header. Stripping to `[a-z0-9-]` prevents CRLF injection and open-redirect via a crafted `?slug=`. + +**Verify manually after deploy:** + +```bash +curl -sI "https://lukeattheroost.com/episode.html?slug=episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho" | head -3 +# Expect: HTTP/2 301 + location: https://lukeattheroost.com/episode/episode-58-.../ +``` + +**Commit:** `git commit -m "Redirect legacy episode URLs and drop crawler UA gate"` + +--- + +## Task 7: Retire the client-rendered page + +**Files:** +- Delete: `website/episode.html`, `website/js/episode.js` +- Modify: `website/js/app.js` — episode links must point at `/episode/<slug>/` +- Modify: `website/_redirects` — keep `/episodes.html /episode 302` + +Check every internal link first: + +```bash +grep -rn "episode.html?slug=" website/ --include=*.html --include=*.js +``` + +All must become `/episode/<slug>/`. Also fix `publish_episode.py:1168`, which builds the `episode_url` used in social posts and YouTube descriptions: + +```python +episode_url = f"https://lukeattheroost.com/episode/{episode_slug}/" +``` + +**Commit:** `git commit -m "Point internal links at clean episode URLs"` + +--- + +## Task 8: Wire into the publish pipeline + +**Files:** +- Modify: `publish_episode.py` — after the transcript is written to `website/transcripts/` + +```python +subprocess.run([sys.executable, "generate_episode_pages.py", "--sitemap"], check=False) +``` + +`check=False` on purpose: a generator failure must not abort a publish that has already pushed audio to Castopod. Log loudly instead. + +**Test:** `tests/test_publish_generates_page.py` — assert the generator is invoked with `--sitemap` after a successful publish (monkeypatch `subprocess.run`). + +**Commit:** `git commit -m "Regenerate episode pages during publish"` + +--- + +## Task 9: Deploy and verify + +```bash +./venv/bin/python -m pytest tests/ -q # full suite green +python generate_episode_pages.py --sitemap # 57 pages + sitemap +npx wrangler pages deploy website/ --project-name=lukeattheroost --branch=main +``` + +**Post-deploy checks — all must pass:** + +```bash +# 1. Page is static: transcript present with JS disabled +curl -s https://lukeattheroost.com/episode/episode-58-.../ | grep -c "Mitchell Flat" # >= 1 + +# 2. Real title in raw HTML, no UA spoofing +curl -s https://lukeattheroost.com/episode/episode-58-.../ | grep -o "<title>.*" + +# 3. Same bytes for Googlebot as for a browser (no cloaking) +diff <(curl -s -A "Mozilla/5.0" URL) <(curl -s -A "Googlebot/2.1" URL) && echo "no cloaking" + +# 4. Legacy URL 301s +curl -sI "https://lukeattheroost.com/episode.html?slug=episode-58-..." | grep -i "^location" + +# 5. Schema validates +# paste page source into https://validator.schema.org/ +``` + +Then: submit the new `sitemap.xml` in Google Search Console and request indexing on three episode pages as a sample. + +--- + +## Risks + +| Risk | Mitigation | +|---|---| +| URL change loses existing ranking | 301 (permanent) from Task 6 passes equity; the old form stays supported forever | +| Published YouTube/social links break | Same 301. Ep 58's YouTube description contains a `?slug=` link — verify it after deploy | +| Transcripts contain explicit content | Show is already rated explicit; consider `` on episode pages | +| Whisper errors become permanent indexed text | Transcripts are already public at `/transcripts/*.txt`; this changes exposure, not accuracy | +| 3 MB of new HTML on every deploy | Static assets, Cloudflare-cached; wrangler uploads only changed files | +| Feed unreachable at build time | `--feed` flag lets the build use a cached copy; generator exits non-zero rather than writing empty pages | + +## Out of scope + +Tier 3 — the "Big Bend episodes" topic hub, and off-site work (Alpine Avalanche, Marfa/Big Bend groups, Sul Ross). Worth doing after these pages exist to link to. diff --git a/website/index.html b/website/index.html index 319e786..6d9f4b3 100644 --- a/website/index.html +++ b/website/index.html @@ -3,22 +3,22 @@ - Luke at the Roost — AI Call-In Comedy Podcast - - + Luke at the Roost — Late-Night Call-In Comedy from Alpine, Texas + + - - + + - - + + @@ -39,7 +39,7 @@ "@context": "https://schema.org", "@type": "PodcastSeries", "name": "Luke at the Roost", - "description": "An AI-powered late-night call-in comedy podcast. Real callers and AI-generated characters with unique personalities talk to host Luke MacNeil about life, love, relationships, and everything in between. Broadcast from a desert hermit's RV, featuring 37+ episodes of unpredictable conversations.", + "description": "A late-night call-in comedy podcast broadcast from Alpine, Texas, in the Big Bend country of far West Texas. Real callers and AI-generated characters from Marfa, Terlingua, Marathon, Fort Stockton and the Big Bend talk to host Luke MacNeil about life, love, relationships, the Marfa Lights, and everything in between. 50+ episodes of unpredictable conversations.", "url": "https://lukeattheroost.com", "image": "https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3", "author": { @@ -56,7 +56,34 @@ "inLanguage": "en", "webFeed": "https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml", "startDate": "2026-02", - "keywords": "AI podcast, comedy, call-in radio, artificial intelligence, AI-generated callers, late-night talk show", + "keywords": "Big Bend podcast, Alpine Texas, Marfa, Marfa Lights, Terlingua, Marathon Texas, Fort Stockton, West Texas, call-in radio, late-night talk show, AI podcast, AI-generated callers, comedy", + "contentLocation": { + "@type": "Place", + "name": "Big Bend, West Texas", + "address": { + "@type": "PostalAddress", + "addressLocality": "Alpine", + "addressRegion": "TX", + "addressCountry": "US" + }, + "geo": { + "@type": "GeoCoordinates", + "latitude": 30.3585, + "longitude": -103.661 + } + }, + "spatialCoverage": { + "@type": "Place", + "name": "Big Bend region, far West Texas", + "description": "Alpine, Marfa, Marathon, Terlingua, Fort Stockton, Fort Davis, Presidio and Big Bend National Park." + }, + "about": [ + { "@type": "Place", "name": "Alpine, Texas" }, + { "@type": "Place", "name": "Marfa, Texas" }, + { "@type": "Place", "name": "Terlingua, Texas" }, + { "@type": "Place", "name": "Big Bend National Park" }, + { "@type": "Thing", "name": "Marfa Lights" } + ], "sameAs": [ "https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF", "https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848", diff --git a/website/llms.txt b/website/llms.txt index 1605f7e..007076f 100644 --- a/website/llms.txt +++ b/website/llms.txt @@ -1,10 +1,10 @@ # Luke at the Roost -> An AI-powered late-night call-in comedy podcast hosted by Luke MacNeil +> A late-night call-in comedy podcast from Alpine, Texas, in the Big Bend country of far West Texas, hosted by Luke MacNeil ## What Is This Show? -Luke at the Roost is a one-of-a-kind podcast that blends real callers with AI-generated characters in a late-night call-in radio format. The host, Luke MacNeil, broadcasts from a fictional desert hermit's RV, taking calls about relationships, life decisions, workplace drama, and everything in between. The show features AI-generated callers with unique personalities, backstories, and voices alongside real human callers, creating an unpredictable and comedic listening experience. +Luke at the Roost is a one-of-a-kind podcast that blends real callers with AI-generated characters in a late-night call-in radio format. The show broadcasts from Alpine, Texas — the hub of the Big Bend region of far West Texas — and its callers come from Alpine, Marfa, Marathon, Terlingua, Fort Stockton, Fort Davis, Presidio and the Big Bend backcountry. Host Luke MacNeil takes calls about relationships, life decisions, workplace drama, and everything in between, alongside real human callers, creating an unpredictable and comedic listening experience. The show is entirely custom-built: callers are generated in real-time using large language models with structured personality systems, text-to-speech voices are matched to personality traits, and the entire production pipeline — from recording to post-production to publishing — is automated. @@ -15,19 +15,22 @@ The show is entirely custom-built: callers are generated in real-time using larg - **Genre**: Comedy / Talk / Call-In - **Format**: Late-night call-in radio show (podcast) - **Frequency**: Daily episodes -- **Episodes**: 37+ published episodes +- **Episodes**: 50+ published episodes - **Rating**: Explicit (18+) - **Call-in number**: 208-439-LUKE (208-439-5853) - **Started**: February 2026 - **Publisher**: MacNeil Media Group +- **Based in**: Alpine, Texas (Brewster County), in the Big Bend region of far West Texas +- **Region covered**: Alpine, Marfa, Marathon, Terlingua, Fort Stockton, Fort Davis, Presidio, Big Bend National Park ## What Makes It Unique -- **AI-generated callers**: Each caller is created in real-time with structured backgrounds, personality traits, communication styles, and emotional states using large language models -- **Voice-personality matching**: 68 voice profiles are matched to caller personalities using a scoring system -- **Adaptive call shapes**: Calls follow different dramatic arcs (escalation, revelation, debate, etc.) matched to personality types +- **AI-generated callers**: A roster of ten callers is written at the top of each show, each with an identity, the situation they're in, the reason they're calling that night, a secret want, and an emotional register +- **Real Big Bend geography**: Callers are grounded in accurate facts about Alpine, Marfa, Marathon, Terlingua, Fort Stockton and Big Bend — the Marfa Lights, the Chinati Foundation, the Gage Hotel, the Terlingua chili cookoff, Sul Ross State University, the McDonald Observatory — and are held to real places rather than invented ones +- **Voice-personality matching**: Callers are matched to a voice from an 82-voice roster that fits their age and character +- **Anti-collision rosters**: No two callers in a show may share an obsession, profession archetype or story theme - **Live research intern**: An AI character named Devon monitors conversations and provides real-time research using web search tools -- **Multi-stem recording**: Host, caller, music, SFX, and ads are recorded as separate audio stems +- **Multi-stem recording**: Host, caller, Devon, music, SFX, ads and station idents are recorded as seven separate audio stems - **Automated post-production**: Gap removal, voice compression, music ducking, stereo mixing, and loudness normalization run automatically - **Mixed format**: Real human callers dial in alongside AI characters — listeners can't always tell which is which @@ -67,7 +70,7 @@ The show is entirely custom-built: callers are generated in real-time using larg The show runs on a custom Python backend using: - OpenRouter for LLM access (multiple models including Grok, MiniMax) - ElevenLabs and Inworld for text-to-speech -- A structured caller generation system with personality matching, call shapes, and inter-caller awareness +- A structured caller generation system with voice-personality matching, anti-collision rosters, and inter-caller awareness - Automated post-production pipeline (compression, ducking, loudness normalization) - Castopod for podcast hosting - BunnyCDN for audio delivery @@ -91,7 +94,10 @@ Devon is the show's AI intern character — a 23-year-old NMSU grad who does liv Most AI podcasts are fully scripted or use AI to read text. Luke at the Roost generates unique callers with structured personalities in real-time, creates dynamic conversations, and mixes in real human callers. The host is a real person having genuine reactions to unpredictable AI characters. **Where is the show based?** -The fictional setting is a desert hermit's RV. The show is produced in New Mexico by MacNeil Media Group. +The show broadcasts from Alpine, Texas, the Brewster County seat and the hub of the Big Bend region of far West Texas. It is produced in Alpine by MacNeil Media Group. + +**What places does the show talk about?** +The Big Bend country of far West Texas. Recurring subjects include the Marfa Lights and the Chinati Foundation in Marfa, the Terlingua ghost town and its chili cookoff, the Gage Hotel in Marathon, Sul Ross State University in Alpine, the McDonald Observatory near Fort Davis, oilfield work around Fort Stockton, the Chisos Mountains and dark skies of Big Bend National Park, and life along the Rio Grande border at Presidio and Boquillas. ## Show Tone & Content @@ -99,7 +105,7 @@ The show is rated **explicit** and features adult language, dark humor, and fran ## Recurring Characters -- **Luke MacNeil** — The host. Real human. Gives advice from his RV in the desert. Blunt, occasionally profound, frequently profane. +- **Luke MacNeil** — The host. Real human. Broadcasts from Alpine, Texas. Blunt, occasionally profound, frequently profane. - **Devon** — The AI intern. 23-year-old NMSU grad. Monitors calls and provides live research. Eager, slightly incompetent, gets yelled at. Uses web search tools in real-time. - **Regular callers** — Some AI callers return across episodes with stored memories, evolving arcs, and relationships with other regulars.