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

# How it works

> One sentence, followed all the way through — intent, plan, validation, streamed apply, the approval gate, undo, and publishing back to wherever your content lives.

Someone on your marketing team types *"add a testimonials section below the hero with three customer quotes"*. This page follows that sentence to a published change, and names what stops it at every step.

The short version: a sentence becomes a **plan**, the plan is a list of **typed operations**, each operation is validated against **your** block schema, valid ones stream into a live preview of your real page, destructive ones stop and wait for a person, everything is undoable, and publishing hands the result back to wherever your content actually lives. At no point does anything in this pipeline write a file in your repository — the vocabulary it works in has no verb for that. See [core concepts](/concepts) for why that boundary is structural rather than procedural.

```mermaid theme={null}
sequenceDiagram
    participant P as Person
    participant E as Editor
    participant O as Orchestrator
    participant S as Your site

    P->>E: add a testimonials section below the hero
    E->>O: POST /chat/start with session, slug, message
    O-->>E: streamId
    E->>O: GET /chat/stream?streamId=… (SSE)
    O->>O: 1. Route the intent
    O->>O: 2. Plan (deterministic, router, or full model)
    O->>O: 3. Validate each op against your block schema
    O-->>E: op_candidate · plan_meta · field_draft
    O->>O: 4. Apply valid ops to the draft
    O-->>E: op_applied (streamed)
    E->>S: liveDraft / draftUpdated (postMessage)
    S->>O: GET /draft/pages
    S-->>E: rendered page, in the iframe
    P->>E: keep · undo · refine · publish
```

## Step 1: Route the intent

Not every request needs a model, and the cheapest correct answer wins.

The orchestrator first decides what kind of request this is: a structural edit, a page operation, a question about the content, something too ambiguous to act on, or something off-topic. That routing happens in tiers:

* **Deterministic planner** — unambiguous requests that can be turned into operations by parsing alone produce a plan with **no model call at all**.
* **Fast intent router** — a small model on the `fast` tier handles the next tier of requests.
* **Full planner** — everything else.

When the router and the full planner both apply, they run **concurrently**: the router gets a head start (`CHAT_ROUTER_HEAD_START_MS`, default 200 ms) and, if it produces a valid plan, the full planner is aborted. If it fails, the full planner is already running, so nothing was lost waiting to find out.

An ambiguous request does not guess. *"Make it better"* comes back as `needs_clarification` with a question, because a plan built on a guess is worse than a question.

## Step 2: Plan

For anything the fast paths cannot answer, the planner is given:

* the **system prompt** that defines the operation vocabulary and how to use it;
* your **block schemas** — which block types exist on *this* site, what props each has, what values are legal;
* the **current page**, as a full `PageDoc`;
* the **user's message** and recent conversation history;
* **site context** from your site config — purpose, tone and content constraints, when you have declared them.

The model answers with an `EditPlan`: an `intent`, a `summary_for_user`, a `change_log`, and an `ops` array. The plan is streamed, so candidate operations reach the editor as they are produced rather than after the last token.

<Note>
  The schemas the planner sees come from the **block manifest your site serves**, not from a catalogue Avocado ships. That is why a custom block with thin field metadata gets edited badly: the manifest is the AI's instruction manual. [Coverage checks](/integration/coverage) tell you how much of it your pages actually expose.
</Note>

## Step 3: Validate against your block schema

Every operation is checked before it is allowed to change anything:

1. **Does it parse?** The operation must match one of the nineteen members of `operationSchema`.
2. **Does the target exist?** The page, the block, the anchor block, the list index.
3. **Are the props legal?** When your site supplied a block manifest, the resulting props are validated against that manifest's `propsSchema` — this is the path your custom blocks take. Without a manifest entry, they are validated against the registered Zod schema.
4. **Is a list row in the list's shape?** A row added to a list that declares a discriminator must say which shape it is. A row may only use keys the list already knows — from its field metadata, its schema, or another row. Either mistake is rejected here, and the message names the fields to use.
5. **Does the site allow it?** A block declared `fixed` cannot be moved, removed or duplicated, and a field declared `readOnly` cannot be written.

Two behaviours are worth knowing about.

**Props your schema never mentioned are preserved.** Validation decides what is legal, not what is kept. A block carrying a CMS source snapshot, the locale it was read in, or an origin slot recorded by a migration keeps all of it through a chat edit.

