Skip to content

Passages

Status: Live

Implemented and deployed to dev (CSL-141). See API Conventions for auth, pagination, and error handling.

A Passage is the atomic content unit: a short, standalone Lewis passage — completable on its own (read, listen, reflect, wander) without opening its source. It is the primary interaction unit and the canonical starting point for tag-graph traversal.

Passages are always Lewis's words, always drawn from a published work. The primary interaction is cloned-voice audio playback. The body is plain text (word-by-word audio sync needs a clean string).

(Formerly called Excerpt — renamed to Passage across model, API, and docs in CSL-141.)

Endpoints

  • GET /api/passages/ returns the published passages outside journeys, paginated (LimitOffset, the repo default).
  • GET /api/passages/todays-featured/ returns the Home hero queue.
  • GET /api/passages/{id}/ returns the reading-screen detail for one passage.

All three are licensed content: they need the app key and a content-access token (X-Content-Token, see Content access), are user-agnostic, and are cached by the app only (Cache-Control: private + ETag). The one exception: the detail of a journey passage also depends on the reader's access to its journey and is private, no-store (see Reading detail).

Each passage can name a primary theme, served as theme_id. The client resolves theme_id against the cached themes set.

Journey passages

An editor writes a new passage for each journey chapter (CSL-490). That passage belongs to its chapter, and a reader meets it only inside the journey:

  • The list, todays-featured, the theme pool (/api/themes/{slug}/passages/) and every related_quotes rail leave it out, even when an editor queued or picked it before.
  • GET /api/passages/{id}/ serves it, because the chapter links there. The payload has the same shape as any other passage, but interpretation.subtitle is empty (see below).
  • Bookmarks and reflections on it work as usual.

The payload marks a journey passage through an empty interpretation.subtitle, so a client can tell the two apart when it needs to — it otherwise never has to.

The hero shows one quote a day. The endpoint returns up to 10 cards in the order readers are meant to see them: the client holds the window and shows the next one each day, asking for more only when it has walked through what it has.

Behaviour that shapes the contract:

  • The order is the editor's queue. Editors arrange a Home quote queue in the CMS, and that drag order is the sequence readers walk. It is a schedule, not a set of pins, so a queue of three returns three cards and the sequence starts again from the first — the response is not topped up from the wider library.
  • A queue with nothing eligible in it falls back to newest-published first — whether it is empty, or every entry has been unpublished or left with no body. The hero is never blank while the library has anything publishable in it. The response shape is identical either way, so a client cannot tell (and does not need to) which one it is looking at.
  • A queued passage that is unpublished drops out. The days close up behind it rather than leaving a gap.
  • ?exclude= is a cursor, not a filter. The client keeps only its current window (up to 10) and sends those ids as ?exclude=1,2,3. Because the order is fixed, the server resumes just past the end of the run those ids form and returns the next window. A client holding items 11 to 20 that sends ?exclude=11..20 gets 21 to 30, not 1 to 10. The client does not accumulate every id it has seen. The last window is enough.
  • A window that wrapped still advances cleanly. When the queue's length is not a multiple of the window size, a window straddles the end — say 20 to 24 then 0 to 4 in a queue of 25. The server resumes at 5, not at 0, so the next window never hands back cards the client is still showing.
  • The response never returns empty and wraps at the end. Once the cursor passes the last passage it wraps to the start, so a short queue cycles rather than running out.

Who decides "today"

The client does. The backend decides what is in the queue and in what order; the app advances one card per local calendar day and persists which one it is on, so closing and reopening the app shows the same quote. Nothing here is per-reader on the server, which is what lets the app cache it.

Two consequences worth designing for: a reader who changes their device date or timezone moves their own position in the queue, and the same reader on two devices can be on different days.

Editor-facing notes on arranging the queue are in the Home editor guide.

Reading detail: GET /api/passages/{id}/

The single-passage payload behind the passage reading screen. It returns 404 when the id is not a published passage: live, with body text. Unlike the list, it serves journey passages. It needs a content-access token. A library passage is cached by the app only (Cache-Control: private + ETag).

