# Commune Podcast API Reference

**Base URL:** `https://communeapi.com`
**Auth:** None required for `/api/podcast/*` endpoints. Anonymous requests are always served. The one exception: a browser that is signed into the Commune tools as an external-partner account (always), or as a guest-domain or email-code account *without* access to the Podcast Search tool, gets `403` on `/api/podcast/*` — the server scopes those session types per tool. Send no `report_session` cookie and this never applies.
**Format:** All responses are JSON. All requests use GET. Timestamps are ISO 8601 (UTC).
**Search UI:** `https://communeapi.com/tools/podcast-search/` (Commune sign-in required — the API itself is open). Its guide is [`../tools/podcast-search.md`](../tools/podcast-search.md).
**Rate limits:** None enforced (see *Notes on the data*).

---

## What's in here

Every episode of *Commune with Jeff Krasno* from the first one in August 2018 through the present — **772 episodes** at the time of writing (`/stats` reports the live figure). Episode metadata and show notes come from the podcast's Megaphone feed, and **the catalog auto-updates daily from that feed**, so new episodes show up on their own. Transcripts are still added manually (see *Keeping the catalog current*).

**28 of those episodes also had a full transcript** when this was written, with speaker labels and timecodes, and an AI-generated detailed summary. Transcripts are being added over time, so that number grows; treat `has_transcript` (and `/stats`) as the source of truth rather than assuming a date range.

The intended workflow is two-step:

1. **Find the right episode** with `/search` or by reading `summary` + `topics`.
2. **Pull the transcript** for that episode and quote or timestamp the exact moment.

---

## Key Concepts

- **id** — Integer primary key for an episode. Stable. Usable anywhere `:id` appears.
- **megaphone_guid** — The GUID from the podcast feed. Also accepted anywhere `:id` appears, so you can look an episode up straight from the RSS feed. Transcripts with no matching feed episode get a synthetic `transcript:<slug>` GUID instead.
- **has_transcript** — Boolean. `true` means `/episodes/:id/transcript` will return turns for it. Only a small subset so far — `/stats` has the current count.
- **summary** — 2–3 paragraph AI-generated summary written from the full transcript. Detailed and specific: it names the studies, protocols, compounds and people discussed. Only present where a transcript exists.
- **topics** — 8–15 short lowercase topic labels generated alongside the summary (e.g. `"gut microbiome"`, `"perimenopause"`). Empty array when there's no summary.
- **notable_segments** — 5–10 `{timecode, title, description}` markers pointing at the interesting moments in an episode. Detail endpoint only.
- **description** — The show notes verbatim from the feed, including the sponsor block. Search deliberately ignores the sponsor block (see *How search works*).
- **segment** / **turn** — One speaker's uninterrupted stretch of talk. The unit transcripts are stored and searched in.
- **timecode** — `HH:MM:SS` into the episode. Source transcripts carry a marker roughly once per minute, so **timecodes are accurate to the minute, not the second**. Use them to jump near a moment, not to cut audio.
- **needs_review** — Something about this row wants a human look — usually a transcript that couldn't be matched to a feed episode. `review_note` says what.
- **rank** — Relevance score for a search hit. Comparable within one result set, not across queries.

---

## Sorting and page size

Both list endpoints — `/search` and `/episodes` — take the same `sort` and `limit` conventions. Neither is required, and leaving them off gives you the behaviour these endpoints have always had.

### Sorting

| `sort` | Order |
|--------|-------|
| `date_desc` | Newest episode first. **Default on `/episodes`** |
| `date_asc` | Oldest episode first |
| `relevance` | Best match first. **Default on `/search`** |

- `relevance` is only available where there's a query to be relevant to. On `/episodes` it silently falls back to `date_desc` rather than erroring, so you can pass the user's chosen sort through to either endpoint without branching.
- The date sorts keep relevance as the tiebreak on `/search`, so two episodes published the same day still lead with the better match.
- `newest` and `oldest` — the spellings `/episodes` shipped with — remain accepted as aliases for `date_desc` and `date_asc`.
- Anything else is a `400 {"error": "Invalid \"sort\" — use date_desc, date_asc or relevance"}`, so typos surface instead of quietly returning the default order.

The resolved sort is echoed back in the response as `sort`.

