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

# Block System Architecture

> How block schemas and renderers are organized, connected, and used across the stack — from definition to AI planning to rendering.

## 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](/integration/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:

| Package | Contains | Used by |
| - | - | - |
| `packages/shared/src/blocks/` | Zod schemas, field metadata, default props | Orchestrator, editor, site, the [Puck](https://puckeditor.com/) visual editor |
| `packages/blocks/src/blocks/` | React renderers, CSS, tests | Site (SSR), editor (preview), Puck canvas |

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

```ts theme={null}
// packages/shared/src/blocks/banner.ts
import { z } from "zod"
import { registerBuiltinBlock } from "./_registry.ts"
import { f } from "./_helpers.ts"

registerBuiltinBlock("Banner", {
  schema: z.object({
    text: z.string().min(1),
    variant: z.enum(["info", "success", "warning"]).default("info").catch("info"),
    ctaText: z.string().optional(),
    ctaHref: z.string().optional(),
    ctaNewTab: z.boolean().optional(),
    backgroundColor: z.string().optional(),
    textColor: z.string().optional(),
  }),
  meta: {
    displayName: "Banner",
    description: "Full-width announcement or alert bar with optional call-to-action button.",
    category: "content",
    fields: {
      text: f.text("Banner text"),
      variant: { kind: "enum", label: "Variant", options: ["info", "success", "warning"], inlineEditable: false },
      ctaText: f.text("Button label"),
      ctaHref: f.link("Button link"),
      ctaNewTab: f.newTab(),
      backgroundColor: { kind: "color", label: "Background color", inlineEditable: false },
      textColor: { kind: "color", label: "Text color", inlineEditable: false },
    },
  }
})

export function bannerDefaultProps(): Record<string, unknown> {
  return {
    text: "We just launched something new — check it out!",
    variant: "info",
    ctaText: "Learn more",
    ctaHref: "/",
  }
}
```

`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](/integration/cms-adapters#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](#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](#shared-blocks-site-wide-content)

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

```ts theme={null}
import { f } from "./_helpers.ts"

f.text("Heading")              // { kind: "text", label: "Heading" }
f.longtext("Description")     // { kind: "text", label: "Description", multiline: true }
f.richtext("Body")            // { kind: "richtext", label: "Body" }
f.url("Link")                 // { kind: "url", label: "Link", inlineEditable: false }
f.link("Button link")         // { kind: "link", label: "Button link", inlineEditable: false }
f.newTab()                    // { kind: "boolean", label: "Open in new tab", inlineEditable: false }
f.image("Hero image", {       // { kind: "image", label: "Hero image", inlineEditable: false,
  aspectRatio: "landscape",   //   imageSpec: { aspectRatio: "landscape", width: 1536, height: 1024 } }
  width: 1536, height: 1024
})
f.imageAlt("Alt text")        // { kind: "imageAlt", label: "Alt text" }
f.icon()                      // { kind: "text", label: "Icon (single emoji)", inlineEditable: false }
f.headingLevel()              // { kind: "headingLevel", label: "Heading type", inlineEditable: false }
f.tone()                      // { kind: "enum", label: "Background", options: ["default", "panel", "inverse"], inlineEditable: false }
```

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

| Kind | Editor UI | AI planning | Preview overlay |
| - | - | - | - |
| `text` | Text input | Free text generation | Inline editable |
| `richtext` | Rich-text (Tiptap) editor — or a ProseMirror JSON editor for document-shaped values | Markdown generation | Inline editable |
| `html` | A document editor over a stored HTML string — converted on the way in and back on the way out, unknown tags preserved | Text inside the markup | Not inline editable by default |
| `url` | URL input | Link generation | Not inline editable |
| `image` | Asset Manager modal | Image resolution (Unsplash or AI generation) | Not inline editable |
| `imageAlt` | Text input | Alt text generation | Not inline editable |
| `enum` | Dropdown selector | Constrained to options | Not inline editable |
| `color` | Color picker | Hex color generation | Not inline editable |
| `number` | Number input | Numeric generation | Not inline editable |
| `boolean` | Toggle | Constrained to true/false | Not inline editable |
| `headingLevel` | Dropdown (h1-h6) | Heading hierarchy | Not inline editable |
| `link` | Link picker over the site's own pages | Internal route resolution | Not inline editable |
| `file` | Document picker (PDFs and other non-image assets) | Document resolution | Not inline editable |
| `reference` | Shows the target, offers no control | **Not offered to the planner at all** | Not inline editable |
| `stringList` | A list of text rows — bullets | A string array | Items edit in place in the preview and the panel |
| `imageList` | A list of image rows, each `{ image, alt }` | Image resolution per row | Not inline editable |

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.

| Flag | What it means | Why it exists |
| - | - | - |
| `inlineEditable: false` | The value is edited in the panel, never in place on the page. | Reaches the preview as `data-editable-inline="false"`. Before it did, "is this prose?" was decided by whether the prop happened to be called `body`. |
| `panelOnly: true` | Nothing on the page draws this field. | Takes the field out of the [`editableCoverage`](/integration/coverage) denominator, so 100% is reachable and anything less is actionable. |
| `readOnly: true` | The value is shown and can never be changed here. Pair it with `readOnlyReason`. | The panel drew a shared asset's alt text as an ordinary input, and the publish refused the edit an hour later. See [below](#fixed-blocks-and-read-only-fields). |
| `internal: true` | The storage system owns this prop. A person must never edit it and a model must never be told it exists. | Underscore-prefixed props (a Storyblok `_uid`, a Contentful `sys`) get it automatically; declare it for props that follow no convention. Honoured by the property panel, the Puck adapter, both coverage checks, the block summary the agent reads, the translation checklist, and the hallucination validator. |

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

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

```ts theme={null}
registerBlock("articleHero", {
  schema: z.object({ title: z.string(), imageUrl: z.string(), imageAlt: z.string(), slug: z.string() }),
  meta: {
    displayName: "Article hero",
    fixed: true,
    fields: {
      title: { kind: "text" },
      imageUrl: { kind: "image" },
      imageAlt: {
        kind: "imageAlt",
        readOnly: true,
        readOnlyReason: "Alt text is the asset's title in Contentful and is shared by every entry that uses it.",
      },
      slug: { kind: "text", readOnly: true, readOnlyReason: "The slug is the page's address." },
    },
  },
})
```

The same two flags in a [field table](/integration/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.

```ts theme={null}
registerBlock("siteFooter", {
  schema: z.object({ copyright: z.string(), services: z.array(z.object({ label: z.string() })) }),
  meta: { displayName: "Footer", shared: true, fields: { copyright: { kind: "text" } } },
})
```

See [Site-wide content](/integration/cms-adapters#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.

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

## How a Block Is Rendered

Renderers live in `packages/blocks/src/blocks/{type}/renderer.tsx`:

```tsx theme={null}
// packages/blocks/src/blocks/banner/renderer.tsx
export function Banner(props: Record<string, unknown>): JSX.Element {
  const text = String(props.text ?? "")
  const variant = String(props.variant ?? "info")
  const ctaText = String(props.ctaText ?? "")
  const ctaHref = String(props.ctaHref ?? "")

  return (
    <section className={`banner banner--${variant}`}>
      <div className="banner__inner section__inner">
        <p data-editable-target="text">{renderInline(text)}</p>
        {ctaText.length > 0 && ctaHref.length > 0 && (
          <PrimaryButton href={ctaHref} data-editable-target="ctaText">
            {ctaText}
          </PrimaryButton>
        )}
      </div>
    </section>
  )
}
```

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:

```mermaid theme={null}
graph LR
    subgraph "packages/shared"
        Reg["registerBlock('Banner', { schema, meta })"]
    end

    subgraph "packages/blocks"
        Ren["renderers = { Banner: BannerComponent }"]
        SBR["SharedBlockRenderer"]
    end

    Reg -->|"type name: 'Banner'"| SBR
    Ren -->|"type name: 'Banner'"| SBR

    SBR -->|"block.type === 'Banner'"| Resolve["renderers[block.type](block.props)"]
```

`SharedBlockRenderer` is the glue:

```tsx theme={null}
// packages/blocks/src/renderer.tsx
export function SharedBlockRenderer({ block }: { block: BlockInstance }) {
  if (isRendererBlockType(block.type)) {
    const Renderer = renderers[block.type]
    return <Renderer {...block.props} />
  }
  const Custom = customRenderers.get(block.type)
  if (Custom) return <Custom {...block.props} />
  return null
}
```

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

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

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

```ts theme={null}
const G = globalThis as Record<string, unknown>
const _blockSchemas: Record<string, z.ZodObject<any>> =
  (G.__ase_blockSchemas as ...) ?? (G.__ase_blockSchemas = {})
const _blockMeta: Record<string, BlockMeta> =
  (G.__ase_blockMeta as ...) ?? (G.__ase_blockMeta = {})
```

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:

| Function | Used by | Purpose |
| - | - | - |
| `getBlockMeta(type)` | Editor property panel, Puck config | Get metadata for a block type |
| `getAllBlockMeta()` | Block manifest API, catalogue | Get all registered metadata |
| `getImageFields(type)` | Orchestrator image resolution | Which props are image fields (cached) |
| `getListImageFields(type)` | Orchestrator image resolution | Which array items have images (cached) |
| `getImageSpec(type, path)` | AI image / Unsplash requests | Aspect ratio for a field path like `cards[0].imageUrl` |
| `isFieldInlineEditable(type, path)` | Preview overlay | Can this field be edited in-place? |
| `isChrome(type)` | Orchestrator ops engine | Is this block pinned (header/footer)? |
| `validateBlockProps(type, props)` | Orchestrator ops engine | Run Zod validation before applying ops |
| `declareBlockCatalogue(input)` | `blockTypes` on both handlers, `registerFieldTable` | Declare which types the site renders and how the picker offers them |
| `blockRole(type)` | Editor picker, planner | `"section"` or `"element"`, after the site's override |
| `buildBlockManifest()` | `GET /api/editor/blocks`, `GET /blocks/manifest` | Serialize the catalogue to the manifest below |
| `defaultPropsForType(type)` | AI planner, ops engine | Seed new blocks with defaults |
| `getBlockJsonSchema(type)` | Block manifest API | Convert Zod to JSON Schema for external consumers |

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

```json theme={null}
{
  "version": 1,
  "blocks": [
    {
      "type": "Banner",
      "displayName": "Banner",
      "description": "Full-width announcement bar with optional CTA.",
      "propsSchema": { "type": "object", "properties": { "text": { "type": "string" }, ... } },
      "defaultProps": { "text": "We just launched...", "variant": "info" },
      "fields": { "text": { "kind": "text", "label": "Banner text" }, ... },
      "category": "content"
    }
  ]
}
```

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](/integration/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.

| Operation | Addresses | Does |
| - | - | - |
| `create_page` | `page` (a whole `PageDoc`) | Adds a page |
| `rename_page` | `pageSlug`, `newPageSlug?`, `newTitle?` | Changes the slug, the title, or both |
| `remove_page` | `pageSlug` | Deletes a page |
| `move_page` | `pageSlug`, `afterPageSlug?` | Moves a page in the page order |
| `duplicate_page` | `pageSlug`, `newPageSlug?`, `newTitle?`, `afterPageSlug?` | Copies a page |
| `update_page_meta` | `pageSlug`, `patch` | Sets `title`, `description` or `ogImage`. Any other key is refused |
| `add_block` | `pageSlug`, `block`, `afterBlockId?` | Inserts a block |
| `update_props` | `pageSlug`, `blockId`, `patch` | Merges a patch into a block's props |
| `remove_block` | `pageSlug`, `blockId` | Deletes a block |
| `move_block` | `pageSlug`, `blockId`, `afterBlockId?` | Moves one block |
| `duplicate_block` | `pageSlug`, `blockId`, `toPageSlug?`, `newBlockId?`, `afterBlockId?` | Copies a block, optionally onto another page |
| `reorder_blocks` | `pageSlug`, `order` (block ids) | States the final order of the page's blocks. Omit chrome blocks |
| `add_item` | `pageSlug`, `blockId`, `listKey`, `item`, `afterItemId?` / `afterIndex?` | Inserts a list row |
| `update_item` | `pageSlug`, `blockId`, `listKey`, `itemId` or `index`, `patch` | Merges a patch into one row |
| `remove_item` | `pageSlug`, `blockId`, `listKey`, `itemId` or `index` | Deletes a row |
| `move_item` | `pageSlug`, `blockId`, `listKey`, `itemId` or `index`, `afterItemId?` / `afterIndex?` | Moves a row |
| `reorder_items` | `pageSlug`, `blockId`, `listKey`, `order` (item ids, or indexes for rows without ids) | States the final order of a list |
| `update_site_config` | `patch` (`name`, `logo`, `navLabels`, `navGroups`) | Changes the site header's config |
| `update_theme` | `patch` (semantic tokens), `cssVars?` | Changes the site-wide theme. An empty string resets a token |

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

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

## Full Lifecycle

```mermaid theme={null}
flowchart TD
    Define["1. Define block<br/>registerBlock('Banner', { schema, meta })"]
    Render["2. Create renderer<br/>blocks/banner/renderer.tsx"]
    Manifest["3. Serve manifest<br/>GET /api/editor/blocks"]
    Contract["4. Build AI contract<br/>blockContractsSummary()"]
    Plan["5. AI generates plan<br/>{ op: 'add_block', type: 'Banner', props: {...} }"]
    Validate["6. Validate props<br/>validateBlockProps('Banner', props)"]
    Apply["7. Apply operation<br/>Update session state"]
    Render2["8. Render<br/>SharedBlockRenderer({ block })"]

    Define --> Manifest
    Define --> Contract
    Define --> Validate
    Render --> Render2
    Manifest --> Contract
    Contract --> Plan
    Plan --> Validate
    Validate --> Apply
    Apply --> Render2
```

***

## How-To Guides

### Add a new block type

<Steps>
  <Step title="Define the schema">
    Create `packages/shared/src/blocks/my-block.ts`:

    ```ts theme={null}
    import { z } from "zod"
    import { registerBuiltinBlock } from "./_registry.ts"
    import { f } from "./_helpers.ts"

    registerBuiltinBlock("MyBlock", {
      schema: z.object({
        title: z.string().min(1),
        body: z.string().min(1),
        imageUrl: z.string().min(1).optional(),
        imageAlt: z.string().optional(),
      }),
      meta: {
        displayName: "My Block",
        description: "A custom content block.",
        category: "content",
        fields: {
          title: f.text("Title"),
          body: f.richtext("Body"),
          imageUrl: f.image("Image", { aspectRatio: "landscape", width: 800, height: 600 }),
          imageAlt: f.imageAlt("Image alt text"),
        },
      }
    })

    export function myBlockDefaultProps(): Record<string, unknown> {
      return { title: "New block", body: "Add your content here." }
    }
    ```
  </Step>

  <Step title="Register the import">
    In `packages/shared/src/blocks/index.ts`, import the module **and** add your defaults function to the `defaults` map:

    ```ts theme={null}
    import { myBlockDefaultProps } from "./my-block.ts"
    // …
    const defaults = { /* … */ MyBlock: myBlockDefaultProps }
    ```

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

  <Step title="Create the renderer">
    Create `packages/blocks/src/blocks/my-block/renderer.tsx`:

    ```tsx theme={null}
    import { BlockImage } from "../block-image"

    export function MyBlock(props: Record<string, unknown>) {
      const title = String(props.title ?? "")
      const body = String(props.body ?? "")
      const imageUrl = typeof props.imageUrl === "string" ? props.imageUrl : undefined

      return (
        <section className="my-block">
          <div className="section__inner">
            <h2 data-editable-target="title">{title}</h2>
            <p data-editable-target="body">{body}</p>
            {imageUrl && (
              <BlockImage src={imageUrl} alt={String(props.imageAlt ?? "")}
                width={800} height={600} data-editable-target="imageUrl" />
            )}
          </div>
        </section>
      )
    }
    ```
  </Step>

  <Step title="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.
  </Step>

  <Step title="Add styles">
    Create `packages/blocks/src/blocks/my-block/styles.css` and import it from `packages/blocks/src/blocks/styles.css`.
  </Step>

  <Step title="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
  </Step>
</Steps>

### Add a field to an existing block

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Update default props">
    If the field should have a starter value, add it to the `*DefaultProps()` function.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Verify">
    `pnpm typecheck` then check the block catalogue and editor.
  </Step>
</Steps>

### Add a list field (repeatable items)

<Steps>
  <Step title="Define the array in the schema">
    ```ts theme={null}
    features: z.array(z.object({
      icon: z.string().optional(),
      title: z.string().min(1),
      description: z.string().min(1),
    })).min(1)
    ```
  </Step>

  <Step title="Add listFields metadata">
    ```ts theme={null}
    meta: {
      // ... scalar fields ...
      listFields: {
        features: {
          label: "Features",
          itemFields: {
            icon: f.text("Icon"),
            title: f.text("Feature title"),
            description: f.longtext("Feature description"),
          }
        }
      }
    }
    ```
  </Step>

  <Step title="Update the renderer">
    Cast and iterate:

    ```tsx theme={null}
    const features = Array.isArray(props.features) ? props.features as Record<string, unknown>[] : []
    {features.map((item, i) => (
      <div key={i}>
        <h3 data-editable-target={`features[${i}].title`}>{String(item.title ?? "")}</h3>
        <p data-editable-target={`features[${i}].description`}>{String(item.description ?? "")}</p>
      </div>
    ))}
    ```

    Note the `features[0].title` path format in `data-editable-target` — this enables inline editing of list items.
  </Step>
</Steps>

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

```ts theme={null}
const _blockNotes: Record<string, string> = {
  // ...existing notes...
  MyBlock: "body supports richtext: **bold**, *italic*, [link](url), paragraph breaks (\\n\\n). imageUrl is optional — omit to render text-only layout.",
}
```

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](/specs/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`:

```ts theme={null}
fields: {
  imageUrl: f.image("Hero image", { aspectRatio: "landscape", width: 1536, height: 1024 }),
  imageAlt: f.imageAlt("Hero image alt text"),
}
```

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

```ts theme={null}
listFields: {
  cards: {
    label: "Cards",
    itemFields: {
      imageUrl: f.image("Card image", { aspectRatio: "landscape", width: 768, height: 512 }),
      imageAlt: f.imageAlt("Card image alt text"),
    }
  }
}
```


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