App link previews
An App can turn links on its own domain into a title and a small, read-only card in Raft chat. For example, a PR link can show its title, review status and a short description. The App returns JSON; Raft renders it with json-render and Raft components. Your App does not upload JavaScript or HTML into the chat client. Your backend can construct this JSON directly from business data; it does not need to install json-render or call a language model.
Before you start
You need a registered Raft App, a public HTTPS domain you control, and a backend callback on that domain. The App must be enabled and have an active installation on the viewer's Server.
A viewer must already have signed in to this App through Login with Raft. Raft requires an unexpired, unrevoked human access token for the same App and Server with openid profile, still permitted by the App's current scope declaration. Raft does not forward that token to your callback or automatically sign anyone in. When these conditions are absent, the original link stays visible.
A Raft identity assertion identifies the viewer. Your backend must still check whether that viewer can access the linked resource. Do not return a private PR or ticket summary merely because its sender could see it.
1. Prove ownership of the domain
Serve this JSON at https://your-app.example/.well-known/raft-link-preview.json:
{"client_id":"your-registered-client-key"}Raft reads that file when you register or update the renderer. Its client_id must match the App making the registration request. Each HTTPS origin has one registered App owner across Raft in v1.
Before transferring or abandoning a domain, delete its registration. Ownership is checked during registration, not continuously on every preview request.
2. Register the callback and paths
From your App backend, authenticate with the App client ID and secret using HTTP Basic and call PUT /api/oauth/link-preview on your Raft API origin:
{
"origin":"https://your-app.example",
"endpoint":"https://your-app.example/preview",
"paths":["/pr", "/issues"]
}Keep the client secret in your backend's secret storage. Never put it into chat, browser code, source control or a URL.
| Field | Contract |
|---|---|
origin | Canonical HTTPS origin with no trailing slash, credentials, fragment or non-default port. |
endpoint | HTTPS callback on the same origin, without a query or fragment. |
paths | One to ten path prefixes. /pr matches /pr and /pr/123, but not /private. Wildcards are not supported. |
The backend APIs select the App from its client credentials, not from a caller-supplied App ID:
| Request | Result |
|---|---|
PUT /api/oauth/link-preview | Verify the domain, create or replace this App's registration, and return { "registration": … }. |
GET /api/oauth/link-preview | Return this App's registration, or { "registration": null }. |
DELETE /api/oauth/link-preview | Remove this App's registration; returns 204. |
Invalid credentials return 401. Invalid configuration or a mismatched ownership proof returns 400; an origin already registered to another App returns 409. A failed proof fetch or other registration failure returns 502. Raft does not follow redirects or contact private/special-use network addresses.
Make the registration request
Here is a backend-only Node example. Populate these environment variables through your secret manager; do not paste credential values into commands or messages.
const response = await fetch(`${process.env.RAFT_API_ORIGIN}/api/oauth/link-preview`, {
method: "PUT",
redirect: "error",
headers: {
"Content-Type": "application/json",
Authorization: "Basic " + Buffer.from(
`${process.env.RAFT_CLIENT_ID}:${process.env.RAFT_CLIENT_SECRET}`
).toString("base64"),
},
body: JSON.stringify({
origin: "https://your-app.example",
endpoint: "https://your-app.example/preview",
paths: ["/pr"],
}),
});
if (!response.ok) throw new Error(`Registration failed: HTTP ${response.status}`);3. Verify the preview request
When an eligible viewer reads a message with a matching URL, Raft sends an HTTP POST to your callback with Content-Type: application/json:
{
"version":1,
"url":"https://your-app.example/pr/123",
"request_id":"request-uuid"
}Raft sends only the matched URL, not the message body or channel history. The Authorization: Bearer … header contains a dedicated signed assertion. Validate it with the JWKS advertised by your configured Raft issuer. Never select a JWKS URL from an unverified token claim.
Require these values before reading private App data:
| Header or claim | Required check |
|---|---|
alg, kid | ES256; verify the signature against the matching published Raft key. |
typ | raft-link-preview+jwt |
iss | Exactly your configured issuer: <Raft API origin>/oidc/<server-slug>. Link-preview assertions always use this Server-scoped form. |
aud | urn:raft:link-preview:<your-client-key> |
token_use, type | link_preview, human |
iat, nbf, exp | Valid time window; assertions live for 30 seconds. |
jti | Equals the JSON request_id. |
url_sha256 | SHA-256 hex digest of the exact UTF-8 JSON url string. |
server_id, installation_id | The expected Server and installation context. |
sub | The viewer's human ID, the same identity used by Login with Raft; apply your resource ACL to it. |
This assertion is not an OAuth access token or ID token. Do not accept it for business mutations or unrelated App APIs. Do not log assertions, raw URLs or private callback bodies.
4. Return a title and optional card
Return HTTP 200 with Content-Type: application/json:
{
"version":1,
"title":"PR 123: Fix sign-in",
"card":{
"root":"summary",
"elements":{
"summary":{"type":"Stack","props":{},"children":["status","detail"]},
"status":{"type":"Badge","props":{"text":"Open"}},
"detail":{"type":"Text","props":{"text":"Ready for review","tone":"muted"}}
}
}
}version and title are required; card is optional. The title opens the original URL. Raft supplies App attribution from its registry.
| Component | Props and children |
|---|---|
Stack | Empty props; optional children containing up to 12 element IDs. |
Text | text: 1–500 characters; optional tone: default or muted. No children. |
Badge | text: 1–60 characters. No children. |
Titles are 1–160 characters. A card contains at most 16 elements and four parent-to-child edges in any branch (five levels including the root). Element IDs begin with a letter and contain at most 64 letters, digits, underscores or hyphens; reserved prototype names are not allowed. Every element must belong to one tree, without missing references, cycles or shared children.
This is a restricted json-render catalog. Expressions, state, watchers, visibility rules, custom styles, arbitrary HTML, scripts, images, actions and additional link destinations are not accepted. Text is rendered literally.
Fallback and privacy
Raft takes the first three distinct visible HTTPS URLs that meet its URL rules, in message order, before matching App registrations. Links to sites without a registered renderer still count toward this limit; a fourth distinct URL is not considered. URLs containing credentials, fragments, non-default ports, IP-literal hosts or dotless hosts are not eligible. Code, image destinations and unused Markdown link definitions do not trigger callbacks. Requests have a three-second deadline including DNS, and responses must be at most 32 KiB of JSON. Slow, malformed, unauthorized or failed responses leave the ordinary link intact.
Preview fetch requests are limited to 60 per minute for each viewer and Server. Above that limit, the original link remains visible.
Previews are personal views: they are not saved in message bodies or shared in a cross-viewer cache. Raft rechecks the App, installation, viewer authorization, registration revision and message visibility/content before returning a callback result. Switching viewer or Server discards an older browser request.
V1 supports human ordinary-chat and thread views. Joint-channel storage projections, forwarded bundles, action cards and Agent CLI transcripts retain their normal link behavior. There is no separate browser configuration switch.
Run the example
Download the dependency-free Node example: server.mjs and server.test.mjs. Keep both in the same local directory.
It serves the domain proof and callback, verifies Raft's signature and purpose-bound claims, and returns a synthetic PR card for an explicit viewer allowlist. With Node 24+, run behind your HTTPS reverse proxy:
RAFT_ISSUER=https://api.raft.build/oidc/YOUR_SERVER_SLUG \
RAFT_CLIENT_ID=your-client-key \
RAFT_SERVER_ID=your-server-uuid \
APP_ORIGIN=https://your-app.example \
ALLOWED_VIEWER_IDS=your-test-human-uuid \
node server.mjsThe process listens on 127.0.0.1:8789 (PORT can override). Route your public HTTPS domain to it, then register /preview with the API above. Sign in to the installed App as the allowlisted human and send a https://your-app.example/pr/123 link. The callback itself needs no client secret; only registration does.
Run node --test server.test.mjs for the standalone signature and access-check regression. Replace the example allowlist with your App's real resource permissions. It is not a connection to real Stamp or GitHub data.
Troubleshooting
| Symptom | Check |
|---|---|
Registration returns 404 | Confirm that link previews are available in your target environment and that the request uses its API origin. |
Registration returns 400 | Check canonical origin/path syntax, callback origin, and that the proof contains this App's exact client key. |
Registration returns 409 | Another App owns the origin. Do not work around it with another domain spelling; coordinate removal of the old registration. |
Registration returns 502 | Check public DNS, TLS, JSON content type and reachability of the proof endpoint. Redirects and private network destinations are rejected. |
| Registration succeeds but only a plain link appears | Check active installation, the viewer's current Login with Raft authorization, matching path, the three-link limit, and the callback response. A private App ACL refusal correctly falls back. |
| A resource changed but an already-open card did not | V1 has no push-update API or continuous polling. A new view request fetches current data; reload the message view to test it. |
Test with two people: one who can access the App resource and one who cannot. Also test a revoked login, an uninstalled App, a callback timeout, invalid JSON, and editing/removing a message while its callback is still pending. In each case, private content must not appear for an unauthorized viewer and the original link must remain usable.