### Page size

`limit` takes a number or the string `all`:

```
GET /api/podcast/episodes?limit=100          → 100 per page
GET /api/podcast/episodes?limit=all          → the whole result set, no paging
GET /api/podcast/episodes?limit=0            → same as all
```

The catalog is a few hundred episodes, so `limit=all` is a cheap way to pull everything in one request — no `offset` loop needed. It's capped at 2,000 rows as a guard rail; nothing in this API returns that many today. Numeric limits are clamped to each endpoint's range (1–200 for both list endpoints) rather than rejected; a non-numeric value falls back to the endpoint's default. `offset` is clamped to 0–100,000.

When you ask for `all`, the response echoes `"limit": "all"` instead of a number.

### Date filtering

`date_from` and `date_to` bound the publication date on both list endpoints. Each takes a full ISO date or a bare year:

```
GET /api/podcast/episodes?date_from=2024-01-01&date_to=2024-03-31
GET /api/podcast/search?q=sleep&date_from=2025-06-15
GET /api/podcast/episodes?date_from=2024&date_to=2024        → all of 2024
```

- **Both ends are inclusive.** `date_to=2024-03-31` includes episodes published at any time on 31 March; the comparison is against `pub_date`, which carries a time, so a naive `<= '2024-03-31'` would have dropped that day.
- **A bare year expands to the whole year** — `date_from=2024` is 1 January, `date_to=2024` is 31 December.
- **Dates are UTC**, matching `pub_date` in every response.
- A malformed or calendar-invalid value (`2024-02-31`, `last tuesday`) is a `400 {"error": "Invalid \"date_from\" — use YYYY-MM-DD or YYYY"}`.

`year`, `year_from` and `year_to` still work exactly as they always did, and `year_from`/`year_to` now also accept a full date if you'd rather not switch parameter names.

### Boolean filters

`has_transcript` and `needs_review` are available on **both** list endpoints — the filter set is shared between `/search` and `/episodes`, so anything that narrows a browse also narrows a search.

- `true` or `1` selects rows where the flag is set.
- **Any other non-empty value** (`false`, `0`, `no`, …) selects rows where the flag is *not* set.
- An empty value (`?has_transcript=`) or omitting the parameter applies no filter.

```
GET /api/podcast/search?q=sleep&has_transcript=true     → only episodes we can quote from
GET /api/podcast/search?q=sleep&has_transcript=false    → only episodes with show notes alone
GET /api/podcast/episodes?needs_review=true             → rows flagged for a human look
```

---

## Endpoints

### 1. Search

`GET /api/podcast/search`

The main discovery endpoint. Searches across **transcripts, episode titles, guest names, show notes and AI summaries at once**, and tells you which of them matched.

Episodes are ranked on combined evidence and each result carries the individual transcript turns that matched, with timecodes — so one call gets you both "which episode" and "where in it".

**Parameters:**
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| q | string | yes | Search query. Supports quoted phrases (`"cold plunge"`), `OR`, and `-exclusion`. Missing or blank → `400 {"error": "\"q\" parameter is required"}` |
| has_transcript | boolean | no | `true` to restrict to episodes with transcripts, `false` to episodes without — see *Boolean filters* |
| needs_review | boolean | no | `true` for rows flagged for human review, `false` to exclude them — see *Boolean filters* |
| guest | string | no | Partial match on guest name or title |
| date_from | date | no | Published on or after — `YYYY-MM-DD` or `YYYY`. See *Date filtering* |
| date_to | date | no | Published on or before — `YYYY-MM-DD` or `YYYY` |
| year | integer | no | Exact publication year |
| year_from | integer | no | Earliest publication year |
| year_to | integer | no | Latest publication year |
| episode_type | string | no | `full`, `trailer`, or `bonus` |
| segments | integer | no | Transcript turns returned per episode (default 3, max 20, `0` for none) |
| sort | string | no | `relevance` (default here), `date_desc`, `date_asc` — see *Sorting* |
| limit | integer\|`all` | no | Results per page (default 20, max 200, or `all`) — see *Page size* |
| offset | integer | no | Pagination offset (default 0) |

**Example — where has peptide therapy been discussed?**

```
GET /api/podcast/search?q=peptides
```

