> ## Documentation Index
> Fetch the complete documentation index at: https://docs.avocadostudio.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# CMS Adapters

> Plug any content source — JSON file, Contentful, Sanity, Strapi, custom REST — into Avocado Studio via the CmsAdapter interface.

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 interface from `@avocadostudio-ai/orchestrator-core/cms` — one required method, the rest optional:

```ts theme={null}
export interface CmsAdapter {
  readonly id: string                                  // "json-file", "sanity", …
  readonly perspectives?: boolean                      // can you read drafts? (default: no)
  getPages(options?: CmsReadOptions): Promise<PageDoc[]>  // seed on cold session
  onPublish?(                                          // optional writeback
    pages: PageDoc[],
    config: SiteConfig,                                // navLabels, theme overrides
    context?: CmsPublishContext                        // inline assets + context.baseline
  ): Promise<CmsPublishResult>
  readonly capabilities?: CmsCapabilities              // can this site mint pages? delete them?
  getMedia?(query: CmsMediaQuery): Promise<CmsMediaPage>    // the image picker's site tab
  uploadMedia?(input: CmsMediaUpload): Promise<CmsMediaItem> // the picker's upload button
}
```

That's the whole contract. Three 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](#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. And the two media
methods are how the editor's image picker reaches your CMS — see
[media library](#media-library).

* `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.

```mermaid theme={null}
flowchart LR
  studio[the editor] -->|chat| orch[Orchestrator]
  orch -->|adapter.getPages on cold session| store[(Your content store)]
  orch -->|adapter.onPublish on publish| store
  orch -->|drafts + undo + chat| sqlite[(SQLite working copy)]
```

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:

| caller | asks for | why |
| - | - | - |
| session bootstrap | `draft` | seeds the working copy — you want to edit the latest work, including edits made in the CMS and not yet published |
| `GET /publish/diff` | `published` | "what will change on the live site" is a question about the live version and nothing else |

So `getPages` takes an optional `{ perspective: "draft" | "published" }`:

```ts theme={null}
getPages: (options) => getMyPages(options?.perspective ?? "published")
```

<Note>
  **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.
</Note>

### 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:

```ts theme={null}
export const draftClient = process.env.SANITY_API_TOKEN
  ? createClient({ ...config, token: process.env.SANITY_API_TOKEN, perspective: "drafts" })
  : null

export function sanityAdapter(): CmsAdapter {
  return {
    id: "sanity",
    perspectives: Boolean(draftClient),
    getPages: (options) => getSanityPages(options?.perspective),
    // …
  }
}
```

`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.

```ts theme={null}
import { createOrchestrator } from "@avocadostudio-ai/site-sdk/server"
import { jsonFileAdapter } from "@avocadostudio-ai/orchestrator-core/cms"
import path from "node:path"

const handler = createOrchestrator({
  adapter: jsonFileAdapter({
    path: path.join(process.cwd(), "lib", "published-content.json"),
    writeOnPublish: false  // default; flip to true to overwrite the file on publish
  })
})
```

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.

<Warning>
  **`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.
</Warning>

### `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.

```ts theme={null}
import { editorApiAdapter } from "@avocadostudio-ai/orchestrator-core/cms"

const handler = createOrchestrator({
  adapter: editorApiAdapter({
    origin: "https://your-site.com",   // or set AUTO_BOOTSTRAP_SITE_ORIGIN
    path: "/api/editor/pages",         // default
    siteId: "main",                    // optional ?siteId= query
    timeoutMs: 8000,                   // default
    headers: {                         // optional — for protected endpoints
      authorization: `Bearer ${process.env.EDITOR_API_TOKEN}`
    }
  })
})
```

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:

```ts theme={null}
// app/api/avocado/[[...path]]/route.ts
import { createOrchestrator } from "@avocadostudio-ai/site-sdk/server"
import { jsonFileAdapter } from "@avocadostudio-ai/orchestrator-core/cms"
import path from "node:path"

export const runtime = "nodejs"
export const dynamic = "force-dynamic"

const handler = createOrchestrator({
  adapter: jsonFileAdapter({
    path: path.join(process.cwd(), "lib", "published-content.json")
  }),
  siteId: "my-site"  // optional; defaults to "library" when adapter is set
})

export const POST = handler
export const GET = handler
export const OPTIONS = handler
```

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:

| | | why |
| - | - | - |
| `better-sqlite3` | \~27 MB | the draft, history and version log |
| `sharp` + libvips | \~16 MB | image resizing for uploads and generated images |
| `@anthropic-ai/sdk` | \~10 MB | planning |
| `openai` | \~7 MB | planning |
| `@modelcontextprotocol/sdk` | \~6 MB | the MCP surface |
| | **\~66 MB** | |

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.

<Warning>
  **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.

  ```ts theme={null}
  // app/api/editor/[...path]/route.ts — alongside the orchestrator, not instead of it
  import { createEditorApiHandler } from "@avocadostudio-ai/site-sdk/routes"

  export const { GET, POST, OPTIONS } = createEditorApiHandler({
    getPages: async () => (await readSite("published")).pages,
    registerBlocks: registerSiteBlocks,
    getManifest,
    // Pass this to BOTH handlers — see the note below.
    blockTypes: SITE_BLOCK_TYPES,
    // No `onPublish` here: publishing goes through the orchestrator's
    // /api/avocado/publish, and one publish button wants one destination.
  })
  ```
</Warning>

<Warning>
  **`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.
</Warning>

### Sections, building blocks and the picker

`blockTypes` also takes an object, which declares the same membership (the union
of every list) and says how the add-block picker offers each type:

```ts theme={null}
export const SITE_BLOCK_TYPES = {
  sections: ["Hero", "CardGrid", "FeatureGrid", "CTA", "FAQAccordion"],
  elements: ["Card", "Quote"],                // folded into "Building blocks"
  hidden:   ["Banner"],                       // renders and edits, never offered for insert
  groups:   [{ label: "Recipes", types: ["site_RecipeHero", "site_RecipeList"] }],
} satisfies BlockCatalogueConfig
```

The picker lists sections first, then your groups, then building blocks, folded
until opened or searched. The planner is told the same: when asked to add a
section it prefers a section type, and it adds a hidden type only when the user
names it. Pages are still a flat list, so an element is placed exactly like a
section; this changes how it is offered, not where it can go.

Without the object form, each type's own `meta.role` decides (`"section"` unless
set). Among the built-ins only `Card` is an element.

<Warning>
  **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.

  **Your own preview reads in-process, and needs no token.** Since 0.27,
  `fetchEditorPage`, `fetchEditorSlugs` and `fetchEditorSiteConfig` read the
  mounted orchestrator directly when it runs in the same process: no network, no
  URL and no credential. That applies when `ORCHESTRATOR_URL` is unset or points
  at the mount's own path. Before 0.27 these reads went over HTTP and were
  refused like any other caller, so a site that set only `ACCESS_PASSWORD_HASH`
  previewed **published** content, and every edit appeared to do nothing.

  They still go over HTTP, and still need `ORCHESTRATOR_ACCESS_TOKEN` (or an
  explicit `accessToken` option, `--token` for `avocado-register`), in three
  cases:

  * `ORCHESTRATOR_URL` names a different orchestrator;
  * a call passes its own `orchestratorUrl`;
  * the preview renders in a process where the orchestrator route has not loaded,
    such as separate serverless functions.

  Miss the token there and the page renders published content and looks correct.
  Watch the server log for `[site-sdk/draft] … answered 401`.
</Warning>

### 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:

```bash theme={null}
curl -X POST https://your-site.com/api/avocado/ops \
  -H 'content-type: application/json' \
  -d '{
        "session": "sess_abc",
        "siteId": "my-site",
        "ops": [
          { "op": "update_props", "pageSlug": "/preise", "blockId": "hero-1",
            "patch": { "headline": "Neue Preise" } },
          { "op": "update_item", "pageSlug": "/preise", "blockId": "cards-1",
            "listKey": "cards", "index": 0, "patch": { "title": "Basis" } }
        ]
      }'
