The contract
Every adapter implements the same two-method interface from@avocadostudio-ai/orchestrator-core:
onPublish receives the
session’s SiteConfig and a context carrying inline image blobs plus context.baseline
— the baseline is what lets a field-level publisher diff instead of overwriting, and it
holds the pages your adapter last returned, not the live site. See
which baseline to diff against. And capabilities
declares what the site can actually honour, so an agent is told “this site cannot create
pages” rather than creating one that fails the publish transaction.
getPages()is called lazily on the first chat for a fresh session. Its result seeds SQLite so chat turns like “edit the homepage hero” resolve against your real content instead of a 404.onPublish()is called when a client POSTs to/publishon the library-mode handler with{ session, siteId }. The orchestrator reads the current draft from SQLite and hands the resultingPageDoc[]to the adapter. If you omitonPublish,/publishbecomes a200no-op (withwritten: falsein the response) and SQLite still holds the draft.
Drafts and published content
Most content stores keep two versions of a document: the one visitors see, and the one an editor is working on. Sanity calls them perspectives, Contentful splits them across the Delivery and Preview APIs, Strapi calls them published and draft entries. Avocado reads your content twice, for two different questions:
So
getPages takes an optional { perspective: "draft" | "published" }:
Ignoring
options is a valid adapter. You get exactly the behaviour Avocado had before this parameter existed: one list for both callers. Nothing breaks — the site is simply blind to CMS-side drafts.Declaring perspectives
Set perspectives: true only when both sides are genuinely reachable. Silence means no, which is the opposite default from capabilities and deliberately so: those are permissions, where the safe answer is to allow what nobody forbade; this is an ability, where the safe answer is not to claim one nobody implemented.
Reading drafts usually needs a token the deployment may not have, so the honest form is often a runtime value rather than a constant:
examples/sanity-site is the worked version of exactly this.
What it looks like when you get it wrong
An adapter that reads only published content answers the same list to both callers, so the publish diff compares the live site against a copy of itself and reports everything unchanged — including pages whose author can see their own unpublished edits in the CMS right now. That is a true statement in the most confusing shape a correct answer can take.whoami reports capabilities.readsDraftPerspective so an agent can tell “nothing is pending” from “nothing is visible”. Its value is derived from perspectives, never declared as a capability.
Bundled adapters
Two adapter implementations ship in@avocadostudio-ai/orchestrator-core/cms:
jsonFileAdapter
Reads PageDoc[] from a JSON file on disk. Smallest possible adapter — useful when your content is already checked into git, or for prototypes before you pick a real CMS.
PageDoc[] or an object with a pages key. Each entry is parsed through the lenient PageDoc schema, so partial or extra fields are tolerated. writeOnPublish: true writes a bare PageDoc[] back.
editorApiAdapter
Fetches PageDoc[] from a site’s /api/editor/pages endpoint. Use this when your site already exposes a page-listing API, or when the orchestrator runs out-of-process from the site.
PageDoc schema are dropped from the seed and a warning is logged with the candidate index, slug, and first Zod issue path — pass logger: yourLogger if you want those routed to your own log sink.
Wiring it up
Library-mode integration (orchestrator mounted as a Next.js catch-all route inside your site) is the recommended pattern. One file, fully drop-in:getPages(), seeds SQLite, and the planner can immediately reason about your pages.
What this costs to install
Library mode puts the orchestrator inside your site’s dependency tree, and that is worth knowing before your first deploy rather than at it. Unpacked, on darwin-arm64:
Two of those are native and platform-specific, so the number on your build
machine differs from the number on Vercel; the shape does not.
@anthropic-ai/claude-agent-sdk is not in that list, and used to be. It is
an optional peer dependency now: only the agent surface
(orchestrator-core/agent/*) imports it, nothing on the library-mode path
does, and its platform binary alone is larger than everything above put
together. If you mount the agent routes, install it yourself; if you are a
marketing site with a chat box, you will never see it.
If the footprint is the deciding factor, the standalone orchestrator is the
other shape: your site keeps only @avocadostudio-ai/site-sdk and talks to the
orchestrator over HTTP. You trade a dependency tree for a service to run.
Editing the draft directly
Every documented example of an operation is about chat, where the planner builds the payload. Driving the same write path by hand — which is what an integration test does, and what an agent does — the envelope has to be guessed. It is this:pageSlugis per operation, not on the envelope. One request may touch several pages. A missing one is rejected withpath: [0, "pageSlug"].- The batch is atomic. Operations are applied to a staged copy and committed together, so an operation that errors takes the whole request down with it — 400, and a draft that is exactly as it was. (An operation that is merely a no-op is different: it is reported as skipped and its neighbours still apply.)
"dryRun": true to validate without applying. The response is a different
shape, because the question is different:
@avocadostudio-ai/shared/contract/operation.schema.json is the
machine-readable version.
Publishing back
Onceadapter.onPublish is defined, your editor (or any client) can publish a session’s draft to the upstream store with one POST:
ok / written / count are the contract; status, slugs and message
are there because the editor reads them, and a response without them was
reported to the user as a failed publish.
The orchestrator reads the current draft for the scoped session out of SQLite and hands the resulting PageDoc[] to adapter.onPublish(pages). If the adapter has no onPublish, the route still returns 200 but with written: false — useful for sites that publish via CI / git commit rather than a runtime writeback.
An adapter that does have an onPublish and deliberately writes nothing —
a dry run, a staged publish, a queue, a review-before-write flow — returns
{ ok: true, written: false } and the route reports that verbatim. Omitting
written means true, so adapters written before the field existed are
unaffected.
Saying what the publish did
A publish that worked can still have something to say, andnotes is where it
says it:
GET /publish/log records that the
run wrote nothing, rather than “Published 12 pages” for a run that did not.
Notes ride along on a failure too, which is where “9 of 14 patches were written
before this failed” belongs.
notes and unsupported are different channels on purpose:
The distinction is not cosmetic. Before
notes existed, unsupported was the
only way for a successful adapter to put a sentence in front of a person, so a
clean dry run announced itself as a list of things the site could not do. Use
unsupported only for changes that genuinely did not make it — an image the
CMS can only store as an asset reference, a new block with no counterpart
component.
Notes are prose from your codebase shown to whoever pressed Publish and kept in
a database row. The route trims them, drops non-strings and empties, caps each
at 500 characters and keeps the first 20.
Which baseline to diff against
context.baseline is the page list the adapter last returned, and on a CMS that
declares perspectives that means the CMS’s draft, not the live site. The
session was seeded from getPages({ perspective: "draft" }) and the baseline is
that same list, so a diff against it reports what this session changed.
That is the question a publish needs answered. Diff against the live site
instead — by taking a second read of the published perspective — and the
difference includes every unpublished edit anybody made in the CMS. An Avocado
publish then ships all of it, from a button whose label says nothing about that.
The read is cheap, which is what makes the mistake easy.
context.published is deprecated — read context.baseline. The same array is
still on context.published, because adapters were written against that name and
removing it would break them. But the name taught the opposite of the truth: an
integrator who reads “published” concludes the baseline is the live site, and that
one wrong reading is exactly the publish bug described above. The field stays; the
name is wrong. Use baseline in anything you write from here.undefined means no baseline available, never “the site was empty”: it is
absent when the session was never bootstrapped from the adapter, and publishing
every field on the empty-site assumption is the overwrite the baseline exists to
prevent.
Keys Avocado owns
Avocado stamps a stableid onto every row of every list your block meta
declares, so the property panel can keep rows in place under reordering and a
planner can address one by name. It lives in props, because props is the
only thing persisted — and it comes back out of /draft/pages looking exactly
like content.
Compare a draft against freshly-read CMS content without accounting for it and
every block that has a list reports as changed, from the first load,
forever. A one-field edit to one page can produce a publish
that wants to rewrite every document with a list on it. The same publish touches
one once the stamps are out.
i_ plus eight hex characters that
generateItemId produces. A row carrying the CMS’s own key (a Sanity _key, a
Contentful sys.id, a Storyblok _uid flattened to id) keeps it, because
that is content and the patch path needs it. The draft itself is untouched: you
get a copy, and every operation still addresses rows by the id it holds.
diffFields never needed this — it walks the specs you declare, and no spec
declares id. It is the comparison you write before reaching diffFields
that this is for.
Session scoping. When
adapter is set, sessions auto-scope to siteId (default "library") so they bypass the legacy demo-content seed path. If you see a chat returning demo blocks instead of your content, pass an explicit siteId to force the scope.Writing a custom adapter
If your content lives somewherejsonFileAdapter and editorApiAdapter don’t reach, write your own. The contract is small enough to inline:
createOrchestrator({ adapter: myCmsAdapter({ spaceId: "..." }) }). Failures inside getPages() are logged but non-fatal — the session simply starts empty.
If you build a non-trivial adapter (Storyblok, Hygraph, Payload, Directus, etc.), get in touch — a worked adapter for a CMS we have not covered is the fastest way to get that CMS onto the tested list.
Rich text
Do not flatten a rich-text field to a string on the way in. Marks, links and lists go missing on the first publish, and nothing logs it — the sentence is still there, so the page looks right until someone reads it.@avocadostudio-ai/richtext converts between a ProseMirror document — the
shape the property panel edits natively — and each CMS’s own rich text. Adding
a CMS costs one converter pair, not one per other CMS:
Anything the pivot cannot model — a Contentful embedded entry, a Storyblok
blok node, a Portable Text _type nobody registered — is carried as an
avocadoUnknownBlock holding the source object untouched, rendered read-only
in the panel, and re-emitted unchanged on the way out. A block the editor does
not understand is not a block the editor may delete.
Declare the prop so the panel knows it is a document rather than a string: the
manifest keys off type being pinned to the literal "doc".
Custom block schemas
If your site renders custom block shapes (e.g. aHero with carouselImages instead of canonical imageUrl), register your schemas with the global block registry alongside the adapter:
lib/my-blocks.ts, call registerBlock("Hero", { schema, meta }) for each type you want to override. Import z from @avocadostudio-ai/site-sdk/blocks rather than from zod — a ZodObject is assignable only to one built by the same copy of the library, and a site that also uses Sanity has zod 3 hoisted. See Custom blocks for the full schema shape.
Older versions of this guide told you to put a side-effect
import "@/lib/my-blocks"
last in the file and rely on ESM source order. Don’t — Next’s bundler does not reliably
preserve that order across the RSC, SSR and route-handler layers, so the canonical
schemas sometimes re-register on top of yours. The registerBlocks hook exists to
replace that trick. createEditorApiHandler takes the same option, and re-runs it on
every /blocks request.Hero and TwoColumn, that fill in
props an older stored page may lack — and they are keyed on the type name,
which is all your hero and ours have in common. Against a schema they have
never seen they invented content: an imageUrl pointing at the placeholder, an
English alt string, a left/right pair copied out of props you render
differently, a variant overwritten with "default". None of it showed in the
preview, because your components render your props and ignore the rest — it
showed at publish, as changes attributed to a session that had edited nothing.
Your registration now turns them off for your types and leaves them on for any
built-in you still use.
Example apps
Five working examples live underexamples/ in the repo. Each one boots in under two minutes:
contentful-site, sanity-site and strapi-site ship both wirings side-by-side: a
library-mode /api/avocado/[[...path]]/route.ts that mounts
createOrchestrator({ adapter: ... }) (recommended), plus the split-mode /api/editor/*
route for sites that want the orchestrator deployed separately. contentful-marketing-site
has only the split-mode route.
contentful-marketing-site and sanity-site both default to port 3004, so you cannot run
the two at once without overriding one (next dev -p 3006).Contentful
Free Community plan is enough. The setup script creates the full content model (20 block types +page + siteConfig).
examples/contentful-site/README.md.
Sanity
The example ships with an embedded Sanity Studio at/studio alongside the Avocado editor.
http://localhost:3004 as a CORS origin in Sanity project settings (with credentials allowed).
Full walkthrough: examples/sanity-site/README.md.
Strapi
Self-hosted, open-source. The setup script generates Strapi v5 schema files from the Avocado block registry.page and site-config.
Full walkthrough: examples/strapi-site/README.md.
See also
- Next.js integration — full route-handler reference
- Block system — how
PageDocandBlockInstanceare shaped - Custom blocks — registering your own components
- Publishing — how
onPublishties into the publish flow