Paste a link

shippedThe unfurl is LIVE (v1032, 2026-08-30). Composer chip → frozen card in the bubble → T1 skeleton digest injected into the panel context at send — pipeline in lib/unfurl.py, plumbing in run_room.py (sidecars under rooms/<rid>/links/, one digest per lid per room, transcript mirror). The T2 deep-read (<read> below) is the recorded follow-up, not yet built — the sidecar already freezes every section for it. This page's demo still runs the whole pipeline standalone.

Live mock, real pipeline. Paste a real URL into the composer below. Before send — the server fetches the page and a preview chip appears above the input (✕ to dismiss). After send — the message carries the frozen card, and the right panel shows the model context as an append-only timeline: the T1 skeleton digest goes in at send; click an outline pill to play a persona ordering a full section via the in-band <read> tag (T2, appended next turn).

Backend not reachable — start it with python docs\unfurl_server.py then open http://127.0.0.1:8013/.

the room — composer + bubble

room · mock
your messages appear here
try:

what the persona sees — real extraction

no link sent yet
After you hit send, the model context grows here as an append-only timeline — [L1 · T0+T1 digest] injected at send: title/source header, outline, lede, first paragraph per section, conclusion (≤ 6,000 chars) [L1 · T2 「section」] appended ONLY when a persona orders it by emitting <read link="L1" section="…"> Click an outline pill inside a digest block to play the persona and order a section.
architectureThe server fetches, the message freezes. Browsers can't read foreign pages (CORS), so the box does the fetch — Telegram's model. But the result is stored into the message at send time and never re-fetched — WhatsApp's persistence. That combination is what keeps the §8 append-only prompt cache valid.
tiersDepth follows engagement, not position. T0 header (~150 chars) + T1 skeleton digest (outline · lede · first paragraph per section · conclusion, ≤ 6,000 chars) injected at send — uniform shallow coverage of the whole article instead of a hard head-cut. T2 full sections appended only when ordered, so links nobody engages with stay cheap. Pages without heading structure fall back to the 12,000-char paragraph-boundary cap. At §9 compaction a link collapses back to T0 + a one-line gist.
read tag · deferredHow a persona orders T2 — the follow-up, not yet live. It emits <read link="L3" section="…"> in its output — the same in-band trick as <speak>/<confer>; our code validates the id + heading against the frozen extract and appends the section next turn (no second model call; a one-beat delay is natural in a chat room). The T1 outline is the menu it can order from. Guardrails: max 2 fulfilments per turn, dedup, ids anchored — never re-matched from names.
guardhttp/https only · web ports only (80/443) · private IPs refused on every redirect hop · DNS resolved ONCE and the socket PINNED to that IP (Host + SNI carry the real name) · Content-Type checked before parsing · 10 s timeout · 2.5 MB read cap. The box fetches user-supplied URLs, so the SSRF guard is part of the pipeline from day one — and the pin closes the TTL-0 rebinding race (answer public for the check, 169.254.169.254 for the connect) that a resolve-then-refetch guard leaves open.
learned from the fieldThe 2026-08-30 hardening sweep (OWASP SSRF cheat sheet · Slack/Discord unfurl docs · GitLab #293733 · trafilatura's own fallback cascade). Adopted: the rebinding pin and port allowlist above · the card image is proxied — the box fetches it once through the guard, re-encodes to a webp thumb in the sidecar, and readers hit /api/rooms/…/linkimg/Ln, never the origin (a hotlinked og:image leaks every reader's IP/UA — the reason Slack ships Slack-ImgProxy) · utm_*/click-id params stripped before fetch · a generic failed-extract detector (250-char floor, halved for CJK; <15% of the page's visible text = boilerplate) driving a ladder: recall retry → the JS-hidden-body recovery → wall verdict (paywall/bot-wall fingerprints → the digest honestly says「behind a wall」and carries only the card's own words, never the wall's boilerplate). Declined, knowingly: oEmbed (rich embeds we don't render — og + title is our ladder) · headless rendering (production unfurlers don't; cost/latency without proof of need) · robots.txt (Slack's stated position: an unfurl acts for a human, it isn't crawling) · AMP-alternate retries (a declining asset). One place our evidence beat the field's recipe: ftr-site-config's WeChat rule (un-hide the styles, re-extract) does NOT transplant to trafilatura — measured; the leaf-block subtree recovery is ours and it works.
dismiss✕ means nothing is attached. No card for humans, no context for personas — the URL goes out as plain linkified text. Same URL won't re-unfurl until it changes.
link unfurl demo · backend: docs/unfurl_server.py (port 8013, a thin harness) · production: lib/unfurl.py + lib/run_room.py (shipped v1032)