Let your AI agent send preview links
Connect any assistant (Claude, ChatGPT, your own agent such as Instinct) to Preview Lab. It asks for a message, gets back the text and a link, and sends it. The link unfurls into a branded card, and every tap is counted.
- You ask your agent
- Agent calls
compose_preview - Gets text + link
- Texts it
- Phone shows the card
- Tap → your page, click counted
Replace YOUR_API_KEY with this server’s API key. Keep it secret: anyone with it can publish links.
Try it
Edit the JSON an agent would send, then preview it. This is a dry run: nothing is published.
1. MCP (recommended)
Endpoint: https://getpreviewlab.com/mcp (Streamable HTTP, Authorization: Bearer key).
Claude Code:
claude mcp add --transport http preview-lab https://getpreviewlab.com/mcp \ --header "Authorization: Bearer YOUR_API_KEY"
Other MCP clients (Cursor, agent frameworks, Instinct):
{
"mcpServers": {
"preview-lab": {
"type": "http",
"url": "https://getpreviewlab.com/mcp",
"headers": {
"Authorization": "Bearer YOUR_API_KEY"
}
}
}
}Claude.ai and ChatGPT connectors sign in with OAuth, which Preview Lab does not support yet. Use Claude Code, an agent framework, or the REST API below.
2. One REST call
curl -X POST https://getpreviewlab.com/api/compose \
-H 'content-type: application/json' \
-H 'x-api-key: YOUR_API_KEY' \
-d '{
"brand": "kinetic",
"messageType": "offer",
"details": { "deal": "25% OFF", "on": "your first 3 months", "ends": "Ends Sunday", "code": "KINETIC25" },
"angle": "personal",
"text": "Kinetic: Thanks for the call, {name}! 25% off your first 3 months. Code KINETIC25. Reply STOP to opt out",
"recipientName": "Sam",
"targetUrl": "https://example.com/kinetic/join"
}'Reply:
{
"cardId": "aS2rH5pr",
"url": "https://getpreviewlab.com/l/aS2rH5pr",
"text": "Kinetic: Thanks for the call, Sam! 25% off … https://getpreviewlab.com/l/aS2rH5pr",
"destination": "https://example.com/kinetic/join",
…
}Missing details fail with a clear message, so an agent never sends sample data. Also: POST /api/sequences, POST /api/tests, GET /api/catalog. Full list in the README.
3. Full control: any card, photos, motion, the link itself
Start from a message type, a promo, an existing link, or a blank card with a title, and set any part of the design, or just a look (neon, sunset-pop, editorial, luxe, ocean, candy, retro, mono, forest, arcade, memo). Every limit matches the editor (GET /api/card-options lists them, no key needed). No key yet? POST /api/looks and POST /api/preview (MCP try_looks, preview_card) draw cards for anyone, and /looks shows your words in every look.
curl -X POST https://getpreviewlab.com/api/links \
-H 'content-type: application/json' \
-H 'x-api-key: YOUR_API_KEY' \
-d '{
"from": { "promo": "percent-off", "brand": "Kinetic", "offer": "30% off" },
"design": { "template": "blast", "badge": "TODAY", "cta": "Shop now →",
"colors": { "bg1": "#0b1d2a", "bg2": "#123b52", "accent": "#c6ff1a" } },
"motion": { "preset": "count", "number": "30%" },
"targetUrl": "https://kinetic.example/sale"
}'| POST /api/links | create_card. dryRun: true returns a preview image and stores nothing. |
| GET /api/links?q=… | search_links (also paused, motion, testId, limit). |
| GET /api/links/{id} | get_link: the full spec, destination, paused, version, motion files, stats. |
| PATCH /api/links/{id} | update_link: any spec field, targetUrl, paused or status, label. A visual change returns a new ?v= link: send that one. { "status": "revoked" } is final: the link answers 410 and can't be changed again (409). Pausing a paused link changes nothing. |
| POST /api/variants | card_variants, no key: { "base": {…card}, "variants": [{ "label": "50% off", "design": { "big": "50%" } }] } (1–4). A bundle per card: url, image, tags, alt, readyToSend, motion. Untracked; publish the winner with POST /api/tests. |
| GET /s/{token}/motion.gif | No key: a moving card as a 600×315, 2.4-second GIF that plays once (?download=1 to download). Also /l/{id}/motion.gif. Link previews keep the still. |
| POST /api/domains/{id}/test | Website rules: { "paths": ["/blog/launch"] } → the rule that applies (most specific wins), every match, and a before/after diff of the og:/twitter: tags. Saves nothing. |
| DELETE /api/links/{id} | delete_link. |
| PUT /api/links/{id}/motion | set_motion: { "preset": "strike", "was": "$40" }, { "preset": "count", "number": "50%" }, or { "preset": null }. |
| GET /api/links/{id}/motion?wait=30 | motion_status: render state, MP4/GIF/WebP/still URLs and sizes, what each app gets. |
| GET /api/links/{id}/preview | check_preview for one of your links (?apps=imessage,whatsapp, ?fmt=gif). No fake previews are counted. |
| POST /api/preview-check | check_preview for any URL: { "url": "…" }. |
| POST /api/uploads | upload_media: { "url": "https://…" } (fetched here; private addresses refused), { "base64": "…" }, or the raw bytes. PNG/JPEG/WebP → design.photo; GIF/MP4 → motion.background. 8 MB, ≥ 200 px a side, GIF/MP4 ≤ 15 s. |
| POST /api/tests/looks | create_look_test: { "title": "…", "looks": ["neon", "editorial"] } (plus any create_card field). |
| POST /api/motion/test | create_motion_test: { "cardId": "…", "motion": "slam" }. |
| POST /api/library/docs | upload_document: the raw file (X-File-Name header; ?tags=&brand=&look=), or JSON { "url" } / { "base64" }. PDF, PPTX, DOCX, XLSX, PNG/JPEG/WebP, text; 15 MB. |
| GET /api/library/search?q=… | search_library (also tag, kind). |
| GET /api/library/docs | list_documents. GET/PATCH/DELETE /api/library/docs/{id}: get, update, delete. |
| POST /api/library/docs/{id}/shares | share_document: { "recipient": "Dana", "company": "Forkful", "look": "editorial" }. |
| PATCH /api/library/docs/{id}/shares/{linkId} | update_share: { "status": "paused" }, "revoked" (final), or { "expiresAt": null }. New documents open only from live share links. |
| GET /api/library/docs/{id}/engagement | document_engagement: opens, seconds per page, share links, latest visits. |
| POST /api/library/docs/{id}/file | replace_document: the new file as raw bytes (X-File-Name) or JSON { "url" } / { "base64" }. Same id, same links; share cards get the new cover. |
| GET /api/library/insights?days=30 | library_insights: what gets read across the library, and what doesn't. |
| POST /api/library/rooms | create_room: { "title": "Forkful renewal", "note": "…", "docIds": ["…", "…"] }. GET/PATCH/DELETE /api/library/rooms/{id}: get, update, delete. |
| POST /api/library/rooms/{id}/shares | share_room: { "recipient": "Dana", "company": "Forkful" }. PATCH …/shares/{linkId}: update_room_share. GET …/engagement: room_engagement. |
Library documents open through a share link: /l/{linkId} leads to /d/{id}?s={linkId} (page images, find in the document, download if allowed). New documents are private: the plain /d/{id} opens only when openLink is on. The card at /d/{id}/og.jpg is always public. Reviewing the site? Start with What's new and /llms.txt.
Retrying a write? Send Idempotency-Key: <8–128 letters, digits, _ or -> on any /api/ write: a retry gets the first answer (Idempotent-Replayed: true), never a second link; the same key with a different request is a 409. Colours you pick must read at 4.5:1 (WCAG AA), or the card is refused with the ratio.
Paused links keep their preview, but a tap lands on a “paused” page and is not counted. iMessage gets the MP4, Discord the GIF, everyone else the still JPEG, until the render is ready.
4. Build the link yourself (signed)
An agent that holds the key can write a promo link with no API call. The server checks the signature, builds the card on the first open, and counts taps. Changing any field breaks the link, so nobody can point your card somewhere else.
https://getpreviewlab.com/t/<promo>?brand=…&offer=…&code=…&url=…&ends=…&angle=…&sig=…
Signature: HMAC-SHA256 with your API key, over <promo>? plus the fields that have a value, in the order angle, brand, code, color, ends, offer, url, v, written name=value with strict URL encoding (only A–Z a–z 0–9 - _ . ~ stay as is), joined by &. Keep the first 32 hex characters. Promo ids: GET /api/promos.
import { createHmac } from 'node:crypto';
const enc = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
function promoLink(key, promo, fields) {
const names = ['angle', 'brand', 'code', 'color', 'ends', 'offer', 'url', 'v'].filter((k) => fields[k]);
const query = names.map((k) => k + '=' + enc(fields[k])).join('&');
const sig = createHmac('sha256', key).update(promo + '?' + query).digest('hex').slice(0, 32);
return 'https://getpreviewlab.com/t/' + promo + '?' + query + '&sig=' + sig;
}
promoLink(KEY, 'flash-sale', { brand: 'Orbit', offer: '20% off all cold brew', code: 'BREW20', ends: 'This weekend', url: 'https://orbit.example' });import hashlib, hmac
from urllib.parse import quote
def promo_link(key, promo, **fields):
names = [k for k in ['angle', 'brand', 'code', 'color', 'ends', 'offer', 'url', 'v'] if fields.get(k)]
query = '&'.join(f"{k}={quote(str(fields[k]), safe='')}" for k in names)
sig = hmac.new(key.encode(), f'{promo}?{query}'.encode(), hashlib.sha256).hexdigest()[:32]
return f'https://getpreviewlab.com/t/{promo}?{query}&sig={sig}'Check your code against POST /api/promos/sign (same fields, key required): it returns the same link.
Tools
| promo_options | One sentence → 3–5 promo texts with cards (nothing published) |
| create_promo_link | Publish one promo option → text + tracked link |
| sign_promo_link | A signed promo link, stored on first open |
| list_promos | The 20 promo cards, each with a “use when” line |
| list_brands | Brand themes you can send as |
| list_message_types | Message types and the details each needs |
| compose_preview | Any message type (reminders, updates…) → text + link |
| create_sequence | A follow-up series (after the call, day 3, last day…) |
| create_ab_test | 2–6 versions of one message |
| pick_test_message | Which version to send next (counts the send) |
| change_destination | Point a sent link at a new page |
| get_stats | Sends, clicks, click-through, previews by app |
| list_recent_links | Latest links with stats |
| preview_card | No key: draw any card and see it, with a preview link (never redirects) |
| try_looks | No key: the same card in 4 art directions (neon, editorial, sunset-pop…), as pictures |
| card_variants | No key: a base card + 1–4 overrides → complete link bundles (tags, image, motion GIF), canonical URLs |
| list_card_options | Every layout, palette, look, icon, limit and motion preset create_card takes |
| create_card | Any card: text, theme or colours, layout, badge, button, logo, photo, motion → link |
| get_link | One link with its full spec, version, motion and stats |
| update_link | Change card, motion, destination, label or status (live, paused, revoked: final) on the same link (new ?v= when it looks different) |
| delete_link | Delete a link for good |
| search_links | Find links by words, paused, motion or test |
| upload_media | A photo (PNG/JPEG/WebP) or a GIF/MP4, by URL or base64 |
| check_preview | What iMessage, WhatsApp, Discord, Slack, Telegram, X and Google Messages each show, with warnings |
| list_motion_presets | The 9 motion presets, what each needs, and which format each app gets |
| set_motion | Add, change or remove a link’s motion (Strike: was; Count: number) |
| motion_status | Render state and the MP4, GIF, WebP and still URLs (can wait for it) |
| create_motion_test | A/B test: the same card, motion against still |
| create_look_test | A/B test: the same card in 2–4 looks; clicks pick the art direction |
| upload_document | Library: a PDF, PowerPoint, Word, Excel, image or text file → a preview card from its first page and a viewer link |
| search_library | Library: any word on any page, ranked, with page numbers and snippets |
| share_document | Library: a tracked link per person, with its own card (“For Dana · Forkful”) and an optional expiry |
| update_share | Library: pause, resume, revoke (final) or re-date one share link |
| document_engagement | Library: opens, seconds per page, each share link, latest visits |
| list_documents | Library: newest first, with opens and tags |
| get_document | Library: one document, its pages, share links and stats |
| update_document | Library: title, summary, tags, look, button, downloadable, paused |
| replace_document | Library: a new file under the same id; every link and room opens it at once |
| delete_document | Library: the file, pages, search entries, visits and share links |
| library_insights | Library: most read, never shared, shared but never opened, shared but old, opens by day, latest visits |
| create_room | Rooms: several documents and your note behind one link (a deal room) |
| share_room | Rooms: a tracked link per person; it opens the room and every document in it |
| update_room_share | Rooms: pause, resume, revoke (final) or re-date one room link |
| room_engagement | Rooms: visits, and opens, seconds and furthest page per document, per link |
| list_rooms | Rooms: every room with its live links and visits |
| get_room | Rooms: one room, its documents, its links and their stats |
| update_room | Rooms: title, note, look, or the document list |
| delete_room | Rooms: the room, its links and visits (the documents stay) |
Sending rules
Preview Lab makes the links; your agent or app sends the texts. Text only people who opted in, 8 AM–9 PM in their time zone, and stop when they reply, buy or text STOP.