> ## 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 Schema Contracts

> How block schemas are described to the LLM planner, how contracts are assembled, and why we use prop-list contracts instead of JSON Schema.

## Overview

This page is for developers who want to know what the planner is told about
blocks, in particular for a site with its own block types. The planner needs to
know what blocks exist and what props they accept. Rather than sending raw Zod
schemas or JSON Schema to the model, Avocado compiles block definitions into a
**contract** format written for model comprehension and token efficiency.

## Contract pipeline

```mermaid theme={null}
graph LR
    subgraph "packages/shared"
        Zod["Zod block schemas<br/>hero.ts, card-grid.ts, ..."]
        Meta["BlockMeta<br/>listFields, imageSpecs, field kinds"]
    end

    subgraph "packages/orchestrator-core"
        Notes["_blockNotes<br/>hand-written guidance strings"]
        Builder["blockContractsSummary()<br/>deterministic-planner-suggestions.ts"]
        Adaptive["buildPlannerSchemaContext()<br/>planner.ts"]
    end

    Zod --> Builder
    Meta --> Builder
    Notes --> Builder
    Builder --> Adaptive
    Adaptive -->|"full / targeted / minimal"| LLM["LLM Prompt"]
```

### Layer 1: Zod block schemas

Each block type is defined in `packages/shared/src/blocks/*.ts` with a Zod schema and metadata:

```typescript theme={null}
// packages/shared/src/blocks/hero.ts (abridged)
z.object({
  heading: z.string().min(1),
  subheading: z.string().min(1),
  ctaText: z.string().min(1),
  ctaHref: z.string().min(1),
  ctaNewTab: z.boolean().optional(),
  imageUrl: z.string().min(1),
  imageAlt: z.string().min(1),
  imagePosition: z.enum(["left", "right", "full"]).default("right").catch("right"),
  textAlign: z.enum(["left", "center"]).default("left").catch("left"),
  eyebrow: z.string().optional(),
  secondaryCtaText: z.string().optional(),
  secondaryCtaHref: z.string().optional(),
  secondaryCtaNewTab: z.boolean().optional(),
})
```

A site's own blocks arrive the same way, registered with `registerBlock`, or as
JSON Schema in the block manifest the site serves.

Metadata includes `listFields` (array item shapes), `imageSpecs` (aspect ratios, dimensions), and field `kind` markers (e.g. `richtext`).

### Layer 2: hand-written notes

`_blockNotes` in `packages/orchestrator-core/src/nlp/deterministic-planner-suggestions.ts` provides behavioral guidance that Zod schemas cannot express:

```typescript theme={null}
_blockNotes = {
  Hero: "imagePosition controls layout and must be 'left', 'right', or 'full' (default 'right')... use 'center' textAlign with 'full' imagePosition for centered hero layouts...",
  CardGrid: "cardVariant applies to ALL cards, not per-card. full-bleed REQUIRES imageUrl on each card...",
  FAQAccordion: "answers support richtext: **bold**, *italic*, [link](url), paragraph breaks (\\n\\n)...",
  Footer: "links field: one 'Label|URL' per line separated by \\n...",
  // 13 built-in block types
}
```

### Layer 3: contract assembly

`blockContractsSummary()` walks each registered block's Zod schema and produces
an entry like this (abridged):

```json theme={null}
{
  "Hero": {
    "allowedProps": ["heading", "subheading", "ctaText", "ctaHref", "imageUrl", "imageAlt", "imagePosition", "textAlign", "eyebrow", "secondaryCtaText", "secondaryCtaHref"],
    "required": ["heading", "subheading", "ctaText", "ctaHref", "imageUrl", "imageAlt"],
    "optional": ["imagePosition", "textAlign", "eyebrow", "secondaryCtaText", "secondaryCtaHref"],
    "notes": "imagePosition controls layout and must be 'left', 'right', or 'full'..."
  }
}
```

Entries can also carry `richTextProps`, the props that accept rich text. When
`_blockNotes` has no entry for a block type, which is the case for every block a
site registers itself, the builder derives notes from metadata: array item
shapes, image specs, field kinds and where the block may be placed. For a block
that exists only in the site's manifest, the contract is derived from its JSON
Schema, and every prop is listed as required.

### Layer 4: adaptive budgeting

`buildPlannerSchemaContext()` in `packages/orchestrator-core/src/chat/planner.ts` selects which contracts to include based on intent:

| Mode | When | Payload |
| - | - | - |
| **full** | Generation of new blocks, batch overrides, page-wide translation | Every block type's contract. For the 20 built-in blocks that was about 9 KB at 0.19.0. |
| **targeted** | Single-block edits | Only the block types the message or selection names |
| **minimal** | Remove, move, delete, rename | No contracts, just the `knownBlockTypes` list |

The payload is capped at `CHAT_SCHEMA_BUDGET_BYTES` (default 10000). If the
preferred mode exceeds the budget, it falls back, `full → targeted → minimal`,
without an error. A site with many block types of its own can cross the cap, and
then full-page translation quietly gets less schema. [Telemetry](/observability/chat-telemetry-events)
records the mode that was used as `contractMode`, with `contractBytes`.

