Skip to main content
Avocado Studio doesn’t ship its own content database. Pages live in your CMS (or a JSON file in git, or MDX, or an internal API); the orchestrator holds draft / undo / chat state in SQLite and reads published content from your store via a small adapter. That means you keep your existing CMS, your existing content model, your existing publishing workflow. Avocado adds the AI chat editor and the visual editor on top of it. Switching content stores is a swap of one adapter, not a migration.

The contract

Every adapter implements the same two-method interface from @avocadostudio-ai/orchestrator-core:
That’s the whole contract. Two parts of it are easy to miss: 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 /publish on the library-mode handler with { session, siteId }. The orchestrator reads the current draft from SQLite and hands the resulting PageDoc[] to the adapter. If you omit onPublish, /publish becomes a 200 no-op (with written: false in the response) and SQLite still holds the draft.
The adapter is the seed and the sink, never the live working copy. All in-flight edits live in SQLite; that’s what makes the same chat UX work against a static JSON file, a Sanity space, or anything in between.

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.
The file may be either a bare 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.
writeOnPublish defaults to false, and with it off the adapter has no onPublish at all — a publish through this adapter writes nothing and answers ok: true, written: false with a reason saying so, while the editor prints that the adapter is read-only and the edits are still a draft. That is the right default for content checked into git and rebuilt by CI, and the wrong one for a demo that tells its user publishing rewrites the file. Decide which you are.It is also a local-machine feature. A deployed runtime filesystem is read-only on Vercel and in any container built from an image, so a publish that works in next dev fails after deploy. Publishing in production goes to something that persists — a CMS, a database, or a commit.

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.
Pages that fail the lenient 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:
That’s it — the first chat turn for a new session calls 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.
Library mode does not remove the site contract. Mount /api/editor/[...path] as well, or the editor opens with a Limited badge and no property panel.The two routes answer different questions and are not alternatives. The orchestrator route above answers about a session — the draft, the chat, the publish. The site contract answers about the site, and the editor asks the site origin for it directly: GET /api/editor/blocks is where it gets your block manifest. It asks the site rather than the orchestrator because in a split deployment the orchestrator is a different host that has never seen your components; library mode only makes both ends the same process, it does not change who is asked.The failure is quiet and looks like something else. Every orchestrator endpoint is green, /api/avocado/blocks/manifest serves your manifest perfectly — nothing asks it — and the only symptom is repeated GET /api/editor/blocks 404 in your own server log and a badge whose meaning (“no manifest, text edits only”) you have to already know.
blockTypes belongs on both handlers. createOrchestrator({ blockTypes }) narrows the planner and add_block; createEditorApiHandler({ blockTypes }) narrows /api/editor/blocks, which is the list the editor’s block picker and any agent reading the manifest actually see. The declaration is shared state, so passing it to one looks like enough — but a Next route module is evaluated on the first request to that route, and the editor asks for the manifest before it has any reason to call the orchestrator. Set it in one place and the first answer is your blocks plus all of Avocado’s built-ins, which your site has no renderer for.Symptom: GET /api/editor/blocks returns your types and Hero, FeatureGrid, Testimonials and the rest. Check it with curl -s localhost:3000/api/editor/blocks | jq '.blocks | length' before you trust the picker.
A production mount needs a credential. createOrchestrator() gates every non-public route. With neither ACCESS_PASSWORD_HASH nor ORCHESTRATOR_ACCESS_TOKEN set, and no auth hook passed, it refuses every request with a 401 under NODE_ENV=production — deliberately failing closed rather than shipping an open mount that can edit and publish your site. Set one of those env vars, or pass your own auth hook, before deploying. The curl below then needs the matching x-access-token header.A mount in that state says so, in three places. Nobody holds a credential for it, so there is nobody to withhold the explanation from — the only person reading it is the operator looking at a 401 on their own deployment. The 401 body carries a reason naming the two variables alongside the unchanged error: "unauthorized"; GET /auth/status reports mode: "closed" and the same reason, which is how the editor tells “closed” from “open” — gateEnabled answers only “should I prompt for a password?”, and a closed mount has no password to prompt for; and POST /auth/verify answers 503 with that reason rather than minting a token. That last one matters because the token it used to hand out opened nothing: a login that says yes while the system is shut leaves you holding a credential and 401ing everywhere with no way to connect the two.So does your own site. The draft reads that render your preview — fetchEditorPage, fetchEditorSlugs, fetchEditorSiteConfig, and avocado-register — go over HTTP like any other caller and are refused like any other caller. They pick the token up from ORCHESTRATOR_ACCESS_TOKEN automatically, or take an explicit accessToken option (--token for the CLI). Miss it and the failure is silent by construction: the draft is unavailable, so the page renders published content and looks correct. Watch the server log for [site-sdk/draft] … answered 401.

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:
Two things are easy to get wrong and both fail loudly, which is the only reason they cost one attempt rather than ten:
  • pageSlug is per operation, not on the envelope. One request may touch several pages. A missing one is rejected with path: [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.)
Add "dryRun": true to validate without applying. The response is a different shape, because the question is different:
Nothing is written and no version is bumped. Use it to check a plan an agent produced before letting it land. The full operation vocabulary is in the block system, and @avocadostudio-ai/shared/contract/operation.schema.json is the machine-readable version.

Publishing back

Once adapter.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, and notes is where it says it:
The editor shows each note as its own line under the publish message, and the route puts them in the publish-log row — so 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 stable id 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.
It removes only ids Avocado generated — the 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 somewhere jsonFileAdapter and editorApiAdapter don’t reach, write your own. The contract is small enough to inline:
Then pass the instance into 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. a Hero with carouselImages instead of canonical imageUrl), register your schemas with the global block registry alongside the adapter:
Inside 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.
Registering over a canonical name also tells Avocado to stop applying its own migrations to that type. It has two, for 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 under examples/ 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).
Full walkthrough: examples/contentful-site/README.md.

Sanity

The example ships with an embedded Sanity Studio at /studio alongside the Avocado editor.
Add http://localhost:3004 as a CORS origin in Sanity project settings (with credentials allowed).
Turn stega off in the client your adapter reads through. If your site already uses Sanity’s Presentation tool, its draft client is configured with stega: { enabled: true } — every string it returns carries hundreds of zero-width characters that encode which field produced it. They are invisible everywhere a human looks, and this adapter reads through exactly that client, because perspective: "draft" is the read the docs above tell you to make.Fed to Avocado, an encoded string poisons four things at once: the planner reasons over text that is mostly invisible padding, the property panel shows text that looks right and is not, the publish diff compares an encoded string against a clean one and reports every field on the site as changed, and a real publish writes the markers into your dataset.Pass a client created with stega: false (or createClient({...}).withConfig({ stega: false })) to the adapter and keep the stega-enabled one for rendering. Contentful’s Content Source Maps encode the same way; the same rule applies.
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.
Generate an API token from the Strapi admin (Settings → API Tokens) with read+write for page and site-config. Full walkthrough: examples/strapi-site/README.md.

See also