A block is one entry in a page's layout. Each block has a type and a props object, and the shape of props depends on type. This page lists every block type in the authoring vocabulary, with its exact props, so an agent can write a valid layout on the first try.
The layout shape
A page's layout is an object with one key:
{ "blocks": [ { "type": "HeroSection", "props": { "...": "..." } } ] }
blocks is an ordered array. blocks: [] is valid: an empty page is a real, storable state, not an error.
Each entry in blocks has:
type, a string naming the block (see the sections below for the exact spelling).props, an object shaped by that block's schema.presentation, optional. A small set of visual attributes any block can carry:background(backgroundorsurface),border(none,hairline, orrule),shadow(smormd), androtation(tilt-leftortilt-right, blocks only). At least one of these must be set ifpresentationis present at all;presentation: {}is rejected.anchor, optional. A stable in-page id so another link can jump to this block, for example/#section-contact. It must matchsection-followed by lowercase letters, digits, and single hyphens (for examplesection-how-it-works), at most 64 characters. Two blocks on the same page cannot claim the same anchor.
There is no maximum on how many blocks a page may contain. Three narrower rules apply instead: a page may hold at most one PostList, at most one FAQ, and at most one FormBlock; every anchor on the page must be unique. Nothing else about block count or ordering is restricted.
This page describes blocks as used in a page's layout. Region and post-detail templates accept most of the same block types under their own rules; call describe_schema for those.
A worked example
{
"slug": "about",
"title": "About us",
"layout": {
"blocks": [
{
"type": "HeroSection",
"props": {
"headline": "We build small, fast things",
"subhead": "A two-person team shipping since 2019.",
"ctaLabel": "Get in touch",
"ctaHref": "/#section-contact"
}
},
{
"type": "RichText",
"props": {
"markdown": "## What we do\n\nWe design and ship web products end to end."
}
},
{
"type": "Callout",
"props": {
"variant": "tip",
"title": "Response time",
"markdown": "We reply to every message within one business day."
},
"anchor": "section-contact"
}
]
},
"upsert": true
}
Call this with create_page. It creates an unpublished draft; call publish to make it live. upsert: true means "create, or update if a page with this slug already exists."
HeroSection
type: "HeroSection". A headline banner with an optional lead sentence and one call-to-action link.
| Prop | Type | Required | Notes |
|---|---|---|---|
headline | string, max 200 chars | Yes | |
subhead | string, max 500 chars | No | |
ctaLabel | string, max 50 chars | No | |
ctaHref | string | No | Must be an absolute http:// or https:// URL, or a root-relative path starting with exactly one / (for example /pricing or /#section-contact). No other scheme (javascript:, data:, and so on), no // protocol-relative path, no backslash, no whitespace or control characters. |
RichText
type: "RichText". A free-form Markdown (with MDX and GitHub-flavored tables) body.
| Prop | Type | Required | Notes |
|---|---|---|---|
markdown | string | Yes | No length limit. Compiled and sanitized at render time. |
ImageBlock
type: "ImageBlock". One image reference with optional alt text and caption.
| Prop | Type | Required | Notes |
|---|---|---|---|
assetId | string | Yes | Must name an asset that exists. That is checked when the page is saved, not when the layout is parsed. |
alt | string, max 300 chars | No | |
caption | string | No | No length limit. |
ProductGrid
type: "ProductGrid". A hand-picked grid of products. Needs the shop plugin: see "Blocks that need a plugin" below.
| Prop | Type | Required | Notes |
|---|---|---|---|
productIds | array of string, max 48 items | No | |
columns | integer, 1 to 4 | No |
props: {} is valid: every field is optional, so an empty grid is a legitimate placeholder.
Callout
type: "Callout". An admonition box (note, tip, warning, or danger) wrapping a Markdown body.
| Prop | Type | Required | Notes |
|---|---|---|---|
variant | enum: note, tip, warning, danger | Yes | |
title | string, max 120 chars | No | |
markdown | string | Yes | No length limit. |
PostList
type: "PostList". One bounded, indexed feed of published blog posts. At most one PostList may appear on a page.
props.source selects the feed and decides which other props apply:
| Prop | Type | Required | Notes |
|---|---|---|---|
source | enum: latest, tag | Yes | |
tag | string, 1 to 80 characters, at most 191 UTF-8 bytes | Only when source: "tag" | Must be NFC-normalized, trimmed, and free of control characters. Forbidden when source: "latest". |
limit | integer, 1 to 12 | No | Defaults to 6. |
columns | one of 1, 2, 3 | No | Defaults to 3. |
There is no offset, arbitrary collection query, sort direction, category filter, or client-side pagination: PostList is one fixed feed shape, not a query builder.
SocialLinks
type: "SocialLinks". A row of social profile links, as icons or icons with labels.
| Prop | Type | Required | Notes |
|---|---|---|---|
links | array of {platform, href}, max 16 items | No | When omitted, the block shows the site's own social profiles instead. An explicit list never merges with those; it replaces them. |
size | enum: sm, md, lg | No | |
variant | enum: icons, icons-labels | No |
Each entry in links:
| Field | Type | Required | Notes |
|---|---|---|---|
platform | enum: website, github, linkedin, mastodon, bluesky, instagram, youtube, facebook, x, substack, telegram | Yes | Repeated platforms are allowed (for example two website links). |
href | string | Yes | Absolute HTTPS URL, at most 2048 characters, no embedded credentials, no #fragment. github, linkedin, bluesky, instagram, youtube, facebook, x, and telegram must live on that platform's recognized host (x accepts x.com or twitter.com; telegram accepts t.me or telegram.me; a www. prefix is accepted everywhere). mastodon and substack accept any HTTPS host. Two links that resolve to the same canonical URL are rejected. |
FAQ
type: "FAQ". A page's question-and-answer disclosure list. It is also the one source the site reads to publish FAQPage or QAPage structured data for the page. Page-only: rejected inside a template. At most one FAQ may appear on a page.
| Prop | Type | Required | Notes |
|---|---|---|---|
items | array of {question, answer}, 1 to 12 items | Yes | |
schemaType | enum: FAQPage, QAPage, none | Yes | No default; you must choose. none renders the disclosure UI but publishes no structured-data entry. |
Each entry in items:
| Field | Type | Required | Notes |
|---|---|---|---|
question | string, 1 to 300 chars | Yes | NFC-normalized, trimmed, single line, no control characters. Must be unique (case-sensitively) within the block. |
answer | string, 1 to 4000 chars | Yes | NFC-normalized, not blank. Markdown/MDX body; its rendered plain text must also come out non-empty (a bare image with no alt text, or a link with no label, does not count as content). |
If schemaType is QAPage, items must contain exactly one entry.
FormBlock
type: "FormBlock". An anonymous-write contact or lead form, submitted through the site's public form endpoint. Page-only: rejected inside a template. At most one FormBlock may appear on a page.
| Prop | Type | Required | Notes |
|---|---|---|---|
fields | array of field objects, 1 to 20 items | Yes | See field shape below. Exactly one field must have type: "email", and that field must be required: true. |
submit | string, max 80 chars | Yes | The submit button label. |
success | string, max 300 chars | Yes | Shown after a successful submission. |
error | string, max 300 chars | Yes | Shown after a failed submission. |
espConnectionId | string, max 64 chars | No | An email-service-provider connection id. |
mailerConnectionId | string, max 64 chars | No | An SMTP connection id. |
captchaConnectionId | string, max 64 chars | No | A captcha connection id. |
honeypot | string, matches ^[a-z][a-z0-9_]{0,31}$ | Yes | A spam-trap field name. Must not equal any declared field name. |
actions | array of action objects, 1 to 8 items | No | See action shapes below. |
At least one of espConnectionId or mailerConnectionId must be set. This rule applies even when actions is used: it is not relaxed by the presence of actions.
Each entry in fields:
| Field | Type | Required | Notes |
|---|---|---|---|
name | string, matches ^[a-z][a-z0-9_]{0,31}$ | Yes | Unique among the form's fields. Cannot equal formId or cf-turnstile-response (reserved). |
label | string, max 120 chars | Yes | |
type | enum: text, email, textarea, checkbox, select | Yes | |
required | boolean | Yes | |
placeholder | string, max 160 chars | No | Only allowed on text, email, or textarea. |
options | array of string, max 100 chars each | Required on select, forbidden otherwise | A select field needs 2 to 20 unique options. |
width | enum: 20, 25, 33, 50, 66, 75, 100 | No | Percent width. Omitted means full width. |
actions, when present, is what actually delivers a submission: it must contain at least one email, esp, or webhook action (a captcha action alone verifies a visitor and delivers nothing). Every entry has a type and a connectionId (string, max 64 chars); an email action also carries to, subject, and body:
Action type | Extra fields | Notes |
|---|---|---|
email | to (string, max 254 chars), subject (string, max 200 chars), body (string, max 8000 chars) | to is either the exact placeholder {submitter} (sends to the address the visitor typed) or one plain-ASCII address. subject and body may reference {{formRef}}, {{submissionId}}, {{submittedAt}}, {{email}}, {{fields}}, or {{field.<declared-field-name>}}; any other token, or a stray brace, is rejected. An action addressed to {submitter} may not reference any field token or {{fields}} at all. |
esp | none | |
webhook | none | |
captcha | none | A precondition, checked before anything is stored, regardless of where it sits in the array. |
Count limits inside actions: at most 1 captcha, at most 1 esp, at most 4 webhook, at most 2 email (at most 1 of those addressed to {submitter}). If any action is an email addressed to {submitter}, the array must contain exactly one captcha action. Duplicate actions (same type, connection, and recipient) are rejected.
Actions do not run before the response. The captcha precondition is checked first, then the submission is stored and each remaining action becomes a queued unit of work that a later background pass executes with its own retry and backoff. The visitor sees the success message as soon as the submission is stored, not when the email or the webhook has gone out. Array order is stable identity for retries, not an execution order.
One caveat worth knowing today: the form endpoint currently only accepts the visitor's captcha response when captchaConnectionId is also set on the block, even if a captcha action is present. Set captchaConnectionId if the form should verify a captcha.
Poll
type: "Poll". A poll owned by the vote plugin, addressed by its slug. Needs the vote plugin: see "Blocks that need a plugin" below. Page-only: rejected inside a template.
| Prop | Type | Required | Notes |
|---|---|---|---|
slug | string, 1 to 191 chars | Yes | The slug of an existing poll, created and managed through manage_poll. |
The block carries only the address. The question, options, deadline, and whether results are shown all live on the poll itself, so there is no second copy to fall out of sync. A slug naming a poll that does not exist renders nothing.
Countdown
type: "Countdown". A deadline, counted down, with what to show once it passes.
| Prop | Type | Required | Notes |
|---|---|---|---|
endsAt | string, ISO-8601 datetime with an explicit offset | Yes | For example 2026-08-21T18:00:00Z. A value with no offset (2026-08-21T18:00:00) is rejected, because it would mean a different moment in every timezone that reads it. |
heading | string, 1 to 120 chars | Yes | Shown above the clock. |
description | string, max 500 chars | No | Shown under the heading while the clock runs. |
completedMessage | string, max 500 chars | No | Shown instead of everything else once endsAt has passed. If omitted, the block renders nothing at all after the deadline. |
Blocks that need a plugin
ProductGrid and Poll are always part of the block vocabulary, and describe_schema always lists both. But each only does anything useful when its owning plugin is enabled on the server:
ProductGridneeds the shop plugin.Pollneeds the vote plugin.
Plugin enablement is a server-level setting, not something a page author changes per page. If you are not sure which plugins are enabled on a given install, ask the operator.
The vocabulary is closed
The block types on this page are the complete set. A create_page or update_page call with an unknown type, or with a prop that is not listed for its block, is rejected: nothing is silently dropped or ignored. Call describe_schema to get the live JSON Schema for every block, generated from the same validators the write path enforces, so it can never drift from what this page describes.