Skip to main content

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 with registerBlock 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 in packages/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 runs validateBlockProps() against this schema before applying any operation.

Metadata (meta)

Rich metadata layered on top of the schema:
  • fields — per-field FieldMeta with kind, label, imageSpec, inlineEditable
  • listFields — describes array fields (like cards in CardGrid) with item-level field metadata in itemFields. Rows of several shapes use discriminator and itemFieldsByType; a list held by a row (a card’s own buttons) goes in itemListFields, never in itemFields, which would draw it as a text input
  • displayName, description, category — used by the editor’s block picker and property panel. category is one of content, media, navigation, conversion, layout
  • role — "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 single Card, 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 picker
  • chrome — if true, the block is structurally pinned (e.g. SiteHeader, Footer) and cannot be added, moved, or removed
  • fixed — if true, 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 fields
  • shared — if true, 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.
A declaration cannot clear internal on an underscore-prefixed key. The value in _key is the address a field-level publish patches an array member by — editing it re-points or orphans the very patch that would have saved it.

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_block and duplicate_block on it, and any move that would shift it. reorder_blocks may list it only at its current position, or omit it.
  • The planner sees fixed: true on 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: false on 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 readOnlyReason as 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.
  • editableCoverage expects no marker for it.
The same two flags in a field table are 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_props and the list-item ops, from chat, the panel, /ops or MCP — is applied to every draft page holding a block with that id and type, in the same atomic step, and each page’s updatedAt is stamped so every preview refreshes. /ops reports where it went in sharedPropagations.
  • 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/diff reports the change once in sharedBlocks — { 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 has status: "conflict" and a variants list 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_page keeps its id so the copy stays in step.
Identity is the id, matched with the type: a page-owned instance of the same type under its own id (a campaign page’s own footer) is untouched. Give page-owned instances ids no other page uses. 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.
See Site-wide content for injecting the block into every page and writing it back once.

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 in packages/blocks/src/blocks/{type}/renderer.tsx:
Key patterns:
  • 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:
It checks the built-in renderer map first, then falls back to custom renderers registered at runtime (for site-specific blocks from migrations or CMS integrations).
There is no compile-time check that every schema has a matching renderer. If you add a schema in shared but forget the renderer in blocks, the block will validate but render as empty. The block catalogue page (/catalogue) is the easiest way to verify all blocks render correctly.

The Registry Singleton

The registry uses globalThis to ensure a single instance survives Next.js webpack module duplication across RSC, SSR, and API route layers:
Without this, 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 from GET /api/editor/blocks. This endpoint serializes registered blocks into a JSON payload the editor and AI planner can consume:
Each entry may also carry 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.
add_item and update_item refuse keys the list has never seen. A row key is allowed when the list’s field metadata or its props schema declares it, when another row already holds it, when it is id, or when it starts with _. Anything else is a schema_violation naming the list’s own fields. Before this check, a row written as { q, a } into a list whose rows are { title, body } was accepted, reported as applied, and rendered as an empty row. Lists that declare no item fields are not checked.

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 packages/shared/src/blocks/index.ts, import the module and add your defaults function to the defaults map:
The import alone registers the schema but leaves the defaults unwired, and 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 /catalogue on 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 in packages/orchestrator-core/src/nlp/deterministic-planner-suggestions.ts:
Notes are injected into the AI contract as natural language guidance. They’re most valuable for:
  • Enum semantics (“use center textAlign with full imagePosition”)
  • 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”)
See Block Schema Contracts for details on how contracts are assembled and sent to the LLM.

Add image fields with AI resolution

Mark image fields with f.image() and include an imageSpec:
The 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: