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:- If
PUBLISH_TARGET=<name>env var is set and that target is registered → use it verbatim. - Otherwise iterate registered targets in registration order; pick the first whose
canHandle(ctx)returnstrue. - Fall back to the legacy
PUBLISH_MODEenv:git(default) ordeploy_hook→deploy-hook.
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.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.
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.
A publish may not remove every page
A publish whosepages 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
getPagesto 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 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”:
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
maxPagesRemovedis 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. Optionallygit 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
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:/publish/status to surface “building / ready / failed” in real time.
Building a custom target
PublishTarget is a two-method interface:
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:
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 + timestampsavocado-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
VERSION_LOG_CAP (default 100). Older snapshots are evicted FIFO.
See also
- CMS adapters — the site-side hooks the site-contract target calls into
- Architecture — where publishing fits in the orchestrator pipeline
- MCP server — Publishing tools — drive publishes from Claude Desktop