#Widget Catalogue

Widgets are the building blocks of an app page. Each is one entry in a page's widgets record — widgetComponent selects the widget, config is its options (config values may carry {{ … }} smart values, see Smart Values):

"widgets": {
  "tasks": { "widgetComponent": "data_grid", "config": { "header": "Open tasks", "columns": [ … ] } }
}

A page places its widgets with its layout; the layout's own building blocks — sections, toolbars, tabs, headers, footers — are in App Layouts. For the structure that holds pages (dashboards → pages → widgets) see App spec structure.

#A real app, rendered

Everything below renders with the actual app widgets — this is a live Tasks dashboard (a heading, a task table, and a create-task form), not a screenshot:

Loading live app preview…
{
  "title": "Tasks · main dashboard",
  "widgets": [
    {
      "widgetName": "headline",
      "config": {
        "text": "Tasks",
        "headerLevel": 1
      }
    },
    {
      "widgetName": "table",
      "config": {
        "header": "Open tasks",
        "columns": [
          {
            "field": "title",
            "header": "Task",
            "type": "string"
          },
          {
            "field": "state",
            "header": "State",
            "type": "string"
          },
          {
            "field": "priority",
            "header": "Priority",
            "type": "number"
          }
        ],
        "rows": [
          {
            "id": "t1",
            "title": "Draft kickoff brief",
            "state": "doing",
            "priority": 1
          },
          {
            "id": "t2",
            "title": "Set up CI",
            "state": "todo",
            "priority": 2
          },
          {
            "id": "t3",
            "title": "Write API smoke test",
            "state": "done",
            "priority": 3
          }
        ]
      }
    },
    {
      "widgetName": "form",
      "config": {
        "header": "New task",
        "fields": [
          {
            "id": "title",
            "label": "Task title",
            "type": "string",
            "required": true,
            "placeholder": "What needs doing?"
          },
          {
            "id": "state",
            "label": "State",
            "type": "string",
            "options": [
              {
                "label": "To do",
                "value": "todo"
              },
              {
                "label": "Doing",
                "value": "doing"
              },
              {
                "label": "Done",
                "value": "done"
              }
            ]
          },
          {
            "id": "priority",
            "label": "Priority",
            "type": "number",
            "min": 1,
            "max": 5
          }
        ],
        "submit": {
          "label": "Add task"
        }
      }
    }
  ]
}

This page uses the table widget (its rows are inline). In a production app the server-resolved data_grid does the same job at scale — reading rows from a named dataSource, paging, and carrying row actions. The form submits a command and buttons navigate or run actions; previews here are visual, so interactions are inert.

Widget What it is
headline A heading.
data_grid A paged, sortable, filterable grid over a named page source.
table A table over rows handed in as a value, optionally grouped.
cards A list of rows drawn as cards — a comment thread, an activity feed.
actions A row of buttons: authored actions and decided controls.
form An input form, plain or bound to entities.
filter_bar Free-input filters that publish their values as state.
chips One row of preset filter chips.
page_tabs Tab buttons that navigate between pages, with params.
section_switcher Buttons that switch sections within one page.
breadcrumbs A breadcrumb trail.
entity_info A labelled field list for one entity.
custom_markup Authored markup with named handlers.

#Content & data widgets

#headline

A static (or smart-valued) heading.

Field Type Req Notes
text string ✓ Heading text (may be {{ }}).
headerLevel int 1–3 Visual level; defaults to 1.
Loading live widget preview…
{
  "widgetName": "headline",
  "config": {
    "text": "Vessels",
    "headerLevel": 2
  }
}

#data_grid

The workhorse: a sortable grid over one named page source, with a text filter per column, row actions, menus of decided controls, status cells, selection and expandable rows, paged by a page-number pager, a cursor, or a Load more button. Filtering by a picker composes from OUTSIDE the grid: a filter_bar or chips widget publishes state that the grid's SOURCE reads through optional filter particles. Data Grids is the full guide — sources (including rows read from an action with $job), paging, server-paged $http, row actions, exports and the wire format.

Field Type Req Notes
dataSource string ✓ The NAME of an entry in the page's dataSources map — a $query, $rows, $http or $job source ($typeSchema cannot feed a grid).
columns array ✓ { type, field, header?, display?, lookup?, statusMap?, sortable?, filterable?, minWidth?, maxWidth? }. type is string / number / date / dateTime / status. field is an attribute, Type.attr, a system column ($name, $created_at, …) or a dotted path, depending on the source.
actions / leftActions rowAction[] Row controls, all in one actions column: an icon action { icon, command, accessor?, hrefAccessor? } or a menu { menu: [{ label, command, … }] }. The command sees the row's id as itemId.
fromRow / fromRowMenu string / object The path to each row's decided controls, and how their row menu lists them — see Data Grids → Menus of decided controls.
selectable boolean Checkbox column; checked ids are thisWidget.selectedRows / page.widgets.<id>.selectedRows.
expand boolean An expand chevron per row; the open row's id is page.widgets.<id>.state.expand, read by a widget you place.
pageSize int 1–200 Rows per page (default 20); a paged grid reads at most 100 a page.
paginate true | false | "cursor" | "more" The pager (default), previous / next by cursor, every row on one page, or a Load more button — see Data Grids → Paging.
surfaceKey string The surface the grid's sort and filters belong to, for one grid that lists different things in place.
header string Wraps the grid in a titled section.
noResultsMessage string Text shown in place of the grid when the source has no rows.

