Rich Feed
Rich Feed 2c — Link Previews
Titled link-preview cards — opt-in, one per reply, fetched and sanitised server-side.
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.
isPublicHttpUrl SSRF predicate + parseOpenGraph — no I/O, exhaustively unit-tested against IP-literal/private-range/userinfo/port abuse.handleUnfurl — guarded fetch (manual redirects re-validated, 4s timeout, HTML-only, 512KB cap) → OG parse → KV cache → always-200 JSON.parseLinkBlock — pure, mirrors parseChips; composes after chips are peeled off.unfurl() client — never throws, resolves to {url, error} on any failure.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
docs/superpowers/specs/2026-06-13-rich-feed-2c-link-previews-design.mdplan ·
docs/superpowers/plans/2026-06-13-rich-feed-2c-link-previews.md