Forms
A form view writes one record. Required keys: route, type, entity. mode is create (the default) or edit.
{
"route": "/suppliers/new",
"type": "form",
"entity": "suppliers",
"mode": "create",
"title": { "en": "New supplier" },
"fields": [
{ "name": "code", "label": "Code", "type": "text", "required": true,
"transform": "uppercase", "validation": { "pattern": "^[A-Z0-9_]{2,20}$" } },
{ "name": "name", "label": { "en": "Name" }, "type": "text", "required": true },
{ "name": "country", "label": { "en": "Country" }, "type": "text", "span": "half" },
{ "name": "status", "label": "Status", "type": "select",
"vocabulary": "suppliers.statuses", "default": "active" }
],
"submit_label": { "en": "Create" },
"on_success": "/suppliers/:id",
"errors": {
"23505": { "en": "A supplier with that code already exists." }
},
"permissions": ["suppliers.write"]
}
Routes: a create form uses a static route (/suppliers/new); an edit form includes :id (/suppliers/:id/edit). on_success replaces :id with the created or updated id.
Fields
A field is { name, type } plus a label and its options. 34 types:
text textarea number money boolean date
datetime select multi-select relation reference
json image email url tel media
media-collection localized-text rules fieldset
record-list string-list key-value record-view editor-link secret
address measure price market-assignment
tree-assign line-selection
Some of them need a companion object, and the distinctions are worth knowing:
| Type | Notes |
|---|---|
select, multi-select | Need options, or better a vocabulary. |
relation | A direct read of a target table inside the same app. Needs a relation object. |
reference | A searchable picker over the platform's cross-app lookup, so it may point at another app's entity. Needs a reference object, and the target entity must declare a lookup block in its schema.json — without it the picker degrades. |
media, media-collection | Open the storage media picker and store a delivery key (single, or a gallery). media config constrains the picker. Inert in the local preview. |
fieldset, record-list | Need fields — the keys of the object being edited. |
key-value | An editable map, with keys_from, keys_readonly, show_missing, preserve_unknown_keys. |
rules | A rule builder. Needs a rules config, and the app is the source of truth for its own rule vocabulary — the Cockpit renders what it is handed and never invents a field or an operator, so a condition the app would reject cannot be built in the UI. |
secret | Pair it with write_only. |
editor-link | Points at the Cockpit surface that owns this document. It is the one place in a manifest that names a Cockpit route, deliberately: only the app knows that its definition column is the thing the builder edits. |
measure, price, address | Composite values. |
market-assignment | Which markets the row applies to. Written through the scopes API, not as a column. |
tree-assign | Assign into a self-referencing hierarchy; needs tree. |
line-selection | Needs lines, naming the capability that answers the parent record's lines and how much of each is still open. |
template (on any field) | A read-only value composed from the field's siblings, in the closed {prefix}{counter:pad(6)} syntax, re-read on every keystroke so a preview follows what the operator types. Never written back. |
fields on a jsonb column is the point, not a formality
Declaring fields on a fieldset or record-list is what turns a jsonb column into a form: the Cockpit writes exactly those keys and nothing else, so a typo an operator could make in a JSON textarea is not expressible.
line-selection reads the budget, it does not derive it
GET /v1/orders/{id}/shippable exists.Common field keys
| Key | Does |
|---|---|
required, default, placeholder, description, hint | The obvious ones. |
readonly | Surfaces the value and disables editing. Detail views default to readonly when omitted. |
readonly_when + readonly_reason | Conditional, with the reason said out loud. |
write_once | Editable on create, fixed afterwards. |
write_only | Never read back, replaced rather than edited. A secret the Cockpit could display is a secret already lost, so this also keeps the column out of every read. Pair with replace_action and replace_warning. |
visible_when | Show only while the predicate holds. A field whose parent choice makes it meaningless is hidden and nulled — that is what keeps a matrix basis off a fixed-price method. |
on_empty | What an empty value writes: omit (drop the key so the database applies its default), null, or empty. Inferred from the schema when absent. This is the difference between letting the database decide and writing '' into a constrained column. |
transform | uppercase, lowercase or trim. |
validation | min, max, min_length, max_length, pattern, min_items, max_items. |
span | auto, half or full. |
vocabulary | {app}.{name}. Prefer it for any enum an app enforces — options stays valid as a literal fallback. |
options_from, on_pick, derives, from, link, junction | Options from another entity, side effects of a pick, derived siblings, reading out of a jsonb document, links, junction tables. |
currency_field | For money: the column naming the currency. A field that cannot say gets labelled EUR, which on an order priced in anything else is the wrong number, not a formatting detail. |
item_label | The singular noun for one row of a record-list or key-value — unit, entry, key. |
localized, channel_scoped | The value is held per locale, or per channel, rather than once per record. Used to explain why the field cannot be edited on a surface that named no locale, and to keep it out of a rule — a rule has no scope to evaluate it in. |
storage | Where the value is written, when it is not a column of its own — a path inside a jsonb document. The write-side pair of from, and what a custom-field app needs. |
source | The value comes from the app rather than from the record or a sibling. The read-side counterpart of write, and just as narrow: for the cases where an app route is the honest origin and a row read is a plausible wrong answer — an order position's unit price taken off the price book. |
Sections
"sections": [
{ "title": { "en": "Identity" }, "fields": [] },
{ "title": { "en": "Commercial terms" }, "columns": 2, "fields": [] }
]
Group the form. Same shape as a detail view's sections — see Detail views.
Steps — a wizard
"steps": [
{ "key": "basics", "title": { "en": "Basics" }, "fields": [] },
{ "key": "terms", "title": { "en": "Terms" }, "fields": [],
"when": { "kind": "contract" }, "display": "cards" }
]
A form that splits across steps declares its fields there rather than at the top level. when skips a step conditionally; display is fields or cards.
Constraints — cross-field rules
"constraints": [
{ "op": "lte", "left": "min_order_value", "right": "max_order_value",
"message": { "en": "The minimum cannot exceed the maximum." } },
{ "op": "requires", "left": "kind", "when": { "kind": "psp" }, "require": ["provider"],
"message": { "en": "A PSP method needs a provider." } }
]
op is lte, gte, lt, gt, eq, neq or requires. Every constraint carries a message — the sentence the operator sees.
Seeds
"seed": { "from_query": ["supplier_id"], "values": { "status": "draft" } }
Pre-fills a create form — from the route's query parameters, or with fixed values. This is how a child list's "Add" action pre-binds the child's parent_key.
Runtime field lists
"fields_from": { "capability": "products.attribute-schema", "params": { "family_code": ":family_code" } }
Fields resolved at runtime and appended to the declared ones. This is the only way to express a form whose shape is tenant data rather than app metadata — an attribute-driven product form is exactly that, which is why GET /v1/products/attribute-schema exists.
The same seam is section.fields_from on a detail view; here it is on the surface that actually writes.
Error messages
"errors": {
"23505": { "en": "A supplier with that code already exists.",
"de": "Ein Lieferant mit diesem Code existiert bereits." },
"23503": { "en": "That supplier group no longer exists." }
}
23505 duplicate key value violates unique constraint …. Map the codes your form can actually provoke.After a successful save
"submit_label": { "en": "Create" },
"success_message": { "en": "Supplier created." },
"on_success": "/suppliers/:id",
"then": [
{ "kind": "api", "method": "POST", "endpoint": "/suppliers/:id/sync" }
]
then runs follow-up actions after the write lands — a sync, a recompute, a notification. submit_label defaults to "Save".
Where the save goes
By default the form writes the entity: a create is a row insert, an edit a row update.
"write": { "method": "POST", "endpoint": "/suppliers" }
Declare write when the change has a side effect the database cannot produce on its own. See Actions and writes.
Concurrency
"concurrency": "merge"
last-write-wins (default) or merge. Same semantics as on a detail view — see Detail views. For a catalog form two people work at once, merge is the right answer.
Where to go next
- Actions and writes —
write,then, and the action kinds. - Detail views — where a form is reached from, and inline edit panels.
- List views — the column types, which mirror many of the field types.
- Value lists — what a
vocabularyresolves against.