**Example — every mention of peptides in one call, oldest first:**

```
GET /api/podcast/search?q=peptides&sort=date_asc&limit=all
```

**Example — menopause, but only where we have a transcript to quote:**

```
GET /api/podcast/search?q=menopause&has_transcript=true&segments=5
```

**Example — exact phrase, within a date range:**

```
GET /api/podcast/search?q=%22cold+plunge%22&date_from=2025-01-01&date_to=2025-12-31
```

**Response:**

```json
{
  "query": "peptides",
  "total": 10,
  "sort": "relevance",
  "limit": 20,
  "offset": 0,
  "results": [
    {
      "id": 12,
      "megaphone_guid": "30eec722-7039-11f1-a403-4fe7bce4c4d1",
      "title": "Peptide Therapy: Healing, Longevity, and GLP-1s with Dr. Aleksandra Gajer",
      "guest": "Dr. Aleksandra Gajer",
      "episode_number": null,
      "pub_date": "2026-06-25T13:00:00.000Z",
      "duration_seconds": 4756,
      "duration": "01:19:16",
      "episode_type": "full",
      "audio_url": "https://traffic.megaphone.fm/COMM9441162550.mp3",
      "image_url": "https://megaphone.imgix.net/podcasts/30eec722-.../image/....jpg",
      "blog_url": null,
      "summary": "Dr. Aleksandra Gajer — a University of Maryland–trained MD who did emergency medicine residency at GW …",
      "topics": ["peptide therapy", "GLP-1s", "BPC-157", "TB-500", "growth hormone"],
      "has_transcript": true,
      "segment_count": 391,
      "needs_review": false,
      "rank": 0.917,
      "match_types": ["title", "description", "summary", "transcript"],
      "snippet": "…<mark>Peptides</mark> have jumped from the fringe to the mainstream, and most of what you'll find online comes from…",
      "transcript_match_count": 69,
      "transcript_matches": [
        {
          "seq": 99,
          "speaker": "Aleksandra",
          "is_host": false,
          "start_seconds": 420,
          "timecode": "00:07:00",
          "snippet": "talk about <mark>peptides</mark> because it's often framed as something very fringe and new, but <mark>peptides</mark> have been around…",
          "rank": 0.0951
        }
      ]
    }
  ]
}
```

**Search-specific fields:**

| Field | Meaning |
|-------|---------|
| `rank` | Combined relevance. Higher is better. Only comparable inside one response |
| `match_types` | Which fields matched: any of `title` (episode title *or* guest name), `description`, `summary`, `transcript` |
| `snippet` | Highlighted extract from the show notes or summary. `null` when only the transcript matched |
| `transcript_match_count` | **Total** matching turns in the episode — often far more than are returned |
| `transcript_matches` | The top-ranked turns, best match first (ties broken by `seq`). `seq` is the turn's position in the transcript |

Snippets contain `<mark>…</mark>` tags around the matched terms. Everything else in them is plain text.

---

### 2. Browse Episodes

`GET /api/podcast/episodes`

The full catalog with filters. Use this for systematic passes — "everything from 2024", "every episode we have a transcript for".

**Parameters:**
| Param | Type | Description |
|-------|------|-------------|
| has_transcript | boolean | `true` / `false` — see *Boolean filters* |
| guest | string | Partial match on guest name or title |
| date_from | date | Published on or after — `YYYY-MM-DD` or `YYYY`. See *Date filtering* |
| date_to | date | Published on or before — `YYYY-MM-DD` or `YYYY` |
| year | integer | Exact publication year |
| year_from | integer | Earliest publication year |
| year_to | integer | Latest publication year |
| episode_type | string | `full`, `trailer`, `bonus` |
| needs_review | boolean | `true` to see only rows flagged for human review, `false` to exclude them — see *Boolean filters* |
| sort | string | `date_desc` (default) or `date_asc` — see *Sorting* |
| limit | integer\|`all` | Default 50, max 200, or `all` — see *Page size* |
| offset | integer | Default 0 |

**Example — every episode with a transcript:**

```
GET /api/podcast/episodes?has_transcript=true&limit=all
```

**Example — everything Marianne Williamson appears on, oldest first:**

```
GET /api/podcast/episodes?guest=Marianne+Williamson&sort=date_asc
```

