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-types or via core.nodetype.update({fields: [...]}). Values stored in fields_data on each node.
  • Taxonomy terms — per-term metadata, schema declared on the taxonomy. Values stored in fields_data on 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.json field schema; values stored on each block instance under blocks_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 alias text.
  • textarea — multi-line plain text.
  • richtext — WYSIWYG (TipTap-based) producing sanitised HTML.
  • number — with min, max, step options.
  • range — slider; same numeric options as number plus 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; options array of plain strings.
  • radio — inline radios; same option shape as select.
  • checkbox — multi-value checkbox group; stores an array of selected option strings.
OPTIONS = 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 by node_types[]). Stores an array of node ids.
  • term — picks a taxonomy term, stores {slug, name}. Requires a taxonomy attribute on the field schema (and optionally node_type for term-typed fields scoped to a particular node type).

Layout

  • object — collapsible group of named sub-fields. Sub-fields declared in fields[] on the parent.
  • array — drag-reorderable repeating sub-records. Same shape as object for sub-fields. Accepts the legacy alias repeater.

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: false for 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 for checkbox, etc.).
  • Placeholder — input placeholder text. Cosmetic only.
  • Help / Description — hint text rendered beneath the input. Also surfaced through MCP via core.field_types.list so AI agents understand the intent of each field.
  • Width — grid column span. Default is full; configurable as half, 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.
WRITE GOOD HELP TEXT
Help labels aren't decoration. They're the channel by which the CMS tells the AI agent what a field is for. "Cooking time in minutes; leave 0 if the dish is no-cook." is dramatically better than "Time", both for human editors and for agents calling 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.

field_schema example
{
  "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 }
  ]
}
TERM FIELD SHAPE
Term-typed fields require a 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.