The preview supplies one page of rows the way the engine would (data keyed by the source name; total drives the pager):

Loading live widget preview…
{
  "widgetName": "data_grid",
  "config": {
    "header": "Open tasks",
    "dataSource": "tasks",
    "pageSize": 3,
    "columns": [
      {
        "type": "string",
        "field": "title",
        "header": "Task"
      },
      {
        "type": "string",
        "field": "state",
        "header": "State",
        "lookup": {
          "todo": "To do",
          "doing": "Doing"
        }
      },
      {
        "type": "date",
        "field": "due",
        "header": "Due"
      }
    ],
    "actions": [
      {
        "icon": "openLink",
        "command": []
      }
    ]
  },
  "data": {
    "tasks": {
      "rows": [
        {
          "id": "t1",
          "attributes": {
            "title": "Draft kickoff brief",
            "state": "doing",
            "due": "2026-10-01"
          }
        },
        {
          "id": "t2",
          "attributes": {
            "title": "Set up CI",
            "state": "todo",
            "due": "2026-10-03"
          }
        },
        {
          "id": "t3",
          "attributes": {
            "title": "Write API smoke test",
            "state": "todo",
            "due": "2026-10-08"
          }
        }
      ],
      "total": 5
    }
  }
}

#table

A table over rows handed in as a value — a page-data value, an action's output, a config slice — rather than a named source: no server fetch, no pager. Columns read each row flat (row[field], or a dotted path for a status column). A row with no id is keyed by its position.

Field Type Req Notes
columns array ✓ { field, header?, type?, statusMap? } — type is string (default), number or status. A status column shows the status view at its field path, worded by its statusMap (Status cells).
rows array ✓ The rows — usually { "$jsonata": "page.data.<source>" } (a {{ }} template would turn the array into text). null renders an empty table.
actions / leftActions rowAction[] Per-row controls, as on a data_grid.
fromRow string Path on each row to its list of decided controls: the table adds one row menu of them, each item enabled only on the rows that offer it enabled.
fromRowMenu object { items?, match?, merge? } — list the menu's items yourself, or merge them into the first actions menu; see Data Grids → fromRowMenu.
groupBy object { field, labelField, command? } — one heading and one table per distinct field value, in first-row order. With command, each heading is a button whose command reads {{ thisWidget.state.groupKey }} / groupLabel.
header, noResultsMessage string Copy.
Loading live widget preview…
{
  "widgetName": "table",
  "config": {
    "header": "Recent",
    "columns": [
      {
        "field": "name",
        "header": "Name",
        "type": "string"
      },
      {
        "field": "status",
        "header": "Status",
        "type": "string"
      }
    ],
    "rows": [
      {
        "id": "1",
        "name": "Aurora",
        "status": "active"
      },
      {
        "id": "2",
        "name": "Borealis",
        "status": "docked"
      }
    ]
  }
}

A checklist of steps. A process read hands a page one item's steps — each with its words, a status view and its controls — and a table draws them with a status column and a row menu:

"dataSources": {
  "steps": {
    "$job": {
      "action": "describeProcessActions",
      "config": { "processes": ["inspection"], "anchorIds": ["{{ page.params.id }}"] },
      "rowsPath": "anchors.0.steps"
    }
  }
},
"widgets": {
  "checklist": {
    "widgetComponent": "table",
    "config": {
      "rows": { "$jsonata": "page.data.steps" },
      "groupBy": { "field": "category", "labelField": "category" },
      "columns": [
        { "field": "label", "header": "Step" },
        { "field": "status", "header": "Status", "type": "status",
          "statusMap": [ { "state": "DONE", "glyph": "✅", "label": "{label} on {date}" } ] }
      ],
      "fromRow": "actions"
    }
  }
}
Loading live widget preview…
{
  "widgetName": "table",
  "config": {
    "columns": [
      {
        "field": "emit",
        "header": "Step"
      },
      {
        "field": "status",
        "header": "Status",
        "type": "status",
        "statusMap": [
          {
            "state": "DONE",
            "glyph": "✅",
            "label": "{label} on {date}"
          }
        ]
      }
    ],
    "rows": [
      {
        "emit": "Survey",
        "status": {
          "state": "IN_PROGRESS",
          "label": "Surveying",
          "tone": "active",
          "badges": [],
          "stateAt": "2026-10-05T16:40:00Z"
        },
        "actions": [
          {
            "actionId": "inspection:survey:v1:complete",
            "transition": "complete",
            "label": "Pass",
            "icon": null,
            "variant": null,
            "cta": true,
            "surfaces": null,
            "enabled": true,
            "reason": null,
            "details": null,
            "confirm": null,
            "intent": {
              "kind": "process.perform",
              "target": {
                "process": "inspection",
                "emit": "survey",
                "anchorId": "b7fd4216-8dda-455c-a60d-ceb88dafe8b0"
              },
              "transition": "complete",
              "inputs": {},
              "expectedUpdatedAt": "2026-10-05T16:40:00Z"
            }
          },
          {
            "actionId": "inspection:survey:v1:undo",
            "transition": "undo",
            "label": "Undo",
            "icon": null,
            "variant": null,
            "cta": false,
            "surfaces": null,
            "enabled": true,
            "reason": null,
            "details": null,
            "confirm": null,
            "intent": {
              "kind": "process.undo",
              "target": {
                "row": {
                  "type": "Check",
                  "id": "eff63216-7b7e-4e22-a8aa-ea45cc441394"
                }
              },
              "expectedUpdatedAt": "2026-10-05T16:40:00Z"
            }
          }
        ]
      },
      {
        "emit": "Paperwork",
        "status": {
          "state": "DONE",
          "label": "Filed",
          "tone": "done",
          "badges": [],
          "stateAt": "2026-10-02T09:00:00Z"
        },
        "actions": []
      }
    ],
    "fromRow": "actions"
  }
}

