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
- User pastes a link in a Chirp channel
- Chirp fetches the page HTML and parses OpenGraph tags (as usual)
- Chirp checks for a
<link rel="alternate" type="application/json+oembed">tag - If found, Chirp fetches the oEmbed JSON endpoint
- If the response includes an
x_appextension, Chirp renders a rich card - 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"
/>
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
| Field | Type | Required | Description |
|---|---|---|---|
version | string | Yes | Must be "1.0" |
type | string | Yes | Must be "rich" |
title | string | Yes | Resource title |
provider_name | string | No | Your app's display name |
provider_url | string | No | Your app's base URL |
provider_icon | string | No | URL to your app's icon (shown in the card header) |
description | string | No | Short description (max ~200 chars) |
author_name | string | No | Creator/author name |
thumbnail_url | string | No | Preview image URL |
thumbnail_width | number | No | Image width in pixels |
thumbnail_height | number | No | Image height in pixels |
The x_app Extension
The x_app object is what triggers Chirp's rich card rendering instead of a generic preview.
| Field | Type | Required | Description |
|---|---|---|---|
app_type | string | Yes | Unique identifier for your app (e.g., "traveler", "watchradar") |
resource_type | string | Yes | Type of resource (e.g., "trip", "media", "event_type") |
color | string | No | Accent color for the card (hex, e.g., "#10b981") |
data | object | No | Arbitrary 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.datadisplayed 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
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.).