CUSTOM FRONT END
Use Letterstory as your CMS
No CMS? Your site (Lovable, React, Next.js, a static generator) reads published posts straight from Letterstory’s API. No connector or mapping. If your site builds from a GitHub repo, use /docs/onboard/cms.
Before you start
On Next.js? Connect GitHub in Settings → Integrations → GitHub and use “Scaffold a custom front-end integration” to open a ready-made PR (see an example PR).
- 1Create an API key (Settings → API keys → Create key) with only Read articles, plus Manage webhooks for rebuilds. Send it in the x-integrations-key header, not Authorization: Bearer.
- 2Know your front end’s shape: a server can hold the key. A browser-only app (like a default Lovable build) needs a small proxy.
- 3Publish at least one article. Drafts aren’t readable.
You are wiring a customer's own front end (a Lovable app, a hand-built React/Next.js site, a static site generator — anything that is NOT Webflow, Framer, Sanity or Contentful) to read content straight from Letterstory. There is no CMS connector here and no field mapping: the front end calls Letterstory's own API for published posts. If the customer's site instead pulls content from a GitHub repo at build time, that's a different guide (/docs/onboard/cms, where GitHub is a provider like any CMS) — it opens pull requests rather than serving reads, and this prompt doesn't cover it.
Connect first:
- This guide is for an EXISTING Letterstory customer. If they haven't signed up yet, that happens at https://app.letterstory.com/crossing/letter with a work email, in a browser.
- Ask for a Letterstory API key if you don't have one (Letterstory dashboard -> Settings -> API keys -> Create key). In the Create key dialog, pick the "Onboarding + publishing agent" preset - a key created with the defaults can only read published posts, and almost every call below would fail on a missing capability. Keys are issued in the browser, not over the API. When creating it, select only the "Read articles" capability — this key never needs to write anything, so scope it down; a leaked read-only key can only see what's already public. Send it as an x-integrations-key header (Authorization: Bearer is for OAuth access tokens, a different credential type — it rejects a plain API key with 401).
- Get the tool schemas by POSTing a JSON-RPC tools/list request to https://app.letterstory.com/api/mcp — plain HTTP, no MCP client needed. That's the only place per-tool parameters live; tool names below are anchors, not full signatures.
- Call get_onboarding_plan. Its cms step covers connectors, not this path — this integration doesn't correspond to any step in the plan, because there's no fact to establish: reading the API isn't a state Letterstory tracks. Run this guide because the customer said their site has no CMS, not because the plan told you to.
Phase 1 - Where the key lives matters
- If the front end is a server (Next.js server component, a Node/Python backend, a build-time static-site generator), the key can live there directly as a normal server-side secret.
- If the front end is CLIENT-SIDE ONLY — and a default Lovable app is exactly this: a Vite/React bundle with no server of its own — do NOT put the key directly in that bundle's code or fetch calls. Anyone can read it out of the shipped JavaScript or the browser's network tab, and from there call the API as the organization for as long as the key is valid. Put a thin proxy in front of it instead: a Supabase Edge Function (Lovable projects commonly already have a Supabase connection), a Vercel/Netlify serverless function, or any small backend the customer already has — it holds the key server-side and the browser calls that instead.
- Ask the customer which shape their site is before proceeding; the answer decides whether phase 2 runs directly in their front end or behind a proxy they stand up first.
Phase 2 - Fetch and render published content
- list_published returns every published article for the organization, newest first; get_published fetches one by article_id or slug. Both accept format=md for Markdown or leave it for HTML — pick whichever the front end already knows how to render.
- Only published content comes back. An article drafted or edited in Letterstory but not yet published won't appear here; publish_article promotes one to published.
- list_published pages with a cursor: when has_more comes back true, pass the returned next_cursor back as cursor to get the rest. Don't assume one page is everything once an organization has more than a handful of posts.
- Verify: call list_published (or the REST route, GET /api/integrations/published) and confirm the count and titles match what the customer expects to see live, then load the actual rendered page on their site and confirm one real post's title and body match what the API returned — a correct API response and a correct render are two different facts.
Phase 3 - Your own extra fields (FAQ, itemList, seoTitle, or anything else)
- If the customer has their own structured fields beyond title/content/author/tags/summary — FAQ Q&A, itemList schema, a distinct SEO title, whatever they name — set_collection_config's custom_fields section declares them per collection: each has a key (the name it shows up under), a label, a generation_prompt (the instruction handed to the model), a shape (text | list_of_text | object | list_of_objects), and a write_policy ('always' regenerates every pass; 'create_only' is written once and never touched again — use this for anything the customer wants to hand-edit afterward without it being overwritten).
- Once declared, list_published/get_published return a custom_fields object on every article — custom_fields.<key> — no new endpoint, no new key capability beyond the "Read articles" scope this guide already asked for.
- New posts pick a field up automatically the next time they're written. For posts that already exist when a field is added, call run_custom_fields_pass with the collection_id to backfill (or article_id to backfill just one) — it sweeps up to 50 articles per call; call again while has_more is true.
- A field's generation_prompt is free text sent straight to the model, so anyone holding the "Configure/run collections" (collection:write) capability that set_collection_config needs can shape what gets written into every future post, not just toggle a setting — mention this if the customer is deciding who gets that key.
- Verify: after declaring a field and running the backfill, call get_published (or the REST route) for one known article and confirm custom_fields.<key> has the expected shape and content, not just that the call succeeded.
Phase 4 - Automate the rebuild, or receive publishes directly into your own backend (skip if the front end fetches live on every request)
- A statically-generated or cached front end needs to know when something new publishes; so does a backend that mirrors Letterstory content into its own database rather than rendering it live. set_webhook registers one URL that receives article.published (this guide's tools also fire article.finished and a flow-run-completed event on the same URL, told apart by the x-letterstory-event header) — point it at a Vercel/Netlify deploy hook for a rebuild, or the customer's own endpoint for a direct integration. Setting or reading it needs the "Manage webhooks" capability on the key, in addition to "Read articles".
- THE DELIVERY IS A NOTIFICATION, NOT THE ARTICLE. The POST body for article.published is {event, article_id, org_id, title, slug, published_version, published_at} — no content, no summary, no tags, no cover image. If the receiving endpoint needs the full article, call get_published({article_id}) right after receiving the notification; that returns everything (content, summary, tags, author, cover image, custom_fields, etc). A republish sends the same event again with a higher published_version — upsert on article_id, keep the highest version seen, don't treat it as a duplicate to ignore.
- Every delivery also carries x-letterstory-delivery-id (format is per-event; for article.published it is article_id + published_version + api_key_id joined by colons, stable per publish) — use it as your idempotency key — and, if a webhook_secret was set, x-letterstory-signature (sha256= followed by an HMAC of the raw body).
- Deliveries are sent ONCE with a 10-second timeout and are NOT retried on a non-2xx response or timeout. If the endpoint being built needs to be resilient to being briefly down, also poll list_published on an interval (e.g. hourly) as a safety net — don't rely on the webhook alone for completeness.
- The URL must be https; a plain http:// URL is rejected at call time even though the schema doesn't show that constraint as validation.
- There is no tool that reports whether the webhook delivered or the rebuild succeeded — get_webhook only confirms what's configured, not that a call landed. Real verification: publish or edit one article, then have the customer check their own receiving server or deploy logs for the request (headers/body should match the shape above), and reload the live site (or query their own database, for a direct integration) to confirm the change actually shows up. Say this limitation out loud rather than reporting a false pass.
- Full reference for every event's payload fields, headers, and delivery-id format: https://app.letterstory.com/docs/webhooks.
- Skip this phase entirely if the front end calls list_published/get_published on every page load instead of caching — there's nothing to invalidate.
Guardrails:
- Never suggest embedding a write-capable key (article:write, article:publish, or anything beyond "Read articles") in a client-side bundle. This integration only needs reads; if the customer later wants their own site to trigger publishing, that key belongs behind a server, not in the browser.
- A key with only "Read articles" can still see every published post's full content — scope it down, but don't tell the customer a leaked read-only key is harmless, only that it's limited to what's already public.
- If something here doesn't match what tools/list or /api/openapi actually shows, trust the live schema over this prompt and tell the customer what changed.The three steps
Get a read-only key, and decide where it lives
Create a key with only "Read articles". A server can hold it as a secret. A browser-only app (like a default Lovable project) must never ship it; put a small proxy in front, such as a Supabase Edge Function or a Vercel or Netlify function.
- list_published
- get_published
Fetch and render published content
list_published returns published articles, newest first; get_published fetches one by article_id or slug. Use format=md for Markdown. Page with next_cursor.
- list_published
- get_published
- publish_article
Automate the rebuild
For a static or cached site, set_webhook calls a URL (e.g. a deploy hook) on publish. The body has article_id and a delivery id, not the article; call get_published for content. Deliveries aren't retried, so also poll list_published. URL must be https; the key needs "Manage webhooks". Details: /docs/webhooks. Skip if the site fetches on every load.
- set_webhook
- get_webhook
- get_published