**Failures are classified, not lumped together.** A failure is one of `schema_violation`, `not_found`, `ambiguity`, `no_effective_change`, `malformed_output`, `planner_refusal`, `incomplete_output`, `operation_failed`, `unsupported_by_site`, `canceled` or `internal_error`. Only `schema_violation` is repairable by re-prompting, so only that category triggers a repair pass, which tells the model exactly which path failed and how. Everything else either needs a person or needs nothing, and spending another model call on it would only be slower. If the repair also fails, the message says what went wrong. See [chat troubleshooting](/observability/chat-troubleshooting).

## Step 4: Apply, as the plan streams

Valid operations are applied to the draft page as they arrive, not after the full response. Each applied operation snapshots nothing new on its own — the turn took one snapshot before it started — and bumps the draft version so the preview knows to catch up. Consecutive applies are spaced by at least `CHAT_STREAM_APPLY_MIN_STEP_MS` (default 260 ms) so the preview reads as a sequence of changes rather than a flicker.

Some operations are deliberately **not** applied mid-stream:

* `remove_block`, and every page-level operation (`create_page`, `duplicate_page`, `rename_page`, `remove_page`, `move_page`), are held until the whole plan is known. Deleting blocks as they stream would erase content *before* the destructive-action gate could evaluate the plan's full scope, and then present an approval card for something already gone.
* Once one structural operation has been deferred, every later operation in that stream is deferred too — a later `update_props` may target a page the deferred operation has not created yet.

If an operation fails partway through a streamed apply, the turn rolls back to the snapshot it took and re-applies the corrected plan. The editor is told: `rollback_started`, then `rollback_done`.

### What the stream carries

The editor starts a turn with `POST /chat/start`, which returns a `streamId`, and reads the turn from `GET /chat/stream?streamId=…`. A dropped connection can reconnect to the same stream, and `POST /chat/cancel` stops it. `POST /chat/stream` runs a turn and streams it in a single request; `POST /chat` returns only the final JSON.

The stream is Server-Sent Events. Each frame is `data: ` followed by one JSON object with a `type` field — there is no SSE `event:` line, so a client dispatches on `type`.

| `type` | Meaning |
| - | - |
| `scope` | Sent first: whether the request is about one `block` (with its `blockId`), the `page`, or the `site` |
| `token` | A token of the planner's own output |
| `thinking_start` / `thinking_token` / `thinking_end` | Extended-thinking output, when the model produces it |
| `plan_meta` | Intent, summary, estimated operation count |
| `op_candidate` | An operation parsed from the streaming response |
| `field_draft` | A single field's new value, for live in-place preview |
| `op_applied` | An operation applied, with the new preview version |
| `op_skipped` | An operation deliberately not applied, with a reason |
| `summary_token` / `changelog_entry` | The user-facing summary and change log |
| `image_progress` | Progress on deferred image lookup or generation |
| `rollback_started` / `rollback_done` | A failed apply being unwound |
| `status` / `heartbeat` | Progress messages while planning or applying |
| `final` | The terminal result, including `summary_for_user` |
| `error` / `canceled` | Terminal failure, or a request the user stopped |

There is no `preview_updated` event. Preview refresh is driven by the draft version bump described next.

## Step 5: The live preview updates

The preview is your real page, rendered by your own components, in an iframe.

1. During streaming, the editor pushes `liveDraft` frames over the `site-editor/v1` postMessage protocol, so individual field values change in place as the model writes them.
2. When operations land, the editor sends `draftUpdated`.
3. The site re-fetches draft content from the orchestrator (`GET /draft/pages`) and React re-renders the blocks that changed.
4. The changed or newly added block can be focused and scrolled into view.

Because the preview is your site in draft mode, what you are looking at is what a visitor would get — your components, your CSS, your fonts, your layout. It is not a canvas approximating them.

### Where the latency went

| Optimisation | What it does |
| - | - |
| **Tiered planning** | Deterministic plans cost no model call; the fast router handles simple edits on the `fast` tier. |
| **Parallel planner** | The intent router and the full planner run concurrently, and the router's success aborts the planner (`CHAT_PARALLEL_PLANNER`). |
| **Streamed op apply** | Operations are validated and applied as they stream, not after the full response (`CHAT_STREAMED_OP_APPLY`). |
| **Deferred image resolution** | Text and structure land immediately; image search and generation resolve in the background and stream in (`CHAT_DEFER_IMAGE_RESOLUTION`). See [asset manager](/features/asset-picker). |
| **Adaptive schema context** | The planner is sent the schema context the request needs, rather than the whole catalogue every time (`CHAT_ADAPTIVE_SCHEMA_CONTEXT`, off by default). |

