Overview
This page is for a developer working on the block system itself, or registering blocks from a site. On an existing site you register your own components withregisterBlock from @avocadostudio-ai/site-sdk/blocks — see
custom blocks. The contracts below (field kinds,
flags, the manifest and the operations) are the same either way.
The built-in blocks are split across two packages:
The split exists because the orchestrator (Node/Fastify) needs block schemas for validation and AI planning but has no React dependency. Renderers live in a separate package so React is only pulled in where it’s needed.
How a Block Is Defined
Every block has three parts, co-located in a single file inpackages/shared/src/blocks/:
registerBuiltinBlock is internal: it calls registerBlock and marks the type
as Avocado’s own. A site calls registerBlock(type, { schema, meta }).
Registering a site block under a built-in name replaces the built-in’s schema
and drops that mark, so the built-in’s prop migrations stop applying to it.
.catch("info") on an enum makes an unknown stored value fall back instead of
failing validation. Content outlives schemas.
Schema
The Zod object defines the data shape — field types, enums with defaults, required vs optional, array constraints. This is the source of truth for validation. The orchestrator runsvalidateBlockProps() against this schema before applying any operation.
Metadata (meta)
Rich metadata layered on top of the schema:
fields— per-fieldFieldMetawithkind,label,imageSpec,inlineEditablelistFields— describes array fields (likecardsin CardGrid) with item-level field metadata initemFields. Rows of several shapes usediscriminatoranditemFieldsByType; a list held by a row (a card’s own buttons) goes initemListFields, never initemFields, which would draw it as a text inputdisplayName,description,category— used by the editor’s block picker and property panel.categoryis one ofcontent,media,navigation,conversion,layoutrole—"section"(the default) or"element". A section is a full-width band a page is made of; an element is a smaller building block, such as a singleCard, that reads as a fragment on its own. The add-block picker lists sections first and folds elements into their own group, and the planner prefers a section when asked for one. A site can override it per type — see sections, building blocks and the pickerchrome— iftrue, the block is structurally pinned (e.g. SiteHeader, Footer) and cannot be added, moved, or removedfixed— iftrue, the site renders this page block at a fixed position: it cannot be moved, removed or duplicated, and nothing is moved across it. See Fixed blocks and read-only fieldsshared— iftrue, the block is site-wide content: every instance with the same block id, on any page, is one piece of content. See Shared blocks
Default Props
An exported function (e.g.bannerDefaultProps()) that returns sensible starter content. Used when the AI or user adds a new block — the defaults are the starting point that the AI then modifies.
Field Metadata Vocabulary
The_helpers.ts file provides factory functions for declaring field metadata:
f is internal to packages/shared. A site writes the same objects by hand —
{ kind: "text" } — in meta.fields.
tone is the section background every section-owning built-in carries:
default is the page ground, panel the step in from it, inverse the
full-bleed band. It reaches the property panel as a Background select and
the planner as a schema field. An unknown value falls back to the page ground.
The kind field drives behavior across the stack:
That is the complete vocabulary — sixteen kinds. It is written in two places that a compiler check keeps in step: the
FieldKind union and the FIELD_KINDS list behind fieldMetaSchema. select and textarea are not kinds, though they are the natural guesses; use enum and text with multiline instead. There is no array or object kind: a list of objects is a listFields entry, not a field.
richtext takes two more options. inline: true makes a document-valued field one line — marks, no paragraphs or lists — for a headline. decorators: [{ name, title? }] declares character styles beyond the built-in five, such as a brand highlight, so the editor keeps them instead of dropping them on load.
Four flags that change what a field means
kind says how a field is drawn. These say whether it should be drawn, or mentioned, at all — and each one exists because its absence produced a specific class of bug.
Fixed blocks and read-only fields
Some things on a page are real content and still not the editor’s to change. Two flags say so, and every writer honours them — the editor, chat,/ops and MCP alike — so the refusal happens where the person is, not at publish time.
fixed: true on a block (in meta, in a manifest entry, or on a registerFieldTable block spec) says the site draws the block at a fixed position. Use it for sections that are slices of one CMS entry rendered in a fixed order: a Contentful blog post is one entry, but the template renders it as a hero, a body and a related-articles grid, and those are three blocks whose order the page’s block list does not decide.
- The editor hides move up, move down, delete and add-above/below on the block, and disables moving a neighbour across it.
- The ops engine refuses
remove_block,move_blockandduplicate_blockon it, and any move that would shift it.reorder_blocksmay list it only at its current position, or omit it. - The planner sees
fixed: trueon the block in its page outline and is told not to propose structural changes to it. - Its props are edited as usual. Adding other sections around it is left to the site: declare
structuralEdits: falseon the adapter if a page cannot take new sections at all.
readOnly: true on a field, with an optional readOnlyReason, says the value is shown but cannot be stored. Use it for asset alt text shared across entries (on Contentful the alt is the asset’s title), slugs, publish dates, and anything else the site’s publisher refuses to write.
- The property panel draws the value disabled, with
readOnlyReasonas its help text. - The ops engine refuses a change to it with the reason (category
unsupported_by_site). One exception: an alt text that changes in the same patch as its image is kept and the image goes through, so image swaps still work where only the alt is shared. - The planner is told which props are read-only and why; page-wide translation skips them.
editableCoverageexpects no marker for it.
fixed: true on the block spec and readOnly / readOnlyReason on a field; an image’s alt text has its own alt: { readOnly, readOnlyReason }. A site serving its own manifest writes them on the entry — fixed beside chrome, readOnly / readOnlyReason inside fields.
Shared blocks (site-wide content)
A header, a footer, the business address, one call to action every page ends on: content that appears on every page and is written once.shared: true on a block (in meta, in a manifest entry, or on a registerFieldTable block spec) declares it. The site places the block on each page under the same block id — global-footer on every page — and every instance with that id is one piece of content.
- Draft. An edit to its props on one page —
update_propsand the list-item ops, from chat, the panel,/opsor MCP — is applied to every draft page holding a block with that id and type, in the same atomic step, and each page’supdatedAtis stamped so every preview refreshes./opsreports where it went insharedPropagations. - Undo. Undo on the page the edit was made on reverts it on every page; redo brings it back everywhere. Undoing an unrelated edit on another page leaves the shared block alone. Restoring or discarding one page’s history keeps the site’s current shared content.
- Publish review.
GET /publish/diffreports the change once insharedBlocks—{ blockId, type, status: "modified", pages, fieldDiffs }— and counts its fields once. The editor shows it as “Footer — affects 5 pages”. If the drafts disagree (edited before the type was declared shared, or written around the engine), the entry hasstatus: "conflict"and avariantslist of each version and its pages, and the dialog warns before publishing. - Publish. Publishing a subset of pages ships the ticked page’s version of a shared block on every page that carries it, so the site never receives two versions under one id.
- Planner. The block’s outline entry carries
shared: true, and the planner is told one op on the current page changes it everywhere. - Structure stays per page. Adding, moving and removing a shared block affect only the page they name, and
duplicate_pagekeeps its id so the copy stays in step.
shared is independent of chrome and fixed. The built-in SiteHeader and Footer are chrome with per-page ids, and are not shared unless a site declares them so.
Why reference refuses the edit
A CMS reference is a pointer, not a URL. Storyblok stores an internal link as { linktype: "story", id: <uuid>, cached_url: "faq" } and renders it per language — /faq on the German page, /fr/faq on the French one. Neither rendered string is what the document holds; Contentful entry links and Sanity references have the same shape.
Flattening that to an href breaks the projection in both directions at once. The publish diff reports the reference as changed on every page forever, because the rendered href never equals the stored object. And writing the href back replaces the pointer with a hardcoded URL — the page renders identically and the link silently stops following renames, which is the one thing the reference was for.
So the panel shows where it points and offers no control, the planner is not told the prop exists, and an update_props naming one is dropped with a note saying the change belongs in the CMS. referenceLabel reads the three CMS spellings so “opaque” does not mean [object Object] in the one place a person checks where a CTA goes; referenceLabelKey overrides it for a readable key under an unguessable name.
Re-pointing a reference needs the CMS’s own document ids, which only an integration has. Showing the target and refusing the edit is the honest answer until a picker exists; a text input is the corrupting one.
How a Block Is Rendered
Renderers live inpackages/blocks/src/blocks/{type}/renderer.tsx:
- Untyped props — renderers accept
Record<string, unknown>and coerce to safe types. Validation happens upstream in the orchestrator. data-editable-target— marks DOM elements for inline editing in the preview overlay. The value matches a prop key.- No imports from
shared(usually) — renderers are stateless view functions. They don’t validate or re-fetch metadata.
How They Connect
Schemas and renderers are joined by type name convention — the string"Banner" passed to registerBlock() must match the key in the renderers map:
SharedBlockRenderer is the glue:
The Registry Singleton
The registry usesglobalThis to ensure a single instance survives Next.js webpack module duplication across RSC, SSR, and API route layers:
registerBlock() in a custom block file would populate a different registry copy than getBlockMeta() reads — blocks would appear registered but metadata would be missing.
Runtime Queries
The registry exposes query functions used across the stack:Block Manifest API
When the editor connects to a site, it fetches the block manifest fromGET /api/editor/blocks. This endpoint serializes registered blocks into a JSON payload the editor and AI planner can consume:
listFields, chrome, fixed, shared, and three
keys that say how the add-block picker offers the type: role ("element";
absent means a section), insertable: false (renders and edits, never offered
for insertion), and group (a picker heading of the site’s own). Each of those
three is emitted only when it differs from the default. fields and
listFields carry what JSON Schema cannot say — kind, multiline, the flags
above — and the editor merges them over what it derives from propsSchema.
buildBlockManifest() builds this from the registry, narrowed to the site’s
catalogue when one is declared. For built-in blocks, getBlockJsonSchema() converts the Zod schema to JSON Schema and strips validation-only constraints (minLength, required, $schema, additionalProperties) — the editor only needs the structural shape. defaultProps appears only for a block that declares defaults.
A site that does not register its blocks can author the manifest directly as JSON Schema in propsSchema — see custom blocks.
Operations
Every edit — from chat, the property panel,POST /ops or the MCP server — is one of these operations. The schema is operationSchema in @avocadostudio-ai/shared, and @avocadostudio-ai/shared/contract/operation.schema.json is the same thing as JSON Schema.
update_item takes patch; add_item takes item. Address list rows by itemId wherever you can: every row carries a stable id, and an index shifts when a sibling is added or removed.
Full Lifecycle
How-To Guides
Add a new block type
1
Define the schema
Create
packages/shared/src/blocks/my-block.ts:2
Register the import
In The import alone registers the schema but leaves the defaults unwired, and
packages/shared/src/blocks/index.ts, import the module and add your defaults function to the defaults map:defaultPropsForType() then falls back to a CTA-shaped object — so a new block
would start life with title / description / ctaText / ctaHref.3
Create the renderer
Create
packages/blocks/src/blocks/my-block/renderer.tsx:4
Register the renderer
Add
MyBlock to the renderers map in packages/blocks/src/blocks/index.ts and to the rendererBlockTypes list in block-types.ts, which the RendererBlockType union is derived from.5
Add styles
Create
packages/blocks/src/blocks/my-block/styles.css and import it from packages/blocks/src/blocks/styles.css.6
Verify
pnpm typecheck— catches missing fields or type mismatches- Visit
/catalogueon the site to see the block render with default props - Open the editor and ask the AI to “add a MyBlock” — the planner should pick it up from the manifest
Add a field to an existing block
1
Update the Zod schema
Add the field to the block’s
z.object() in packages/shared/src/blocks/{type}.ts. Use .optional() if it’s not required.2
Add field metadata
Add an entry to
meta.fields using the f.* helpers. Choose the right kind — it determines editor UI, AI behavior, and inline editability.3
Update default props
If the field should have a starter value, add it to the
*DefaultProps() function.4
Update the renderer
Read the new prop in the renderer component. Add
data-editable-target="fieldName" if it should be inline-editable in the preview.5
Verify
pnpm typecheck then check the block catalogue and editor.Add a list field (repeatable items)
1
Define the array in the schema
2
Add listFields metadata
3
Update the renderer
Cast and iterate:Note the
features[0].title path format in data-editable-target — this enables inline editing of list items.Add AI guidance for a block
If the AI makes mistakes with your block’s props (wrong enum values, missing cross-field dependencies), add a note inpackages/orchestrator-core/src/nlp/deterministic-planner-suggestions.ts:
- Enum semantics (“use
centertextAlign withfullimagePosition”) - Cross-field dependencies (“full-bleed variant REQUIRES imageUrl”)
- Richtext conventions (which markdown subset is supported)
- Optional field toggle behavior (“omit or set empty to hide”)
Add image fields with AI resolution
Mark image fields withf.image() and include an imageSpec:
imageSpec tells the AI planner what dimensions to request from the AI image generator (Gemini or OpenAI gpt-image-*, per IMAGE_GEN_PROVIDER) or Unsplash. The imageAlt kind pairs with the image field for accessibility. Both are auto-detected by getImageFields() and getImageSpec() — no additional wiring needed.
For list items with images, declare them in listFields.itemFields: