MalloryEstate Briefing
M·020Design draft
← All briefings

Rich Feed

Rich Feed 2c — Link Previews

Titled link-preview cards — opt-in, one per reply, fetched and sanitised server-side.

Dated 2026-06-13Programme Rich Feed

Situation

Wave 2a rendered assistant replies as sanitized markdown with tables/images as artifact cards and a trailing chips fence; external links stayed as plain gold anchors, never unfurled. 2c adds one new artifact type to that frame: a titled link-preview card (title/description/site name/favicon-or-image) for an external page Mallory deliberately points to.

The spoken channel stays untouched — toSpeech() already strips bare URLs and fenced blocks, so a card is a silent visual attachment layered onto the existing voice-first rule, not a new speech surface.

Key decisions

Opt-in, one card, last link only

A reply is unfurled only when Mallory ends it with a dedicated trailing ``link` fence holding one bare URL — mirroring the chips` convention; inline anchors in prose stay plain, at most one card per reply.

Access-gated GET /unfurl

Sits below the same verifyAccess gate as the rest of the API, fetches only the public web, and never reaches mallory-runtime — preserving the edge-local invariant.

TTS untouched

toSpeech() already drops all fenced blocks and bare URLs, so the link fence needs zero speech-path change — pinned by a regression test rather than trusted to hold.

KV cache with negative TTL

CONTEXT_KV keyed unfurl:<sha256-of-normalized-url>, 7-day positive / 1-hour negative TTL so a transient failure isn't retried on every render.

Fail open to a plain anchor

Any guard rejection, fetch error, timeout, non-HTML, or empty parse returns 200 JSON with an error field; the web always falls back to the ordinary 2a anchor, never blocking the reply.

Card matches the 2a aesthetic

Bordered, no fill, --hairline border, --gold accent for site name, --panel only in the optional image well — the whole card is one focusable <a>.

Architecture

A pure SSRF-guarded parser feeds an Access-gated Worker route; the web peels a trailing fence and renders a fetch-on-mount card with a hard fallback to a plain link.

src/lib/unfurl.ts
Pure isPublicHttpUrl SSRF predicate + parseOpenGraph — no I/O, exhaustively unit-tested against IP-literal/private-range/userinfo/port abuse.
src/routes/unfurl.ts (GET /unfurl)
handleUnfurl — guarded fetch (manual redirects re-validated, 4s timeout, HTML-only, 512KB cap) → OG parse → KV cache → always-200 JSON.
web/src/lib/replyBlocks.ts
parseLinkBlock — pure, mirrors parseChips; composes after chips are peeled off.
web/src/lib/api.ts
unfurl() client — never throws, resolves to {url, error} on any failure.
LinkPreviewCard.svelte
Fetch-on-mount card; skeleton while pending, plain gold anchor fallback on error/empty title.
src/persona.ts
One SPOKEN DELIVERY bullet permitting a single silent link block per reply.

Risks & guardrails

SSRF via the OG image / redirects

OG image and every redirect Location are re-validated with isPublicHttpUrl (3-hop cap, manual redirect mode) so a public URL can't 302 its way to cloud metadata or an internal host.

Prompt-injection via fetched pages

The unfurl result flows only to the client renderer, never back into a prompt — a compromised page's content can't reach the model on this path.

Numeric/encoded IP evasion

Decimal, octal, and hex-packed IPv4 hosts (e.g. 2130706433, 0x7f000001) are normalized and rejected outright, not just dotted-quad ranges.

Delivery

Worker: SSRF guard + parser + route

Pure isPublicHttpUrl/parseOpenGraph behind unit tests, then handleUnfurl wired below the Access gate in src/index.ts.

Web: parse, client, card, feed wiring

parseLinkBlock + toSpeech regression test, an unfurl() API client, LinkPreviewCard.svelte, and feed wiring between prose and chips in +page.svelte.

Persona nudge

One bullet in src/persona.ts enabling the rare, single link block; no other Worker logic changes.

Deploy

PR-only; no D1 migration; Alex reviews visuals via mc-brand-check/design-system-check/a11y before merge and deploy.

Out of scope

Inline-anchor unfurling or multiple cards per replyUnfurling internal/obsidian:/tel:/mailto: linksoEmbed/rich embeds (YouTube players, tweets)Image proxying/caching through R2Feeding unfurled text back into model context
spec · docs/superpowers/specs/2026-06-13-rich-feed-2c-link-previews-design.md
plan · docs/superpowers/plans/2026-06-13-rich-feed-2c-link-previews.md
Mallory · Estate briefing