Custom Fields
The pervasive metadata system attached to almost everything.
Custom fields, everywhere
Custom fields are Squilla's most pervasive feature. If you've used ACF in WordPress you'll feel at home — except they're built into the kernel, defined declaratively in JSON, stored as JSONB, and exposed verbatim through MCP and Tengo. The same field-type vocabulary is reused across every surface, so the same image field type renders the same picker in a node-type schema, a term schema, a layout-block schema, and a content-block schema.
Where they attach
- Node types — the type-level schema, edited at
/admin/node-typesor viacore.nodetype.update({fields: [...]}). Values stored infields_dataon each node. - Taxonomy terms — per-term metadata, schema declared on the taxonomy. Values stored in
fields_dataon each term. - Layout blocks (partials) — page-level overrides; each page can set its own values for a partial's fields, stored in the node's
layout_data[partial_slug]. Global per-partial values are on the roadmap. - Content blocks — the
block.jsonfield schema; values stored on each block instance underblocks_data[i].fields.
Same editor UX everywhere. Same field-type catalogue. Same validation rules. The same widths, defaults, help labels, and conditional-display logic.
The canonical field types
The kernel registers 21 field types in internal/cms/field_types/registry.go. Run core.field_types.list for the live catalogue with per-type how_to guidance for AI agents.
Basic
string— single-line input. Accepts the legacy aliastext.textarea— multi-line plain text.richtext— WYSIWYG (TipTap-based) producing sanitised HTML.number— withmin,max,stepoptions.range— slider; same numeric options asnumberplus a visual handle.email,url— type-specific inputs with format validation.date— calendar picker; ISO-8601 strings stored.color— colour picker; hex strings stored.
Choice
toggle— on/off switch storing a boolean.select— single-value dropdown;optionsarray of plain strings.radio— inline radios; same option shape asselect.checkbox— multi-value checkbox group; stores an array of selected option strings.
select, radio, and checkbox options must be plain strings (["a","b","c"]), never {label, value} objects. The admin renders option entries as React children and crashes with React error #31 ("Objects are not valid as a React child") on the object form. The MCP and Tengo loaders enforce this at write time so a malformed schema never reaches the editor.Media
image— single image, opens the media picker. Stores the full media object{id, slug, url, alt, width, height, sizes: {...}}so the renderer can request size variants without a follow-up DB hit.gallery— ordered array of media objects.file— any media file (documents, video, audio).
Relational
link— stores{label, url, target}. The picker can resolve to a node slug for portability.reference— picks one or more nodes (filterable bynode_types[]). Stores an array of node ids.term— picks a taxonomy term, stores{slug, name}. Requires ataxonomyattribute on the field schema (and optionallynode_typefor term-typed fields scoped to a particular node type).
Layout
object— collapsible group of named sub-fields. Sub-fields declared infields[]on the parent.array— drag-reorderable repeating sub-records. Same shape asobjectfor sub-fields. Accepts the legacy aliasrepeater.
Extensions can register additional types through admin_ui.field_types[] in the manifest. The bundled forms extension contributes vibe-form; media-manager contributes nothing extra (image/gallery/file are kernel types). Install an extension and the new types appear in core.field_types.list.
Per-field configuration
Every field, regardless of type, supports the same configuration knobs. The schema editor in the admin renders one form per knob; the JSON shape is identical whether you author it through MCP, Tengo, or the admin UI.
- Key / Name — the JSON key under which the value is stored.
key:on blocks,name:on node types and term schemas. Renaming after the fact does not rewrite existing values — the admin shows old values in a legacy group until you migrate. - Title / Label — user-facing label in the admin form.
- Required — admin-side validation; save blocked if empty. Honours
required: falsefor explicit opt-out on fields that have a default. - Default / initialValue — prefilled when creating a new node / term / block instance. Field-type-shaped (a string for
string, an array forcheckbox, etc.). - Placeholder — input placeholder text. Cosmetic only.
- Help / Description — hint text rendered beneath the input. Also surfaced through MCP via
core.field_types.listso AI agents understand the intent of each field. - Width — grid column span. Default is
full; configurable ashalf,third,quarter,two-thirds,three-quarters, with optional breakpoint overrides (sm,md,lg). - Conditional display — show/hide based on another field's value, with AND/OR groups using
=,!=,>,<,>=,<=,contains.
core.node.create. Treat them like API documentation, because that's what they are.Repeaters and nested objects
The array field type (repeater in the legacy alias) stores an ordered list of sub-records. Each sub-record has its own field schema declared under fields (or sub_fields in the legacy form). Sub-fields can be of any type — including more arrays — so you can model FAQ items, ingredient groups, image galleries with captions and links, or pricing-table rows with feature lists nested inside.
{
"name": "faqs",
"title": "Frequently asked questions",
"type": "array",
"min": 1,
"max": 20,
"button_label": "Add FAQ",
"fields": [
{ "name": "question", "title": "Question", "type": "string", "required": true },
{ "name": "answer", "title": "Answer", "type": "richtext", "required": true }
]
}
taxonomy in the schema, and stored values must be {slug, name} objects — not bare slug strings — so the admin's term-field component pre-selects correctly. Templates accept either shape on render for safety. The core.guide tool has this fact in its gotchas table under term_field_shape; mistakes here silently render an empty pre-selection in the admin even when the value is otherwise correct.