**Example — the entire catalog in one call, oldest episode first:**

```
GET /api/podcast/episodes?sort=date_asc&limit=all
```

**Response:**

```json
{
  "total": 28,
  "sort": "date_desc",
  "limit": 50,
  "offset": 0,
  "results": [
    {
      "id": 1,
      "megaphone_guid": "743be176-8bc9-11f1-8c78-5b2d2c6652cc",
      "title": "Momgevity: Redefining Longevity for Busy Moms with Dr. Robin Berzin",
      "guest": "Dr. Robin Berzin",
      "episode_number": null,
      "pub_date": "2026-07-30T13:05:00.000Z",
      "duration_seconds": 2550,
      "duration": "00:42:30",
      "episode_type": "full",
      "audio_url": "https://traffic.megaphone.fm/COMM7139195597.mp3",
      "image_url": "https://megaphone.imgix.net/podcasts/743be176-.../image/....jpg",
      "blog_url": null,
      "summary": "Dr. Robin Berzin, founder and CEO of Parsley Health, returns to talk with Jeff Krasno about \"momgevity\" …",
      "topics": ["momgevity", "longevity", "motherhood", "meditation", "perimenopause"],
      "has_transcript": true,
      "segment_count": 275,
      "needs_review": false
    }
  ]
}
```

Note: `episode_number` is only populated for the era when episodes were numbered in the title (roughly through 2024). It is `null` otherwise.

---

### 3. Get One Episode

`GET /api/podcast/episodes/:id`

Full metadata for a single episode. `:id` accepts either the numeric `id` or the `megaphone_guid`.

Everything from the browse response, plus:

| Field | Description |
|-------|-------------|
| `description` | Complete show notes from the feed, sponsor block included |
| `notable_segments` | Array of `{timecode, title, description}` — the moments worth jumping to |
| `transcript_source` | Filename of the transcript export this episode was built from |
| `summary_generated_at` | When the AI summary was produced |
| `review_note` | Why this row is flagged, when `needs_review` is `true` |

**Example:**

```
GET /api/podcast/episodes/12
GET /api/podcast/episodes/30eec722-7039-11f1-a403-4fe7bce4c4d1
```

**Response (abbreviated):**

```json
{
  "id": 12,
  "title": "Peptide Therapy: Healing, Longevity, and GLP-1s with Dr. Aleksandra Gajer",
  "guest": "Dr. Aleksandra Gajer",
  "pub_date": "2026-06-25T13:00:00.000Z",
  "duration": "01:19:16",
  "has_transcript": true,
  "segment_count": 391,
  "summary": "Dr. Aleksandra Gajer — a University of Maryland–trained MD … walks Jeff through peptide therapy from the ground up …",
  "topics": ["peptide therapy", "GLP-1s", "BPC-157", "TB-500"],
  "description": "What if the goal isn't just living longer, but feeling vital the whole way through? …",
  "notable_segments": [
    {
      "timecode": "00:02:00",
      "title": "Why she left the ER",
      "description": "Gajer describes having two children during med school and residency and contracting Lyme disease from a tick bite, which shifted her lens from treating disease to creating health."
    }
  ],
  "transcript_source": "AleksandraGajer_INT_2026_Transcript.docx",
  "summary_generated_at": "2026-08-03T16:26:36.456Z",
  "review_note": null,
  "needs_review": false
}
```

Returns `404 {"error": "Episode not found"}` for an unknown id.

---

### 4. Get a Transcript

`GET /api/podcast/episodes/:id/transcript`

The full transcript as ordered speaker turns. `:id` accepts the numeric id or the GUID.

**Parameters:**
| Param | Type | Description |
|-------|------|-------------|
| format | string | `json` (default) or `text` for a flat `[HH:MM:SS] Speaker: line` rendering (`text/plain`; the `[HH:MM:SS] ` prefix is omitted for turns with no timecode) |
| limit | integer | Turns per page (default 5000, max 5000 — enough for any current transcript in one call) |
| offset | integer | Pagination offset (default 0, max 100,000) |

`limit` does not accept `all` here; the default already covers a whole transcript.

**Example:**

```
GET /api/podcast/episodes/12/transcript
GET /api/podcast/episodes/12/transcript?format=text
```

