A form's fields array holds field objects. This page describes their
shape, type by type, for anyone building forms through the API or the
MCP server.
If you are building forms in the dashboard, you do not need this page — the builder writes these objects for you. Start with the forms guide instead.
The API validates this shape on every write. A field that does not match
comes back as a 400 validation_failed with one entry per problem in
details, naming the path and what is wrong, so you can correct it
before anything is stored. See conventions for
the error envelope.
Every field has the same four tiers
{
"id": "patient_name",
"label": "Patient name",
"helper_text": "Full legal name",
"type": "text",
"interface": "single_line",
"required": true,
"read_only": false,
"formula": null,
"visible_when": null,
"attributes": {
"max_length": 100
}
}- Identity —
id,label,helper_text - Kind —
typeandinterface - Behavior —
required,read_only,formula,visible_when,hidden,hide_when_empty,default_value - Configuration —
attributes, whose shape depends on the type and interface
Unknown keys are rejected. A misspelled key is an error, not a silently ignored setting.
Universal keys
| Key | Required | Type | Notes |
|---|---|---|---|
id | yes | string | Lowercase letters, numbers, and underscores, starting with a letter or underscore. Unique within the form, or within a subform. This is the key your data arrives under. |
label | yes | string | Shown above the field. On a none field it holds the heading or description text itself. |
helper_text | no | string | Short hint below the field. Not allowed on none or page. |
type | yes | enum | See the type list below. |
interface | yes | enum | The input variant for that type. Always required, on every type, including page. |
required | no | boolean | Defaults to false. Not allowed on none or page. |
read_only | no | boolean | Defaults to false. Not allowed on none or page. |
formula | no | string | A JavaScript function body whose return value becomes the field's value. Not allowed on none or page. |
visible_when | no | string | A JavaScript function body. When it returns something falsy, the field is hidden. Allowed on every type. |
hidden | no | boolean | Never shown to the person filling the form. Not allowed on none or page. |
hide_when_empty | no | boolean | Shown only once the field has a value. Not allowed on none or page. |
default_value | no | varies | The value a new record starts with. text, number, list, and date_time only. |
default_formula | no | string | An expression worked out once, when the record is created. Same four types. |
attributes | yes | object | Type-specific configuration. Always required, but often {}. |
interface is required on every type
This is the single most common mistake when authoring through the API. Types with only one interface still need it spelled out:
page→"interface": "section"gps→"interface": "point"lookup→"interface": "dropdown"api→"interface": "request"
Two icons, two places
A form has an icon. So does a page. They are different keys:
- The form's icon is a top-level key on the form object, alongside
nameandfields. - A page's icon lives inside that page's
attributes, not at the top of the field object.
{
"name": "Quick trial",
"icon": "mdi:clipboard-text",
"fields": [
{
"id": "trial_details",
"label": "Trial details",
"type": "page",
"interface": "section",
"attributes": { "icon": "mdi:notebook", "fields": [] }
}
]
}What an icon value may be
Both keys take the same two forms, and nothing else:
mdi:<name>— a Material Design Icon, such asmdi:clipboard-textormdi:map-marker. Browse the full set at pictogrammers.com/library/mdi and prefix the name withmdi:.https://…— a URL to an image you have uploaded.
The mdi: prefix is required. A bare name like clipboard-text is not
a valid icon value, even though the icon itself exists.
An icon we cannot draw does not fail your request. The form saves, and the icon renders as a plain document instead. This is deliberate: an icon is cosmetic, and rejecting one would mean a form that saved today stops saving tomorrow. But it does mean a typo is silent on the API — so check the icon in the form builder, which flags any value that will not render and suggests the closest real name.
Values worth double-checking, because they read as though they should work and do not:
| You might write | You want |
|---|---|
document | mdi:file-document |
geo-pin | mdi:map-marker |
check-circle | mdi:check-circle |
camera | mdi:camera |
table | mdi:table |
badge | mdi:badge-account |
Types
page
Groups the fields of one section behind a navigation bar. Collects no data of its own: its children write to the same flat record they would without the page, so adding pages to a form never changes the shape of its data.
Interfaces: section
| Attribute | Type | Notes |
|---|---|---|
fields | array | The fields on this page. May not contain further pages. |
icon | string | Optional icon for the page button. |
{
"id": "trial_details",
"label": "Trial details",
"type": "page",
"interface": "section",
"attributes": {
"icon": "mdi:notebook",
"fields": [
{
"id": "trial_name",
"label": "Trial name",
"type": "text",
"interface": "single_line",
"required": true,
"attributes": { "max_length": 120 }
}
]
}
}Pages take visible_when — hiding a page hides its button and its
children — but not required, read_only, or formula, because they
collect nothing.
text
Interfaces: single_line, multi_line, scanning
| Attribute | Type | Notes |
|---|---|---|
max_length | integer | Maximum characters. |
mask | string | Regular expression the value must match. |
auto_correct | boolean | single_line and multi_line. |
auto_capitalize | boolean | single_line and multi_line. |
keyboard_type | enum | single_line only: default, email_address, numeric, phone_pad, url, number_pad, decimal_pad. |
secure_text_entry | boolean | single_line only. Masks what is typed. |
symbologies | array | scanning only, at least one: code_128, code_39, upc_a, ean_13, qr, data_matrix. |
{
"id": "sample_barcode",
"label": "Sample barcode",
"type": "text",
"interface": "scanning",
"attributes": { "symbologies": ["qr", "code_128"] }
}Stored value: a string, or null.
number
Interfaces: keyboard, tally, slider
| Attribute | Type | Notes |
|---|---|---|
min | number | Lowest accepted value. |
max | number | Highest accepted value. |
increment | number | Step size for tally buttons and the slider. |
digits | object | Decimal-place configuration. |
format | object | Display format: { "style": "decimal" | "currency" | "percent", "currency": "USD", "use_grouping": true }. currency is required when the style is currency. |
{
"id": "plant_count",
"label": "Plant count",
"type": "number",
"interface": "tally",
"default_value": 0,
"attributes": { "min": 0, "increment": 1 }
}Stored value: a number, or null.
list
Interfaces: dropdown, toggle_vertical, toggle_horizontal
| Attribute | Type | Notes |
|---|---|---|
options | array | At least one { "label": "..." }. An option may also carry visible_when. |
allow_multiple | boolean | When true the stored value is an array. |
source | object | Populate the options from a data source instead of listing them. |
Option labels are the stored values. There is no separate value slot, so renaming an option changes what new records store while older records keep the old text.
{
"id": "status",
"label": "Status",
"type": "list",
"interface": "dropdown",
"required": true,
"default_value": "In progress",
"attributes": {
"options": [
{ "label": "In progress" },
{ "label": "Complete" },
{ "label": "Closed", "visible_when": "return fieldscroll.user.roles.includes('org_admin');" }
]
}
}Stored value: a string, an array of strings when allow_multiple is
true, or null.
date_time
Interfaces: date, time, both
| Attribute | Type | Notes |
|---|---|---|
min_date | string | ISO 8601. |
max_date | string | ISO 8601. |
normalize_to_utc | boolean | Store as UTC rather than local time. |
picker_style | string | calendar (default) or spinner. Applies to the date portion only. |
{
"id": "observed_on",
"label": "Observed on",
"type": "date_time",
"interface": "date",
"attributes": {}
}Stored value: an ISO 8601 string, or null.
Pick spinner when the date is usually far from today — a birth date, an
expiry, a historical observation. A calendar is one tap for next Tuesday and
several for a date three years out, because changing the year means going
through a second control. Time always uses a spinner; it has no competing
representation.
lookup
Select a row from a data source.
Interfaces: dropdown
| Attribute | Type | Notes |
|---|---|---|
lookup_source_id | string | The data source to search. |
display_fields | array | One to ten column keys shown in the picker rows. |
search_fields | array | Column keys the search box matches against. |
filters | array | Narrows the rows offered. See below, and the lookups guide. |
sort_by | array | Up to three { "column": "...", "direction": "asc" } entries. |
auto_select | boolean | Select automatically when exactly one row matches. |
{
"id": "grower_lookup",
"label": "Grower",
"type": "lookup",
"interface": "dropdown",
"required": true,
"attributes": {
"lookup_source_id": "8f14e45f-ceea-467a-9c3d-4b0b1e0a1c22",
"display_fields": ["name", "region"],
"search_fields": ["name"],
"sort_by": [{ "column": "name", "direction": "asc" }]
}
}Stored value: the selected row as an object, including a _row_id,
or null.
Filters
Each entry in filters is one of three shapes.
static — compare a column against a fixed value.
{ "type": "static", "field": "active", "operator": "eq", "value": true }dynamic — compare a column against another field's current answer.
{
"type": "dynamic",
"field": "species_id",
"operator": "eq",
"source_field_id": "species.id",
"scope": "top"
}| Key | Notes |
|---|---|
field | The data source column to filter on. |
operator | eq, neq, gt, gte, lt, lte, contains, not_contains, in, not_in. |
source_field_id | The form field to read the answer from. May be a field id, or a field id followed by one property (species.id). |
scope | Which level to read from: fields (default, the current level), parent (one level up), or top (the form root). |
Two things about dynamic are worth stating plainly, because both fail
quietly — the filter is skipped and every row shows.
scope matters inside a subform. A filter in a repeating section reads that
row's own answers by default. To filter by something answered once at the top
of the form, say "scope": "top". "parent" reads one level up, which
differs from top only when sections are nested inside sections.
source_field_id may read one property. A lookup field stores the whole
selected row, so species is an object. Write species.id to compare
against an id column. One property only — for anything more, use computed.
computed — compare against the result of an expression, evaluated per
row after the query.
{
"type": "computed",
"expression": "return row.capacity - row.booked;",
"operator": "gt",
"value": 0
}The expression receives row (the candidate data source row) plus the same
fields, parent, top, meta, and fieldscroll scopes a formula gets.
subform
A repeatable group of fields. Unlike a page, a subform creates its own data scope: its children's values live inside the subform's value, not in the flat record.
Interfaces: single, multiple
| Attribute | Type | Notes |
|---|---|---|
fields | array | The fields in each row. |
min_items | integer | Fewest rows accepted. |
max_items | integer | Most rows accepted. |
display_fields | array | Field ids that summarize each row in the collapsed list. |
display_style | enum | inline or dedicated. A subform containing pages must be dedicated — the page bar needs a full screen. |
{
"id": "evaluations",
"label": "Evaluations",
"type": "subform",
"interface": "multiple",
"required": true,
"attributes": {
"fields": [
{
"id": "variety",
"label": "Variety",
"type": "text",
"interface": "single_line",
"required": true,
"attributes": {}
}
]
}
}Stored value: an array of row objects for multiple, a single row
object for single.
media
Interfaces: image, video, file_upload, signature
| Attribute | Type | Notes |
|---|---|---|
capture_mode | enum | prompt, camera_only, or gallery_only. |
max_file_size | integer | Bytes. |
allowed_mime_types | array | Restricts what can be attached. |
max_duration_seconds | integer | Video only. |
The signature interface takes empty attributes — a signature is a PNG
drawn on screen, so the image and video settings do not apply to it.
{
"id": "plot_photo",
"label": "Plot photo",
"type": "media",
"interface": "image",
"attributes": { "capture_mode": "camera_only" }
}Stored value: an object describing the file, or null.
gps
Interfaces: point
| Attribute | Type | Notes |
|---|---|---|
movable | boolean | Let the person adjust the pin. |
min_accuracy | number | Required accuracy in metres. |
timeout | integer | Milliseconds to wait for a fix. Defaults to 10000. |
{
"id": "plot_location",
"label": "Plot location",
"type": "gps",
"interface": "point",
"attributes": { "min_accuracy": 20 }
}Stored value: a GeoJSON Point, or null.
api
Make a REST request from the device and store the response.
Interfaces: request
| Attribute | Type | Notes |
|---|---|---|
method | enum | GET, POST, PUT, PATCH, DELETE. |
url | string | Must start with https://. |
headers | array | { "key": "...", "value": "..." } entries. |
body | string | Only on POST, PUT, and PATCH. |
timeout_ms | integer | Up to 60000. |
max_response_bytes | integer | Up to 262144. |
{
"id": "weather",
"label": "Weather at plot",
"type": "api",
"interface": "request",
"attributes": {
"method": "GET",
"url": "https://api.example.com/weather",
"headers": []
}
}Stored value: an object carrying the response and a status, or
null.
none
Static copy. Collects nothing, so it takes no required, read_only,
or formula — the text itself goes in label.
Interfaces: heading, description
{
"id": "safety_notice",
"label": "Wear gloves for the whole of this section.",
"type": "none",
"interface": "description",
"attributes": {}
}How the behavior keys fit together
Seven keys, two questions. Combinations that contradict each other are rejected on write rather than resolved quietly, so a form that stores is a form that means one thing.
Does this field render? First match wins.
| # | Condition | Result |
|---|---|---|
| 1 | hidden is true | never rendered, and exempt from required |
| 2 | hide_when_empty is true and the field is empty | not rendered |
| 3 | visible_when returns something falsy | not rendered |
| 4 | otherwise | rendered |
Rejected: hidden together with hide_when_empty. A hidden field never
renders, so the second rule could never apply.
Where does the value come from? Pick one.
| Key | When it runs | Who owns the value afterwards |
|---|---|---|
formula | every time the form opens, and whenever an input changes | the formula |
default_formula | once, when the record or row is created | the record |
default_value | once, when the record or row is created | the record |
| none | — | the record |
Rejected: any two of those three on one field.
Behavior keys in detail
formula
The body of a JavaScript function. Whatever it returns becomes the field's value, recalculated as its inputs change.
{ "formula": "return (fields.quantity || 0) * (fields.unit_price || 0);" }Inside a subform row, fields is that row, parent is the level above,
top is the root form, and meta carries the row's position
(meta.index, meta.count, meta.isFirst, meta.isLast). At the root
form parent and top mirror fields, and meta is not set — so
check for it before using it.
visible_when
Same calling convention. The return value is read as true or false.
{ "visible_when": "return fields.country === 'us';" }A hidden field skips validation, so a hidden required field will not block a submission.
If a set of visible_when rules refers back to itself in a circle, the
fields taking part in that circle are shown and report an error against
themselves. Every other field in the form still evaluates normally.
Writing a form with a circular rule returns a warning; publishing one is
refused.
A rule that reads a field which is not on the form is treated the same
way. Rather than reading the missing field as empty — which would
usually hide the field carrying the rule, and with it the check that the
field is required — the field is shown and reports an error against
itself. Writing such a form returns an unknown_field_reference warning;
publishing it, or setting it ready for testing, is refused.
fieldscroll — the platform's own context
Every expression also receives a fieldscroll object, carrying what the
platform knows about the session rather than about the form's answers.
Today that is who is filling it:
{ "visible_when": "return !!fieldscroll.user && fieldscroll.user.roles.includes('org_admin');" }| Key | Type | Notes |
|---|---|---|
fieldscroll.user.id | string | The account filling the form. |
fieldscroll.user.email | string | May be null. |
fieldscroll.user.roles | array | Role names held in the collecting organization. |
fieldscroll.user.is_owner | boolean | True for the instance owner. |
fieldscroll is always an object, so a guard never throws. Its members are
what may be absent — check fieldscroll.user before reading through it.
Because it is grouped rather than flat, a formula can capture a whole group in one go instead of naming every key:
{ "formula": "return JSON.stringify(fieldscroll.user);" }Gate on a role rather than an email.
fieldscroll.user.email === 'someone@example.com' writes a person into a
form definition and stops working the day they leave;
fieldscroll.user.roles.includes('org_admin') survives staff changes.
This is an affordance, not a permission boundary. An option hidden this way is hidden in the interface only — the value can still be submitted through the API, and anyone who can read the form definition can read every option in it. If you need real per-option authorization, talk to us rather than relying on this.
hidden
The field is never shown to the person filling the form, but still holds a value. Use it for ids and keys that feed lookup filters or downstream systems.
{ "hidden": true }A hidden field is exempt from required, because a required field
nobody can see cannot be filled in.
hide_when_empty
The field appears only once it has a value. Written for derived fields: "show this only if it turned out to have something in it."
{ "hide_when_empty": true }Checked after formulas run. 0 and false count as values; an empty
string, an empty list, and nothing at all count as empty.
default_value
The value a new record starts with. The person filling the form can change it.
{ "default_value": "In progress" }Applied when a record is opened for filling — on a device, or in the builder's test preview — and when a subform row is added there. It is never reapplied to a record that already exists, so it cannot overwrite a deliberate blank.
Records you submit through the API are stored exactly as sent: POST /records and the MCP create_record tool apply no defaults, because
there is no fill session to seed. Send the value yourself if you want it.
Supported on text, number, list, and date_time. A list default
names one of that field's own option labels; an array of labels is
allowed only when allow_multiple is true. A number default has to sit
inside min and max, a text default inside max_length, and a
date_time default is an ISO 8601 string like the stored value.
default_formula
An expression worked out once, when the record is created, and then left alone.
{ "default_formula": "return fieldscroll.user.email;" }Use it to capture something about the moment of creation. A formula
cannot: it runs again every time the form opens, so a formula reading
fieldscroll.user ends up recording whoever opened the record most
recently rather than the person who filled it in.
{
"id": "original_user",
"label": "Original user",
"type": "text",
"interface": "single_line",
"hidden": true,
"default_formula": "return fieldscroll.user.email;",
"attributes": {}
}Same four types as default_value, same calling convention as formula —
fields (whatever the same pass has seeded already) and fieldscroll are
both in scope.
If the expression fails — no fill session, so no fieldscroll.user, or
simply a bug — the field starts empty. A broken default never blocks the
form. The result is not type-checked, so it should match the field's own
type; a mismatch behaves like a bad formula result.
A complete paged form
Copy-pasteable. Two pages, a lookup, a subform, and a hidden id that feeds a filter — the shape most real forms end up with.
{
"name": "Quick trial",
"form_identifier": "quick_trial",
"icon": "mdi:clipboard-text",
"display_fields": ["trial_name"],
"fields": [
{
"id": "trial_details",
"label": "Trial details",
"type": "page",
"interface": "section",
"attributes": {
"icon": "mdi:notebook",
"fields": [
{
"id": "trial_name",
"label": "Trial name",
"type": "text",
"interface": "single_line",
"required": true,
"attributes": { "max_length": 120 }
},
{
"id": "status",
"label": "Status",
"type": "list",
"interface": "dropdown",
"required": true,
"default_value": "In progress",
"attributes": {
"options": [
{ "label": "In progress" },
{ "label": "Complete" }
]
}
},
{
"id": "species_lookup",
"label": "Species",
"type": "lookup",
"interface": "dropdown",
"required": true,
"attributes": {
"lookup_source_id": "8f14e45f-ceea-467a-9c3d-4b0b1e0a1c22",
"display_fields": ["name"],
"search_fields": ["name"]
}
},
{
"id": "species_id",
"label": "Species id",
"type": "text",
"interface": "single_line",
"hidden": true,
"formula": "return fields.species_lookup ? fields.species_lookup._row_id : null;",
"attributes": {}
}
]
}
},
{
"id": "observations_page",
"label": "Observations",
"type": "page",
"interface": "section",
"attributes": {
"icon": "mdi:format-list-checks",
"fields": [
{
"id": "evaluations",
"label": "Evaluations",
"type": "subform",
"interface": "multiple",
"required": true,
"attributes": {
"fields": [
{
"id": "variety_lookup",
"label": "Variety",
"type": "lookup",
"interface": "dropdown",
"required": true,
"attributes": {
"lookup_source_id": "1c9f3a52-4d10-4f9d-9d2c-77b5b7a8f001",
"display_fields": ["name", "subname"],
"search_fields": ["name"],
"filters": [
{
"type": "dynamic",
"field": "species",
"operator": "eq",
"source_field_id": "species_id",
"scope": "top"
}
]
}
},
{
"id": "plant_count",
"label": "Plant count",
"type": "number",
"interface": "tally",
"default_value": 0,
"attributes": { "min": 0 }
}
]
}
}
]
}
}
]
}POST that body to
/api/v1/organizations/{organization_id}/forms and it will be accepted
as written.
Checking your work
- The OpenAPI spec at fieldscroll.app/api/v1/openapi.json carries the same field schema this page describes, generated from it. Point a code generator at it and your field objects are typed.
- A
400 validation_failednames every problem it found, with a path per entry indetails, so a form with a dozen mistakes reports a dozen entries rather than the first one. Past fifty, the list is capped and a final entry says how many were left out. GET /forms/{id}returns a form you already have, which is a good template — with one caveat that cost a customer a day: a sample only teaches the shapes it happens to contain. A form with no pages tells you nothing about pages. This page is the complete list.