Connect your CMS
Put posts on your own website through Webflow, Framer, Sanity, Contentful or a GitHub content repo, or fetch them into a custom front end.
Generated from the app (prompt-cms), so always current.
Writing in Letterstory and publishing to your own site are two different things. This connects them.
Two directions share one connection, and they are separate jobs: pulling the posts already in your CMS into Letterstory, and pushing Letterstory posts out to your CMS. Most people want both, in that order.
The prompt
You are connecting a Letterstory organization to the CMS that serves its public site, so posts written in Letterstory land on the customer's own front end. Two directions are possible and they are NOT the same job: IMPORTING the customer's existing CMS posts into Letterstory, and PUBLISHING Letterstory posts out to the CMS. Decide with the user which one they want before you start; most customers eventually want both, in that order. A customer with a CUSTOM front end (their own Next.js/other app) connects no provider at all: their app reads published content over the API, and Phase 4 wires the rebuild hook so it refreshes when something publishes.
Connect first:
- This guide is for an EXISTING Letterstory customer. If the user has somehow not signed up yet, that happens at https://app.letterstory.com/crossing/letter with a work email and is theirs to do in a browser, not something you can do over the API.
- Ask the user for their 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 not issued over the API, so this step also happens in the browser. Send it as an x-integrations-key header (Authorization: Bearer is for OAuth access tokens, a different credential type - it will reject 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 is fine, you do not need an MCP client. That is the ONLY place per-tool parameters live. https://app.letterstory.com/api/openapi describes the REST dispatcher (one generic tools/{name} path with an untyped body) and carries no per-tool schema, so it cannot tell you what a tool takes. Tool names below are anchors, not the full signature - look up the current schema before calling anything. Note that /api/openapi describes the REST dispatcher rather than one path per tool; tools/list is where the per-tool schemas are.
- Call get_onboarding_plan. Its cms step is this guide. It is OPTIONAL by disposition: Letterstory suggests it, the user decides. Do not run it because it is "ready" - run it because the user said they want their posts on their own site.
- Call list_cms_connections to see what is already connected. One connection per org per provider; re-connecting REPLACES the stored credentials rather than adding a second.
Phase 1 - Connect the provider
- Ask which platform serves their public site. Each provider needs different credentials, and api_token is always required: webflow (api_token), framer (project_url + api_token), sanity (project_id + dataset + api_token), contentful (space_id + environment_id + api_token), github (api_token - for a customer whose repo IS their CMS: they build their site from markdown files in it, and Letterstory publishes by opening a pull request).
- Tell the user where the token comes from rather than making them hunt: Webflow is Site settings -> Apps & integrations -> API access; Sanity is sanity.io/manage -> API -> Tokens; Contentful is a Content Management API token in Settings -> API keys; Framer is the site's plugin settings; GitHub is a fine-grained personal access token (github.com -> Settings -> Developer settings) with Contents (read/write) and Pull requests (read/write) on the content repo. A read-only token is NOT enough if they want to publish - the token needs write access.
- Call connect_cms. Credentials are validated against the provider BEFORE they are stored, so a bad token is rejected rather than silently saved - an error here is a real credential problem, not a Letterstory bug. Stored credentials are encrypted. GitHub's token is org-wide: it is the same token set_github_connection stores and GitHub seers read, so connect it once per org even if they later add more repos.
- Verify: list_cms_connections shows the provider as connected, with the site/space/account it resolved to. Read that label back to the user - connecting the wrong site is the single easiest mistake to make here and it is invisible until you publish into it.
Phase 2 - Establish the mapping (do NOT skip this; publishing has no target without it)
- A connection alone is not a destination. Letterstory publishes to a MAPPING. For webflow/framer/sanity/contentful that is a specific CMS collection plus the field-by-field correspondence to a Letterstory post. For github it is a publish target: repo_owner, repo_name, base_branch and content_path_template (where one post's file goes, with {slug} in it - "content/posts/{slug}.md" if omitted). Until a mapping exists, list_publish_targets returns an EMPTY list and there is nothing to publish to. This is the single most common place this flow stalls.
- GitHub: call set_github_publish_target. It is checked against GitHub before it is saved - a repo the token cannot reach or push to, a base_branch that does not exist, or a template without {slug} is refused with the reason; softer findings (for example, the post directory is empty on that branch) come back in warnings. Read warnings to the user. Before relying on the default path, ASK the customer (or their engineers) where their site reads posts from and what frontmatter it expects - the target only writes where the template says.
- Webflow/Sanity/Contentful: the field mapping is auto-detected from the live connection on the first import_from_cms. Framer has no detector: it needs a mapping that already exists (list_cms_mappings).
- The customer's CMS collection must actually have the fields a post needs - at minimum a title/name, a slug, and a rich-text body. If their collection is missing a body field, auto-detection has nothing to map onto and publishing will fail later in a much more confusing way.
- Verify: list_cms_mappings shows the mapping (for github, the target), and list_publish_targets returns it. If it is still empty, the mapping did not get created - go back, do not proceed.
Phase 2b - The main blog collection: import the existing posts and wire it to the CMS
- This guide assumes the org's MAIN BLOG collection exists, because that is what posts are written in and published from. Call get_onboarding_plan: if its collection step is not done, create it now with onboard_collection and pass cms_provider (webflow, framer, sanity, contentful or github) - that one job imports the existing blog straight from the CMS or repo, builds the topic map, binds SEO demand, runs a GEO probe and stocks Headlines, exactly as a sitemap import would. The rest of that step is https://app.letterstory.com/docs/onboard/collection.
- If the main blog already exists, import into it with import_from_cms (collection_id + provider). Adopt-matching means posts it already has (from a sitemap import, say) are linked, not duplicated, and re-running never duplicates. Passing provider alone also works: it wires the org's main blog (its default client collection), never a phantom.
- To pull nothing and only publish, bind_collection_cms wires the collection to the mapping instead. bind_collection_cms errors with "No <provider> field mapping exists yet" (for github, "No GitHub publish target exists yet") when there is none - that means go back to Phase 2, not that something is broken.
- list_collections marks phantoms with hosted: true. A phantom is an independent publication Letterstory runs for the customer on another domain; it is NEVER the main blog. Wiring a phantom to the customer's CMS is refused, and so is publishing a phantom's post to it - do not try to work around either.
- Verify: get_collection_config on the main blog shows the provider under publishing (cms_provider), and list_articles on it shows the imported posts.
Phase 3 - Publish out
- Webflow specifically: the site must have been PUBLISHED at least once in Webflow before it will accept CMS writes. A brand-new site that has never been published returns "409 Conflict: The site is not published" no matter how correct the token and mapping are. If you see that, the fix is in Webflow, not in Letterstory - tell the user to hit Publish once.
- A Letterstory post must exist and be published in Letterstory before it can go out - publish_article does that (imported posts are already live on their site and need nothing). It must belong to the main blog: a post written for a phantom is refused by every connector.
- Call publish_to_connector with the article, the connector, and the target_id from list_publish_targets. It returns a publish_id and starts asynchronously. For GitHub, this only OPENS (or updates) a pull request on the target repo - nothing reaches the customer's site until they review and merge it themselves. Say that plainly; do not tell the customer their post is live off a github publish alone.
- Poll get_publish_status with that publish_id until it reports completed or failed. Do not report success off the initial pending response.
- Use get_publish_failures to see what has failed recently across the org - useful when a customer says "some posts aren't showing up" and you need the pattern rather than one status.
- Automate it: publish_to_connector is one post at a time. To have every post the Planner writes go out on its own, keep the main blog wired to the mapping (Phase 2b) and turn publishing.cms on with set_collection_config - from then on the Planner publishes to the CMS at each slot (for github, it opens a PR per post), subject to the collection's approval mode. Confirm with the user first; this writes to their live site (or opens PRs on their repo) without anyone calling a tool.
- Verify: for webflow/framer/sanity/contentful, load the customer's ACTUAL public site and confirm the post renders there, correctly - "completed" only means the CMS accepted the write, and a caching/rebuild front end can still not show it. For github, verify instead that the pull request exists on the target repo with the article's content - that PR being open IS success here, whether or not the customer has merged it.
Phase 4 - Automate the rebuild, or receive publishes into the customer's own backend (a custom front end; skip for a pure connector setup)
- Set up a publish webhook with set_webhook so publishing an article notifies their side (e.g. a Vercel/Netlify deploy hook URL to trigger a rebuild, or their own endpoint to receive the notification directly). The URL must be https - a plain http:// URL is rejected at call time even though the schema doesn't show that constraint.
- 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. If the customer's endpoint needs the full article, call get_published({article_id}) right after receiving it. A republish resends the same event with a higher published_version - upsert on article_id, keep the highest version, don't treat it as a duplicate. Every delivery also carries x-letterstory-delivery-id (stable per delivery - use it for idempotency) and, if a webhook_secret is set, x-letterstory-signature (HMAC-SHA256 of the raw body). Deliveries are sent ONCE with a 10-second timeout and are NOT retried, so recommend a periodic list_published poll (e.g. hourly) as a safety net. Full field-by-field reference: https://app.letterstory.com/docs/webhooks.
- There is no tool that reports webhook delivery or rebuild success. get_publish_status is for external connector publishes (Webflow/Framer/etc.) and get_phantom_diagnostics is for Letterstory's own hosted "deployment" pipeline (a different, phantom-adjacent product) - neither applies to a webhook aimed at a customer's own infrastructure, and both will error or return nothing useful here. Real verification is: publish or update one article, then have the user check their own receiving server/deploy logs for the request (headers/body should match the shape above), and re-fetch list_published / get_published (or re-load the live site once it exists) to confirm the change landed. Say this limitation out loud rather than reporting a false pass.
Guardrails:
- Re-connecting a provider REPLACES its credentials. Before you call connect_cms (or set_github_connection) on a provider that list_cms_connections (or get_seer_connections, which also reports GitHub's org-wide connection status) already shows as connected, confirm with the user - you may be pointing a working integration at a different site or repo.
- Publishing writes to the customer's live website, or (for GitHub) opens a real PR against their repo. Confirm before the first publish, and prefer publishing ONE post and verifying it end to end over pushing a batch and discovering a field-mapping mistake across fifty posts.
- disconnect_cms removes stored credentials for a CMS provider; it does not delete anything already published to their CMS. GitHub's set_github_connection can be re-run to rotate the token, and delete_github_publish_target removes one target.
- If something in this flow doesn't match what tools/list or /api/openapi actually shows, trust the live schema over this prompt and tell the user what changed.
Two things that catch people out
A connection is not a destination. Connecting a provider does not create somewhere to publish to. Letterstory publishes to a field mapping: a specific CMS collection plus how each field lines up with a post. Until one exists, there is nothing to publish to and the target list comes back empty. Importing from your CMS creates the mapping automatically, which is the easiest route even if you do not especially want the old posts. For a GitHub repo the destination is a publish target instead (repo, branch, and the path each post's file goes to), and publishing opens a pull request rather than going live.
Webflow will not accept writes to a site that has never been published. A brand new site returns a conflict error no matter how correct your token is. Hit Publish in Webflow once and it works.
Your CMS collection also needs somewhere for the content to go: at minimum a title, a slug and a rich text body. Field detection maps onto what exists, it cannot invent a body field.
A custom front end instead
If your site is your own application, connect nothing. Your app reads published posts over the API with a key you generate in Settings, and a publish webhook tells it to rebuild when something goes live.
What done looks like
The post is on your actual website, not just marked complete in Letterstory. A completed publish means your CMS accepted it, which is not the same as your site having rebuilt.