**Response:**

```json
{
  "episode_id": 12,
  "title": "Peptide Therapy: Healing, Longevity, and GLP-1s with Dr. Aleksandra Gajer",
  "guest": "Dr. Aleksandra Gajer",
  "pub_date": "2026-06-25T13:00:00.000Z",
  "transcript_source": "AleksandraGajer_INT_2026_Transcript.docx",
  "total_segments": 391,
  "limit": 5000,
  "offset": 0,
  "segments": [
    {
      "seq": 99,
      "speaker": "Aleksandra",
      "is_host": false,
      "start_seconds": 420,
      "timecode": "00:07:00",
      "text": "So the word peptide just means a small protein — a short chain of amino acids …"
    }
  ]
}
```

`is_host` is `true` for Jeff Krasno's turns. `timecode` / `start_seconds` are `null` for the handful of turns that occur before the first timecode marker in a source file (~2% of turns, all at the very start of an episode).

Returns `404 {"error": "No transcript available for this episode"}` when the episode exists but has no transcript — check `has_transcript` first to avoid the round trip.

---

### 5. List Guests

`GET /api/podcast/guests`

Guests parsed from episode titles, with how often they appear. Useful for "who has been on most" and for spelling a `guest` filter correctly.

**Parameters:**
| Param | Type | Description |
|-------|------|-------------|
| q | string | Partial name match |
| min_count | integer | Only guests with at least this many episodes (default 1, max 1000) |
| limit | integer | Default 200, max 1000 |

**Response:**

```json
{
  "total": 200,
  "results": [
    { "guest": "Danielle LaPorte", "episodes": 12, "with_transcript": 0, "latest": "2025-04-05T07:00:07.000Z" },
    { "guest": "Dr. Mark Hyman",   "episodes": 12, "with_transcript": 0, "latest": "2025-03-15T22:05:09.000Z" },
    { "guest": "Marianne Williamson", "episodes": 11, "with_transcript": 1, "latest": "2025-12-25T08:05:00.000Z" }
  ]
}
```

`total` is the number of rows in this response (after `limit`), not the number of distinct guests in the catalog. Results are ordered by episode count, then guest name.

Guest names are parsed from title patterns (`… with <name>`, `<name> on …`), so episodes whose titles don't name a guest — solo episodes, meditations, the *Is This Right for Me?* series — have `guest: null` and don't appear here. Search by topic instead for those.

---

### 6. Catalog Stats

`GET /api/podcast/stats`

Coverage snapshot. Handy for confirming how much of the catalog currently has transcripts, and for checking that the nightly feed refresh is still running.

```json
{
  "episodes": 772,
  "episodes_with_transcript": 28,
  "episodes_with_summary": 28,
  "episodes_needing_review": 3,
  "transcript_segments": 8068,
  "earliest_episode": "2018-08-07T15:03:00.000Z",
  "latest_episode": "2026-07-30T13:05:00.000Z",
  "last_feed_check": "2026-08-03T18:00:10.835Z",
  "last_feed_status": "ok",
  "last_feed_error": null,
  "last_feed_episodes": 770,
  "last_feed_new_episodes": 0,
  "last_feed_new_at": null
}
```

The `last_feed_*` fields describe the most recent run of the nightly RSS refresh (see *Keeping the catalog current*):

| Field | Meaning |
|-------|---------|
| `last_feed_check` | When the job last ran, successfully or not. A value more than ~26 hours old means the job has stopped running. |
| `last_feed_status` | `"ok"` or `"error"`. |
| `last_feed_error` | The failure message when `last_feed_status` is `"error"`, otherwise `null`. |
| `last_feed_episodes` | Episodes present in the feed on the last successful run. |
| `last_feed_new_episodes` | Episodes inserted on the last successful run — usually `0`. |
| `last_feed_new_at` | When episodes were last actually added. `null` until the job adds its first one. |

A failed run leaves `last_feed_episodes` / `last_feed_new_episodes` reporting the last *successful* run rather than zeroing them. All six fields are `null` before the job has ever run.

---

## Errors

Every error is JSON of the form `{"error": "<message>"}` (the only non-JSON response is a successful `?format=text` transcript).

