Per-deep-research Open Graph preview cards
Status: Shipped Sep 2026 (staging) Companion doc: PER-RADAR-OG-CARDS.md — same architecture applied to the /radar surfaceWhat this changes
Before this change, every deep-research (DR) completion URL Silvia sent over Linq / SMS unfurled with the site-wide generic Silvia OG card — same image, same title (“Research — In-depth research reports…”), same description for every completion. iMessage bubbles for DR completions felt beige and utility-grade; the user couldn’t distinguish “your Nvidia stress test is done” from any other Silvia link until they tapped through. After this change, every DR completion URL (bubble 2 inapp/api/sms/research-complete/route.ts — the bare
https://www.cfosilvia.com/research?session=<id>&utm_source=sms URL)
renders with a DR-specific preview card:
- Title = the user’s DR question, sanitized + truncated to ≤80 chars (e.g. “Stress test my portfolio against a 2008-style crash.”)
- Description = a data-dense meta line — duration + source count (e.g. “27 min · 199 sources analyzed — tap for the full analysis.”)
- Image = a 1200×630 PNG rendered on-demand at
/api/og/research/[sessionId]showing the Silvia mark, aDEEP RESEARCHpill, the sanitized question, the meta line, the completion date, and aView full report →CTA.
Architecture
Files
Failure modes (fail-closed at every layer)
Every non-happy branch redirects to the site-wide generic Silvia OG card (/opengraph-image) — same visual experience as before
this change:
Correctness contract: the URL bubble is never worse than
today. Broken data is a preview downgrade, never a broken image,
and always a single card (never a stacked double-render).
Why the route redirects instead of 404-ing on failure
The radar OG’s first ship (Sep 12) 404’d on every failure branch and listed/opengraph-image as openGraph.images[1] for defence
against that 404. Real-world iMessage testing showed iMessage’s
Link Presentation framework STACKS every og:image entry into a
separate preview card, so the fallback rendered as a SECOND card
every time — a double-card unfurl instead of a fallback. That fix
was landed on the radar OG and this DR OG ships with the corrected
posture from day one: openGraph.images carries exactly one entry,
the route redirects 302 to /opengraph-image on every failure
branch. Result: iMessage renders exactly one card, always.
Security / safe-subset policy
The OG endpoint is unauth-callable (iMessage’s unfurler is anonymous), so anyone who knows / guesses asession_id can
attempt to mint a Silvia-branded card for that DR. DR content is
materially more sensitive than radar content — DR questions
can contain SSN benefits, dates of birth, dollar-figure planning
inputs, spouse names, and other financial PII (verified against
the prod corpus, Sept 2026). This surface is therefore stricter
than its radar sibling in two dimensions:
-
Field-level whitelist. Only
session_id,question,duration_seconds,total_tool_calls, andcompleted_atare ever fetched (seelib/research/og-fetch.ts). NOs3_key(path to the full PDF), NOclerk_id, NO error messages, NO token counts, NO ECS internals, NOlegacy_response. -
Strict delivery gate. The row must satisfy
status = 'completed'AND one of:- Primary path —
dr_sms_notifications.sent_at IS NOT NULLfor the samesession_id. Proven delivery of the DR completion SMS to a user-visible channel (bubble 1 in/api/sms/research-complete, stamped just before bubble 2’s URL fires — see that route’s block comment for the exact ordering). - Grace path —
completed_atwithin the last 2 hours. Covers the tiny happy-path race betweenstatus = 'completed'being written by the DR backend and our completion route stampingsent_at. In practice sub-second, but the 2-hour bound matches radar-OG for consistency (seelib/radar/ og-fetch.ts— theOG_DELIVERY_GATE_GRACE_MSconstant is re-exported from that module so both surfaces stay in lockstep).
- Primary path —
-
Shape validation.
session_idmust match/^[A-Za-z0-9_-]{1,64}$/before touching the DB. Same shape used bySESSION_ID_FORMATinapp/api/sms/research-complete/ route.ts(line 146) andapp/api/email/scenario-complete/ route.ts(line 81). Rejects path traversal, HTML injection, SQL-ish payloads, and DoS-via-long-string. -
Sanitizer share-through. The
questionis run throughsanitizeRadarSummaryForEmailbefore rendering into the image or the OG tag, so the same class of LLM-adjacent leaks the sanitizer catches on radar summaries (markdown, code fences, leaked XML tool-invocation blocks) is caught here too. - Aggressive truncation. Title cap at 80 chars, description cap at 200 chars. For a long-form DR question with multi-paragraph planning inputs (verified against a prod row containing SSN dollar figures + DOBs), the truncation strips the sensitive tail — the title only ever carries the first 79 chars of the question, which is typically the high-level framing (“Give me a road map on how to…”) rather than the PII-bearing input parameters.
-
Fail-closed redirects. Every failure branch returns a
same-origin 302 redirect to
/opengraph-imagewithCache-Control: no-store, so a transient DB blip cannot poison the CDN with a bad response for a legitimate DR. Theno-storeon the redirect means the CDN forwards each request through to the origin (which will resolve as soon as the underlying issue clears) and never pins a fallback-for-a-live-DR.
What DOES NOT get an OG card (deliberate)
- Webapp-triggered DRs (no SMS handoff row) that completed more than 2 hours ago. If a user pastes the URL into Slack / email later, they’ll see the generic Silvia card. Rationale: no delivery proof → the card would be enumeration bait for arbitrary historical DRs. If real user demand appears for webapp-share cards, we ship a v2 with a different gate (e.g. the DR owner’s own opt-in click).
- Running / failed / cancelled DRs. A preview card advertising a DR that then 404s (or 500s) on tap-through would be worse UX than the generic card.
Cache
Happy-path responses carry:s-maxage=600— CDN caches the image for 10 minutes.max-age=300— browsers / third-party unfurl caches keep it for 5 min.- No
stale-while-revalidate— a deleted DR’s card is purged from the CDN within 10 min, and revalidation on the 11th minute hits the origin’s 302-redirect branch (which itself carriesCache-Control: no-store, so the deleted-DR-specific image cannot be re-pinned; the unfurl bot follows the redirect to/opengraph-image— the site-wide Silvia hero — instead).
Testing
Unit + route tests total 78+ across three new files (grows as bot findings land regression guards):lib/research/__tests__/og-fetch.test.ts— 35 tests (validator + fetch + delivery gate + grace window + payload shape)app/(app)/research/__tests__/generate-research-metadata.test.ts— 29 tests (builder + duration/tool-call formatting + PII-safe truncation)app/api/og/research/[sessionId]/__tests__/route.test.ts— 14 tests (route + 302 fallback + image mint + card content)
Follow-ups (deliberately deferred)
- Webapp-share card gate. As above, webapp-triggered DRs (no
SMS handoff) fall back to the generic card. A v2 could open the
gate for DRs whose owner has explicitly opted-in to sharing
(e.g. a “Share this report” button that also flips a
share_publicflag on the row). No user demand today; ships when needed. - Card variants. Phase 1 is a single card layout. If we ever want a data-visualisation variant (small sparkline of duration vs. token spend, source-mix pie chart, etc.), that’s a follow-up Satori composition.
- Explicit cache invalidation. If a DR gets edited / retracted
(rare — the question is one-shot), the CDN may serve the old
card for up to the
s-maxagewindow (10 min) before it revalidates against the origin. Acceptable for a preview surface; if this becomes a real complaint, add a?v=<timestamp>query param to the OG URL at build time. - Metrics. OG image requests aren’t attributed to a specific
outbound channel today. If important, add a
?src=smsUTM-style query param at theopenGraph.images[].urlbuild site (lands in Vercel Edge logs, can be aggregated post-hoc).