# → { "status": "applied", "summary": "Applied operations.",
#      "changes": [...], "previewVersion": 12, "updatedSlug": "/preise" }
```

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:

```json theme={null}
{ "status": "preview", "dryRun": true,
  "appliedCount": 2, "skippedCount": 0, "failedCount": 0,
  "opResults": [ /* one entry per operation, with its status */ ],
  "preview": { /* the before → after diff */ } }
```

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](/integration/block-system#operations), and
`@avocadostudio-ai/shared/contract/operation.schema.json` is the
machine-readable version.

Every op is checked against the site's own block manifest: read-only fields,
fixed blocks, list discriminators, and the keys a list row may carry. The
editor sends that manifest with each chat turn. A request without one — from a
script, or from the [MCP server](/integration/mcp-server) — is checked against
the mount's own manifest, the one `GET /api/avocado/blocks/manifest` serves, so
an op on a type outside your catalogue is refused here too. A refusal carries
an `errorCode` such as `schema_violation` alongside the message.

### 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:

```bash theme={null}
curl -X POST https://your-site.com/api/avocado/publish \
  -H 'content-type: application/json' \
  -d '{"session":"sess_abc","siteId":"my-site"}'
# → {
#      "ok": true, "written": true, "count": 3,
#      "status": "ready", "slugs": ["/", "/preise", "/events"],
#      "message": "Published 3 pages."
#    }
```

`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:

