Skip to main content

Rich Link Previews (oEmbed)

Chirp supports oEmbed auto-discovery with a custom x_app extension, allowing external applications to provide rich, structured link previews when their URLs are shared in chat.

When a user pastes a link, Chirp already fetches OpenGraph meta tags for a basic preview. With oEmbed support, apps can deliver richer cards with app-specific data like trip dates, movie ratings, or booking details.

How It Works​

  1. User pastes a link in a Chirp channel
  2. Chirp fetches the page HTML and parses OpenGraph tags (as usual)
  3. Chirp checks for a <link rel="alternate" type="application/json+oembed"> tag
  4. If found, Chirp fetches the oEmbed JSON endpoint
  5. If the response includes an x_app extension, Chirp renders a rich card
  6. Otherwise, it falls back to the standard OpenGraph preview

Implementing oEmbed for Your App​

Step 1: Create an oEmbed Endpoint​

Add a GET /api/oembed endpoint that accepts a url query parameter and returns JSON:

GET /api/oembed?url=https://yourapp.com/resource/123&format=json

Step 2: Return oEmbed JSON​

Your endpoint should return standard oEmbed fields plus the x_app extension:

{
"version": "1.0",
"type": "rich",
"provider_name": "Your App Name",
"provider_url": "https://yourapp.com",
"provider_icon": "https://yourapp.com/favicon.svg",
"title": "Resource Title",
"description": "A short description of the resource",
"author_name": "Author Name",
"thumbnail_url": "https://yourapp.com/images/resource-cover.jpg",
"thumbnail_width": 800,
"thumbnail_height": 400,
"x_app": {
"app_type": "yourapp",
"resource_type": "resource",
"color": "#10b981",
"data": {
"custom_field_1": "value1",
"custom_field_2": 42
}
}
}

Step 3: Add Discovery Tag​

In your HTML pages (via SSR or server-side rendering), add an oEmbed discovery <link> tag inside <head>:

<link
rel="alternate"
type="application/json+oembed"
href="https://yourapp.com/api/oembed?url=https%3A%2F%2Fyourapp.com%2Fresource%2F123&format=json"
title="Resource Title"
/>
tip

The href must be URL-encoded and point to your oEmbed endpoint with the current page URL as the url parameter.

oEmbed Response Fields​

Standard Fields​

FieldTypeRequiredDescription
versionstringYesMust be "1.0"
typestringYesMust be "rich"
titlestringYesResource title
provider_namestringNoYour app's display name
provider_urlstringNoYour app's base URL
provider_iconstringNoURL to your app's icon (shown in the card header)
descriptionstringNoShort description (max ~200 chars)
author_namestringNoCreator/author name
thumbnail_urlstringNoPreview image URL
thumbnail_widthnumberNoImage width in pixels
thumbnail_heightnumberNoImage height in pixels

The x_app Extension​

The x_app object is what triggers Chirp's rich card rendering instead of a generic preview.

FieldTypeRequiredDescription
app_typestringYesUnique identifier for your app (e.g., "traveler", "watchradar")
resource_typestringYesType of resource (e.g., "trip", "media", "event_type")
colorstringNoAccent color for the card (hex, e.g., "#10b981")
dataobjectNoArbitrary structured data specific to your resource

Card Rendering​

Chirp includes built-in card layouts for known app types. For unknown app types, a generic rich card is rendered with:

  • Colored left border (using x_app.color)
  • Provider icon and name
  • Title and description
  • Thumbnail image
  • Up to 4 data fields from x_app.data displayed as key-value pairs

Built-in Card Types​

Traveler (app_type: "traveler")​

For resource_type: "trip":

{
"x_app": {
"app_type": "traveler",
"resource_type": "trip",
"color": "#10b981",
"data": {
"location": "Tokyo, Japan",
"start_date": "2026-07-15",
"end_date": "2026-07-29",
"member_count": 4,
"activity_count": 23
}
}
}

Renders a card with a cover image, location pin, date range, member and activity counts.

WatchRadar (app_type: "watchradar")​

For resource_type: "media":

{
"x_app": {
"app_type": "watchradar",
"resource_type": "media",
"color": "#667eea",
"data": {
"year": 2026,
"media_type": "tv",
"genres": ["Animation", "Fantasy", "Action"],
"rating": 8.5
}
}
}

Renders a horizontal card with poster, title + year, star rating, and genre pills.

MyCalBook (app_type: "mycalbook")​

For resource_type: "event_type":

{
"x_app": {
"app_type": "mycalbook",
"resource_type": "event_type",
"color": "#6366f1",
"data": {
"durations": [15, 30, 60],
"location": "Google Meet",
"host": "alice"
}
}
}

Renders a card with duration pills, location, and host info.

VRChronicles (app_type: "vrchronicles")​

For resource_type: "world":

{
"x_app": {
"app_type": "vrchronicles",
"resource_type": "world",
"color": "#4ecdc4",
"data": {
"vrc_world_id": "wrld_abc123",
"author": "WorldCreator",
"capacity": 32,
"performance": "Good",
"lighting": "Baked",
"platforms": ["PC", "Quest"],
"categories": ["Chill", "Social", "Music"],
"screenshot_count": 8
}
}
}

Renders a card with cover image, platform badges, author, capacity, performance/lighting info, and category tags.

Security​

  • The oEmbed endpoint URL must be on the same domain as the original page URL. Cross-domain oEmbed endpoints are rejected to prevent SSRF.
  • Only HTTPS oEmbed endpoints are fetched.
  • Responses are limited to JSON content type.
  • A 5-second timeout is enforced on oEmbed fetches.

Example: Express.js Implementation​

Here's a minimal example for a Node.js/Express app:

const router = require('express').Router();

// GET /api/oembed?url=...&format=json
router.get('/', async (req, res) => {
const { url, format } = req.query;

if (!url) return res.status(400).json({ error: 'url parameter is required' });
if (format && format !== 'json') return res.status(501).json({ error: 'Only JSON format supported' });

// Parse the URL to identify the resource
const parsed = new URL(url);
const match = parsed.pathname.match(/^\/resource\/(\d+)$/);
if (!match) return res.status(404).json({ error: 'Not found' });

// Fetch your resource data
const resource = await getResourceById(match[1]);
if (!resource) return res.status(404).json({ error: 'Not found' });

const baseUrl = `${req.protocol}://${req.get('host')}`;

res.json({
version: '1.0',
type: 'rich',
provider_name: 'My App',
provider_url: baseUrl,
provider_icon: `${baseUrl}/favicon.svg`,
title: resource.title,
description: resource.description,
thumbnail_url: resource.imageUrl,
thumbnail_width: 800,
thumbnail_height: 400,
x_app: {
app_type: 'myapp',
resource_type: 'resource',
color: '#3b82f6',
data: {
status: resource.status,
created_by: resource.author
}
}
});
});

module.exports = router;

Then in your SSR handler, inject the discovery tag:

const oembedUrl = `${baseUrl}/api/oembed?url=${encodeURIComponent(fullUrl)}&format=json`;
const linkTag = `<link rel="alternate" type="application/json+oembed" href="${oembedUrl}" title="${pageTitle}" />`;
// Inject into <head> alongside your OpenGraph meta tags
note

Your app should still serve OpenGraph meta tags. The oEmbed data enhances the preview but OpenGraph is used as a fallback and is also consumed by other platforms (Slack, Discord, Twitter, etc.).