A journey passage follows its journey's access (Journey access): 402 with required_entitlement when the journey is locked for the reader, 404 when the journey is a draft or the passage has no chapter, and private, no-store when served. Sign-in is optional; send the bearer token so a signed-in reader's access is checked.

Beyond the card fields, it adds the full reading layer:

  • interpretation is a structured object with three fields. heading is the passage title. subtitle is fixed at "Seeing this through Lewis's lens" — except for a journey passage, where it is "": the client can't see, edit or control that copy from a journey's CMS, and there's no field to add one without breaking the journey bulk-import format. body is the "What This Means" text as Markdown. The empty subtitle is "" rather than null because app builds already installed read it as a required string; it becomes null once those builds are retired. body can be empty, because interpretations are authored later with AI help, so a live passage can ship without one.
  • reflection_prompt is the editorial reflect question. It can be empty.
  • related_quotes holds up to 4 related quotes in the editor's stored order. Each is a lean card of id, slug, and quote only. The rail renders just the quote, and a tap fetches that passage's full detail by id, so the card omits source, audio, and theme_id. This rail is display-only. The CMS curates its membership and order, and the runtime never ranks or computes it. The server drops non-published picks, journey passages and the passage itself, and returns [] when none remain, so the FE omits the section.
  • Two computed onward rails power the footer carousels (CSL-207). more_on_topic returns Lewis's own Writings that share the passage's source book or theme. related_content returns contributor Articles matched by shared tags, with a theme fallback when the passage has no tags. Each card's supporting_line is the "From {author}" byline. Both are content cards, live-only, self-excluded, and capped at 10, and each is [] when nothing matches. Both rails are computed, unlike the curated related_quotes rail above.

Audio

The detail payload carries an audio object keyed by field: body, interpretation and reflection_prompt, each a track or null when that field has not been narrated. A passage is narrated one field at a time, so the three play in the order the screen shows them, and the player stops before the question to take the reader to the reflection screen. Some passages have no narration at all; every track is null and the control is hidden.

The list and hero cards carry the same audio object, key for key. A reader who presses play on the Home quote card wants the passage, not the quote alone, so the card holds every track and plays through without a detail fetch. Read the tracks to decide whether to draw the control, the same way on both surfaces.

The track shape and the placeholder-audio warning are in Audio.

Card notes

  • The list and hero cards carry no top-level kind. Both responses return passages only, so each card omits the type discriminator. The detail payload does carry kind: "passage", because it follows the shared content-detail contract, where kind names the piece type across content kinds.
  • A card trims nothing the reader can act on. Both audio and source are identical to the detail's, because a card is a surface a reader plays and taps, not just reads: trimming the audio would cut the listen short, and trimming the source would mean a second request to open the book.
  • source is polymorphic, so it does carry a kind. A passage's origin is a Book (kind: "book", whose cover is an image rendition set) or a Writing (kind is letter, essay, or chapter). It is null when the passage cites neither.
  • A Book source is the whole book, everywhere. id, slug, title, publication_year, synopsis, cover, buy_cta — the seven fields Featured Books serves — plus kind: "book", which every source carries because a source is polymorphic and Featured Books, being books only, is not. (A writing that cites a book — GET /api/writings/{id}/ — returns this same eight-field book shape for its own source. That is a Book source reached from a writing, not the Writing-source shape in the next bullet.) It fills the reading screen's "Discover About the Book" block and the book modal the Home hero opens when its source row is tapped, with no follow-up request either time. Any of publication_year, cover and buy_cta can be null, and synopsis can be empty, when an editor has not filled them in; hide that part rather than substituting anything. (Until 2026-09, a card's source was a leaner four-field version of this.)
  • A Writing source is a bare reference — kind, id, slug, title — on the card and the detail alike. A letter or an essay has no cover, no publication year and nothing to buy, so kind is how a client knows there is no book behind the row.
  • Eligibility. Only live passages with body text appear, and never a journey passage.

See Swagger for the precise field list, pagination envelope, and status codes.