```ts theme={null}
return {
  ok: true,
  written: false,
  notes: ["Computed 12 patches; nothing written (DRY_RUN=1)."]
}
```

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:

| | means | shown as |
| - | - | - |
| `notes` | this is what shipping looked like | a plain line |
| `unsupported` | the publish succeeded, this change did not ship | `Not published: …` |

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.

<Note>
  **`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.
</Note>

`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.

```ts theme={null}
import { withoutGeneratedItemIds } from "@avocadostudio-ai/site-sdk/publish"

// Both sides, before any structural comparison.
const mine = withoutGeneratedItemIds(page.blocks)
```

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.

### Site-wide content

A header, footer, business details or CTA is stored once — a `global.json`, a
CMS singleton — and rendered on every page. Avocado edits `PageDoc` blocks, so
the adapter projects it into every page and writes it back once:

1. **Declare the type shared.** `shared: true` in its `meta`, manifest entry or
   field-table spec. See [Shared blocks](/integration/block-system#shared-blocks-site-wide-content).
2. **Inject it into every `PageDoc` under one fixed id.** `getPages()` appends
   the same block to each page — same `id`, same `type`, same props:

   ```ts theme={null}
   const footer = { id: "global-footer", type: "siteFooter", props: global.footer }

   async getPages() {
     return pageFiles.map((page) => ({ ...page, blocks: [...page.blocks, footer] }))
   }
   ```

   The id is the identity. Use one no page-owned block will ever take.
3. **Write it once in `onPublish`.** Split each page's blocks into page-owned
   and shared, write the page-owned ones to the page, and write each shared id
   once from any page carrying it:

   ```ts theme={null}
   async onPublish(pages) {
     const shared = new Map<string, BlockInstance>()
     for (const page of pages) {
       for (const block of page.blocks) {
         if (block.id === "global-footer") shared.set(block.id, block)
       }
       await writePage(page.slug, page.blocks.filter((b) => b.id !== "global-footer"))
     }
     const footer = shared.get("global-footer")
     if (footer) await writeGlobal({ footer: footer.props })
     return { ok: true }
   }
   ```

The orchestrator keeps every draft copy in step: an edit on one page reaches
every page holding the id, undo reverts it everywhere, and a subset publish
ships the ticked page's version on every page. So the pages you receive agree,
and there is nothing to reconcile. A draft written before the type was declared
shared can still disagree; the publish review reports that as a conflict with
each version and its pages, and your `onPublish` may refuse the same case
rather than pick one.

### Media library

The editor's image picker shows a tab for your CMS's media only when the
adapter implements `getMedia`. Silence means no, as with `perspectives`: a tab
it cannot fill is worse than no tab. `uploadMedia` adds an upload button the
same way; throw from it to refuse a file, and the message is shown to the
editor.

For Contentful, Sanity and Strapi, `cmsMediaSource` and `cmsMediaUploader` from
`@avocadostudio-ai/orchestrator-core/cms` implement them from connection
details:

```ts theme={null}
import { cmsMediaSource, cmsMediaUploader, type CmsAdapter } from "@avocadostudio-ai/orchestrator-core/cms"

const media = { provider: "sanity", projectId, dataset, token } as const
const uploadMedia = cmsMediaUploader(media)

export const adapter: CmsAdapter = {
  id: "sanity",
  getPages,
  getMedia: cmsMediaSource(media),
  ...(uploadMedia ? { uploadMedia } : {}),
}
```

Spread `uploadMedia` rather than assigning it. `cmsMediaUploader` returns
`null` when the provider or the token cannot take an upload — today only Sanity
can, and only with a `token` — and the editor shows its upload control on the
strength of the method existing. A reader whose upstream call fails answers an
empty page rather than throwing.

<Note>
  **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.
</Note>

## 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:

```ts theme={null}
// lib/my-cms-adapter.ts
import type { CmsAdapter } from "@avocadostudio-ai/orchestrator-core/cms"
import type { PageDoc } from "@avocadostudio-ai/site-sdk"
import { myCmsClient } from "./my-cms-client"

export function myCmsAdapter(opts: { spaceId: string }): CmsAdapter {
  return {
    id: "my-cms",
    // Only if you actually read both sides — see "Drafts and published content".
    perspectives: true,
    async getPages(options): Promise<PageDoc[]> {
      const raw = await myCmsClient.listPages(opts.spaceId, {
        draft: options?.perspective === "draft"
      })
      return raw.map(mapMyCmsToPageDoc)
    },
    async onPublish(pages, config, context): Promise<CmsPublishResult> {
      await Promise.all(pages.map((p) => myCmsClient.upsert(mapPageDocToMyCms(p, config))))
      // `context?.baseline` is what the adapter last handed the orchestrator —
      // diff against it rather than overwriting. See "Which baseline" below.
      return { ok: true }
    }
  }
}
```

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:

```ts theme={null}
import { fromStoryblok, toStoryblok } from "@avocadostudio-ai/richtext"

// reading
props.body = fromStoryblok(blok.body)
// publishing
patch.body = toStoryblok(props.body)
```

| CMS | in | out |
| - | - | - |
| Storyblok | `fromStoryblok` | `toStoryblok` |
| Contentful | `fromContentful` | `toContentful` |
| Sanity (Portable Text) | `fromPortableText` | `toPortableText` |
| Strapi (Blocks) | `fromStrapiBlocks` | `toStrapiBlocks` |
| markdown | `fromMarkdown` | `toMarkdown` |
| HTML | `fromHtml` | `toHtml` |

`@avocadostudio-ai/site-sdk/lens` re-exports every converter, and each CMS pack
re-exports its own, so a site that already depends on the SDK does not need
`@avocadostudio-ai/richtext` as a direct dependency.

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"`.

```ts theme={null}
body: {
  type: "object",
  properties: { type: { const: "doc" }, content: { type: "array", items: { type: "object" } } },
  required: ["type"]
}
```

## 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:

```ts theme={null}
// app/api/avocado/[[...path]]/route.ts
import { createOrchestrator } from "@avocadostudio-ai/site-sdk/server"
import { jsonFileAdapter } from "@avocadostudio-ai/orchestrator-core/cms"

import { registerMyBlocks } from "@/lib/my-blocks"

const handler = createOrchestrator({
  adapter: jsonFileAdapter({ path: "..." }),
  // Runs when the handler is built, after orchestrator-core's transitive imports
  // have registered the canonical schemas — so your overrides land on top.
  registerBlocks: registerMyBlocks,
})
```

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](/integration/custom-blocks) for the full schema shape.

