Munin
Munin developer portal
Get a key →

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

  1. cms_get_entry to read the current version.
  2. cms_update_entry with ifVersion to refine the draft — textReplacements for edits inside a long field, data for fields you replace whole.
  3. Decide: publish now (cms_publish_entry) or later (cms_schedule_publish).
  4. If something's wrong post-publish: cms_unpublish_entry (back to draft) or cms_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}"
          }
        }
      }
    }
    

    settings REPLACES the stored object — read the collection first and send back every key you want to keep (previewUrl above is a reminder, not a requirement). A template that doesn't resolve to an http(s) URL is ignored, and the announcement posts without a link.

    On a localized collection, {locale} substitutes the locale code as stored — nb-NO, not no. Sites whose paths don't spell locales that way take a map instead of a string, keyed by locale code with an optional default for 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 default publishes 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, or heading field, 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 bumps version. 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_entry first to clear the schedule, then republish manually.
  • Don't skip cms_list_versions before 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 with textReplacements.
  • skill://cms/preview-entry — the draft-side sibling of settings.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 posts settings.socialDraftOnPublish queues.