---
title: "Publishing and the draft lifecycle"
description: "The ComStack draft lifecycle over MCP: draft on edit, doc-id rotation, slug as the durable handle, zero-diff and duplicate-draft rules."
url: "https://comstack.ai/docs/mcp/publishing/"
---
## 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

| 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

```text
(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 token** — `confirmation_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](/docs/mcp/errors.md) for the full list.

## Related

- [First-run guide](/docs/mcp/guide.md) — the whole loop in order
- [Page types](/docs/mcp/page-types.md) — what gets drafted for knowledge vs info
- [Errors](/docs/mcp/errors.md) — `duplicate_draft_target` and token errors in full
- [Publishing API](/docs/api/publish.md) — the underlying HTTP pipeline
- [Workflow examples](/docs/mcp/examples.md) — end-to-end tool sequences