<Note>
  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.
</Note>

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:

| Example | Content store | Pattern | Port |
| - | - | - | - |
| `examples/sample-site/` | Local JSON file | Zero-config starter — uses `jsonFileAdapter` | 3002 |
| `examples/contentful-site/` | Contentful | Headless SaaS CMS | 3003 |
| `examples/contentful-marketing-site/` | Contentful | Marketing-site flavor | 3004 |
| `examples/sanity-site/` | Sanity | Headless + embedded Studio | 3004 |
| `examples/strapi-site/` | Strapi v5 | Self-hosted open-source CMS | 3005 |

`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.

<Note>
  `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`).
</Note>

### Contentful

Free Community plan is enough. The setup script creates the full content model (20 block types + `page` + `siteConfig`).

```bash theme={null}
CONTENTFUL_SPACE_ID=<from app.contentful.com → Settings → General>
CONTENTFUL_DELIVERY_TOKEN=<from API keys → Content delivery tokens>
CONTENTFUL_MANAGEMENT_TOKEN=<from API keys → Content management tokens>
CONTENTFUL_ENVIRONMENT=master

pnpm --filter contentful-site contentful:setup     # idempotent
pnpm --filter contentful-site dev                  # http://localhost:3003
```

Full walkthrough: `examples/contentful-site/README.md`.

### Sanity

The example ships with an **embedded Sanity Studio at `/studio`** alongside the Avocado editor.

```bash theme={null}
NEXT_PUBLIC_SANITY_PROJECT_ID=<from sanity.io/manage>
NEXT_PUBLIC_SANITY_DATASET=production
SANITY_API_TOKEN=<from API → Tokens (Editor permissions)>

pnpm --filter sanity-site sanity:schema-gen        # only after upgrading block fields
pnpm --filter sanity-site dev                      # http://localhost:3004
```

Add `http://localhost:3004` as a CORS origin in Sanity project settings (with credentials allowed).

<Warning>
  **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.
</Warning>

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.

```bash theme={null}
npx create-strapi@latest strapi-backend --quickstart --skip-cloud --no-run
STRAPI_PROJECT=/absolute/path/to/strapi-backend pnpm --filter strapi-site strapi:setup
cd /absolute/path/to/strapi-backend && npm run develop    # http://localhost:1337/admin

# In another terminal:
pnpm --filter strapi-site dev                              # http://localhost:3005
```

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

* [Next.js integration](/integration/nextjs-integration) — full route-handler reference
* [Block system](/integration/block-system) — how `PageDoc` and `BlockInstance` are shaped
* [Custom blocks](/integration/custom-blocks) — registering your own components
* [Publishing](/integration/publishing) — how `onPublish` ties into the publish flow


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.