A press on a row's item reads the rows fresh and does what the clicked row's descriptor says — see Decided controls.

#cards

A list of rows drawn as cards, in the order given: each row its own card with a heading line, a body and a right-aligned caption, a divider between cards. It is what a comment thread or an activity feed needs. It writes nothing — a page adds to the list with a form beside it.

Field Type Req Notes
rows array ✓ The rows, usually { "$jsonata": "page.data.<source>" }. null is an empty list.
body string ✓ Path on each row to the card's body.
title string Path on each row to the card's heading line.
caption string Path on each row to the card's caption.
captionType enum text (default) shows the caption as written; date shows a timestamp as its day.
noResultsMessage string Shown when there are no rows; without it an empty list draws nothing.

Text, numbers and booleans show as written; a path with nothing at it shows nothing.

Loading live widget preview…
{
  "widgetName": "cards",
  "config": {
    "rows": [
      {
        "id": "c1",
        "author": "Ana",
        "text": "Hull inspected, no findings.",
        "at": "2026-09-30T08:15:00Z"
      },
      {
        "id": "c2",
        "author": "Ben",
        "text": "Certificates uploaded.",
        "at": "2026-10-02T14:40:00Z"
      }
    ],
    "title": "author",
    "body": "text",
    "caption": "at",
    "captionType": "date",
    "noResultsMessage": "No comments yet"
  }
}

A thread over entity rows reads their attributes by path:

"dataSources": {
  "comments": { "$query": { "typeName": "Comment", "sort": [{ "path": ["created_at"], "direction": "asc" }] } }
},
"widgets": {
  "thread": { "widgetComponent": "cards", "config": {
    "rows": { "$jsonata": "page.data.comments" },
    "title": "attributes.author", "body": "attributes.text",
    "caption": "created_at", "captionType": "date",
    "noResultsMessage": "No comments yet"
  } }
}

#actions

A free-standing row of buttons. Each entry is either an authored action — a label and what it does — or a decided control, a descriptor a process read hands the page (see Decided controls); one list may mix both. To pin the row to the bottom of a modal, wrap it in a footer (App Layouts → footer).