<Warning>
  **This selection is behind a flag that is off by default.** Adaptive budgeting
  runs only when `CHAT_ADAPTIVE_SCHEMA_CONTEXT` is set to `1`/`true`/`yes`/`on`.
  Without it the planner uses an older gate that includes contracts only for
  generation verbs (`create`, `add`, `insert`, `build`, `generate`), SEO/metadata
  requests, character-count requests, batch overrides, page-wide translation, and
  messages carrying four or more quote characters.

  The practical consequence is the opposite of what the table above suggests: a
  plain single-field edit — "change the heading to X", the most common thing an
  editor types — reaches the model with **no block contracts at all**. On
  Avocado's own blocks the model's priors cover the gap. On a site with its own
  block types there is nothing to fall back on, and the model has to guess prop
  names it was never shown.

  If your site brings its own blocks, set `CHAT_ADAPTIVE_SCHEMA_CONTEXT=1`.
</Warning>

## Comparison with Puck AI's approach

[Puck](https://puckeditor.com/) AI uses **field-level JSON Schema** co-located with each field's UI config:

```javascript theme={null}
// Puck AI
fields: {
  title: {
    type: "text",
    ai: {
      schema: { type: "string" },
      instructions: "Always use caps",
      stream: true,
      required: true,
    }
  }
}
```

Side-by-side:

| Aspect | Avocado Studio | Puck AI |
| - | - | - |
| Schema format | Prop lists + natural language notes | JSON Schema per field |
| Schema source | Auto-derived from Zod | Manual `ai.schema` (auto-inferred for standard types) |
| Guidance | `notes` string in contract | `instructions` string per field/component |
| Token optimization | Adaptive budgeting (full/targeted/minimal) | Puck Cloud manages internally |
| LLM hosting | Self-hosted, full prompt control | Puck Cloud (opaque) |
| Complex types | Zod → `z.toJSONSchema()` for external blocks | `z.toJSONSchema()` for custom fields |

## Why not JSON Schema

We evaluated switching from prop-list contracts to auto-generated JSON Schema (via `z.toJSONSchema()` from our Zod definitions). The analysis identified several downsides:

### 1. Behavioral notes cannot be expressed in JSON Schema

The most valuable part of our contracts is the hand-written guidance. JSON Schema has no equivalent for:

| What notes capture | JSON Schema equivalent |
| - | - |
| "use `center` textAlign with `full` imagePosition" | None (cross-field semantics) |
| "full-bleed variant REQUIRES imageUrl" | Partial (`if/then`, but LLMs handle poorly) |
| FAQ answers use `\n\n` for paragraphs, `- item` for lists | None (format conventions) |
| "omit secondaryCtaText or set empty to hide" | None (toggle behavior) |
| Footer links: `Label\|URL` per line with `\n` | `pattern` regex (opaque to LLM) |
| "do NOT mention specific image source in summary" | None (output behavior) |

Switching means either losing this guidance (more hallucination) or keeping notes alongside JSON Schema (duplicating info, higher token cost).

### 2. JSON Schema is more verbose

An equivalent JSON Schema, with `properties`, `type`, `enum`, `items` and
`required`, was measured at 25-40% larger than a prop-list contract for structure
alone, before any behavioral guidance.

Across 20 blocks, that growth would push the full payload past the 10,000-byte
budget. The adaptive fallback would then trigger more often, degrading more
requests to "targeted" or "minimal" mode.

### 3. JSON Schema expressiveness doesn't help LLMs

Features like `minItems`, `pattern`, `exclusiveMaximum`, `if/then` are designed for machine validators, not LLM comprehension. LLMs respond better to natural language ("columns must be '2', '3', or '4'") than to `{"enum": ["2","3","4"]}`.

### 4. Structured outputs don't need per-field JSON Schema

OpenAI structured outputs (`response_format`) need JSON Schema for the **response shape** (EditPlan), not for block prop schemas. We already support this via `CHAT_STRICT_JSON_RESPONSE`. Block prop values are freeform `Record<string, unknown>` — constraining them via `response_format` would require a discriminated union of all block types, which is fragile and explodes schema size.

### 5. Auto-derivation already handles simple cases

`blockContractsSummary()` auto-derives notes from Zod metadata when `_blockNotes` has no entry. Hand-written notes exist precisely for cases where auto-derivation isn't enough.

### 6. External blocks already use JSON Schema

For manifest-only blocks from external sites (no Zod schemas), the contract
builder already derives contracts from JSON Schema. So we get JSON Schema benefits where they matter without paying the cost for our own blocks.

## Where JSON Schema would help

The main opportunity is adding more **auto-derived note patterns** to reduce the hand-written surface:

* Auto-generate richtext convention docs from field `kind: "richtext"`
* Auto-generate enum default guidance from `z.enum().default()`
* Auto-generate cross-field dependency hints from related optional fields

This would keep the current format while reducing manual maintenance — better than replacing the format entirely.

## What to read next

<CardGroup cols={2}>
  <Card title="Block system" icon="cubes" href="/integration/block-system">
    How manifests, field metadata and validation fit together in practice.
  </Card>

  <Card title="Custom blocks" icon="code" href="/integration/custom-blocks">
    Registering your own React components against these contracts.
  </Card>
</CardGroup>


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