Authoring Blocks
block.json, view.html, scoped CSS/JS, and the test_data invariant.
Anatomy of a block
A block lives under themes/<t>/blocks/<slug>/ and consists of two required files plus three optional ones. The kernel discovers blocks by scanning theme.json's blocks[] array; an entry can either inline its fields and template, or reference a directory with the dir key and let the loader read the files automatically.
block.json— slug, label, icon,field_schema,test_data, optionalcache_output, optionalgroup.view.html— Go template that consumes the field values at root.style.css— block-scoped CSS, injected via{{ .app.block_styles }}in the layout's<head>exactly once per page even when the block appears multiple times.script.js— block-scoped JS, injected via{{ .app.block_scripts }}.preview.svg/preview.png— optional thumbnail shown in the admin's block picker.
Extensions can ship blocks too — the bundled content-blocks extension is forty-plus block types alone (every cb-* entry in core.block_types.list). Block authoring is identical between themes and extensions; only the manifest field differs.
The field schema
Every field has a type, a key, and per-type configuration. Common shapes:
{
"slug": "hero",
"label": "Hero",
"icon": "Stars",
"group": "Marketing",
"cache_output": false,
"field_schema": [
{ "key": "heading", "type": "text", "required": true, "width": "full" },
{ "key": "subhead", "type": "textarea", "width": "full" },
{ "key": "image", "type": "image", "width": "half" },
{ "key": "cta", "type": "link", "width": "half" },
{ "key": "items", "type": "repeater",
"sub_fields": [
{ "key": "label", "type": "text" },
{ "key": "icon", "type": "text" }
]
}
],
"test_data": {
"heading": "The CMS where AI does the work",
"subhead": "Activate a theme. The CMS reorganises itself around it.",
"image": { "src": "theme-asset:hero", "alt": "Squilla" },
"cta": { "label": "Get started", "url": "/docs", "target": "_self" },
"items": [
{ "label": "50ms TTFB", "icon": "Zap" },
{ "label": "AI-native", "icon": "Sparkles" }
]
}
}
"key":. The same fields registered via Tengo core/nodetypes.register use "name":. Mismatching the two produces empty admin inputs even when fields_data is correct, because the form renderer matches saved values against the wrong key. Run core.guide and read the field_schema_vocabulary gotcha if you forget which is which.The test_data invariant
Every key in field_schema must have a corresponding entry in test_data. The admin renders test_data as the live preview when an editor opens a block they haven't touched yet, and core.render.block uses it as the default payload when no fields argument is passed. A missing entry shows up as an empty area or a broken template; core.theme.checklist flags every block that's missing test data so you catch it before publishing.
The same applies in reverse: every key in test_data should appear in field_schema. Stray keys are harmless at render time but clutter the admin's preview state and trip up sanity audits.
No fallback values
If you write {{ .heading | or "default" }}, you're hiding a data bug.Fallback values mask the case where seed data was supposed to populate the field but didn't. Pass real defaults via fields_data in the seed and let templates fail visibly when something is wrong. The block renderer catches per-block errors and renders a small red badge in place of the failed block; your page still loads, the bug is obvious, and you can fix the root cause instead of chasing why a fallback string appeared in production.
Field types
The kernel ships 21 canonical field types in internal/cms/field_types/registry.go: string, textarea, richtext (Tiptap with sanitiser), number, range, email, url, date, color, toggle, select, radio, checkbox, image, gallery, file, link, reference, term, object, array. Plus the legacy aliases text (→ string) and repeater (→ array) accepted by the loader for backwards compatibility.
Extensions register additional types through admin_ui.field_types[]. The bundled forms extension contributes vibe-form; media-manager contributes nothing extra (image/gallery/file are kernel types). Get the canonical list at runtime with core.field_types.list.
Per-field configuration
Every field accepts: required (bool), default, placeholder, help (string), and width (full | half | third). The help string is surfaced through MCP so an AI agent can understand what the field is for — write helpful copy. Type-specific keys add to those: select, radio, and checkbox take options[] (plain strings, never {label, value} objects — the React renderer crashes on objects); repeater/array takes sub_fields[]; reference takes node_types[]; term takes taxonomy.
Caching
Set "cache_output": true on a block to opt into render-result caching. The kernel keys the cache by the block-type slug plus a hash of the field values, so two blocks with the same fields share one render. Use this for blocks whose output is a pure function of their fields (a marketing card, a logo cloud); avoid it for blocks that read the current node, the current user, or anything else outside the field map. The block-output cache is invalidated on theme activation and on any update to the block type.
view.html
Block templates are Go html/template templates rendered through the kernel's internal/rendering package, which adds a per-block FuncMap on top of the standard library helpers. Field values are at the root, so .heading reads the heading field directly. Inside a range, the dot rebinds to the iteration value as usual.
<section class="hero">
<h1>{{ .heading }}</h1>
{{ if .subhead }}<p>{{ .subhead }}</p>{{ end }}
{{ if .image }}
<img
src="{{ image_url .image.src "hero" }}"
srcset="{{ image_srcset .image.src "hero" "hero-2x" }}"
alt="{{ .image.alt }}">
{{ end }}
{{ if .cta }}
<a href="{{ .cta.url }}" target="{{ .cta.target }}">{{ .cta.label }}</a>
{{ end }}
<ul>
{{ range .items }}
<li data-icon="{{ .icon }}">{{ .label }}</li>
{{ end }}
</ul>
</section>
Scoped CSS/JS
style.css and script.js next to view.html are auto-injected when the block is used on a page. The kernel deduplicates: a block used five times on a page injects its CSS and JS exactly once. Do not write global selectors — scope every rule to the block's root element (a .hero wrapper class is the convention). The bundled doc-codeblock block's style.css in the squilla theme is a good reference for how to keep block CSS contained.
If you need critical CSS hoisted into <head>, drop it into the layout's head_styles emission instead — block-scoped CSS lands wherever the layout invokes {{ .app.block_styles }}, which is conventionally just before </head> after the theme's bundle.