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 everyrelated_quotesrail 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, butinterpretation.subtitleis 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.
Today's featured: GET /api/passages/todays-featured/¶
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..20gets 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:
interpretationis a structured object with three fields.headingis the passage title.subtitleis 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.bodyis the "What This Means" text as Markdown. The empty subtitle is""rather thannullbecause app builds already installed read it as a required string; it becomesnullonce those builds are retired.bodycan be empty, because interpretations are authored later with AI help, so a live passage can ship without one.reflection_promptis the editorial reflect question. It can be empty.related_quotesholds up to 4 related quotes in the editor's stored order. Each is a lean card ofid,slug, andquoteonly. The rail renders just the quote, and a tap fetches that passage's full detail by id, so the card omitssource,audio, andtheme_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_topicreturns Lewis's own Writings that share the passage's source book or theme.related_contentreturns contributor Articles matched by shared tags, with a theme fallback when the passage has no tags. Each card'ssupporting_lineis 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 curatedrelated_quotesrail 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 carrykind: "passage", because it follows the shared content-detail contract, wherekindnames the piece type across content kinds. - A card trims nothing the reader can act on. Both
audioandsourceare 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. sourceis polymorphic, so it does carry akind. A passage's origin is a Book (kind: "book", whosecoveris an image rendition set) or a Writing (kindisletter,essay, orchapter). It isnullwhen the passage cites neither.- A Book
sourceis the whole book, everywhere.id,slug,title,publication_year,synopsis,cover,buy_cta— the seven fields Featured Books serves — pluskind: "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 ownsource. 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 ofpublication_year,coverandbuy_ctacan benull, andsynopsiscan be empty, when an editor has not filled them in; hide that part rather than substituting anything. (Until 2026-09, a card'ssourcewas a leaner four-field version of this.) - A Writing
sourceis 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, sokindis 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.