Publish a CMS entry
Every CMS entry has a status (draft | published | scheduled | archived) and a monotonic version. Updates use optimistic locking: every write requires the ifVersion you read. If somebody else (human or worker) updated the entry between your read and write, the write fails and you must re-read.
TL;DR
cms_get_entryto read the currentversion.cms_update_entrywithifVersionto refine the draft —textReplacementsfor edits inside a long field,datafor fields you replace whole.- Decide: publish now (
cms_publish_entry) or later (cms_schedule_publish). - If something's wrong post-publish:
cms_unpublish_entry(back to draft) orcms_restore_version(roll forward to a historical version).
Who publishes, and from where
Publishing is a human decision. A draft entry sits in the dashboard review queue, and — where the organisation has connected Slack — it also raises an approval card in the approvals channel with Publish and Dismiss buttons (skill://slack/connect-slack). Both act through this same tool path, with the same ifVersion check, so a draft edited after the card was posted is refused rather than published from a stale reading of it.
That is the reason not to publish a draft on your own initiative: an entry going live is the operator's call, not an obvious next step after writing one. File the draft and let it be decided.
Step 1 — read the draft
{ "name": "cms_get_entry", "arguments": { "id": "<entryId>" } }
Returns { id, collection, slug, locale, status, data, version, publishedAt, ... }. Hold on to version — every subsequent write needs it.
If you're picking from the queue: cms_list_entries with { "status": "draft", "limit": 50 } first. That returns { entries, returned, dropped, truncated } with long text shortened to a lead — enough to choose a draft, not enough to review one. Read the chosen entry in full before editing it.
Step 2 — refine the draft
{
"name": "cms_update_entry",
"arguments": {
"id": "<entryId>",
"ifVersion": 7,
"data": { /* just the keys you want to change; omitted keys are preserved. Send `key: null` to clear. */ },
"slug": "optional-new-slug",
"locale": "optional-new-locale"
}
}
Update merges the patch into the existing payload, then increments version to 8 and re-validates the merged result against the collection schema, regenerates the search-text + embedding, and rewires inbound references. You now have version 8 — use it for the next write.
To change a sentence, a paragraph or an image inside a long field, don't resend the field. Pass textReplacements instead — exact find-and-replace edits applied to the stored text, reaching into block prose on a blocks field:
{
"name": "cms_update_entry",
"arguments": {
"id": "<entryId>",
"ifVersion": 7,
"textReplacements": [
{ "field": "body", "oldText": "ships every tuesday", "newText": "ships every Tuesday" }
],
"responseFormat": "summary"
}
}
Each oldText must occur exactly once in the field (or set replaceAll: true); a miss fails the whole call with cms_replacement_no_match and nothing is written. A field goes in data or in textReplacements, not both. The full editing loop, including how blocks and inline images are matched, is skill://cms/revise-entry.
responseFormat: "summary" returns the entry in the cms_list_entries shape — long text shortened to a lead with a word count in fieldSummary — which is all you need to confirm the write landed. Create, update and restore default to full; publish, unpublish and schedule default to summary because they don't change content.
If you get a cms_version_conflict error, re-read with cms_get_entry and retry. Don't blindly bump the number.
Step 3 — publish
Immediate
{ "name": "cms_publish_entry", "arguments": { "id": "<entryId>", "ifVersion": 8 } }
Stamps publishedAt, flips status: 'published', returns the entry at version 9 — as a summary, since the content did not change. Pass "responseFormat": "full" if you need the body back.
Backdated (migrated content)
{
"name": "cms_publish_entry",
"arguments": {
"id": "<entryId>",
"ifVersion": 8,
"publishedAt": "2019-04-12T08:30:00Z"
}
}
Pass publishedAt when the entry was originally published somewhere else — importing a blog from another CMS, for instance. Without it every migrated article gets today's date and the archive loses its chronological order. cms_create_entry takes the same field alongside status: "published", so a one-shot import can set it at creation time. The delivery API orders by publishedAt descending, so this is what a frontend archive sorts on — no need to duplicate the date into a data field.
Scheduled
{
"name": "cms_schedule_publish",
"arguments": {
"id": "<entryId>",
"ifVersion": 8,
"scheduledAt": "2026-05-15T08:00:00Z"
}
}
A worker drains the schedule queue every ~60 seconds. Status becomes scheduled; the worker flips it to published at or after scheduledAt.
Scheduled entries are listed under Scheduled on the dashboard overview alongside scheduled outreach sends, where an operator can read the entry and call the publish off. Calling it off returns the entry to draft and clears scheduledAt — the same transition as cms_unpublish_entry.
Publishing announces itself in Slack
Not a step — there is nothing to call, and no reason to post to Slack yourself after publishing. If the org has Slack connected, the publish itself posts an announcement (":rocket: Published — <title>", the collection and locale, and a link to the live article) into the content channel when one is routed, otherwise the default channel. Scheduled publishes announce when the worker promotes them. Telling the operator where it will land is skill://slack/connect-slack.
Two things you can do to make it useful:
-
Set a live URL template, once per collection, so Munin can link the rendered article. Placeholders:
{slug},{locale},{collection}(percent-encoded on substitution). Without it the announcement still posts, just without a link.{ "name": "cms_update_collection", "arguments": { "idOrSlug": "blog", "patch": { "settings": { "liveUrl": "https://www.example.com/{locale}/blog/{slug}", "previewUrl": "https://www.example.com/api/preview?token={token}&slug={slug}&locale={locale}" } } } }settingsREPLACES the stored object — read the collection first and send back every key you want to keep (previewUrlabove is a reminder, not a requirement). A template that doesn't resolve to anhttp(s)URL is ignored, and the announcement posts without a link.On a localized collection,
{locale}substitutes the locale code as stored —nb-NO, notno. Sites whose paths don't spell locales that way take a map instead of a string, keyed by locale code with an optionaldefaultfor the rest:{ "liveUrl": { "default": "https://www.example.com/en/blog/{slug}", "nb-NO": "https://www.example.com/no/blog/{slug}", "sv-SE": "https://www.example.com/sv/blog/{slug}" } }Keys match case-insensitively. A locale the map names neither directly nor through
defaultpublishes without a link, which is how you keep a locale that isn't on the site yet out of the announcement. -
Give the collection a title-ish field. The headline comes from the entry's
title,headline,name, orheadingfield, in that order; with none of them the slug is used.
Re-publishing an entry that is already published (a no-op status transition) does not announce again — only a real draft/scheduled → published move does.
Publishing can also draft the social posts
Off by default, and per collection. Set socialDraftOnPublish: true in the collection's
settings — same read-merge-write as liveUrl above, in the same call if you like — and the
first time an entry there reaches published, Munin queues a drafting pass that writes
three or four companion posts and leaves them in the review queue for a person to pick
from. Nothing is posted; the drafts just appear alongside the other things waiting to be
decided.
{
"name": "cms_update_collection",
"arguments": {
"idOrSlug": "blog",
"patch": {
"settings": {
"liveUrl": "https://www.example.com/{locale}/blog/{slug}",
"socialDraftOnPublish": true
}
}
}
}
It needs the liveUrl template — a post with nothing to link to is not worth drafting —
and it runs once per entry per platform: a scheduled entry promoted by the worker counts,
an already-published entry edited and republished does not. An organisation with both
LinkedIn and a Facebook Page connected gets one set for each, since the two are written
differently. Turn it on for collections holding articles; leave it off for team pages and
product records, which is why the default is off.
skill://social/draft-companion-posts is what the pass follows.
Publishing several locales of the same article does not fill the channel with near-identical headlines: the first locale posts to the channel and the rest of its translation group thread under it the same UTC day. Nothing to pass — the grouping follows translationGroupId, which cms_create_entry's translationOf and cms_link_translation already set.
Step 4 — rollback paths
Unpublish (back to draft)
{ "name": "cms_unpublish_entry", "arguments": { "id": "<entryId>", "ifVersion": 9 } }
Clears publishedAt and sets status: 'draft'. Content unchanged.
Restore an earlier version
{ "name": "cms_list_versions", "arguments": { "entryId": "<entryId>" } }
→ pick the version you want to restore.
{
"name": "cms_restore_version",
"arguments": { "entryId": "<entryId>", "version": 5, "ifVersion": 9 }
}
Restore is itself a write — it creates a new version (10) carrying the data from version 5. Old versions remain in history. If the entry was published, it stays published with the restored data.
What NOT to do
- Don't reuse a stale
ifVersion. Every successful write bumpsversion. The next write must use the new number, not the one you originally read. - Don't manually publish an entry that's currently
scheduled. The worker may run between your manual publish and its own tick, overwriting your data with the older scheduled snapshot. If you need to take over a scheduled entry,cms_unpublish_entryfirst to clear the schedule, then republish manually. - Don't skip
cms_list_versionsbefore restoring. Versions are 1-indexed and stable, but only the listing tells you what's actually different.
Related
skill://cms/revise-entry— editing an existing entry in place withtextReplacements.skill://cms/preview-entry— the draft-side sibling ofsettings.liveUrl.skill://slack/connect-slack— the approval card for a draft, and routing for the channels the card and the publish announcement land in.skill://cms/localize-entry— managing per-locale entries.skill://cms/upload-asset-and-embed— how to embed assets in entry data.skill://cms/migrate-content— moving entries between collections.skill://social/draft-companion-posts— the companion postssettings.socialDraftOnPublishqueues.