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
fasttier handles the next tier of requests. - Full planner — everything else.
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.
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.
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 tell you how much of it your pages actually expose.
Step 3: Validate against your block schema
Every operation is checked before it is allowed to change anything:- Does it parse? The operation must match one of the nineteen members of
operationSchema. - Does the target exist? The page, the block, the anchor block, the list index.
- 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. - Is a polymorphic list row complete? A row added to a list that declares a discriminator must say which shape it is, or it is rejected here with the admissible types in the message.
schema_violation, not_found, ambiguity, no_effective_change, malformed_output, planner_refusal, incomplete_output 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.
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 leastCHAT_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_propsmay target a page the deferred operation has not created yet.
rollback_started, then rollback_done.
What the stream carries
POST /chat responds with 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.
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.- During streaming, the editor pushes
liveDraftframes over thesite-editor/v1postMessage protocol, so individual field values change in place as the model writes them. - When operations land, the editor sends
draftUpdated. - The site re-fetches draft content from the orchestrator (
GET /draft/pages) and React re-renders the blocks that changed. - The changed or newly added block can be focused and scrolled into view.
Where the latency went
Each is an environment flag, on by default and individually switchable 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_blockoperations; - would remove more than half the blocks on any one page.
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, andGET /history/statussays 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.
GET /history/loglists them;POST /history/restoregoes back to one. Restoring starts a new branch — the state you restored from goes onto the undo stack, so restoring is itself undoable. POST /history/discardthrows away the whole draft for a slug and returns to what is published.
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.
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.
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:
@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 and CMS adapters.
The PublishTarget interface
Where a publish goes is a plugin point:
name— stable identifier, used by the registry and thePUBLISH_TARGETenvironment variable.canHandle()— optional selection hint. A target with nocanHandleis only reachable when selected explicitly or by the legacyPUBLISH_MODEfallback.publish()— receives thePublishContext(session,scopedSession,siteId,siteOrigin,pages,slugs,siteConfig,generatedImageDir,logger) and returns aPublishOutcome(ok,httpStatus,tracker,response).
POST /publish:
1
An explicit override
If
PUBLISH_TARGET=<name> is set and that target is registered, it is used verbatim.2
The first target that claims the request
Registered targets are iterated in registration order and the first whose
canHandle(ctx) returns true wins.3
The legacy fallback
PUBLISH_MODE decides: git (the default) or deploy_hook.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. See 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
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.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 atexamples/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.Core concepts
Blocks, operations, drafts, approval — the mental model in one page.
Architecture
The services and packages behind each step above.
Publishing
The publish seam in full — diffs, baselines, targets, CMS specifics.
Chat troubleshooting
When a plan does not do what the sentence asked.