> ## Documentation Index
> Fetch the complete documentation index at: https://docs.avocadostudio.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Publishing

> Promote draft content to production. Built-in targets cover Git snapshots, Vercel deploy hooks, and the site-contract POST — which is authenticated, and which refuses a publish that would empty the site.

This page is for the developer deciding where a publish writes, and writing the
code that receives it. When a user clicks **Publish** in the editor, the
orchestrator takes the draft and hands it to whatever writes your content.
Which code that is depends on how the orchestrator runs:

| Orchestrator | `POST /publish` calls | Your code |
| - | - | - |
| Library mode, `createOrchestrator({ adapter })` inside your app | `adapter.onPublish(pages, config, context)` | the [`CmsAdapter`](/integration/cms-adapters#publishing-back) |
| Standalone server | a **publish target** — by default the site contract, which posts to your site's `/api/editor/publish` | `onPublish` on `createEditorApiHandler` |

Both receive full `PageDoc`s. A target that writes them back wholesale is right
for a JSON file and wrong for a CMS; see
[publishing a field-level diff](#publishing-a-field-level-diff).

## What a publish carries

* **A subset of pages.** `POST /publish` takes `slugs`; the editor's publish
  review sends the ticked pages. Every other page goes out exactly as it is
  live, so your code still receives the whole site. `includeSiteConfig: false`
  holds back header and nav changes.
* **Shared blocks once.** A block type declared `shared: true` is one piece of
  content under one block id. `GET /publish/diff` reports a change to it once,
  in `sharedBlocks`, with the pages it affects. A subset publish ships the
  ticked page's version on every page that carries it, so the pages you
  receive agree. See [shared blocks](/integration/block-system#shared-blocks-site-wide-content).
* **What did not ship.** `onPublish` may return `unsupported` (one sentence per
  change that could not be written, shown as "Not published: …"), `notes`
  (what shipping looked like) and `written: false` (deliberately wrote
  nothing). Library mode and the site contract read the same three fields —
  see [saying what the publish did](/integration/cms-adapters#saying-what-the-publish-did).

With the pre-alpha site ops agents switched on (`SITE_OPS_AGENTS=1`; off by
default), an open Edit safety alert holds publishing the pages it covers with
a `409` until someone reverts it or marks it intended.

## Publish targets (standalone server)

The standalone server ships three built-in targets and a registry for plugging in your own. The same `POST /publish` endpoint dispatches to whichever target wins selection.

### The three built-in targets

| Target | Name | When it's used |
| - | - | - |
| **Site contract** | `site-contract` | The default. Posts the draft pages to your Next.js site's `/api/editor/publish` route. Your site's adapter then persists to your CMS. |
| **Git** | `git` | Writes a JSON snapshot to disk and (optionally) commits + pushes it. Good for "git as your CMS" setups or local dev. |
| **Deploy hook** | `deploy-hook` | Calls a Vercel/Netlify deploy hook URL. Use when content is committed externally and you just need to trigger a redeploy. |

#### Selection order

Every publish call runs through the registry in this order:

1. If `PUBLISH_TARGET=<name>` env var is set and that target is registered → use it verbatim.
2. Otherwise iterate registered targets in registration order; pick the first whose `canHandle(ctx)` returns `true`.
3. Fall back to the legacy `PUBLISH_MODE` env: `git` (default) or `deploy_hook` → `deploy-hook`.

Only `site-contract` self-selects: it claims any request that supplied a `siteOrigin`. `git` and `deploy-hook` declare no `canHandle`, so they are reached only through `PUBLISH_TARGET=<name>` or the legacy `PUBLISH_MODE` fallback.

### Site contract (the default)

Most production deployments use this. Avocado posts the draft to your site's `/api/editor/publish` route, your site validates the shared token, and your adapter writes to the CMS.

```mermaid theme={null}
sequenceDiagram
  Editor->>Orchestrator: POST /publish
  Orchestrator->>Site: POST /api/editor/publish (x-publish-token)
  Site->>Site: token check, then destructive-publish guard
  Site->>CMS: adapter.writePage(doc)
  CMS-->>Site: ok
  Site-->>Orchestrator: { ok: true }
  Orchestrator-->>Editor: published
```

Your site's `/api/editor/publish` handler is wired by the [Site SDK](/integration/nextjs-integration). It validates the shared secret, then calls your CMS adapter's write path. The orchestrator never talks to your CMS directly — your site is always in the middle, which means your existing CMS auth, hooks, and validation all still run.

#### The publish route is authenticated

This route overwrites a site's content, so publishing anywhere that is not your own machine requires a token.

```bash theme={null}
# On the orchestrator. Sent as the `x-publish-token` header.
PUBLISH_TOKEN=<shared secret>
```

On the site, pass the same value as `publishSecret` to `createEditorApiHandler` — the
handler trims the incoming `x-publish-token` and compares it to that string exactly.
`DRAFT_MODE_SECRET` is a different secret for draft-mode preview, and is not read on the
publish path.

```ts theme={null}
export const { GET, POST, OPTIONS } = createEditorApiHandler({
  getPages,
  onPublish,
  publishSecret: process.env.PUBLISH_TOKEN,
})
```

**With no `publishSecret` configured, the route refuses every request under
`NODE_ENV=production`** — HTTP 401, `error: "unauthorized"`, and a `reason` that names
`PUBLISH_TOKEN` and `publishSecret`. It is not optional-if-you-remember: `publishSecret`
used to be an ordinary option, every scaffold wired it to a variable nothing ever set, and
the resulting endpoint accepted any caller's `pages` array. A deployment can be correctly
gated on `/api/avocado/*` and wide open here, because the two route groups are different
handlers.

Development is deliberately left open — publishing to your own machine is the point — but
it is the same code path that ships, so the first unauthenticated publish in a process
prints the refusal text to the console as a warning.

<Warning>
  `PUBLISH_TOKEN` is only sent when it is set on the **orchestrator**. If the site
  has a `publishSecret` and the orchestrator has no `PUBLISH_TOKEN`, the site answers
  every publish **401** `Invalid or missing publish token`, and the editor sees a failed
  publish carrying that sentence. Set the same value on both.
</Warning>

#### A publish may not remove every page

A publish whose `pages` array is empty is refused with HTTP 409 and
`error: "refused"`. To empty a site on purpose, resend the same request with
`"allowDelete": true` in the body.

The rule is drawn at "all of them" rather than at "any of them" because of what each case
actually is. Removing some pages is an ordinary edit, made deliberately, one page at a
time — gating it would gate normal work. What produces an *empty* array is almost never a
person deleting a whole site: it is a client publishing what it thinks it has after its
own state failed to load. Before the guard existed, the route validated shape and nothing
else, and `[]` is a valid array — so that request was indistinguishable from one fixing a
heading, and it answered `{"ok":true,"slugs":[]}`. Losing a site to a failed fetch is not
a decision anybody made, and `allowDelete` is a thing somebody types on purpose and cannot
arrive at by omission.

```mermaid theme={null}
flowchart TD
  A[POST /api/editor/publish] --> B{allowDelete true?}
  B -- yes --> P[publish]
  B -- no --> C{pages array empty?}
  C -- yes --> E{site already empty?}
  E -- yes --> P
  E -- no --> R[409 refused]
  C -- no --> D{maxPagesRemoved set, baseline readable?}
  D -- no --> P
  D -- yes --> F{removes more than the bound?}
  F -- yes --> R
  F -- no --> P
```

Two edges are deliberate and worth knowing before you hit them:

* **A site that is already empty may still publish empty.** That publish removes nothing,
  and refusing it would fail a brand-new integration on its very first publish, before it
  has any content to protect.
* **A baseline read that throws does not open the gate.** The handler reads your
  `getPages` to find out what is currently published, and does so best-effort: a CMS read
  that times out becomes "no baseline" rather than an error, because a publish is not the
  moment to fail on a read. But "no baseline" is not "the site was empty" — an empty
  publish with no baseline is still refused. Not knowing what is there is not a reason to
  overwrite it with nothing.

The refusal says what it protected. With a baseline it reads `The site currently has 3
pages.`; without one, `The site currently has its pages.` The guard runs before
`onPublish`, so a refused publish never reaches your adapter.

#### maxPagesRemoved

`maxPagesRemoved` is for a site that wants a tighter bound than "not all of them":

```ts theme={null}
export const { GET, POST, OPTIONS } = createEditorApiHandler({
  getPages,
  onPublish,
  publishSecret: process.env.PUBLISH_TOKEN,
  maxPagesRemoved: 2,
})
```

A publish removing more pages than the bound is refused with the same 409, and the
`reason` names the slugs it stopped. Three things follow from how it is measured:

* Only removals are counted, by comparing the incoming slugs against the baseline's — so a
  publish that **renames** a slug spends one of the budget, even though the page is still
  there under its new slug. Added pages cost nothing.
* It is measured against `getPages`, so it is only enforceable when that read succeeds. An
  unreadable baseline means the bound cannot be applied at all.
* It never loosens the main rule. A publish removing *every* page is refused whatever
  `maxPagesRemoved` is set to.

`createEditorApiHandler` passes its own `getPages` — the same getter that serves
`/api/editor/pages` — as the baseline, so any route built through it can count what a
publish would remove. A `createPublishHandler` wired by hand may omit `getPages`; it then
gets the baseline-free rule, which still refuses an empty publish but cannot enforce
`maxPagesRemoved`.

#### Applying the rule to a hand-wired route

The rule is exported on its own, so a route you built yourself can apply exactly the same
decision rather than an approximation of it. It is available from both routes entry
points — `@avocadostudio-ai/site-sdk/routes` for Next, and
`@avocadostudio-ai/site-sdk/routes/core` for every other host:

```ts theme={null}
import { checkDestructivePublish } from "@avocadostudio-ai/site-sdk/routes"
// Outside Next:
// import { checkDestructivePublish } from "@avocadostudio-ai/site-sdk/routes/core"

const guard = checkDestructivePublish({
  next: body.pages,                       // what the publish would leave behind
  before: await getPages(),               // or undefined, if you cannot read it
  allowDelete: body.allowDelete === true,
  maxPagesRemoved: 2,                     // optional
})

if (guard.refused) {
  return Response.json(
    { ok: false, error: "refused", reason: guard.reason },
    { status: 409 }
  )
}
```

`checkDestructivePublish` returns `{ refused: false }` or `{ refused: true, reason }` —
the `PublishGuardResult` type, exported alongside it. `DESTRUCTIVE_PUBLISH_HINT`, the
sentence every empty-publish refusal starts with, is exported too, so a client can match
on it.

#### What the route answers

`POST /api/editor/publish`, in the order the handler checks things:

| Status | `error` | `reason` | When |
| - | - | - | - |
| 401 | `unauthorized` | names `PUBLISH_TOKEN` and `publishSecret` | No `publishSecret` configured, under `NODE_ENV=production` |
| 401 | `Invalid or missing publish token` | — | `publishSecret` set; `x-publish-token` missing or not equal to it |
| 400 | `pages must be an array` | — | `body.pages` is not an array |
| 409 | `refused` | names `allowDelete`, and what the publish would have removed | The destructive-publish guard |
| 500 | the adapter's error, or the thrown message | — | `onPublish` returned `ok: false`, or something threw |
| 200 | — | — | Published. Body carries `ok: true` and the published `slugs`. |

The two refusals with something to explain — the unconfigured 401 and the 409 — put the
machine-readable verdict in `error` and the sentence for a person in `reason`. Read both:
`error` is what your code branches on, `reason` is the only part that tells a human what
to do next. The bad-token 401 has no `reason`; its whole message is the `error` string.

### Git target

Writes the draft pages as a JSON snapshot under your site's content directory. Optionally `git commit && git push` the change so Vercel / Netlify pick it up.

Good for:

* Local development without a CMS
* "Git as your CMS" setups where content is checked into the repo
* Static sites where publishing means triggering a rebuild

**Env vars:**

```bash theme={null}
PUBLISH_TARGET=git
PUBLISH_GIT_BRANCH=main               # branch to push to (default: main)
PUBLISH_GIT_STRICT=1                  # optional: abort if the working tree is dirty
PUBLISH_GIT_TOKEN=<token>             # or GITHUB_TOKEN, for the push
PUBLISH_GIT_AUTHOR_NAME="Avocado Studio"
PUBLISH_GIT_AUTHOR_EMAIL=publish@avocadostudio.ai
```

<Warning>
  The git target takes no path configuration. It resolves the repo root as
  `process.cwd()/../..` and writes to the literal `apps/site/lib/published-content.json`,
  which means it only works from inside this monorepo. For your own site, use
  `site-contract`, `deploy-hook`, or a custom target.
</Warning>

Every publish writes the snapshot, `git add`s it, commits with a generated message and
pushes — there is no opt-in flag. If the push fails (auth, conflicts) the call returns
**400** and the tracker records the error.

The destructive-publish guard lives in the site's publish route, not in the orchestrator,
so it does not apply here: this target writes the snapshot it is given.

### Deploy hook target

Calls a Vercel or Netlify deploy hook URL. Useful when content is already in place — committed to the repo, or written to a CMS by some other process — and "publish" just means "redeploy."

**Env vars:**

```bash theme={null}
PUBLISH_TARGET=deploy-hook
VERCEL_DEPLOY_HOOK_URL=https://api.vercel.com/v1/integrations/deploy/...
```

The orchestrator POSTs to the URL, captures the deployment id, and the editor polls `/publish/status` to surface "building / ready / failed" in real time.

### Building a custom target

`PublishTarget` is a two-method interface:

```ts theme={null}
import type { PublishContext, PublishOutcome, PublishTarget } from "@avocadostudio-ai/orchestrator-core"

export class S3PublishTarget implements PublishTarget {
  readonly name = "s3"

  canHandle(ctx: PublishContext): boolean {
    return process.env.PUBLISH_TARGET === "s3"
  }

  async publish(ctx: PublishContext): Promise<PublishOutcome> {
    const now = new Date().toISOString()
    const key = `sites/${ctx.siteId}/content.json`
    await s3.putObject({ Bucket: BUCKET, Key: key, Body: JSON.stringify(ctx.pages) })
    return {
      ok: true,
      httpStatus: 200,
      tracker: {
        session: ctx.session,
        status: "triggered",
        startedAt: now,
        updatedAt: now,
        slugs: ctx.slugs,
      },
      response: { ok: true, slugs: ctx.slugs, bucket: BUCKET, key },
    }
  }
}
```

`PublishTarget`, `PublishContext`, `PublishOutcome` and `registerPublishTarget` are exported from the package root. Register before the server boots:

```ts theme={null}
import { registerPublishTarget } from "@avocadostudio-ai/orchestrator-core"
registerPublishTarget(new S3PublishTarget())
```

The orchestrator's route handler does nothing target-specific — it builds the `PublishContext` (session, scoped session, draft pages, slugs, site config, image dir, logger), calls `target.publish(ctx)`, saves the returned tracker, and replies with `outcome.response` at `outcome.httpStatus`. Your target controls every byte of the wire response.

#### A target that talks to a publish route must surface `reason`

If your target POSTs to a route that can refuse — anything built on the site contract —
put the refusal's `reason` in your failure `response`, falling back to `error`:

```ts theme={null}
reason: siteResult.reason ?? siteResult.error ?? "site publish failed"
```

`SiteContractPublishTarget` does exactly that, and it did not always. It read only
`error`, so the actionable half of every refusal died one hop from the person who tripped
it: the editor showed the word `unauthorized` and nothing about `PUBLISH_TOKEN`, and
would have shown `refused` and nothing about `allowDelete`. A guard whose explanation
never reaches the user is the same failure as having no explanation at all.

## Publishing a field-level diff

`onPublish(pages)` hands you full `PageDoc`s. That is enough when the store's
shape is the editor's shape — a JSON file. A CMS read is a projection: an asset
reference flattened to a URL, a reference resolved to an href, rich text turned
into a document. Writing the projection back replaces the reference with the
flattening, and rewrites every document whether anyone touched it or not.

So a CMS publisher writes a diff: for each field, compare the edited value
against the value it was projected from, and emit a write only where they
differ. `@avocadostudio-ai/site-sdk/publish` is that loop:

```ts theme={null}
import { diffPage, groupPatches, describeUnsupported, sanityPaths } from "@avocadostudio-ai/site-sdk/publish"

const diff = diffPage({
  page,
  ctx: { lang: "fr" },
  paths: sanityPaths,                 // or indexPaths, or your own PathSyntax
  locate: (block) => {
    const source = sourceBlocks.get(block.id)
    if (!source) return null          // reported as unsupported, never skipped silently
    return {
      documentId: page.id,
      prefix: `pageBuilder[_key=="${block.id}"].`,
      specs: SPECS[block.type],       // per field: rehydrate(props, before, ctx), optional write
      source,
    }
  },
})

for (const { documentId, set } of groupPatches(diff.patches)) {
  await client.patch(documentId).set(set).commit()
}
return { ok: true, unsupported: describeUnsupported(diff.unsupported) }
```

* **`paths` is required.** `sanityPaths` addresses list rows by `_key`;
  `indexPaths` by position, which is correct only when the publish is the only
  writer. It used to default to Sanity's form, which emitted `_key` patches
  against stores that have no `_key`.
* **`locate` decides where a block writes.** A block placed by a shared
  section can write to the section's document rather than the page's; return
  an array to split one block across several documents. Return `null` for a
  block with no upstream document — usually one the editor added — and it is
  reported as `unsupported` rather than dropped.
* **Each field spec's `rehydrate`** undoes your projection, given the edited
  props and the value the CMS holds, so it can replace one key of a stored
  object and keep the rest. A `write` function can `reject` a change it cannot
  express.
* **Compare against the right baseline.** In library mode `context.baseline` is
  what the adapter last returned, recovered after a restart; see
  [which baseline to diff against](/integration/cms-adapters#which-baseline-to-diff-against).
  The site contract's `onPublish` gets no baseline, so read the source from the
  CMS.
* **Strip Avocado's row ids before any whole-object comparison.**
  `withoutGeneratedItemIds` is exported from the same subpath. `diffFields`
  never sees them, because no spec declares `id`.

A [field table](/integration/field-table) lens does the same job through
`lens.merge`; use one or the other, not both.

## Publish status

`GET /publish/status?session=<id>&siteId=<slug>` returns the latest tracker:

```json theme={null}
{
  "status": "triggered" | "failed",
  "deploymentId": "dpl_abc123",
  "deployState": "BUILDING",
  "vercelState": "BUILDING",
  "inspectUrl": "https://vercel.com/...",
  "lastCheckError": null
}
```

`deployState` is `READY`, `ERROR`, or the host's in-progress state. Every target
reports it — a site-contract publish that writes files on your machine is `READY` the
moment it returns — so read it rather than `vercelState`, which carries the same value
under the name it had when every publish was a Vercel deploy hook, and is kept for
clients that already read it. `POST /publish` replies carry both too.

The editor polls this endpoint after a publish to show build progress inline. A
tracker's `status` is only `"triggered"` or `"failed"`; targets with no async deployment
(git, S3) report `"triggered"` and are done. When nothing has been published in this
process yet the route answers HTTP 200 with `status: "idle"`.

A refused site publish lands here as a failed tracker: `status: "failed"`, `deployState:
"ERROR"`, and `deployStatus` carrying the site's own status code — 401 or 409 — while the
orchestrator's reply to the editor is a 400 whose `reason` is the site's sentence. The
code the site sent survives in the tracker; the explanation survives in the response.

## Snapshots and rollback

Every publish writes a snapshot to the version log. From the MCP server (or the editor's history panel) you can:

* `avocado-list-snapshots` — list published snapshots with commit shas + timestamps
* `avocado-restore-snapshot` — rewind the draft to a specific snapshot (doesn't trigger a republish; you publish again to push the rolled-back version live)
* `avocado-compute-publish-diff` — diff the current draft against the last published snapshot before publishing

The number of snapshots kept is bounded by `VERSION_LOG_CAP` (default 100). Older snapshots are evicted FIFO.

## See also

* [CMS adapters](/integration/cms-adapters) — the site-side hooks the site-contract target calls into
* [Architecture](/architecture) — where publishing fits in the orchestrator pipeline
* [MCP server — Publishing tools](/integration/mcp-server#publishing) — drive publishes from Claude Desktop


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.