The parallel planner, streamed apply and deferred image resolution are on by default; each flag can be switched off on its own when you are debugging a plan.

## Step 6: The approval gate

Most plans apply and stay undoable. Some stop before anything is applied and wait for a person.

The destructive-action gate holds a plan when it:

* removes a page — always, whatever that page contains;
* touches more than one page in a single turn;
* contains three or more `remove_block` operations;
* would remove more than half the blocks on any one page.

Those thresholds are `BULK_REMOVE_BLOCK_THRESHOLD` and `MAJORITY_WIPE_RATIO` in `packages/orchestrator-core/src/ops/destructive-action-gate.ts`. There is no toggle.

The reasoning is that undo protects recovery, not intent. Undo is per page rather than atomic across pages; it does not help if nobody notices until the redo stack is cleared; and a model that deleted the wrong section deleted it with complete confidence. So the plan comes back as `plan_ready` with its reasons, phrased as what it *would* do, and nothing has been applied.

Approving replays the plan that was already produced — the editor sends the same request with `executionMode: "apply_pending_plan"` and the plan's id. There is no second model call and no chance the approved plan differs from the one you read. Discarding sends `executionMode: "discard_pending_plan"`.

You can also ask for this deliberately on any request: `executionMode: "plan_only"` returns a plan without applying it.

## Step 7: Review, undo, refine

Once a change has landed in the draft, there are four things to do with it: keep it, undo it, refine it with another message, or publish.

* **Undo and redo** are per page, per session, capped at 50 entries each way. `POST /history/undo`, `POST /history/redo`, and `GET /history/status` says whether either is available.
* **A chat turn is one undo entry.** A plan with six operations undoes as one action — a sentence is what the person meant. Direct edits from the visual editor push their own entries as they are made.
* **The version log** keeps up to 100 entries per session, each with a restorable snapshot and a record of who made the change. `GET /history/log` lists them; `POST /history/restore` with a `targetVersion` goes back to one. Restoring starts a new branch — the state you restored *from* goes onto the undo stack, so restoring is itself undoable. In the editor, **Activity** opens this log.
* **`POST /history/discard`** takes a list of `versions` and rolls each page they touch back to the state before the earliest one selected. Later changes on that page go with it, and the response names them. Other pages keep their edits, and a discard can be undone.

Nothing in this step has reached a visitor. Draft content lives in the orchestrator's session state until you publish.

## Publishing

Publishing hands your content back to wherever it actually lives. Avocado is not a CMS and does not store your content: publishing is the moment it gives the content to your store and steps out of the way.

`POST /publish` takes a session, an optional `siteId` and `siteOrigin`, an optional `slugs` array, and an optional `includeSiteConfig` flag.

### Publishing a subset

The editor's publish dialog lists every changed page and lets you tick the ones to ship. Sending only those pages would be a bug, not a feature — `pages` is a snapshot meaning *"make the site be this"*, so handing a target four pages would publish a four-page site and take the other fifty-six down.

So a subset publish is a **merge**: start from what is live, overwrite the selected slugs with their draft versions, and leave every other page exactly as the live site already has it. A diffing adapter then finds changes only in the pages you ticked, because the rest are byte-identical to the baseline it read. A snapshot target writes a site that differs from the current one only in those pages. Neither target needs to know a subset was requested.

<Warning>
  A subset publish is impossible without knowing what is live. If the baseline is missing — or is only the demo seed — the publish is **refused** rather than assembled from a guess. Refusing is the correct answer: shipping a site built on a wrong baseline is how content gets destroyed.
</Warning>

`includeSiteConfig` selects the site-wide chrome — header, navigation, footer — separately, because it lives outside the page tree and is its own tick box. It defaults to true, matching a full publish.

A block declared `shared` is one piece of content on every page that holds it. `GET /publish/diff` reports it once, under `sharedBlocks`, with the pages it reaches.

### The baseline a diff needs

`onPublish(pages, config)` means *"here are the full documents, store them"*. That is implementable when the CMS shape **is** the editor shape — a JSON file — and not otherwise. Every real CMS read is a projection: an asset reference flattened to a URL string, a document reference resolved to one language's href, a rich-text tree flattened to markdown. Writing the projection back replaces the reference with the flattening and destroys the document.

