Problem
Links posted in channels render as bare URLs. When someone shares a fig (an HTML mockup URL), teammates see only the raw link — no title, no image, no context. They have to open a browser tab to know whether the link is worth looking at.
From #general:
shouldn't figs show inline tho?
This applies to any shared link, not just figs: PRs, docs, dashboards, articles.
Proposal
Render an inline link preview card under a message that contains a URL, populated by inspecting the target page's Open Graph meta tags.
Fetch + parse
- Server-side fetch of the URL (never from the client — avoids leaking member IPs to arbitrary hosts and lets us cache once per URL).
- Parse
<head> for, in priority order:
og:title → fall back to twitter:title → <title>
og:description → twitter:description → <meta name="description">
og:image → twitter:image → <link rel="icon">
og:site_name, og:type, og:url (canonical)
- Respect
<meta name="robots" content="noindex">? No — OG tags are explicitly for this. But do respect an explicit opt-out if one is agreed.
Card shape
- Favicon + site name, title (1 line), description (2 lines clamped), image thumbnail.
og:type of image or an image content-type → render the image directly instead of a card.
- No OG tags and no
<title> → render nothing, keep the plain link. Never show a broken/empty card.
Safety / limits (these are the parts that bite if skipped)
- Block private address space: reject resolved IPs in
10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, ::1, unique-local — SSRF via a pasted link is the main risk here.
- Cap redirects (~5), response size (~1 MB of HTML), and fetch timeout (~5s).
- Only fetch
http/https.
- Proxy and re-encode preview images rather than hotlinking, so the recipient's client never talks to the origin.
- Cache negative results too, so a dead link isn't refetched on every render.
Scope
- First link in a message only, to start.
- Sender can remove the preview from their own message.
Open questions
- Cache TTL — figs change while you iterate on them, so a long TTL shows a stale thumbnail. Short TTL, or a
?v= cache-buster convention on fig URLs?
- Do figs already emit OG tags? If not, that's a prerequisite on the fig-rendering side and worth splitting into its own issue.
- Preview on the relay (once, shared) vs. per-client? Relay-side is cheaper and consistent, but means the relay makes outbound requests to arbitrary hosts.
Filed from #general at the request of @adamazad.
Prior art: how Slack does it
Worth copying, since Slack has run this at scale for a decade.
Two layers. Slack splits unfurling into classic and app unfurls (docs):
- Classic unfurl — the default. Slack crawls the URL and builds the preview itself from OpenGraph / X (Twitter) Card metadata. This is the layer this issue is about.
- App unfurl — an app registers up to five domains; when a matching link is posted, the app gets a
link_shared event and calls chat.unfurl to attach custom preview content instead of the crawled one. Requires links:read + links:write. Domains must include a TLD, IP addresses can't be matched, and adding/removing domains requires app reinstall.
The app-unfurl layer is the interesting one for us: figs are a first-party surface, so rather than scraping our own OG tags we could register the fig domain and render a real preview (live thumbnail, dimensions, "open in Figura") from data we already hold. Same shape as Slack's own GitHub/Figma integrations. Worth splitting into a follow-up issue once classic unfurl lands.
Fetch behavior (api.slack.com/robots):
- Three distinct user agents, so origins can identify and rate-limit them separately:
Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)
Slack-ImgProxy 0.19 (+https://api.slack.com/robots)
Slackbot 1.0 (+https://api.slack.com/robots)
- Fetches "as little of the page as it can (using HTTP Range headers) to extract meta tags" — a ranged GET, not a full download. Better than the flat 1 MB cap proposed above; adopt this.
- Metadata priority: oEmbed, Twitter Card, and Open Graph. Note oEmbed is in that list and is missing from the proposal above — it's how rich embeds (video players, live content) work, and it's a discovery
<link rel="alternate" type="application/json+oembed"> in the head. Worth supporting in v1 or explicitly deferring.
- A separate image proxy (
Slack-ImgProxy) fetches preview images, confirming the "proxy, don't hotlink" call above.
- Cached globally across the service for around 30 minutes.
- Slack does not honor
robots.txt — their stated reason: they tried, "received too many complaints from our users because a great portion of the Internet is inaccessible to crawlers," and they don't consider themselves a crawler since fetches are user-initiated. Opt-out is a manual request to Slack. We should make our own call here rather than inherit it by default.
Sender control. unfurl_links (text content) and unfurl_media (images/video/audio) are per-message params on send. Also: Slack never unfurls a link whose display label is a complete substring of the URL minus the protocol.
What this changes in the proposal above
| Proposal said |
Slack does |
Take |
| Cap response at ~1 MB |
Ranged GET for just the <head> |
Adopt ranged GET |
OG → Twitter → <title> |
oEmbed + Twitter Card + OG |
Add oEmbed, or defer explicitly |
| Cache TTL open question |
~30 min, global |
30 min is a reasonable default — and it bounds the stale-fig problem to half an hour |
| Proxy images |
Dedicated image-proxy service w/ own UA |
Confirms the call; use a distinct UA per fetch type |
| — |
Two params: unfurl_links / unfurl_media |
Split the sender-side toggle in two rather than one on/off |
| — |
Registered-domain app unfurls |
Follow-up issue: first-party fig previews |
Problem
Links posted in channels render as bare URLs. When someone shares a fig (an HTML mockup URL), teammates see only the raw link — no title, no image, no context. They have to open a browser tab to know whether the link is worth looking at.
From #general:
This applies to any shared link, not just figs: PRs, docs, dashboards, articles.
Proposal
Render an inline link preview card under a message that contains a URL, populated by inspecting the target page's Open Graph meta tags.
Fetch + parse
<head>for, in priority order:og:title→ fall back totwitter:title→<title>og:description→twitter:description→<meta name="description">og:image→twitter:image→<link rel="icon">og:site_name,og:type,og:url(canonical)<meta name="robots" content="noindex">? No — OG tags are explicitly for this. But do respect an explicit opt-out if one is agreed.Card shape
og:typeofimageor an image content-type → render the image directly instead of a card.<title>→ render nothing, keep the plain link. Never show a broken/empty card.Safety / limits (these are the parts that bite if skipped)
10/8,172.16/12,192.168/16,127/8,169.254/16,::1, unique-local — SSRF via a pasted link is the main risk here.http/https.Scope
Open questions
?v=cache-buster convention on fig URLs?Filed from #general at the request of @adamazad.
Prior art: how Slack does it
Worth copying, since Slack has run this at scale for a decade.
Two layers. Slack splits unfurling into classic and app unfurls (docs):
link_sharedevent and callschat.unfurlto attach custom preview content instead of the crawled one. Requireslinks:read+links:write. Domains must include a TLD, IP addresses can't be matched, and adding/removing domains requires app reinstall.The app-unfurl layer is the interesting one for us: figs are a first-party surface, so rather than scraping our own OG tags we could register the fig domain and render a real preview (live thumbnail, dimensions, "open in Figura") from data we already hold. Same shape as Slack's own GitHub/Figma integrations. Worth splitting into a follow-up issue once classic unfurl lands.
Fetch behavior (api.slack.com/robots):
Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)Slack-ImgProxy 0.19 (+https://api.slack.com/robots)Slackbot 1.0 (+https://api.slack.com/robots)<link rel="alternate" type="application/json+oembed">in the head. Worth supporting in v1 or explicitly deferring.Slack-ImgProxy) fetches preview images, confirming the "proxy, don't hotlink" call above.robots.txt— their stated reason: they tried, "received too many complaints from our users because a great portion of the Internet is inaccessible to crawlers," and they don't consider themselves a crawler since fetches are user-initiated. Opt-out is a manual request to Slack. We should make our own call here rather than inherit it by default.Sender control.
unfurl_links(text content) andunfurl_media(images/video/audio) are per-message params on send. Also: Slack never unfurls a link whose display label is a complete substring of the URL minus the protocol.What this changes in the proposal above
<head><title>unfurl_links/unfurl_media