CMS connection
Put your posts on your own site
Connect Webflow, Framer, Sanity, Contentful or a GitHub repo to import your posts and publish new ones to your site. Follow the steps or hand the prompt to your agent. With GitHub, publishing opens a pull request.
Before you start
- 1Create an API key (Settings → API keys → Create key). Send it in the x-integrations-key header, not Authorization: Bearer.
- 2Have a CMS collection ready with a title, slug and rich-text body field.
- 3Get a token with write access. Webflow: Site settings → Apps & integrations → API access. Sanity: sanity.io/manage → API → Tokens. Contentful: a Content Management API token. Framer: plugin settings. GitHub: a token with Contents and Pull requests write. Read-only tokens fail at publish.
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.The five steps
Connect the provider
Credentials: Webflow (api_token), Framer (project_url, api_token), Sanity (project_id, dataset, api_token), Contentful (space_id, environment_id, api_token), GitHub (api_token). Tokens need write access and are checked before saving. Reconnecting replaces them.
- list_cms_connections
- connect_cms
- disconnect_cms
Establish the mapping
Letterstory publishes to a mapping, not a connection; without one, list_publish_targets is empty. Webflow, Sanity and Contentful auto-detect it on first import; Framer needs an existing one. For GitHub, set_github_publish_target sets repo, branch and a path containing {slug}, and invalid targets are refused with a reason.
- set_github_publish_target
- list_cms_mappings
- list_publish_targets
Import into the main blog and wire it
Posts publish from the main blog. New blog: onboard_collection with cms_provider imports and wires it. Existing blog: import_from_cms (safe to re-run). To publish without importing, use bind_collection_cms. Phantoms can't publish to your CMS.
- onboard_collection
- import_from_cms
- bind_collection_cms
- list_collections
- get_collection_config
Publish out
Publish the post (publish_article), then publish_to_connector and poll get_publish_status. Webflow sites must be published once in Webflow first. On GitHub this opens a PR. To publish on schedule, turn on publishing.cms.
- publish_article
- publish_to_connector
- get_publish_status
- get_publish_failures
- set_collection_config
Automate the rebuild
For custom front ends: set_webhook fires on publish (https only). The body carries article_id; fetch the post with get_published. Deliveries aren't retried, so also poll list_published. Skip if a connector publishes.
- set_webhook
- get_webhook
- list_published
- get_published