A transfer reads like something handed to you: framed, addressed, stamped with a state, closed. A stream reads like a paragraph. The grammar that makes the difference is already specced and already implemented — 2 of ~29 cards were migrated onto it, and the rest were left hand-rolled. This is the visual reference for finishing that migration and extending it past materials to every object type.
Left column in each pair reproduces the string the live MCP returned on 2026-08-12 — structure, spacing and glyphs verbatim. Right column is grammar v2. Plan of record: relay-board docs/product/mcp-object-envelope-display.md.
Sample data is synthetic. Names, titles, Codes and ids are stand-ins at the real strings’ shape and length — this repo publishes, and every branch gets a preview URL. The unredacted capture lives in the board doc, behind Access.
A card that never closes is a card the model talks over. Grouped sections render inside the frame, and the closing rule is followed by a signal line — the cue that the card ended and prose may begin.
⇄ then the address, on its own line, never inline. Transfer → Code. Stream and session → short_id. Series → series id. Without an anchor an object is not in an envelope; it is a paragraph with a heading.
Who holds it, who touched it last, when. Every field is already in the payload. The pitch is a verifiable trail; the card is the cheapest place it has ever been shown.
Same anchor, same state, same custody, same cap footer, same signal — unstyled. Both branches ship in the same commit, enforced by a paired test. A rich-only card is an unfinished card.
Capped sections carry the ratio — ACTIVE (3 of 17) — and the overflow footer names the app. Neither silent truncation nor recitation is the model’s call to make.
▸ means top pick and nothing else. Item types get their own documented table. Nine competing glyphs is what the grammar was written to end; a second undocumented set restarts it.
Six parts, in fixed order. An object card that drops one of them drops it in every host, on every model — there is no client-side renderer to compensate, only the string the model chooses to echo.
Northwind — Dave & Neil debrief 7C4E19 Contacts: Dana Reyes (Ops, pilot champion), Priya Shah (onboarded this call)…━━━━━ MARKERS (3) ━━━━━ 🔔 Northwind 07/24 — follow-ups pending approval ⚙ Maintenance suggested — 3 items stale 14d+ 1--- 6stream loaded — 1 of 4 items shown · app.relayctx.com/stream/k7m2df
The pair that makes it an object and not a paragraph. Sections live inside it. Today the stream card’s pair wraps the header only, and everything below trails outside.
One glyph per object type from _display.py — ⚡ transfer, 🌀 stream, 📂 session, 🗃 series. Single transfer cards lead with the claim-state icon instead.
The anchor never shares a line with the title, so it can never be buried. State uses one vocabulary; meta carries short fragments only.
Holder, creation, last touch, and whether the last touch was a human or an agent. New in v2 and net-neutral on line count — it absorbs meta scattered elsewhere on the card.
Section rules with the (n of m) ratio when capped. Caps are per the grammar: 3–5 active items, 5 markers, 8–15 list rows.
Tells the model the card is complete and content follows. Its absence is the mechanical reason a stream read comes back as a screenful of agent-written bullets.
Amber gutter is live output, verbatim. Teal gutter is v2. Every difference below is grammar already written down in SPEC.relay-display-grammar.md — none of it is new design.
The reference envelope is the most broken card in the surface. No frame, no sigil, no claim-state icon, no anchor line — a **Code:** label instead — and no signal. The one --- it emits separates the header from the body, the exact inverse of its specced role as a frame. The full 6,146-character material body is inlined behind it.
The nicest surface Relay has, and still not an envelope. The --- pair frames the two header lines only, so the card never ends. No anchor at all — the stream carries short_id: k7m2df, already what /stream/ links and relay_search(id=…) accept, and the card links the raw pc_ id instead. No custody. No signal.
Northwind — Dave & Neil debrief (2026-07-24) 7C4E19 Contacts: Dana Reyes (Ops, pilot champion — runs Relay on b━━━━━ MARKERS (3) ━━━━━ 🔔 Northwind 07/24 — follow-ups pending approval ▸ ⚙ Maintenance suggested — Pilot Feedback & Support 📝 Log format — one entry per EAP session no anchor · no custody · no close · no signal · ▸ overloaded
Northwind — Dave & Neil debrief 7C4E19 · very stale Contacts: Dana Reyes (Ops, pilot champion), Priya Shah…━━━━━ MARKERS (3) ━━━━━ 🔔 Northwind 07/24 — follow-ups pending approval ⚙ Maintenance suggested — 3 items stale 14d+ 📝 Log format — one entry per EAP session --- stream loaded — 1 of 4 items shown · app.relayctx.com/stream/k7m2df
Session rows carry someone else’s Code — from H2NQVBRK4P, the origin handoff — as a bare untyped token, while the session’s own short_id never appears. So the one identifier on the row addresses a different object than the row.
Relay rows carry a Code; stream rows carry no identifier of any kind, so a stream can be named in a card and then not be addressable from it. The closing frame is also missing here and present on the relay and session lists — three list renderers, three answers.
Plain is not an abbreviation and not a downgrade path — it is the fallback for terminals and for every host whose model flattens the rich card, which per the client matrix includes claude.ai on long-context Sonnet. It carries the anchor, the state, the custody, the ratio, the overflow and the signal. Rules and emoji are the only things it loses.
Northwind — Dave & Neil debrief 7C4E19 · very stale━━━━━ MARKERS (3) ━━━━━ 🔔 Northwind 07/24 — follow-ups pending approval --- stream loaded — 1 of 4 items shown
The grammar does not change per object; only what fills it does. An object with no resolvable anchor does not get a card — it gets a list row.
| Object | Sigil | Anchor | State | Meta | Signal |
|---|---|---|---|---|---|
| Transfer | ⚡ / ✅👤⏳ | Code | open · received · expired | age · freshness · series | content loaded — see below |
| Stream | 🌀 | short_id | active · archived | weight meter · live count | stream loaded — n of m shown |
| Session | 📂 | short_id | open · closed | date · depth · workspace | session loaded — card above |
| Series | 🗃 | series id | active · expiring · expired | parts · next expiry | series loaded — n parts |
| Resource | 📄 | name | — | type · size | resource loaded — see below |
| Request | 📥 | request id | pending · approved · denied | requester · age | awaiting your decision |
Each rung degrades to the one below with no loss of the anchor, the state, or the custody line. That invariant is the whole spec. Attachment is gated per harness through the shipped relay.mcp.display_config lever — one mechanism, not a second one for each new rung.
ui://Sandboxed iframe panel with postMessage back to the server. The stream panel is the highest-value one Relay could ship — weight meter, items, gates and timeline live in the conversation — and it is the one that later becomes editable. Host-gated to Claude web/Desktop; sequences alongside the MCP 2.0 migration, never ahead of it.
resource_linkrelay://material/{code} shipped Jul 30 and works. relay://stream/{short_id} and relay://session/{short_id} do not exist, so “open in detail” covers exactly one object type. Same read-through pattern, same auth, no new storage, no residency surface.
structuredContentTyped models exist for materials only. Streams, sessions and series hand the host prose to re-parse — and relay_sessions hands it a double-encoded string, the degenerate {result: string} wrapper relay_search already suppressed. Cheapest win in the plan; helps hosts that never render a card at all.
The grammar on this page. Rendering is instruction-driven — there is no client-side renderer, so a card renders richly only because the model echoed it. Everything above this rung is enhancement; this rung is the product.
The floor. Terminals, RELAY_PLAIN harnesses, and any model that flattens the rich tier. Ships in the same commit as its rich branch, enforced by a paired test.
Not hypothetical failure modes — each of these is a string the production MCP returned during the audit that produced this page.
The single worst one, because it is silent: the card looks complete. A user who wants to hand the stream to another agent has to leave for the app to find its address — and the address existed the whole time. An object with no anchor is a paragraph with a heading.
Worse than no frame, because it teaches the model that the card ended two lines in and everything after is fair game to re-flow. This is the documented root cause of both June 2026 card bugs, still live on the stream card.
A bold label, a bare backtick run inline before the title, a suffix inside the title, and the specced ⇄ line — all shipping simultaneously. A user learns to look in a different place on every surface, which is the same as not learning at all.
▸ means top pick on list cards and something else on the stream card. item(s) is a serialiser artifact reaching users. And a header that prints the total above a truncated list is the dashboard’s scope problem, re-created in the MCP.
Confirm short_id is the user-facing address for streams and sessions and that it is stable. The anchor line hard-codes the answer, and every deep link on every card follows it. A fifteen-minute decision blocking a five-day wave.
All ~29 display call sites scored against the grammar, rich and plain. Including the claim card — this entire page assumes it is the good one, and that assumption is documented rather than measured.
A rich card with no plain branch fails the build, alongside the existing render-tier CI guard. This lands before the migration wave, because ~29 display-string swaps with no contract is how the June bugs happened.
Custody adds a line; it must absorb meta already scattered across the card rather than growing it. Every byte added to a card competes with the instruction that protects the card — which today runs ~700 characters of preamble per call, longer than the card itself.