← Preview Lab

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.

  1. You ask your agent
  2. Agent calls compose_preview
  3. Gets text + link
  4. Texts it
  5. Phone shows the card
  6. 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/linkscreate_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/variantscard_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.gifNo 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}/testWebsite 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}/motionset_motion: { "preset": "strike", "was": "$40" }, { "preset": "count", "number": "50%" }, or { "preset": null }.
GET /api/links/{id}/motion?wait=30motion_status: render state, MP4/GIF/WebP/still URLs and sizes, what each app gets.
GET /api/links/{id}/previewcheck_preview for one of your links (?apps=imessage,whatsapp, ?fmt=gif). No fake previews are counted.
POST /api/preview-checkcheck_preview for any URL: { "url": "…" }.
POST /api/uploadsupload_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/lookscreate_look_test: { "title": "…", "looks": ["neon", "editorial"] } (plus any create_card field).
POST /api/motion/testcreate_motion_test: { "cardId": "…", "motion": "slam" }.
POST /api/library/docsupload_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/docslist_documents. GET/PATCH/DELETE /api/library/docs/{id}: get, update, delete.
POST /api/library/docs/{id}/sharesshare_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}/engagementdocument_engagement: opens, seconds per page, share links, latest visits.
POST /api/library/docs/{id}/filereplace_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=30library_insights: what gets read across the library, and what doesn't.
POST /api/library/roomscreate_room: { "title": "Forkful renewal", "note": "…", "docIds": ["…", "…"] }. GET/PATCH/DELETE /api/library/rooms/{id}: get, update, delete.
POST /api/library/rooms/{id}/sharesshare_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_optionsOne sentence → 3–5 promo texts with cards (nothing published)
create_promo_linkPublish one promo option → text + tracked link
sign_promo_linkA signed promo link, stored on first open
list_promosThe 20 promo cards, each with a “use when” line
list_brandsBrand themes you can send as
list_message_typesMessage types and the details each needs
compose_previewAny message type (reminders, updates…) → text + link
create_sequenceA follow-up series (after the call, day 3, last day…)
create_ab_test2–6 versions of one message
pick_test_messageWhich version to send next (counts the send)
change_destinationPoint a sent link at a new page
get_statsSends, clicks, click-through, previews by app
list_recent_linksLatest links with stats
preview_cardNo key: draw any card and see it, with a preview link (never redirects)
try_looksNo key: the same card in 4 art directions (neon, editorial, sunset-pop…), as pictures
card_variantsNo key: a base card + 1–4 overrides → complete link bundles (tags, image, motion GIF), canonical URLs
list_card_optionsEvery layout, palette, look, icon, limit and motion preset create_card takes
create_cardAny card: text, theme or colours, layout, badge, button, logo, photo, motion → link
get_linkOne link with its full spec, version, motion and stats
update_linkChange card, motion, destination, label or status (live, paused, revoked: final) on the same link (new ?v= when it looks different)
delete_linkDelete a link for good
search_linksFind links by words, paused, motion or test
upload_mediaA photo (PNG/JPEG/WebP) or a GIF/MP4, by URL or base64
check_previewWhat iMessage, WhatsApp, Discord, Slack, Telegram, X and Google Messages each show, with warnings
list_motion_presetsThe 9 motion presets, what each needs, and which format each app gets
set_motionAdd, change or remove a link’s motion (Strike: was; Count: number)
motion_statusRender state and the MP4, GIF, WebP and still URLs (can wait for it)
create_motion_testA/B test: the same card, motion against still
create_look_testA/B test: the same card in 2–4 looks; clicks pick the art direction
upload_documentLibrary: a PDF, PowerPoint, Word, Excel, image or text file → a preview card from its first page and a viewer link
search_libraryLibrary: any word on any page, ranked, with page numbers and snippets
share_documentLibrary: a tracked link per person, with its own card (“For Dana · Forkful”) and an optional expiry
update_shareLibrary: pause, resume, revoke (final) or re-date one share link
document_engagementLibrary: opens, seconds per page, each share link, latest visits
list_documentsLibrary: newest first, with opens and tags
get_documentLibrary: one document, its pages, share links and stats
update_documentLibrary: title, summary, tags, look, button, downloadable, paused
replace_documentLibrary: a new file under the same id; every link and room opens it at once
delete_documentLibrary: the file, pages, search entries, visits and share links
library_insightsLibrary: most read, never shared, shared but never opened, shared but old, opens by day, latest visits
create_roomRooms: several documents and your note behind one link (a deal room)
share_roomRooms: a tracked link per person; it opens the room and every document in it
update_room_shareRooms: pause, resume, revoke (final) or re-date one room link
room_engagementRooms: visits, and opens, seconds and furthest page per document, per link
list_roomsRooms: every room with its live links and visits
get_roomRooms: one room, its documents, its links and their stats
update_roomRooms: title, note, look, or the document list
delete_roomRooms: 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.