Skip to content

Publishing and the draft lifecycle

What this is

How a change goes from an edit to a deployed page over MCP — the draft lifecycle, the two-step (really three-call) publish, and the one fact that trips up agents: the document id rotates on every publish, so the durable way to name a page is its slug + language, never its path.

How it works

Drafts

Editing a live page never mutates it. update-page writes a new draft document and leaves the live URL serving the old content until you publish. Two properties matter:

  • Find-or-create. Repeated edits to the same (slug, language) update the same draft — sequential edits collapse to one draft, not a pile.
  • Zero-diff is a no-op. An edit that changes nothing creates no draft, and an already-existing draft that is byte-identical to live is dropped from the manifest, the translation count, and promotion.

Drop a draft you do not want with discard-draft; it refuses live, hidden, and archived paths.

Doc-id rotation, slug as the handle

The Firestore document id is the path. When a live page is edited, the new draft gets a fresh auto-generated id; on publish that draft is promoted in place and the superseded live document is deleted. The net effect: the path that serves a given URL changes after every edit-publish cycle. The page’s canonical_slug is preserved across the cycle.

So address pages by their durable (slug, language) handle — get-page-content and update-page accept it directly — and read the new path back from publish-status.published_urls after each publish. Never cache a path across a publish.

The publish, step by step

  1. publish — a dry run. Returns a per-page diff manifest (change_type create or replace, before/after title + description + byte sizes, will_publish_to) plus a single-use confirmation_token with a 5-minute TTL. It writes nothing. Publishing is all-or-nothing across the project: the manifest lists every pending draft, not just yours — review the whole list before confirming.
  2. publish-confirm — consumes the token, re-checks that no draft or theme changed since the dry run (a drift abort), then runs the pipeline asynchronously: promote → translate → second-pass validate → deploy. Returns immediately with { publish_id, status: "running" }.
  3. publish-status — poll with the publish_id until succeeded (returns published_urls, each carrying the new live path) or failed.

Translations

translations_planned in the manifest counts the locale siblings the pipeline will write — the sum over drafts of the target locales minus the source locale. When a project has no target locales configured, it is 0 (no phantom count).

When to use it

  • Building automation that edits then publishes and needs the post-publish URL
  • Understanding why a second edit did not create a second draft
  • Explaining why a path from last week no longer resolves

Fields you will read

SourceFieldMeaning
publish manifestdrafts[].change_typecreate (new) or replace (existing)
drafts[].will_publish_tothe live URL this draft targets
summary{ total, creates, replaces }
translations_plannedlocale siblings the pipeline will write
confirmation_tokensingle-use, 5-min TTL
publish-confirmpublish_id, statusrunning immediately
publish-statusstatusqueued / running / succeeded / failed
published_urls[]{ title, language, slug, url } — the new live path

Example

(two edits, one draft)
update-page(project_id, slug: 'guides/intro', content: '## v1') -> draft_id X
update-page(project_id, slug: 'guides/intro', content: '## v2') -> draft_id X (same draft)
publish(project_id, project_name)
-> manifest { drafts: [ { slug: 'guides/intro', change_type: 'replace',
will_publish_to: 'https://example.com/guides/intro/' } ],
summary: { total: 1, creates: 0, replaces: 1 },
translations_planned: 0 },
confirmation_token (single-use, 5-min TTL)
publish-confirm(project_id, confirmation_token) -> { publish_id, status: 'running' }
publish-status(project_id, publish_id)
-> { status: 'succeeded',
published_urls: [ { slug: 'guides/intro', language: 'en',
url: 'https://example.com/guides/intro/' } ] }

Common errors

  • duplicate_draft_target — two pending drafts aim at the same (slug, language) URL. publish refuses and mints no token; the error’s collisions[] lists the colliding draft paths. Keep one and discard-draft the rest (or change a slug), then publish again. The same slug in two different locales is fine.
  • Expired or used tokenconfirmation_token is single-use with a 5-minute TTL. Re-run publish.
  • Manifest changed since the dry run — a draft or the theme moved between publish and publish-confirm. The confirm aborts; re-run publish.
  • Stale path — a doc id from before a publish no longer resolves. Re-resolve by (slug, language).

See the error reference for the full list.

Last updated: