Both receive full
PageDocs. A target that writes them back wholesale is right
for a JSON file and wrong for a CMS; see
publishing a field-level diff.
What a publish carries
- A subset of pages.
POST /publishtakesslugs; 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: falseholds back header and nav changes. - Shared blocks once. A block type declared
shared: trueis one piece of content under one block id.GET /publish/diffreports a change to it once, insharedBlocks, 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. - What did not ship.
onPublishmay returnunsupported(one sentence per change that could not be written, shown as “Not published: …”),notes(what shipping looked like) andwritten: false(deliberately wrote nothing). Library mode and the site contract read the same three fields — see saying what the publish did.
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 samePOST /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:
PublishTarget, PublishContext, PublishOutcome and registerPublishTarget are exported from the package root. Register before the server boots:
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.
Publishing a field-level diff
onPublish(pages) hands you full PageDocs. 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:
pathsis required.sanityPathsaddresses list rows by_key;indexPathsby position, which is correct only when the publish is the only writer. It used to default to Sanity’s form, which emitted_keypatches against stores that have no_key.locatedecides 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. Returnnullfor a block with no upstream document — usually one the editor added — and it is reported asunsupportedrather than dropped.- Each field spec’s
rehydrateundoes 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. Awritefunction canrejecta change it cannot express. - Compare against the right baseline. In library mode
context.baselineis what the adapter last returned, recovered after a restart; see which baseline to diff against. The site contract’sonPublishgets no baseline, so read the source from the CMS. - Strip Avocado’s row ids before any whole-object comparison.
withoutGeneratedItemIdsis exported from the same subpath.diffFieldsnever sees them, because no spec declaresid.
lens.merge; use one or the other, not both.
Publish status
GET /publish/status?session=<id>&siteId=<slug> returns the latest tracker:
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 + 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