Feed-batch contract
Per-batch grouping for cardType:'feed' surfaces. Five-on-the-wire / three-on-the-surface kind set, header derivation, DOM contract.
The feed surface groups cardType: 'feed' cards into dated, kind-labelled batches. This page pins:
- The batch envelope on the wire (5-kind open enum) vs the renderer's known set (3 kinds today).
- The section-header derivation rule —
[icon] [relativeDate · kindName]from(kind, pushedAt). - The DOM contract every consumer must produce so cross-surface tests and accessibility selectors stay stable.
Sibling pages: container contract (per-card size/theme), visual container contract (per-card frosted frame), render-mode contract (where each batch lives).
What this contract covers — and does not
kind (morning_brief, diary, …) is Today-product content taxonomy, not TCK-platform vocabulary. This page pins the contract the feed surface exposes: the batch envelope, the renderer's tolerance for unknown kinds, and the DOM shape. It does not pin the product decision of which kinds exist. Today three kinds render (§Presentation mapping); two are wire-superset members the renderer tolerates without rendering.
The wire envelope
today-cloud's GET /v2/today-pages returns batches. Per-batch shape:
| Field | Type | Notes |
|---|---|---|
id | string | {YYYYMMDD}-{kebab-kind} (e.g. 20260511-morning-brief). Stable React key + scroll dedupe key. |
kind | string enum | snake_case in the body — morning_brief, evening_brief, diary, welcome, feature_intro. |
pushedAt | string (ISO 8601 UTC) | Commit instant. |
title | string | null | MVP always null; the renderer derives the label. |
widgets | array | Server-ordered; each carries an inline manifest + an opaque bundle ref { hash, bytes }. |
batch.id's kind segment is kebab-case (morning-brief); batch.kind in the body is snake_case (morning_brief). The renderer keys its presentation mapping off batch.kind directly; it does not parse batch.id.
Five on the wire, three on the surface
today-cloud's widgets table constrains kind to five values via a CHECK constraint:
welcome | feature_intro | morning_brief | evening_brief | diaryThe V2 feed surface commits to rendering three: morning_brief, evening_brief, diary. The other two are real wire values the feed list response can contain, but the section-header contract does not yet define a presentation for them — they degrade to the fallback header (see below).
Treat kind as an open enum: the renderer maps the three known kinds, and has defined graceful behaviour for any unknown value — today's welcome / feature_intro, and any kind a future cloud migration adds before this page is updated. Closing the enum (TypeScript union of exactly three) would make a fourth wire kind a type error at the boundary — meaning a cloud-side additive change hard-breaks an un-updated web build. Open enum + fallback keeps cloud and web independently deployable.
Anchorable kinds (MVP)
Only morning-brief and evening-brief are anchorable in MVP — they have share URLs (https://today.ai/feed/{batchId}) and a single-batch read endpoint. The other three (welcome, feature_intro, diary) appear inside feed-list responses but have no permalink; they're only reachable through full-feed scroll.
Two regex axes encode this:
BATCH_ID_RESPONSE_REGEX— accepts all 5 kinds; list / single-widget responses use it.BATCH_ID_PATH_REGEX— accepts onlymorning-brief/evening-brief; theGET /v2/today-pages/{batchId}path param uses it. Other kinds return400 today_page.invalid_batch_id.
DOM contract
Every feed surface MUST produce the same per-batch element tree so the same E2E selectors / accessibility queries work across consumers:
<section aria-label="Today · Morning Brief" data-batch-id="20260511-morning-brief">
<div data-batch-header>
<span data-batch-icon>
<svg class="lucide lucide-sunrise text-amber-500" aria-hidden="true" />
</span>
<span data-batch-label>Today · Morning Brief</span>
</div>
<ul data-batch-cards>
<li data-card-id="…">
<article class="…rounded-3xl backdrop-blur-sm…">…</article>
</li>
...
</ul>
</section>- Each batch is its own
<section>, accessibly named by the same label string the header shows. - The card list uses
<ul>/<li>list semantics (NOT a plain<div>wrapper). - The header is unframed — plain block; the frosted frame is on the per-card
<article>only. data-batch-idcarries the stable batch id (used by scroll-to-anchor and dedupe logic).
Presentation mapping
kind | Icon | Label suffix | Renders? |
|---|---|---|---|
morning_brief | lucide-sunrise | "Morning Brief" | yes |
evening_brief | lucide-moon-star | "Evening Brief" | yes |
diary | lucide-book-open | "Diary" | yes |
welcome | (fallback lucide-newspaper) | (the wire kind value) | tolerated |
feature_intro | (fallback lucide-sparkles) | (the wire kind value) | tolerated |
| any other | lucide-newspaper | (the wire kind value) | tolerated |
The full label is {relativeDate} · {kindName}, e.g. Today · Morning Brief. The · is U+00B7 with surrounding spaces.
Relative-date rule
The date half of the label is computed in the viewer's locale and time zone:
| Calendar day vs viewer "today" | Rendered as |
|---|---|
| Same day | Today |
| Day before | Yesterday |
| 2–6 days earlier | Weekday name (Monday) |
| Earlier or later than that | Intl.DateTimeFormat(viewer.locale, { month: 'short', day: 'numeric' }) (e.g. May 11) |
The day comparison MUST use the viewer's IANA time zone (Intl.DateTimeFormat().resolvedOptions().timeZone) — pushedAt is UTC, but Today means today in the viewer's calendar, not UTC's. Two cards committed on the same UTC date can land in different calendar days for viewers in different time zones; render each accordingly.
Card ordering inside a batch
Server-ordered. The renderer iterates batch.widgets in the order delivered; it does NOT re-sort. today-cloud's scanner derives the order from the NNN_ prefix on the agent's widget-batch/widgets/<NNN>_<slug>/ directories — the agent's authoring order survives unchanged through the wire.
Failed-widget placeholder
A widget whose host.mountWidget() throws keeps its slot in the batch — render a <FeedCardPlaceholder> for it instead of removing the row. Removing the row would re-flow every subsequent widget into the wrong batch / wrong position. Each placeholder carries an onRetry handler the user can trigger to re-issue host.mountWidget() with the original (manifestId, position).
Render-mode contract
The four transports a TCK widget can reach a viewport through — Unbundled, Bundled, Embed, Iframe — and the theme propagation channel each uses.
Theme-injection contract
Theme is externally injected by the consumer. SDK code does not consult prefers-color-scheme. The one sanctioned read lives at mountHost's auto mode.