Field Type Req Notes
actions array ✓ Authored actions and descriptors, in order. Must be an array — wrap a computed list in $append([], …) (below).
overflow boolean Draw at most one button — the first call to action on offer (a descriptor's cta, or an authored primary), else the first action — and put every other enabled action in one ⋮ dropdown.
hideDisabled boolean Leave every disabled action off the row instead of saying why.

An authored action:

Field Type Notes
label string Required.
command step sequence What a press runs. Exactly one of command and href.
href string Renders a link instead of a button; a press opens it in the client and calls nothing. Build host addresses with $link (Links).
variant enum primary, secondary (default) or ghost.
icon string A leading icon (the row-action icon names, e.g. starOutline).
enabled boolean Whether the action can be used; a smart value, resolved before the widget draws. Absent means enabled.
reason string Why it is disabled, in the viewer's words.

A disabled action — an authored one with enabled: false, or a descriptor with "enabled": false — is drawn as disabled text: its reason (a descriptor's reason.label), else its label. It has no button, and a press on its old id runs nothing.

Loading live widget preview…
{
  "widgetName": "actions",
  "config": {
    "actions": [
      {
        "label": "Edit",
        "variant": "primary",
        "command": []
      },
      {
        "label": "Export CSV",
        "command": []
      },
      {
        "label": "Publish",
        "command": [],
        "enabled": false,
        "reason": "Only an admin can publish"
      }
    ]
  }
}

Gating an action on a permission. enabled is resolved before the widget draws, so it can read any page value — typically a checkPermissions answer:

"dataSources": {
  "access": { "$job": { "action": "checkPermissions",
    "config": { "checks": [ { "resource": "entity", "resourceName": "Vessel", "action": "update" } ] },
    "rowsPath": "allowed" } }
},
"widgets": {
  "edit": { "widgetComponent": "actions", "config": { "actions": [ {
    "label": "Edit", "variant": "primary",
    "command": [ { "kind": "navigate", "page": "edit", "modal": true } ],
    "enabled": { "$jsonata": "page.data.access[0]" },
    "reason": "You cannot edit vessels"
  } ] } }
}

The answer is for display: what the command writes is judged again when it runs.

One primary action per bar. With overflow: true a bar shows one button — the first call to action — and every other enabled action in a ⋮ dropdown; a disabled action stays beside them as disabled text unless hideDisabled leaves it off. This is the shape for an item's step controls:

"bar": {
  "widgetComponent": "actions",
  "config": {
    "overflow": true,
    "actions": { "$jsonata": "$append($append([], page.data.steps.actions), [{ 'label': 'Refresh', 'command': [{ 'kind': 'refresh' }] }])" }
  }
}
Loading live widget preview…
{
  "widgetName": "actions",
  "config": {
    "overflow": true,
    "actions": [
      {
        "actionId": "inspection:paperwork:v1:complete",
        "transition": "complete",
        "label": "File papers",
        "icon": null,
        "variant": null,
        "cta": false,
        "surfaces": null,
        "enabled": true,
        "reason": null,
        "details": null,
        "confirm": null,
        "intent": {
          "kind": "process.perform",
          "target": {
            "process": "inspection",
            "emit": "paperwork",
            "anchorId": "b7fd4216-8dda-455c-a60d-ceb88dafe8b0"
          },
          "transition": "complete",
          "inputs": {},
          "expectedUpdatedAt": null
        }
      },
      {
        "actionId": "inspection:survey:v1:complete",
        "transition": "complete",
        "label": "Pass",
        "icon": null,
        "variant": null,
        "cta": true,
        "surfaces": null,
        "enabled": true,
        "reason": null,
        "details": null,
        "confirm": null,
        "intent": {
          "kind": "process.perform",
          "target": {
            "process": "inspection",
            "emit": "survey",
            "anchorId": "b7fd4216-8dda-455c-a60d-ceb88dafe8b0"
          },
          "transition": "complete",
          "inputs": {},
          "expectedUpdatedAt": "2026-10-05T16:40:00Z"
        }
      },
      {
        "actionId": "inspection:report:v1:complete",
        "transition": "complete",
        "label": "Send report",
        "icon": null,
        "variant": null,
        "cta": false,
        "surfaces": null,
        "enabled": false,
        "reason": {
          "code": "PERMISSION_DENIED",
          "label": "Not permitted"
        },
        "details": null,
        "confirm": null,
        "intent": {
          "kind": "process.perform",
          "target": {
            "process": "inspection",
            "emit": "report",
            "anchorId": "b7fd4216-8dda-455c-a60d-ceb88dafe8b0"
          },
          "transition": "complete",
          "inputs": {},
          "expectedUpdatedAt": null
        }
      },
      {
        "label": "Refresh",
        "command": []
      }
    ]
  }
}

Here Pass (the call to action) is the button, File papers and Refresh are in the dropdown, and Not permitted is the disabled Send report.

$append([], …). JSONata collapses a single match to the object itself and gives undefined when nothing matches; the actions config refuses both. $append([], …) keeps the result an array in every case — the bar then draws nothing for an empty list.

#form

An input form. In its plain shape it collects values and submits a command; with bindings it edits entities or a principal config directly and generates its own submit.

Field Type Req Notes
fields array ✓ The inputs — see the field vocabulary below. May be generated ({ "$jsonata": … }).
submit object { label, command? } — the primary button. With command, the form's values reach the steps as outputs.form.<id>; without it, a bound form generates its own writes.
buttons array Additional buttons, each with a command.
initialValues record Seed values per field id (wins over a field's default).
resetKey string While it stays the same, the user's in-progress values survive every re-render. When it changes, the form rebuilds its values from default / bound data / initialValues.
inline boolean Inline (vs stacked) layout.
emptyFallback object { message, variant? } shown when every field is gated away.
entities record Bound form — entity slots (below).
config object Bound form — { principalType }: fields derive from that principal-config schema and submit writes the config.
links array Bound form — relationships the submit maintains (below).
onSuccess / onError steps Run after a bound submit succeeds / fails.

Resetting a form. A form keeps what the user has typed across re-renders. Give it a resetKey to start over on a trigger of your choosing — typically a smart value over the widget that drives the form's defaults:

{
  "widgetComponent": "form",
  "config": {
    "resetKey": "{{ page.widgets.template_picker.state.template }}",
    "fields": [
      {
        "id": "subject",
        "type": "string",
        "label": "Subject",
        "default": "{{ page.data.template.attributes.subject }}"
      }
    ],
    "submit": { "label": "Send", "command": [/* … */] }
  }
}

Picking another template changes the key, so the form's values (and a submit made in the same request) fall back to the new defaults; edits made while the key stays the same are kept. A null key behaves like no key. The form records its key under the reserved field id __reset_key__ — don't use that id for a field.

Fields. Every unbound field has id, type, and optionally label, placeholder, required, default; a field entry may be $if-gated. The type selects the input:

type Extra keys Renders
string options?, showAsButtons?, showAsDropdown?, numLines?, maxLength?, format? (date) A text input; with options, a select (showAsButtons → a button row, showAsDropdown → an auto-applying dropdown).
number min?, max? A numeric input.
boolean A toggle.
array options (required) A multi-select.
dateRange Two date pickers, published as <id>.from / <id>.to.
secretInput target (required) — { scope, scopeId?, name, env? } A write-only secret input; the value may only flow into a setSecret step. See Secrets.
file previewPage?, accept?, maxFileSize?, buttonLabel?, uploadType? The host's upload control and an × that clears it, on one row. See below.

maxLength caps the characters a text input or text area accepts in the client. It does not apply to a string field rendered as a select, buttons or dropdown, and a submit is not checked against it — validate in the command if the limit matters.

options is either a static [{ value, label }] array or { "dataSource": "<name>", "limit"?: n } binding a page source whose rows carry value and label — a literal $rows array carries them itself; a $query or $http source shapes them with a $pipe map step.

The file field is a composite, not a single input: the host's upload control, labelled with the field's label, and an × that clears only that field, on one row. Every render mints a fresh signed upload link for the control, so it is ready when the form opens, and clearing the field also hands out a new link. The serving provider must declare an upload type (Apps → Declare the provider's upload type); without one the field renders a warning that file upload is unavailable there. The value the field ends up holding is the uploaded temp file's id, which is then ordinary form state — outputs.form.<id> on submit, page.widgets.<form>.state.<id> elsewhere. previewPage names the modal a Preview button opens (no previewPage, no Preview button); accept takes dotted extensions or MIME types; maxFileSize is a per-file byte ceiling capped at 25 MiB. A file field is never bound — its value is a file id that no attribute schema derives — so in is refused where it is authored. The full lifecycle is in Apps → File uploads.

Loading live widget preview…
{
  "widgetName": "form",
  "config": {
    "header": "New task",
    "fields": [
      {
        "id": "title",
        "label": "Title",
        "type": "string",
        "required": true
      },
      {
        "id": "priority",
        "label": "Priority",
        "type": "number"
      },
      {
        "id": "urgent",
        "label": "Urgent",
        "type": "boolean"
      }
    ],
    "submit": {
      "label": "Create"
    }
  }
}

Bound forms. Declaring entities binds the form to one or more entity slots; each slot is { type, id?, matchOn?, seed? } and its mode follows from what it carries — id present ⇒ edit that entity; matchOn present ⇒ upsert by those identifier attributes (prefilled when a match exists, created otherwise); neither ⇒ create. A field binds to a slot with "in": "<slot>", "attribute": "<name>" (or "in": "config", "key": "<name>" for a config binding), and its input type derives from the slot type's schema — the field restates neither. A bound field takes no id: it renders, and its value is published, as <slot>_<attribute> (config_<key>), e.g. page.widgets.<form>.state.cert_expiry_date. links are the relationships the submit maintains: { rule, from, to, direction, mode? }, where an endpoint is a slot name, a concrete entity id, or { field } (a picker whose value is the target id), direction names the endpoint the carrying slot occupies, and mode: "replace" makes the listed targets the whole set for that rule. submit is required with bindings; the writes (upsertEntity per slot, links attached) are generated for you.

{
  "widgetComponent": "form",
  "config": {
    "entities": {
      "cert": { "type": "VesselCertificate", "seed": { "status": "valid" } }
    },
    "fields": [
      { "in": "cert", "attribute": "type", "label": "Certificate" },
      { "in": "cert", "attribute": "expiry_date", "label": "Expires" }
    ],
    "links": [
      {
        "rule": "VesselToVesselCertificate",
        "from": "{{ page.params.vessel_id }}",
        "to": "cert",
        "direction": "to"
      }
    ],
    "submit": { "label": "Add certificate" },
    "onSuccess": [{ "kind": "closeModal" }, { "kind": "refresh" }]
  }
}

#Fields filled from a chosen row

A process transition can take inputs from a select whose chosen row supplies more fields — pick a template, and the template's own fields join the form (the definition side is in Apps → Forms whose chosen row supplies fields). The page reads the transition's form with describeProcessForm, passing the current choice as values; the read answers the combined field list, the select marked suppliesFields: true and each field it supplied marked suppliedBy. Draw it with three widgets:

"functions": {
  "choice": "function() { ($s := $$.page.widgets.switcher.state.template; $s ? $s : $$.page.data.form.fields[suppliesFields = true].value) }"
},
// on the page:
"dataSources": {
  "form": { "$job": { "action": "describeProcessForm", "config": {
    "target": { "process": "inspection", "emit": "report", "anchorId": "{{ page.params.id }}" },
    "transition": "complete",
    "values": { "$jsonata": "{ 'template_key': $choice() }" }
  } } }
},
"layout": [["switcher"], ["editor"]],
"widgets": {
  "switcher": { "widgetComponent": "filter_bar", "config": { "fields": [ {
    "id": "template", "label": "Template", "type": "string", "showAsDropdown": true,
    "default": { "$jsonata": "$choice()" },
    "options": { "$jsonata": "[page.data.form.fields[suppliesFields = true].options.values.{ 'value': $string(value), 'label': label }]" }
  } ] } },
  "editor": { "widgetComponent": "form", "config": {
    "fields": { "$jsonata": "[page.data.form.fields[$not(suppliesFields = true)].{ 'id': name, 'label': label, 'type': 'string', 'required': required, 'default': value }]" },
    "resetKey": { "$jsonata": "$choice()" },
    "submit": { "label": "Send report", "command": [
      { "kind": "actions", "stepId": "send",
        "input": {
          "target": { "process": "inspection", "emit": "report", "anchorId": "{{ page.params.id }}" },
          "inputs": { "$jsonata": "$merge([outputs.form, { 'template_key': $choice() }])" }
        },
        "actions": [ { "stepId": "move", "name": "performTransition", "config": {
          "target": { "$jsonata": "input.target" }, "transition": "complete",
          "inputs": { "$jsonata": "input.inputs" } } } ] },
      { "kind": "notify", "level": "INFO", "message": "Report sent" }
    ] }
  } }
}
  • The switcher sits in a filter_bar outside the form, with showAsDropdown so a pick re-renders at once. Its options are the select's own options from the read.
  • $choice() is the switcher's value, else the choice the read started on — the select's default, or for a required select with none, its first option. The switcher's default, the source's values, the form's resetKey and the submitted select all follow it, so before the viewer picks, every one of them agrees on that first choice.
  • The editor maps every other field to a form field. A pick changes resetKey, so typed values of the previous choice are dropped and the new fields start from their defaults. A chosen row whose fields cannot be read leaves the list as declared, with reason: { "code": "FIELDS_UNAVAILABLE" } on the select.
  • Submit performs the transition with the form's values and the choice. The move re-reads the chosen row as the viewer and judges the inputs against the combined list; a refused move arrives as the step's error (below).

#filter_bar

The free-input filter affordance: a pure STATE PUBLISHER. It renders inputs, round-trips their values as widget state, and does nothing else — the page's SOURCES read page.widgets.<bar>.state.<field> via optional filter particles, and every interaction re-renders in full. No widget references the bar.

Field Type Req Notes
fields array ✓ The same field vocabulary as form (string / multiselect / dateRange…); showAsDropdown / showAsButtons apply on CHANGE.
apply object { label } — rendered by default (label "Apply") when any field applies on submit; a bar of only change-to-apply fields renders none.
clear boolean | object A Clear button — a type-aware STATE reset of this bar's fields (strings → "", arrays → []).
inline boolean Row layout (default) vs stacked.
remember boolean A literal true keeps the value of each field the bar stops showing and restores it when the field shows again — for a bar whose fields change with what it filters. Clear empties it too. A field gated with $if is never restored.
Loading live widget preview…
{
  "widgetName": "filter_bar",
  "config": {
    "fields": [
      {
        "id": "q",
        "label": "Search",
        "type": "string"
      },
      {
        "id": "status",
        "label": "Status",
        "type": "string",
        "options": [
          {
            "value": "open",
            "label": "Open"
          },
          {
            "value": "done",
            "label": "Done"
          }
        ],
        "showAsDropdown": true
      }
    ],
    "clear": true
  }
}

#chips

The preset-filter affordance — one row of exclusive chips publishing the selected value as widget state (the preset face of what filter_bar does with free inputs).

Field Type Req Notes
id string ✓ The state key the selected value publishes to.
options array ✓ { value, label }[] — one chip each.
default string Initially-selected value.
Loading live widget preview…
{
  "widgetName": "chips",
  "config": {
    "id": "status",
    "options": [
      {
        "value": "all",
        "label": "All"
      },
      {
        "value": "open",
        "label": "Open"
      },
      {
        "value": "done",
        "label": "Done"
      }
    ],
    "default": "open"
  }
}

#page_tabs

A row of tab buttons that navigate between pages. A tab may carry params, so several tabs can open the same page with different params — the sections of one record, the modules of one list.

Field Type Req Notes
tabs array ✓ { label, page, params?, children? }[]. params is a flat record of strings, numbers and booleans (smart values allowed) that the tab's navigate carries. children lists pages that count as this tab.

The selected tab is the one whose page and params match the page being shown (compared as text); on a page listed in some tab's children, that tab. When several tabs match by page alone, the first is selected. Clicking a tab navigates with exactly that tab's params — a navigate carries nothing over from the page it leaves — so a tab that must keep the record in view states it:

"tabs": {
  "widgetComponent": "page_tabs",
  "config": { "tabs": [
    { "label": "Particulars", "page": "vessel", "params": { "id": "{{ page.params.id }}", "section": "particulars" } },
    { "label": "History", "page": "vessel", "params": { "id": "{{ page.params.id }}", "section": "history" } }
  ] }
}

The page then branches on page.params.section, and can keep the viewer's section from one record to the next with remember — see App Layouts → Nav params.

Loading live widget preview…
{
  "widgetName": "page_tabs",
  "config": {
    "tabs": [
      {
        "label": "Overview",
        "page": "root"
      },
      {
        "label": "Details",
        "page": "detail",
        "params": {
          "section": "details"
        }
      },
      {
        "label": "Activity",
        "page": "detail",
        "params": {
          "section": "activity"
        }
      }
    ]
  }
}

#section_switcher

A button group that writes a page.data key; sections gate visibility on it via $if (no navigation).

Field Type Req Notes
stateKey string ✓ The page.data key this switcher writes.
options array ✓ { label, value }[] — the selectable sections.
Loading live widget preview…
{
  "widgetName": "section_switcher",
  "config": {
    "stateKey": "view",
    "options": [
      {
        "label": "List",
        "value": "list"
      },
      {
        "label": "Board",
        "value": "board"
      }
    ]
  }
}

A breadcrumb trail — explicit crumbs or an entity ancestry chain, with an optional refresh button.

Field Type Req Notes
crumbs array Explicit { label, page? } crumbs; page is { id, params? }. A crumb without page is plain text.
ancestry object { dataSource, root?, detailPage, self } — walk the named detail read's parent chain into crumbs (dataSource is a dataSources entry name).
refresh boolean Append a ↻ button that re-renders the page.
Loading live widget preview…
{
  "widgetName": "breadcrumbs",
  "config": {
    "crumbs": [
      {
        "label": "Vessels",
        "page": {
          "id": "vessels"
        }
      },
      {
        "label": "MV Northern Star",
        "page": {
          "id": "vessel",
          "params": {
            "id": "v-100"
          }
        }
      },
      {
        "label": "Voyage 2025-0142"
      }
    ],
    "refresh": true
  }
}

#entity_info

A labelled read-only field list for one entity (a definition-list card). The entity arrives through a named source — typically a single: true point-read in the page's dataSources map. A missing value shows as empty.

Field Type Req Notes
dataSource string ✓ Name of the dataSources entry carrying the entity.
fields array ✓ { label, attr }[] — label + attribute, in order.
header string Optional card heading.
Loading live widget preview…
{
  "widgetName": "entity_info",
  "config": {
    "header": "Vessel",
    "dataSource": "vessel",
    "fields": [
      {
        "label": "Name",
        "attr": "name"
      },
      {
        "label": "IMO",
        "attr": "imo"
      },
      {
        "label": "Flag",
        "attr": "flag"
      }
    ]
  },
  "data": {
    "vessel": {
      "id": "v1",
      "attributes": {
        "name": "Aurora",
        "imo": "9876543",
        "flag": "Panama"
      }
    }
  }
}

#custom_markup

An escape hatch: an authored markup element with named action handlers.

Field Type Req Notes
markup element | element[] ✓ One content element (text, button, data list, …), or a list of them — drawn as one section, in order.
actionHandlers record Named handlers → step-sequences, keyed by the id of the element that presses them.
Loading live widget preview…
{
  "widgetName": "custom_markup",
  "config": {
    "markup": {
      "type": "dataList",
      "items": [
        {
          "label": "Vessel",
          "content": "MV Northern Star"
        },
        {
          "label": "Voyage",
          "content": "2025-0142"
        },
        {
          "label": "Status",
          "content": "In transit"
        },
        {
          "label": "ETA",
          "content": "2025-09-14"
        }
      ]
    }
  }
}

#Decided controls

A decided control — a descriptor — is a control the platform has already decided for the viewer: its words, whether they may press it, why not, and what a press does. Process reads hand them out — describeProcessActions through a $job source, or a $query source's process folding them onto each row — and three widgets draw them: an actions row as buttons, a table and a data_grid as a row menu (fromRow). A widget decides nothing about them.

{
  "actionId": "inspection:survey:b7fd4216-8dda-455c-a60d-ceb88dafe8b0:initiate",
  "transition": "initiate",
  "label": "Start survey",
  "icon": null,
  "variant": null,
  "cta": false,
  "surfaces": null,
  "enabled": true,
  "reason": null,
  "details": null,
  "confirm": null,
  "intent": {
    "kind": "process.perform",
    "target": {
      "process": "inspection",
      "emit": "survey",
      "anchorId": "b7fd4216-8dda-455c-a60d-ceb88dafe8b0"
    },
    "transition": "initiate",
    "inputs": {},
    "expectedUpdatedAt": null
  }
}
Field Meaning
actionId The control's identity; a button presses back to it.
transition The transition it performs (null for a control that is not a move).
label Its words.
cta The call to action — drawn primary, and the one button of an overflow bar.
variant primary, secondary or ghost; any other word draws secondary.
surfaces Where the presentation shows it; null means everywhere. Filter with surfaces = null or '<surface>' in surfaces.
enabled Whether the viewer may press it.
reason { code, label } when disabled, null when enabled. The label is drawn; the code is never printed.
intent What a press does: process.perform, process.undo, process.release, or navigate (a transition that takes inputs opens its form page).

A press is answered from a fresh read, never from what was drawn:

  • the control, read fresh and enabled, runs its intent — a move as the viewer's own, judged again when it runs; a navigate to a page the spec declares only as a modal opens it as a modal;
  • read fresh and disabled, nothing runs, and the answer is an ERROR notification in the reason's words with details.refusal_code;
  • not offered any more (another person moved the step, it was pressed already), nothing runs, and the answer is a WARNING that the action is not available here any more;
  • on a row the page drew before its step moved, nothing runs, and the answer is an ERROR with details.refusal_code: "STALE".

Every press re-renders the page, so it then shows where things stand. A descriptor button's element id is action/<actionId> (URL-encoded) under its widget — ":p/detail/bar/action/inspection%3Asurvey%3Ab7fd…%3Ainitiate:".

#Status cells

A process read gives each step a status view: { "state": "IN_PROGRESS", "label": "Surveying", "tone": "active", "badges": [], "stateAt": "2026-10-05T16:40:00Z" } — its rung, the rung's word, its tone (idle, active, waiting, done, failed), badge words (a step whose latest move failed carries Failed), and when it reached the rung (null before its first move). A status column in a table or a data_grid shows it as text: the label, then the badges, joined by ·. A statusMap rewords it — the first entry whose state, tone and badge conditions hold gives a glyph and a label, where {label} is the view's own word and {date} the day it reached the rung:

"statusMap": [
  { "state": "DONE", "glyph": "✅", "label": "{label} on {date}" },
  { "badge": "Failed", "glyph": "⚠" },
  { "tone": "idle", "glyph": "○" }
]

A done step reads ✅ Passed on Oct 6, 2026; a step whose last move failed reads ⚠ Surveying · Failed; a step not started reads ○ Not started.

An href — an authored action's, or the row field an hrefAccessor names — may address a page of the host the app runs in. Name the target with $link("<provider>.<route>", params) and the platform builds the address from the provider's links block:

"providers": {
  "sedna": {
    "auth": { … }, "identity": { … },
    "links": {
      "baseUrl": "https://{company.external_id}.sednanetwork.com",
      "routes": { "vessel": "/fleet/vessels/{id}", "search": "/fleet?q={q}" }
    }
  }
},
// on a page:
"open": { "widgetComponent": "actions", "config": { "actions": [
  { "label": "Open in fleet", "href": "{{ $link('sedna.search', { 'q': 'MV' }) }}" }
] } }
Where the render is served The href
By the target provider The route alone: /fleet?q=MV.
By any other provider The target's absolute address for your tenant: https://acme.sednanetwork.com/fleet?q=MV.
No integration with the target, or a path param left out "" — the link renders inert, and the rest of the page draws.

The full rules — placeholders, the strict strictHrefs mode, the schemes an href may use — are in Apps → Links to provider pages. For a grid row, build the value in the source: Data Grids → Links in a row.

#When a command fails

A command's steps run in order and stop at the first failure. Give the step an onError to answer the failure yourself: its steps run in place of the default notification and read the failure at outputs.__error__ —

Field Holds
status The failure's HTTP status (422, 409, …); 0 when the failure is not a platform error.
code The platform error code (VALIDATION_FAILED, UNPROCESSABLE_ENTITY, …); "" when there is none.
message The user-facing message.
name The failure's class name.

Branch on status and code, never on the message:

{
  "label": "Add vessel",
  "command": [
    {
      "kind": "actions",
      "stepId": "add",
      "actions": [
        {
          "stepId": "upsert",
          "name": "upsertEntity",
          "config": {
            "typeName": "Vessel",
            "payload": { "attributes": { "flag": "PA" } }
          }
        }
      ],
      "onError": [
        {
          "kind": "notify",
          "level": "WARN",
          "message": "{{ 'Not saved (' & $string(outputs.__error__.status) & ' ' & outputs.__error__.code & ')' }}"
        }
      ]
    }
  ]
}

A vessel without its required name answers with the WARNING "Not saved (422 VALIDATION_FAILED)". Without onError, the response carries an ERROR notification whose details.error_code is the same code (and, for a refused process move, details.refusal_code) — see Apps → When a step fails.

#Layout widgets

Layout widgets structure a page rather than showing data. They are placed as inline nodes in a page's layout tree — { "component": "...", "config": {...}, "children": [...] } — instead of entries in the widgets record. Each is documented, with its rules and a rendered example, in App Layouts:

Component Kind What it is
section container A titled, optionally collapsible group with a column grid.
row container A horizontal run of elements; "align": "toolbar" splits it into a left and a right group.
tabs container, top level, id Tabbed groups.
header top level The page's title bar; on a modal it carries the modal's Close button.
footer container, top level The pinned bottom bar for buttons.
divider leaf A horizontal rule.
iframe leaf An embedded external page.
file_viewer leaf An inline document viewer.

The content widgets with a preview render live on this page. Data-bound widgets (data_grid, entity_info) are shown with sample data supplied the way the engine injects it at render time in a real app; descriptors and status views are shown as a process read hands them out. Layout widgets render as part of a page's layout tree, so they are shown in App Layouts; see Apps (Bridge) for an end-to-end app and Data Grids for grids in depth.