Link Previews
Paste a mesh link into Teams, Slack or LinkedIn and one of two things happens: a card — title, description, picture — or a bare URL. This page explains what decides which, and how to get the card. It also disentangles the two features that both answer to the name "OG card" and point in opposite directions.
Two features, two directions
| Direction | What it does | Where it lives | |
|---|---|---|---|
| SEO head + share card | outbound — our pages unfurling elsewhere | serves og:* meta tags and a share image to crawlers |
Memex.Portal.Shared/Seo + SeoHead in the portal GUI shell |
OgCard package |
inbound — other pages rendering inside ours | a layout area drawing link-preview cards for external URLs or mesh nodes, embedded from markdown | the OgCard store package (MeshWeaver.OgCard module) |
When someone says "the link doesn't show a card in Teams", that is the outbound feature —
installing or configuring the OgCard package changes nothing about it, because the inbound area
renders cards for other pages, it does not describe ours to anyone.
The outbound pipeline — what a crawler sees
A chat app's crawler fetches the pasted URL unauthenticated and reads the initial HTML. The portal's first response therefore carries a crawler-facing head, rendered server-side before any Blazor circuit exists:
SeoHead(in the portal shell'sApp.razor) resolves the request path to its node and emits<title>,meta description, canonical URL, the node's own icon, the full Open Graph set (og:site_name/type/title/description/url/image),twitter:card, and — for store plugins — Course/Product JSON-LD.og:imageis the node's authored image when it declares one (PluginContent.OgImage, elseposter/thumbnail), and otherwise/api/og/{path}.png— a 1200×630 card the portal draws itself (OgCardRenderer, SkiaSharp with an embedded font). Having a share image is the default, not something each page remembers to author.The card draws everything the node can say about itself (
SeoEndpoints.CardContent): the node's name as the title; its description —Description, else the content'sabstract/description, else the catalog copytagline/summary/headline; its category (else the type's leaf) as the eyebrow; its own mark, the same backplated<svg>the favicon route rasterizes, drawn large on the right; a price chip whenpriceis positive; and the instance name plus the path in the footer. A node with no mark gets a default badge — a rounded tile in the card's accent carrying the page's initial — so no card is ever text on a dark rectangle (2026-09-18: the Store shared into iMessage as a bare title beside the site favicon). The head declaresog:image:type/width/heightfor the drawn card (an authored image's size is unknown) and mirrors it astwitter:image.Every page has a card. The home page and a route that is no node share as the INSTANCE —
og:titleis the site name andog:imageis/api/og.png, the site card (name + host, nothing read from the mesh). A node the anonymous gate withholds shares as its nearest PUBLIC ANCESTOR when it has one (below), and as the instance when it does not. A private page's own name, description and mark never reach either block.SeoNoScriptBodyserves the page's pre-rendered markdown inside<noscript>, so non-JS crawlers index actual content rather than an empty Blazor shell.
The node's own icon is part of that head, and it is declared twice — as the node's <svg> mark
and as a PNG rendered from it at /api/icon/{path}.png, because Safari renders no SVG favicon at
all. See Content Favicon Rasterization.
The one bit that decides everything: anonymous read
Only what an anonymous visitor may read gets a card. SeoResolver gates every path through
the AnonymousGate and fails closed: a gated node's page serves the generic head, its
/api/og/… card answers 404, and a missing node and a private one are indistinguishable from
outside. This is deliberate — a link preview is served to whoever holds the link, so a card for
a private page would leak its title, description and image past the access system. There is no
way to make a private page unfurl richly, and none should be added.
What that means in practice:
- Every store cover unfurls. Plugin roots are anonymous-readable by design (the cover is
the marketing surface; provisioning writes the Anonymous/Public grants). Measured 2026-08-30:
all 81 catalog covers on
memex.meshweaver.cloudserved complete cards, and cover media — posters,<video>sources — streamed anonymously with range requests. - The documentation unfurls.
Doc/_PolicycarriesPublicRead = true(it GitSyncs from the public MeshWeaver repository, so anonymous read reveals nothing not already on github.com). - A private workspace, thread or space does not unfurl — and must not. The fix for "my link
shows no card" is never to weaken the resolver; it is to decide whether that partition should
be public, and say so in its
_Policy. What a gated page under a PUBLIC root shares instead is the next section — and it is still not that page's own words.
A partition opts in with one bit on its seeded or authored policy:
Content = new PartitionAccessPolicy
{
Create = false, Update = false, Delete = false, // still read-only
PublicRead = true // world-readable → unfurls, indexable
}
PublicRead grants Read to everyone including anonymous; Read merely caps (false =
deny) and never grants — see Access Control.
A gated page under a public root: the public-ancestor card
A public root over gated content is a shape the platform ships on purpose — a store listing, a course catalog, a reporting space whose cover is the marketing surface and whose data is not. A link into one of those used to share as the bare site card, which is the least useful thing it could say.
Measured on www.meshweaver.cloud, 2026-09-20:
| URL | og:title |
og:image |
|---|---|---|
/PG3Reporting |
Fund Reporting | /api/og/PG3Reporting.png |
/PG3Reporting/Funds |
MeshWeaver | /api/og.png |
/PG3Reporting/Funds/InsuranceCore |
MeshWeaver | /api/og.png |
/PG3Reporting/Funds/InsuranceCore/2026-06-30 |
MeshWeaver | /api/og.png |
/Doc/Architecture/AccessControl |
Access Control Architecture | /api/og/Doc/…png |
The last row is the control: it was never about depth. PG3Reporting/_Policy is a
PartitionAccessPolicy with a RedirectOnDenied of PG3Reporting/Subscribe and no PublicRead, so
the root is a public listing and everything under it is gated — and the head, gating through the
AnonymousGate, had nothing to say about any of it.
SeoResolver.ResolvePublicAncestor now walks UP from a withheld page to the nearest ancestor the
gate DOES admit and builds the card from that:
og:title— the ancestor's title, then the requested path's own segments (Fund Reporting · Funds / InsuranceCore / 2026-06-30).og:description— the ancestor's description, plus one localized sentence (seo.gatedCard.accessRoute) when the gated partition declares aRedirectOnDenied, because that declaration is the owner saying a route in exists. Without one, no call to action: advice that leads nowhere is worse than none.og:image— the ancestor's share image, exactly as a page of its own would declare it: its AUTHORED image when it has one, else the drawn/api/og/{ancestor}.png. Either way the same gate already serves it anonymously, so the unfurler can actually fetch what the head declares — and as on a public page,og:image:typeand 1200×630 are declared only for the drawn card, because an authored banner's dimensions are unknown here.noindex, follow— the page's content is gated, so it is not a page to rank; the links stay crawlable.
🚨 Why this discloses nothing
The path segments are already in the URL the sharer pasted. Rendering them back in the title tells the reader nothing they are not already looking at in their own chat window. Everything else on the card belongs to the public ancestor and is already served to anyone who asks for it.
The withheld node's own Name, description, icon and content are never read. That is enforced by
the shape of the code, not by care: the composition
(SeoResolver.ComposeAncestorCard) takes an SeoPageData the gate ADMITTED plus the request path,
and there is deliberately no overload that takes the requested node. The redirect target is not on
the card either — it is the one thing here that is not in the pasted URL, and the card's link
already goes there for whoever clicks it.
SeoPublicAncestorCardTest holds both sides: a public page still resolves its own card unchanged, a
gated page under a public root gets the ancestor's, a gated page with no public ancestor still
gets nothing — and one control names the withheld nodes' own words and asserts they appear in no
field of the card.
When no ancestor is public either, the site card stays. That is the honest floor, and it is the answer for a private page in a private partition: its link says only what its URL already said.
The inbound OgCard layout area
The store package OgCard ships the opposite convenience: a markdown page embeds link-preview
cards for other targets — external URLs (their Open Graph head fetched server-side through the
core OpenGraphPreviewService) or same-mesh nodes (read live off the node stream):
@@("Org/Doc/area/OgCard?url=https://example.org/Page")
@@("Org/Doc/area/OgCard/Some/Node/Path")
Several targets compose into one responsive grid. It is a module (MeshWeaver.OgCard);
delisting it removes the server-side URL-fetch surface and existing embeds render the standard
area-not-found placeholder. The /og-card skill documents the authoring rules.
Verifying an unfurl without pasting into chat
Fetch the page as a crawler would and read the head — the same check the platform's own sweep ran:
curl -sL -A "Mozilla/5.0 (compatible; SkypeUriPreview Preview/0.5)" \
https://memex.meshweaver.cloud/Chess | grep -o '<meta[^>]*og:[^>]*>'
A page that unfurls shows the full og:* set and an og:image you can fetch anonymously. A page
that serves an empty <title> and no og:* tags is not anonymous-readable — that is the gate
working, not the feature missing. (Teams and LinkedIn cache unfurls aggressively; a fixed page
can take hours to re-scrape, and LinkedIn's Post Inspector forces a refresh.)