Server-generated Open Graph cards
Date: 2026-08-21
Status: Implemented. Meta tags ship on every public page; the signed, content-versioned card endpoint serves repository, issue, blob, and commit cards; rasterization runs through librsvg with a committed fallback card when the binary is absent. The release image carries librsvg, fontconfig, and the pinned Geist TTFs; dynamic cards render wherever a rebuilt image runs.
What links to our pages look like today
The application renders no Open Graph or Twitter meta tags at all. A link to
https://openagents.com/OpenAgentsInc/openagents.com pasted into Slack,
X, iMessage, Discord, or LinkedIn renders as either a bare URL or whatever
those platforms guess. Every forge page we built to be public — repository
home, blobs, commits, issues — is invisible in exactly the surfaces where
people share links.
What GitHub does, measured
Facts collected from live responses on 2026-08-21:
- The repository home serves
<meta property="og:image">pointing athttps://opengraph.githubassets.com/{digest}/OpenAgentsInc/openagents. The hex{digest}is derived from the card inputs, so the URL itself is the cache key: change the description and the digest changes. - The image is a 1200×600 RGB PNG. It shows the owner avatar, the
owner/repopath, the description ("Monorepo & docs" for our sister repository), and star/fork/watch counters. cache-control: public, max-age=21600, immutableon the image response.- The blob page serves the identical URL — same digest, same bytes. So do directory listings. A file shared on Slack looks exactly like the repository landing page: nothing about the file appears in the card.
- An individual issue gets its own template and digest
(
.../{digest}/elixir-lang/elixir/issues/10000), with title, body excerpt, author, state, and comment count rendered into the image, andog:image:altcarrying the body excerpt for screen readers.
Two design lessons fall out of this. First, the card URL is content-addressed: immutability plus a long cache makes generation cost irrelevant and stale links harmless. Second, GitHub stops at the repository boundary — a file link wastes its card. That is the gap worth beating.
Goals
- Every public forge page emits correct
og:*andtwitter:*meta tags with a card that says what the page is. - Cards are generated by the Phoenix application itself: no third-party service, consistent with ADR 0001.
- File, commit, and issue cards carry information specific to that resource — deliberately more than GitHub ships.
- Generation is bounded, cached, and safe on untrusted text.
Non-goals
- Per-user personalized cards. Cards describe resources, not viewers.
- Rendering arbitrary README screenshots or images fetched from Git content.
- Supporting scrapers that execute JavaScript; crawlers read the initial server-rendered HTML, which LiveView provides.
Architecture
Layer 1: meta tags
Crawlers do not run JavaScript, so the tags must be in the initial SSR HTML. LiveView renders that HTML once per mount, which is exactly what a crawler fetches.
Add an optional @og assign consumed by the root layout, with a site-level
default so every page produces valid tags even when a view forgets:
<meta property="og:title" content={@og.title} />
<meta property="og:description" content={@og.description} />
<meta property="og:image" content={@og.image_url} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:url" content={@og.url} />
<meta property="og:type" content={@og.type} />
<meta name="twitter:card" content="summary_large_image" />
Each public view fills the map during handle_params/3: repository home,
blob, commit, issue show, docs, changelog, status, home. Views behind
authentication — chat, settings, repositories management — keep the bare
site card or none at all. Private repositories emit no resource-specific tags
whatsoever: the meta tags must not leak the existence or description of
something the visitor could not otherwise see.
Layer 2: the card endpoint
One plug-style controller serves all images:
GET /og/v/{version}/repos/{owner}/{repo}.png
GET /og/v/{version}/blobs/{owner}/{repo}/{ref}/{path...}.png
GET /og/v/{version}/commits/{owner}/{repo}/{sha}.png
GET /og/v/{version}/issues/{owner}/{repo}/{number}.png
GET /og/static/card.png
Properties of this endpoint:
- Content-addressed version.
{version}is a short SHA-256 prefix over the exact inputs (titles, counts, dates, colors). When any input changes, the page emits a new URL and every CDN entry for the old one ages out. Stale shares keep working: the controller renders the current data for any well-formed request and lets the version be advisory, the way GitHub's digest behaves in practice. - HMAC, not enumeration. Requests carry
?sig=computed over the path with a server-side key. Without a valid signature the endpoint 404s, so the endpoint cannot be used as an unbounded free rendering API by third parties. - Concealment. The controller resolves the repository through the same visibility predicate as public code browsing. A private or missing repository returns an indistinguishable 404.
- Cache headers.
cache-control: public, max-age=21600, immutable, matching what GitHub ships, pluscontent-type: image/png. - Bounded work. One rasterization per miss; the LB/CDN absorbs repeats. A concurrency-limited task queue caps worst-case load, and requests queue rather than fan out.
Layer 3: rendering
Cards are laid out as SVG templates in Elixir — pure functions from a data struct to a string, trivially unit-testable — then rasterized to PNG.
Rasterization options considered:
| Option | Verdict |
|---|---|
System rsvg-convert (librsvg) invoked via port |
Recommended. One package in the release image; deterministic output with pinned fonts; battle-tested parser. |
| ImageMagick | Same shape as above but a much larger attack and maintenance surface than librsvg alone. |
| Pure-Elixir PNG encoder plus a glyph atlas | Zero system dependencies, but hand-rolled shaping, wrapping, and hinting; weeks of work for visibly worse type. |
| Rustler NIF wrapping resvg | Best fidelity and speed, but adds a NIF toolchain to every release build and rollback artifact. |
| Headless-Chrome sidecar service | Contradicts the one-application architecture; heaviest option by far. |
The port approach carries three hard requirements, which are also the security boundary:
- Fixed fonts. The release image ships the Geist faces we already use (TTF form for fontconfig). No user-influenced font selection, ever.
- Escaped, capped text. All dynamic strings pass through XML escaping, length caps, and a line-clamping layout helper before entering the template. Titles wrap at two lines with an ellipsis; paths truncate from the left, keeping the filename visible.
- No remote references. The SVG may not contain
<image>, external CSS, or URLs the rasterizer would fetch — that closes SSRF and removes network nondeterminism. Avatars render as initial-filled discs in palette tones, not fetched bitmaps.
If the rasterizer binary is missing at runtime, the endpoint falls back to the static brand card and logs a warning. Social previews degrade; nothing errors.
Card contents
Canvas: 1200×630, dark palette from our token ladder, brand lockup bottom-left
on every card, openagents.com wordmark bottom-right. This is where our
cards diverge from GitHub's on purpose.
| Surface | Card shows |
|---|---|
| Site fallback | Wordmark, one-line product description, logo mark |
| Repository | owner/repo, description (or an honest fallback), visibility pill, open/closed issue counts, default branch, last commit date, "Imported from GitHub" provenance line when a repository import exists |
| Issue | State pill colored by state and close reason, #number, wrapped title, author login, comment count, up to three label chips, opened date |
| Blob | Small owner/repo context line, the full path breadcrumb wrapping across two lines with the filename kept intact, language inferred from extension, file size, line count, branch/ref |
| Commit | Short SHA, wrapped subject, author login, files-changed count, date |
The blob and commit cards are the deliberate step past GitHub: their file links render the generic repository card, while ours answer "what am I about to open?" — language, size, and where it sits in the tree — without a click. Everything shown is already served on the corresponding public page, so the cards leak nothing new; the rule is simply that a card may contain only data the anonymous page already displayed.
Testing
- Golden-file tests pin each SVG template's output for fixture inputs, so a layout regression fails loudly.
- Controller tests cover: PNG content type, cache headers, signature enforcement, version-mismatch behavior, and indistinguishable 404s for private and missing repositories.
- Escaping tests feed hostile strings — angle brackets, control characters, emoji, ten-thousand-character titles — and assert bounded, escaped output.
- The redaction scan extends to card payloads: tokens and credentials never appear in card inputs because card inputs are page-visible data only.
Delivery sequence
All three phases shipped together on 2026-08-21: the meta-tag layer, the signed endpoint with the SVG-to-PNG pipeline, and the deep-surface cards. The implementation notes below record what landed and where.
- Meta tags plus the static brand card.
Layouts.og_tags/1renders the block in the root layout; views without a card get honest site-level tags pointing at the committed brand PNG. - Repository and issue cards.
OpenAgentsWeb.OGbuilds cards from page data, signs content-versioned URLs, andOpenAgentsWeb.OgImageControllerrenders them throughOG.Templates+OG.Rasterizer(librsvg port, concurrency-limited, hard timeout). - Blob and commit cards. The beyond-GitHub information layer: language, size, line count, and ref for files; subject, author, and changed-file count for commits.
Remaining operations work, tracked here so it cannot be forgotten:
- The release image now carries
librsvg2-bin,fontconfig, and the pinned Geist TTFs (v1.7.2, zip and per-file digests checked in the Dockerfile) with a fontconfig alias from the CSS family name "Geist Sans" to the files' internal name "Geist". Dynamic cards render on any node running a rebuilt image; deploy the rebuilt image and the staging check below goes green. - The staging check should assert a dynamic card URL returns bytes other than the committed fallback, proving rasterization end to end.