So a real integration publishes a **field-level diff**, and a diff needs something to diff against. The orchestrator hands a CMS adapter that baseline in the publish context:

```ts theme={null}
interface CmsPublishContext {
  assets?: Record<string, CmsInlineAsset>
  /** The pages as the adapter last reported them. */
  baseline?: PageDoc[]
  /** @deprecated Misnamed — the same array, under the old name. Read `baseline`. */
  published?: PageDoc[]
}
```

<Warning>
  **`baseline` is the CMS's draft, not the live site**, and the distinction decides whether a publish is correct. The session was seeded from `getPages({ perspective: "draft" })`, and the baseline is that same list, so the diff compares like with like and reports what *this session* changed. Diff against the live site instead and the difference includes every unpublished edit anyone else made in the CMS — an Avocado publish would then ship all of it, from a button whose label says nothing about that.

  `baseline` absent means *"no baseline available"*, never *"the site was empty"*. Publishing every field on the second assumption is the overwrite this field exists to prevent.
</Warning>

`@avocadostudio-ai/site-sdk/publish` provides the diff mechanism generically: the walk over your field specs, the unchanged-field skip, list items matched on a stable key rather than an index, routing a block's patches to a document other than the page's, and `sanityPaths` / `indexPaths` for how a store addresses array elements. What it cannot provide is the inversion — `rehydrate` undoes a projection only your integration knows it made — or uploading an asset. Until an integration has those, the honest answer is `unsupported`, which is a first-class result rather than a thrown error: a publisher that silently drops an inexpressible change reports success for an edit the site will never show. See [publishing](/integration/publishing) and [CMS adapters](/integration/cms-adapters).

### The `PublishTarget` interface

Where a publish *goes* is a plugin point:

```typescript theme={null}
interface PublishTarget {
  readonly name: string
  canHandle?(ctx: PublishContext): boolean
  publish(ctx: PublishContext): Promise<PublishOutcome>
}
```

* `name` — stable identifier, used by the registry and the `PUBLISH_TARGET` environment variable.
* `canHandle()` — optional selection hint. A target with no `canHandle` is only reachable when selected explicitly or by the legacy `PUBLISH_MODE` fallback.
* `publish()` — receives the `PublishContext` (`session`, `scopedSession`, `siteId`, `siteOrigin`, `pages`, `slugs`, `siteConfig`, `generatedImageDir`, `logger`) and returns a `PublishOutcome` (`ok`, `httpStatus`, `tracker`, `response`).

Selection order on every `POST /publish`:

<Steps>
  <Step title="An explicit override">
    If `PUBLISH_TARGET=<name>` is set and that target is registered, it is used verbatim.
  </Step>

  <Step title="The first target that claims the request">
    Registered targets are iterated in registration order and the first whose `canHandle(ctx)` returns true wins.
  </Step>

  <Step title="The legacy fallback">
    `PUBLISH_MODE` decides: `git` (the default) or `deploy_hook`.
  </Step>
</Steps>

### Built-in targets

Three targets are registered at module load, in this order.

**`site-contract`** — claims any request that carries a `siteOrigin`. POSTs pages, site config and inline image assets to your site's own `/api/editor/publish` endpoint. Your site owns content storage — a JSON file, a CMS, a database, whatever you already chose. This is the path a real integration uses.

The receiving end can refuse, and two refusals are worth knowing before you deploy. A site running under `NODE_ENV=production` with no publish secret configured answers **401** — an unauthenticated endpoint that overwrites a site's content is not a state to reach by forgetting a variable — and a publish that would remove every page answers **409** unless the body carries `allowDelete: true`. Both send a machine-readable verdict in `error` and a sentence in `reason`, and the editor shows the `reason`. A site's own publish handler can also answer that it wrote nothing (`written: false`), add `notes`, and list changes it cannot express (`unsupported`); the editor shows each of those as "Not published: …". See [publishing](/integration/publishing).

**`git`** — selected when there is no `siteOrigin` and `PUBLISH_MODE=git` (the default). It serialises draft pages to `apps/site/lib/published-content.json`, copies generated images into `apps/site/public/generated-images/`, rewrites localhost image URLs to relative paths, and commits and pushes to `PUBLISH_GIT_BRANCH`. A configured deploy hook then rebuilds.

