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
publish— a dry run. Returns a per-page diff manifest (change_typecreateorreplace, before/after title + description + byte sizes,will_publish_to) plus a single-useconfirmation_tokenwith 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.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" }.publish-status— poll with thepublish_iduntilsucceeded(returnspublished_urls, each carrying the new live path) orfailed.
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
pathfrom last week no longer resolves
Fields you will read
| Source | Field | Meaning |
|---|---|---|
publish manifest | drafts[].change_type | create (new) or replace (existing) |
drafts[].will_publish_to | the live URL this draft targets | |
summary | { total, creates, replaces } | |
translations_planned | locale siblings the pipeline will write | |
confirmation_token | single-use, 5-min TTL | |
publish-confirm | publish_id, status | running immediately |
publish-status | status | queued / 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 Xupdate-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.publishrefuses and mints no token; the error’scollisions[]lists the colliding draft paths. Keep one anddiscard-draftthe rest (or change a slug), then publish again. The same slug in two different locales is fine.- Expired or used token —
confirmation_tokenis single-use with a 5-minute TTL. Re-runpublish. - Manifest changed since the dry run — a draft or the theme moved between
publishandpublish-confirm. The confirm aborts; re-runpublish. - 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.
Related
- First-run guide — the whole loop in order
- Page types — what gets drafted for knowledge vs info
- Errors —
duplicate_draft_targetand token errors in full - Publishing API — the underlying HTTP pipeline
- Workflow examples — end-to-end tool sequences