Skip to main content
When a user clicks Publish in the editor, the orchestrator takes the in-memory draft and hands it to a publish target — a plugin that writes the content to wherever you actually serve from. The product 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

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_hookdeploy-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. Your site’s /api/editor/publish handler is wired by the Site SDK. 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.
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.
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.
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.

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. 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”:
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:
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: 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:
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.
Every publish writes the snapshot, git adds 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:
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:
Register before the server boots:
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:
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.

Publish status

GET /publish/status?session=<id>&siteId=<slug> returns the latest tracker:
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", vercelState: "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