A HEY project link is the thing founders and readers actually post. This document is the contract for what HEY puts behind that link: the social image, the composed post, and the rules both obey.
It is the sharing counterpart to Card V7 — same facts, same wording, different surface.
1. The routes
| Route | What it renders | Used by |
|---|---|---|
GET /og/project/<slug> |
that project's card, 1200×630 PNG | og:image / twitter:image on /project/<slug> |
GET /og/default |
the site card, 1200×630 PNG | the root layout, so /, /explore, /ships, /pulse, the narratives and everything else inherit it |
Both are explicit route handlers (apps/web/src/app/og/**/route.tsx) rather
than Next's opengraph-image file convention. The convention produces a
build-hashed path nobody can type; an address a founder can paste into a card
validator, a test can request and an operator can curl is worth more than the
few lines the convention saves. The two file-convention images that existed
before (app/opengraph-image.tsx and
app/(site)/project/[slug]/opengraph-image.tsx) were removed with this change:
file-based metadata overrides the metadata object, so leaving them in place
would have silently won over openGraph.images.
2. What the project image shows
Exactly the browse card's facts, in the browse card's wording:
| Fact | Source | When absent |
|---|---|---|
| Logo | projects.logo_url, fetched and resized to PNG (lib/og-logo.ts) |
the monogram from ProjectLogo, same initials, same slug-seeded colour |
| Name | projects.name |
never absent |
| Ticker | tokens.symbol of the primary token, $AOS |
omitted — a tokenless project shows no ticker line |
| Activity status | projects.activity_status, with ActivityChip's label and lucide icon |
never absent; research_level = INDEXED renders "Activity not researched yet", the same wording the project page uses |
| Last ship | projects.last_meaningful_ship_at, Last shipped 2d ago |
omitted |
| Market cap | the profile's stored reading (market_snapshots, seven-day window, source precedence) with its provider — $24K market cap · via DEX Screener |
omitted — never $0, never a figure from anywhere else |
| Launch origin | launchedViaFrom — via Pons |
omitted, including when it resolves to Unknown or Independent: those are placeholders for the absence of a launch record, not origins |
HEY mark and heyresearch.xyz |
constant | never absent |
Nothing else is on the image: no score, no ship count, no description, no chart, no holders, no price. An absent fact is absent — the image never fills a gap with a zero or a guess.
Design: Midnight ground (#0A111D), the dark theme's ink and status colours
from globals.css, one status accent, no gradient, no glow. Names step down
through four sizes by length and are clamped at 44 characters; tickers clamp at
16. og-card.test.ts covers the fitting, because satori has no ellipsis and an
unclamped name draws straight over the status row.
An unknown slug returns the site card at 200, not a 404: a crawler following a stale link should still render something HEY-shaped.
3. The logo
lib/og-logo.ts. satori decodes the bytes itself and does not read WebP, so
the image cannot point at /api/logo; the same pipeline runs inline instead —
the guarded fetcher from @hey/domain (SSRF check per redirect hop, size cap),
one sharp resize to a 264px PNG, a bounded in-process cache (200 entries,
16MB, failures remembered for ten minutes).
Bounds that matter, because a crawler asks in bursts:
- only hosts in
LOGO_HOSTSare fetched at all; - the fetch times out at 2.5s, tighter than the proxy's 5s — a slow logo must not hold a crawler's connection;
- a failure is never an error: the card draws the monogram, exactly as the
browse card does when an
<img>fails.
4. The copy rules
lib/share.ts composes every share sentence, and share.test.ts enforces the
rules. One composer, so the image, the page metadata and the Share button
cannot drift apart.
AgentOS ($AOS) — Shipping · last shipped 2d ago · via Pons
https://heyresearch.xyz/project/agentos
- identity, then what HEY can say about the building, then where the token launched; every clause is dropped rather than filled when HEY lacks it;
- no market figure at all in the text — no market cap, no price, no percentage. A sentence about building must not read as a sentence about price, and continued building never implies a price will move;
- no score, no ranking, no percentile;
- banned outright, asserted by test:
100x,alpha,ape,bullish,buy,degen,gem,moon,pump,undervalued; - a project HEY has only indexed says "Activity not researched yet" and makes no activity claim.
5. The share control
components/share-project.tsx, on the project page in the row with Website,
GitHub, X and Sources — a secondary control, because the page keeps one
primary CTA (UI/UX non-negotiable 10).
- Share copies the headline plus the project URL to the clipboard and says
"Copied" for 1.6s, with an
aria-liveannouncement. Falls back to a hidden textarea andexecCommandwhere the clipboard API is refused. - Open on X opens
https://twitter.com/intent/tweet?text=…&url=…in a new tab. The headline and the link are separate parameters, so X does not print the link twice. - Both stop the click from propagating, so dropping the control inside a card body later cannot turn "Share" into "navigate". Both are their own tab stop.
6. Cache policy
lib/og-headers.ts:
cache-control: public, max-age=300, s-maxage=3600, stale-while-revalidate=86400
x-content-type-options: nosniff
content-security-policy: default-src 'none'; sandbox
Five minutes in a browser, an hour in a shared cache, a day of serving the
stale copy while the new one renders. A project whose status changed shows its
new image within the hour, and no crawler ever waits on satori twice. The route
is also rate-limited (api.og, 300/minute): a satori render plus a logo fetch
is the most expensive public GET in the app.
7. Regenerating and checking
The image is generated per request; there is nothing to rebuild. To look at one:
# against a local production build
curl -o /tmp/agentos.png http://127.0.0.1:3000/og/project/agentos
curl -o /tmp/default.png http://127.0.0.1:3000/og/default
The one build artefact is the inlined mark, lib/og-mark.ts. It is
apps/web/public/hey-mark-dark.png at its native 136×128, base64'd, because
the renderer has no access to public/ and a fetch would put a network call in
the render path. Regenerate it when the artwork changes:
node -e "const s=require('sharp');s('apps/web/public/hey-mark-dark.png').png().toBuffer().then(b=>console.log(b.toString('base64')))"
Coverage: e2e/share.spec.ts (PNG signature and 1200×630 dimensions, the
metadata tags, the clipboard round trip, 375px) and the two unit suites
(share.test.ts, og-card.test.ts).
8. Not done
- No copy-for-X block on
/ships. The ship feed and its card belong to the "Shipped this week" work in flight at the same time; adding a control there would have meant editing the same files. It is a small addition once that lands: the composer already takes aShareFacts, and a ship's card has the facts it needs. - No per-ship image.
/og/project/<slug>states the project's latest position, not one ship event.
9. SHARE INTELLIGENCE — the Distribution Composer (2026-09-30)
A canonical HEY record becomes a ready-to-share research object. The project share row above shares a project; the composer shares one record: a ledger ChangeEvent, a weekly report, or a research event's window.
One template module. packages/domain/src/share/templates.ts holds every
word, keyed by ChangeEvent type (plus report.weekly and event.window),
versioned share-v1. Eligible types: build.release, build.ship,
contract.deployed, build.accelerating (development spike), build.resumed,
research.builder_verified, research.published,
contract.implementation_changed, contract.interface_changed,
contract.source_verified, lock.observed, lock.unlock_due,
lock.withdrawn, research.source_changed. Market, usage, integrity and
code-activity events have no template and so no Share control.
The rules the templates keep (each tested over every template × a fixture
matrix in templates.test.ts):
- deterministic, pure, memoized on (record id and revision, template version, the facts it read); no model, no clock;
- the project leads: "AgentOS shipped Agent SDK v0.8. 21 meaningful development events were recorded in the last 30 days, up from 7 in the preceding measured window. Builder status: Shipping. Evidence ↓";
- unknown omits the sentence: no status sentence for
UNKNOWNor an indexed project, no development sentence when velocity was not measured, and the preceding-window comparison only when that window was measured (buildVelocity().previous !== null); - vocabulary: building, shipping, changed, observed, verified, recorded,
evidence, source, latest;
SHARE_BANNED_WORDS(share/words.ts, the one list the project share row also re-exports) now includes sell, rug, safe and guaranteed, matched as whole words. External text (a release title) that carries one is dropped for the template's own words; a project name that carries one is quoted as the external name it is; - external text is cleaned (links, mentions, Markdown, bidi and zero-width characters out) and capped (name 48, title 90); no market figure appears.
Channels. X (twitter.com/intent/tweet, text ≤ 256 so the 23-character
link fits 280), Telegram (t.me/share/url), a short plain text for a social
terminal such as Fomo (≤ 280 characters including its link), Copy link, and
Copy raw facts (Key: value lines with the evidence id, the receipt URL, the
source, the dates and the template version).
Links. Built only from validated ids (share/links.ts): a ledger event
lands on /project/<slug>?change=<event id> (a signal on /signals/<uuid>),
a report on /reports/weekly/<week>, an event on /events/<slug>. Every link
carries utm_source=<x|telegram|fomo|copy>&utm_medium=share&utm_campaign=<template key>,
plus utm_content=<evidence id> when the path does not already name it (a
lock's receipt id beside its event id). A project page opened with ?change=
shows that change after the project's identity (SharedChange) and serves its
card as og:image — only when the event stands publicly on that very project;
any other id shows nothing.
Routes. GET /api/share/<subject> (the composed share as JSON, 400 for a
malformed subject, 404 for one not shareable now, cached five minutes, rate
limit api.share) and GET /og/share/<subject> (the same sentences on the
existing surfaceShareCard, every string capped again; an unknown subject gets
the plain card with no-store). No provider call on either.
Placements. The latest-ship card and every Build timeline row on the
project page (only ships whose ledger event stands — one batched read), the
signal page (when the signal is a ledger event with a template), the weekly
report (replacing its old HEY-first "Share on X" line), every row of an event
page and the page itself, and HEY Today's single-event rows in the Terminal.
The dialog is a native <dialog> (focus held, Escape closes, focus returns to
the trigger), one column under 360px, public tokens in light and dark.
Analytics. share.open (outcome change|report|event), share.copy
(link|facts), share.x, share.telegram, share.fomo from the browser,
each keyed by the template key; share.destination_open from the server when
a page view lands with utm_medium=share (outcome the channel, once per
visitor, channel, campaign and page a day). HEY's own traffic is marked
internal.
10. Share this research, the weekly recap and the researcher path (2026-10-05, share-v2)
The distribution pass found the composer nearly unused: three non-internal
share.open rows ever on production (2026-10-04), against 44 project-row X
intents in 28 days. It shared one record at a time; a researcher wants the
whole answer with its receipts, and a builder wants their week.
Two new subjects. Both are published projects only (a hidden candidate reads as missing, 404), read from HEY's own tables, and composed by the same template module.
| Subject | What it is | Shareable when |
|---|---|---|
research:<slug> |
The Project Research Summary (summary/types.ts) as a post and a citation |
its build or latest-change line is more than unknown and not a market line (isResearchShareable) |
recap:<slug> |
The weekly builder recap: the canonical 7-day velocity (projectIntelligence, window 7) and the page's latest meaningful ship (latestMeaningfulShip) |
the week was measured and counted at least one meaningful event |
The research post leads with the project and the build line's answer ("EqualFiLabs on HEY: Shipped this week."), then the canonical lines a post may carry — Build, Latest change, then Product usage and Contracts only as a FACT — each word for word (UI rule 15), cleaned like any external text. A market, fundamentals or security line is never posted, and neither is any line that states a market figure or a market event (production, Loom: the latest change was "Market event today: Liquidity $23K → $32K…").
The citation is the research subject's facts block:
EqualFiLabs ($statics): research summary, HEY Research
As of: 2026-10-04 17:11 UTC (summary-v1)
Build [Derived]: Shipping: 5 meaningful events in 30 days; latest ship today.
Read by HEY: 2026-10-04 14:04 UTC
Evidence ship:90efb149-…: https://heyresearch.xyz/evidence/ship:90efb149-…
Receipt: https://heyresearch.xyz/api/evidence/ship%3A90efb149-…
Source: https://github.com/EqualFiLabs/statics/releases/tag/genesis-v1.0.0
Market [Fact · market context, not a builder judgement]: Launch pool only · …
…
Cite as: HEY Research, "EqualFiLabs research summary", read 2026-10-04 (UTC). https://heyresearch.xyz/project/equalfilabs-statics-app?utm_source=cite&…
Unknown is not zero. Research, not investment advice. Template: share-v2
Every line is cited, an unknown one as [Unknown] (never dropped, never a
zero); market and security lines are labelled as context; each typed evidence
id (at most three a line) has its page and its receipt; a relative "today" is
anchored by the absolute "Read by HEY" time.
The recap: "AgentOS, the 7 days to 2026-10-05: 4 meaningful development events were recorded, up from 2 in the 7 days before." — the comparison only when the preceding week was measured — then "Latest: Agent SDK v0.8 (2026-10-03)." and the status sentence.
Usage resumed (contract.method_resumed): "A
Every block of facts now carries Evidence page beside Evidence receipt, and its Page link is the new cite channel
(utm_source=cite&utm_medium=share), so a reader who arrives from a
researcher's note is counted apart from a copied link. The short text prefers
the link without its utm_content label when that leaves room for one more
sentence of research.
Length. The X post is fitted by X's own weights (xWeightedLength:
Latin and general punctuation 1, CJK, most symbols and emoji 2), so a name in
another script cannot push the post past X's composer.
Injection audit. Every external string — a project name, a ticker, a
release title, a summary line that quotes one — passes shareText (links,
mentions, Markdown, angle brackets, line breaks, bidi and zero-width
characters out; capped). The composer already did this; the legacy project
post (lib/share.ts: "Copy a post", "Share on X", "Copy research") did not,
and production holds 81 launch records named with a URL, 401 with an @mention
and 54 with angle brackets. It now uses the same rule (shareIdentity); a name
with nothing left is "This project", and a ticker is kept only when it is
ticker-shaped. Satori draws text as text, React escapes the dialog, the intent
URLs are built with URLSearchParams, and every link is HEY's origin plus a
validated path. templates.test.ts and share.test.ts assert one fact per
line, no injected line, link, tag or mention.
Placements. "Share this research" under the project page's summary (the
story footer) and under the Terminal's Research Brief (composed from the
public read, so nothing Terminal-only leaves through it); "Share this week's
recap" as the last item of the project Share menu when the newest meaningful
ship is inside the week; "Share this record" on /evidence/<id> when the id is
a standing ledger event with a template. The dialog labels the facts button
"Copy citation" or "Copy facts + evidence", lets the reader read the block
before copying, and links "How to cite HEY" to /researchers.
/researchers is the researcher path on one screen: a search field, then
a live worked example in five steps — find the project, read the research
summary (its lines quoted as published), put building beside the market,
open the evidence, share the research — and what a citation carries and what
HEY never does (pay, reward or rank anyone for sharing; endorse; talk price).
The example is picked by a rule the page prints: the newest release HEY
recorded on a chain-native published project whose summary is shareable
(lib/researcher-example.ts), never by hand and never by market.
Analytics, additive. share.open outcomes research, recap;
share.copy outcome citation; share.destination_open channel cite;
shareFunnel gains shares.citation and landings.cite; the distribution
console adds shares.citations and counts cite landings. Shares,
citations and the composer are fenced out of every deciding module and
ranking page by neutrality.test.ts.
Tests. templates.test.ts (research and recap compositions, citation
lines, unknown handling, market lines never posted, malicious metadata, X
weights, the property suite over every subject), subject.test.ts,
share.test.ts, and e2e/researcher-share.spec.ts at 375px and desktop (the
project page's Share this research, the citation in the clipboard, the API for
both subjects and a hidden project, the card, a cite landing counted once,
the researcher path through to the evidence page's share).
12. Formats, the builders wall and prepared posts (2026-10-14, growth plan A2)
GET /og/share/<subject>?format=square|portrait draws the same words as the landscape card
(surfaceShareCard) at 1080×1080 or 1080×1350 (app/og/share/[subject]/formats.tsx,
SHARE_CARD_FORMATS); any other value is the landscape card. GET /og/builders-wall/<week> is
the week's new Verified Builders as a wall (app/og/builders-wall/[week]/wall.tsx): the names from
the archived report passed through the published-card read (a page hidden since is dropped), logos
through og-logo.ts (a failure is a monogram), at most twelve tiles with the count line saying how
many there are, and the Terms-of-Service line "Not a Robinhood certification"; an unarchived week
is the site card, uncached.
COMPOSE_WEEKLY_POST (keyed weekly-post:<week>, enqueued by the run that closes a week and by the
daily backstop) writes one DRAFT per channel (X, Telegram) into distribution_posts from the
composer's report.weekly campaign — the same x.text / telegram.text and links.x /
links.telegram (utm_campaign=report.weekly) a reader's Share control offers — with the four image
URLs above (weeklyPostImageUrls). A draft still DRAFT takes the newest composition; a row a human
marked POSTED or SKIPPED is never touched. The console's Prepared posts page shows the drafts with Copy
buttons, the image links, a state and a posted-URL field (one audit line per change,
distribution.post.updated), and the builder line of the day (/api/share/chain-day). HEY never
posts: a person copies, posts by hand and records where. Being posted changes nothing on the site
(hey/neutrality.test.ts, "prepared posts are admin-only and decide nothing").
11. Research cards, Markdown and a researcher's draft (2026-10-10, share-v3)
The adoption pass (RS-01 to RS-05) made research its own shared object.
- Its own card. A research or recap link carries
?share=researchor?share=recap, and the page's metadata then uses/og/share/research:<slug>— the card shows the research answer, not the project's generic card. - Markdown. Every share offers "Copy Markdown": the same facts as the
citation, every line with its evidence ids and receipts (
share.copyoutcomemarkdown). - Dates, not "today". A relative day in a post becomes its UTC date, so a post read a week later still says when.
- A researcher's draft. Tick summary lines and up to five recorded ships,
add your own view and name. The draft is composed in the browser only: the
view and the name are never sent to HEY (
share.composerecords only a line-count bucket), and the Playwright spec asserts it.
Template: share-v3. Sharing never changes a status, score or order.