**`deploy-hook`** — selected when there is no `siteOrigin` and `PUBLISH_MODE=deploy_hook`. Calls `VERCEL_DEPLOY_HOOK_URL`, then polls the Vercel API with `VERCEL_TOKEN` and reports triggered → building → ready (or failed) back to the editor.

### Environment variables

| Variable | Default | What it does |
| - | - | - |
| `PUBLISH_TARGET` | *(none)* | Force one target by name, overriding `canHandle` and `PUBLISH_MODE` |
| `PUBLISH_MODE` | `git` | Legacy fallback selection: `git` or `deploy_hook` |
| `PUBLISH_GIT_BRANCH` | `main` | Branch the `git` target pushes to |
| `PUBLISH_GIT_STRICT` | `0` | `1` aborts the publish when the working tree has unrelated changes |
| `PUBLISH_TOKEN` | *(none)* | Two jobs, one value. When set, the standalone orchestrator's `POST /publish` requires it in the `x-publish-token` header; and the `site-contract` target sends it as that header to your site, whose own publish route requires a matching secret in production |
| `VERCEL_DEPLOY_HOOK_URL` | *(none)* | Deploy hook URL for the `deploy-hook` target |
| `VERCEL_TOKEN` | *(none)* | Vercel API token, for polling deployment status |

### Implementing your own target

To publish somewhere else — S3, GitLab Pages, a CMS API, your own CI — implement the interface and register it before the server starts handling requests.

```typescript theme={null}
import {
  registerPublishTarget,
  type PublishTarget,
  type PublishContext,
  type PublishOutcome,
} from "@avocadostudio-ai/orchestrator-core"

class S3PublishTarget implements PublishTarget {
  readonly name = "s3"

  canHandle(ctx: PublishContext): boolean {
    return ctx.siteId === "my-s3-site"
  }

  async publish(ctx: PublishContext): Promise<PublishOutcome> {
    const { session, pages, slugs, logger } = ctx
    const res = await fetch("https://example.com/publish", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ pages }),
    })

    const now = new Date().toISOString()
    logger.info({ session, slugs, ok: res.ok }, "s3 publish")

    return {
      ok: res.ok,
      httpStatus: res.ok ? 200 : 400,
      tracker: {
        session,
        status: res.ok ? "triggered" : "failed",
        startedAt: now,
        updatedAt: now,
        slugs,
        vercelState: res.ok ? "READY" : "ERROR",
      },
      response: {
        status: res.ok ? "ready" : "failed",
        session,
        slugs,
        message: res.ok ? "Published" : `Upstream returned ${res.status}`,
      },
    }
  }
}

registerPublishTarget(new S3PublishTarget())
```

Your target receives full `PageDoc` objects — structured JSON, never HTML. What it does with them is yours.

### CMS publishing

For CMS-backed sites the site SDK covers the parts every integration needs: resolving image URLs (rewriting localhost references and uploading to the CMS), SSRF validation on external image URLs, deduplicating image uploads within a single publish, and the field-diff machinery described above.

Working implementations live in the repository at `examples/contentful-site/`, `examples/contentful-marketing-site/`, `examples/sanity-site/` and `examples/strapi-site/`, with `examples/sample-site/` covering the JSON-file case.

## What never happened

Follow the whole pipeline back and notice what is absent. A sentence produced a plan; the plan was nineteen possible verbs wide; the verbs changed props, items, order, metadata, pages, navigation and theme tokens; the result was handed to your content store.

No file in your repository was read or written. No component was edited, no route added, no dependency installed, no build triggered except the one your own deploy hook chose to run on content you approved. That is not a policy the orchestrator enforces and could relax under pressure — it is the shape of the only vocabulary it has.

<CardGroup cols={2}>
  <Card title="Core concepts" icon="lightbulb" href="/concepts">
    Blocks, operations, drafts, approval — the mental model in one page.
  </Card>

  <Card title="Architecture" icon="sitemap" href="/architecture">
    The services and packages behind each step above.
  </Card>

  <Card title="Publishing" icon="upload" href="/integration/publishing">
    The publish seam in full — diffs, baselines, targets, CMS specifics.
  </Card>

  <Card title="Chat troubleshooting" icon="bug" href="/observability/chat-troubleshooting">
    When a plan does not do what the sentence asked.
  </Card>
</CardGroup>


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