The technical reference for the editorial pipeline that produces the Daily Travel Brief: 12 mainstream desks plus 2 modal niche pillars (Israel, Kosher), the JSON shapes between every stage, the citation invariant, validation, and resilience. For engineers and journalists. Looking for the plain-English version? How It’s Made →
ok, quiet, or broken.briefs table. Protect-hidden guard. publishedAt is immutable after first publish.Each desk has its own editorial angle, signals, cadence, visibility, and source set — written verbatim into the stage 02 and 03 prompts. Expand any card below to see the publications that desk reads.
Independent ultra-luxury — Aman, Belmond, Rosewood, Soneva, Singita, Oetker, private villas, top-tier cruises, ultra-premium experiences (chain-affiliated luxury sub-brands like Ritz-Carlton/St. Regis live in hotels)
luxury-leisureMass and premium ocean cruise — new ships, itinerary changes, supplier promos, port news
cruise-oceanEuropean and US river cruising — itinerary disruptions, Christmas Markets season, new vessel launches, Mekong/Nile/Douro expansion
cruise-riverUltra-luxury and expedition cruising — Regent, Silversea, Seabourn, Explora, Crystal, Ponant, Lindblad, polar and remote-destination programs
cruise-luxury-expeditionSmall-group and activity-driven adventure — Intrepid, G Adventures, Backroads, eco-lodges, expedition cruises, soft adventure, sustainability
adventureAfrican camps & lodges, conservation news, seasonality, charter logistics
safariFamily and multi-generational travel — Disney parks and cruises, Royal Caribbean family ships, all-inclusive family resorts, Adventures by Disney, soft-adventure family operators
familyDestination weddings and honeymoons — Sandals, Excellence, Hyatt Inclusive Collection, Hard Rock, Karisma, Palace, Caribbean and Mexico AI resorts, safari and Maldives honeymoons, wedding-planning logistics
romanceAirline alliance changes, business hotel news, T&E policy, route impacts
corporateGlobal hotel chain news — brand openings/closures/conversions, loyalty programs (Bonvoy, Hilton Honors, World of Hyatt, IHG One Rewards, ALL-Accor), soft-brand growth, M&A, NCF and commission policy
hotelsEscorted, packaged, and trade-only tour operators — TTC (Trafalgar/Insight/Luxury Gold/Costsaver), Globus family, Tauck, Collette, AAA/CAA Member Choice, USTOA news, and B2B leisure-package wholesalers (ALG Vacations, Pleasant Holidays, Delta Vacations, Classic, GOGO)
toursWellness, longevity, and spa travel — destination spas, medical/longevity programs, retreat operators, wellness-focused resorts and cruises
wellnessIsrael as a destination — Tel Aviv/Jerusalem hotels, El Al & inbound carrier routes, Ben Gurion ops, Israeli hotel chains (Dan, Isrotel, Fattal), tourism ministry, US/UK travel advisories, security context as it affects bookings
israelKosher-observant clients booking anywhere — Pesach programs worldwide, kosher hotels & restaurants outside Israel too, hechsher certifications, kosher cruise charters, kosher airline meals, frum community travel
koshervisibility: 'modal' flag — niche pillars surfaced opt-in by the subscription dispatcher, not rendered to every reader. Health is cadence-aware: a thin day for a weekly desk (Adventure, Safari, Wellness, Kosher) is quiet, not broken.Each desk has its own set of vetted RSS feeds stored in the brief_segment_feeds table. The feed list is editorial metadata — adding or removing a feed is a DB change, not a code deploy.
The loader (brief-feeds/loader.ts) fetches each feed, de-duplicates within a 48-hour window, follows the link to the publisher’s article and extracts the body text (so the ranker reads the full story, not just the RSS blurb), and tags every item with a health signal:
The health label is passed forward into stages 02 and 03 so the AI can tone the edition honestly when sourcing was thin.
{
"status": "ok", // ok | quiet | broken
"feedsTotal": 9,
"feedsOk": 9,
"freshItemCount": 62,
"cadence": "daily",
"thresholds": {
"minFreshItems": 3
}
}claude -p call per desk.The fetched items + the desk’s editorial angle + the desk’s segment-specific signals are packed into a prompt and sent to claude -p (the Claude Code CLI — subscription-billed, no per-token API fees).
The AI’s job is notto write anything yet. It ranks items by newsworthiness through the lens of the desk’s angle and emits a structured RankedResearchResult— 3 to 20 items per desk, each carrying a title, a suggested kind (news/supplier/destination/data-point/opinion), a one-sentence why-it-matters, and indexes into the sources list. Items the composer doesn’t promote to a full magazine section are persisted to briefs.rankedItems (with composedItemIndexesmarking which were consumed) and surface in the “More from the wires” rail on the reader.
{
"title": "Viking's Talactac Era Begins",
"suggested_kind": "supplier",
"why_it_matters": "$4B cash, 58-ship
orderbook, no pivot — sets Viking's
next-decade trajectory.",
"source_indexes": [3, 14, 27],
"rationale": "CEO transitions at the largest
river+ocean cash position in industry are
the single biggest forward-supply signal."
}A second claude -p call takes the ranked items + sources and writes them as a finished magazine edition: a thematic hero combining 2–3 top items into one framing (not a crown for any single story), an ordered list of ~150-word sections with citation indexes, and a ~2-sentence closing note signed by the desk. The system prompt frames Claude as “the editor of the {Department} daily brief” and explicitly forbids hype, inventing facts, padding length, or citing a source not provided. Quality over quantity is encouraged — fewer sections on a quiet day, combining related items rather than repeating.
The user message opens with a ## TODAYblock carrying the canonical edition date (YYYY-MM-DD). The system prompt instructs Claude to resolve relative-time wording in source headlines (“today”, “tomorrow”, “expires soon”, “Final Day”, “this weekend”) against TODAY — never against the source article’s pubDate, which is often 1–3 days older.
{
"hero": {
"headline": "Viking Gets a New Captain,
Spectrum Drops Japan, and Carnival
Dream Drops Anchor",
"dek": "Leah Talactac takes the helm at a
cash-rich Viking; Royal Caribbean cancels
Spectrum's Japan 2027 sailings; ..."
},
"sections": [
{
"kind": "supplier",
"headline": "Viking's Talactac Era Begins",
"body": "...150 words of markdown...",
"citations": [3, 14, 27]
},
// 6–8 more sections
],
"closingNote": "Today's edition is defined
by one irony: ... — The Desk"
}The most error-prone field in stage 03 is citations — a list of 0-based indexes into the sources array. The pipeline enforces a citation invariant: every section must cite at least one source, every index must be in range, and (after a deduplication pass) every index must point at a real source article.
The composed BriefContent is written to the briefs table — one row per (segmentSlug, editionDate). Re-running the pipeline for the same date overwrites the content but preserves identity.
Three guardrails:
status: 'hidden' (an editor has retracted it), the upsert skips. An empty RETURNINGclause signals “declined.”draft, published, hidden, failed — drives what the renderer shows. Only published rows reach the public site.briefs {
id uuid primary
segmentSlug text // 'cruise-ocean' | 'cruise-river' | ...
editionDate date // 'YYYY-MM-DD'
status enum // draft|published|hidden|failed
title text
summary text
heroImageUrl text?
content jsonb? // BriefContent (null on failed)
sources jsonb // ResearchSourceRecord[]
rankedItems jsonb? // full ranker output
composedItemIndexes jsonb? // composer consumed
modelUsage jsonb // elapsed ms per stage
generatedAt timestamp
publishedAt timestamp? // preserved across re-runs
unique(segmentSlug, editionDate)
}The brief reader lives at the root of the Next.js 16 App Router tree. There’s no dashboard shell, no sidebar — the magazine supplies its own chrome via the ReaderRoot wrapper (the same one this page uses).
The route tree on the right is the full public surface — cover, department view, archive, machine feeds. At the bottom of every department view a More from the wiresrail surfaces ranker items the composer didn’t write a full section for, rendered as title + kind chip + why-it-matters + linked sources.
No login required. Reader queries filter status='published', so failed and hidden rows never surface. Pages render statically with ISR; a /api/revalidate webhook fires on publish so fresh issues appear within seconds, not minutes.
src/app/ ├─ layout.tsx // fonts + metadata ├─ page.tsx // / → latest issue ├─ [date]/ │ ├─ page.tsx // /YYYY-MM-DD │ │ // = IssueContents │ │ // (tile cover) │ └─ [segment]/page.tsx // /YYYY-MM-DD/luxury-leisure │ // = DepartmentView ├─ archive/page.tsx // /archive ├─ about/page.tsx // /about ├─ pipeline/ │ ├─ page.tsx // /pipeline (advisor view) │ └─ technical/page.tsx // /pipeline/technical (you are here) ├─ rss.xml/route.ts // /rss.xml ├─ news-sitemap.xml/... // /news-sitemap.xml ├─ subscribe/page.tsx // /subscribe └─ api/ └─ revalidate/ // publish webhook
| Layer | What it handles |
|---|---|
| Schema validation | The AI’s response is parsed as JSON, then validated against BriefContentSchema (Zod). Hand-rolled JSON schema floors set length minimums(so Claude doesn’t truncate); Zod ceilings catch pathological output. Mismatches trigger a corrective retry with the Zod error pasted back into the prompt. |
| Transient CLI errors | The runClaudeCli wrapper retries once on transient errors (429, ECONNRESET, overload). Worst-case spawn ceiling is 4 (each of the 2 validation attempts internally transient-retries once) — vanishingly rare in practice. |
| Citation repair | repairCitations() filters out-of-range indexes, deduplicates, drops empty-citation sections. If zero sections survive but research items existed, the compose stage throws ComposeError — never publishes broken citations. |
| Article-body cache | The article_bodies table caches the Readability-extracted publisher article for 7 days (successes) or 1 hour (failures). Same URL surfaced two days in a row hits the cache, not the publisher. Caching failures keeps us from re-hitting paywalled URLs on every cron run. |
| Quiet-day short-circuit | If a desk’s research produces zero items (a genuine quiet day), compose skips the LLM call entirely. A hand-built BriefContent with an honest “quiet day on the wires” hero is returned. Zero cost. Deterministic. |
| Broken-day tone shift | If sourcing health is broken but items still exist, an extra sentence is appended to the compose prompt: “be honest about a thin or degraded day.” Same edition shape, more transparent voice. |
One call to rank, one call to write. Bounded cost (the daily budget is predictable), bounded variance (two LLM judgments per desk per day), and easy to inspect (artifact files between every stage).
All AI calls go through claude -p (Claude Code CLI, subscription-billed). No per-token API fees during iteration; the cost of prompt experimentation is zero.
Fourteen desks, no anointed “main” desk. The issue cover is a tile grid, not a hero spread — every desk gets equal visual weight. The compose stage’s hero is a thematic wrap-up of that desk’sday, not a story crowned as more important than another desk’s.
The AI is allowed to summarize, synthesize, and frame. It is not allowed to invent. The citation invariant + per-section source attribution gives every claim a traceable lineage back to a numbered article.
brief/ (the pipeline core) has zero DB imports. briefs/(the DB boundary) is the only place rows are read or written. Pipeline iterations don’t risk persistence; persistence changes don’t risk pipeline behavior.
Slow news isn’t a failure mode — it’s a normal state. Cadence-aware health labels, deterministic quiet-day content, and a “be honest” tone instruction mean the brief never lies about a thin day.