| Status | When |
|--------|------|
| `400` | `/search` without `q`; an unknown `sort`; a malformed or calendar-invalid `date_from`, `date_to`, `year_from` or `year_to` (the message names the parameter) |
| `403` | Only for a browser signed in as an external-partner account, or as a guest-domain or email-code account without Podcast Search access (see *Auth*) |
| `404` | `Episode not found`, or `No transcript available for this episode` |
| `500` | A database failure: `Search error` on `/search`, `Query error` everywhere else |

---

## Keeping the catalog current

**The episode catalog updates itself daily.** A job on the server reads the Megaphone feed every night at 3:30 AM UTC and inserts any episode it doesn't already have, so a newly published episode is searchable by its title and show notes within a day. Nothing existing is ever rewritten or removed by that job.

**Transcripts are still added by hand.** A freshly added episode arrives with `has_transcript: false`, no `summary`, `topics` or `notable_segments`, and no `/transcript` endpoint (that returns `404` until segments exist) — exactly like the rest of the back catalog. It becomes fully searchable once someone runs the transcript ingest for it. Plan for both shapes: an episode with rich transcript data and one with show notes only.

---

## How search works

Worth knowing so results make sense:

- **Two levels of evidence.** An episode can match on its own text (title, guest, show notes, AI summary) or on individual transcript turns, or both. `match_types` tells you which. An episode with no transcript can still rank well on its show notes — that's how the 2018–2025 back catalog stays findable.
- **Postgres full-text search, English stemming.** `meditation` also finds *meditate*, *meditating*. Query syntax is `websearch_to_tsquery`: bare words are ANDed, `"quoted phrases"` are exact, `OR` works, and a leading `-` excludes.
- **Sponsor copy is excluded from the index.** Show notes end with a near-identical ad block on hundreds of episodes. Searching `seed` or `thrive` would otherwise return half the catalog. The sponsor text is still returned in `description` — it's just not searchable.
- **Ranking.** Episode-level relevance dominates, transcript turns add to it, and an episode where the term recurs throughout gets a small bonus over one where it's mentioned once. `sort=date_desc` / `date_asc` reorders the same result set chronologically without changing which episodes match.
- **Snippets are windows, not single turns.** Conversation turns are often only a few words (`"Yeah."`). Transcript snippets are built from a window of turns around the hit so they read as sentences, while `timecode` still points at the matching turn.

---

## Notes on the data

- **Timecode precision is one minute.** Source transcripts mark roughly each minute. Good for "jump to about here", not for frame-accurate cuts.
- **Speaker labels come from the source files** and vary in style — some name the guest (`"Aleksandra"`), a few older exports only distinguish `"Speaker 2"` from `"Speaker 3"`. `is_host` is reliable for Jeff.
- **Transcripts are raw.** They include filler, crosstalk and pre-roll chatter before the interview proper. Expect verbatim speech, not edited prose.
- **A handful of rows are flagged `needs_review`** — at the time of writing, two transcripts with no matching published episode and one raw recording that was published as a two-part episode (`episodes_needing_review` in `/stats` is the live count). Query them with `?needs_review=true` on either list endpoint.
- **Nothing here is rate-limited or authenticated**, but it is a single small server. Batch politely — sequential requests rather than large parallel fan-outs — and cache transcripts rather than re-fetching them.

---

## Recipes

**"Find me an episode about X and give me the exact quote."**

```
GET /api/podcast/search?q=X&has_transcript=true
→ pick the top result, note its id and the timecodes in transcript_matches
GET /api/podcast/episodes/<id>/transcript
→ read around those seq numbers for the full quote in context
```

**"What have we published on X across the whole back catalog?"**

```
GET /api/podcast/search?q=X&limit=all&segments=0
→ segments=0 keeps the response small when you only want the episode list
```

**"Everything on X, in the order we published it."**

```
GET /api/podcast/search?q=X&sort=date_asc&limit=all
→ the arc of a topic across the catalog, oldest first
```

**"Summarize everything we have transcripts for."**

```
GET /api/podcast/episodes?has_transcript=true&limit=all
→ every summary and topic list in one call
```

**"Which episodes feature this person?"**

```
GET /api/podcast/guests?q=<partial name>     → confirm the exact spelling
GET /api/podcast/episodes?guest=<name>       → their episodes
```
