Field reference

The shape of every field object you can put in a form's fields array, with a complete example for each type.

Updated 2026-08-16

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
  }
}
  1. Identity — id, label, helper_text
  2. Kind — type and interface
  3. Behavior — required, read_only, formula, visible_when, hidden, hide_when_empty, default_value
  4. 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

KeyRequiredTypeNotes
idyesstringLowercase 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.
labelyesstringShown above the field. On a none field it holds the heading or description text itself.
helper_textnostringShort hint below the field. Not allowed on none or page.
typeyesenumSee the type list below.
interfaceyesenumThe input variant for that type. Always required, on every type, including page.
requirednobooleanDefaults to false. Not allowed on none or page.
read_onlynobooleanDefaults to false. Not allowed on none or page.
formulanostringA JavaScript function body whose return value becomes the field's value. Not allowed on none or page.
visible_whennostringA JavaScript function body. When it returns something falsy, the field is hidden. Allowed on every type.
hiddennobooleanNever shown to the person filling the form. Not allowed on none or page.
hide_when_emptynobooleanShown only once the field has a value. Not allowed on none or page.
default_valuenovariesThe value a new record starts with. text, number, list, and date_time only.
default_formulanostringAn expression worked out once, when the record is created. Same four types.
attributesyesobjectType-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 name and fields.
  • 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:

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 writeYou want
documentmdi:file-document
geo-pinmdi:map-marker
check-circlemdi:check-circle
cameramdi:camera
tablemdi:table
badgemdi: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

AttributeTypeNotes
fieldsarrayThe fields on this page. May not contain further pages.
iconstringOptional 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

AttributeTypeNotes
max_lengthintegerMaximum characters.
maskstringRegular expression the value must match.
auto_correctbooleansingle_line and multi_line.
auto_capitalizebooleansingle_line and multi_line.
keyboard_typeenumsingle_line only: default, email_address, numeric, phone_pad, url, number_pad, decimal_pad.
secure_text_entrybooleansingle_line only. Masks what is typed.
symbologiesarrayscanning 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

AttributeTypeNotes
minnumberLowest accepted value.
maxnumberHighest accepted value.
incrementnumberStep size for tally buttons and the slider.
digitsobjectDecimal-place configuration.
formatobjectDisplay 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

AttributeTypeNotes
optionsarrayAt least one { "label": "..." }. An option may also carry visible_when.
allow_multiplebooleanWhen true the stored value is an array.
sourceobjectPopulate 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

AttributeTypeNotes
min_datestringISO 8601.
max_datestringISO 8601.
normalize_to_utcbooleanStore as UTC rather than local time.
picker_stylestringcalendar (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

AttributeTypeNotes
lookup_source_idstringThe data source to search.
display_fieldsarrayOne to ten column keys shown in the picker rows.
search_fieldsarrayColumn keys the search box matches against.
filtersarrayNarrows the rows offered. See below, and the lookups guide.
sort_byarrayUp to three { "column": "...", "direction": "asc" } entries.
auto_selectbooleanSelect 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"
}
KeyNotes
fieldThe data source column to filter on.
operatoreq, neq, gt, gte, lt, lte, contains, not_contains, in, not_in.
source_field_idThe form field to read the answer from. May be a field id, or a field id followed by one property (species.id).
scopeWhich 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

AttributeTypeNotes
fieldsarrayThe fields in each row.
min_itemsintegerFewest rows accepted.
max_itemsintegerMost rows accepted.
display_fieldsarrayField ids that summarize each row in the collapsed list.
display_styleenuminline 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

AttributeTypeNotes
capture_modeenumprompt, camera_only, or gallery_only.
max_file_sizeintegerBytes.
allowed_mime_typesarrayRestricts what can be attached.
max_duration_secondsintegerVideo 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

AttributeTypeNotes
movablebooleanLet the person adjust the pin.
min_accuracynumberRequired accuracy in metres.
timeoutintegerMilliseconds 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

AttributeTypeNotes
methodenumGET, POST, PUT, PATCH, DELETE.
urlstringMust start with https://.
headersarray{ "key": "...", "value": "..." } entries.
bodystringOnly on POST, PUT, and PATCH.
timeout_msintegerUp to 60000.
max_response_bytesintegerUp 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.

#ConditionResult
1hidden is truenever rendered, and exempt from required
2hide_when_empty is true and the field is emptynot rendered
3visible_when returns something falsynot rendered
4otherwiserendered

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.

KeyWhen it runsWho owns the value afterwards
formulaevery time the form opens, and whenever an input changesthe formula
default_formulaonce, when the record or row is createdthe record
default_valueonce, when the record or row is createdthe 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');" }
KeyTypeNotes
fieldscroll.user.idstringThe account filling the form.
fieldscroll.user.emailstringMay be null.
fieldscroll.user.rolesarrayRole names held in the collecting organization.
fieldscroll.user.is_ownerbooleanTrue 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_failed names every problem it found, with a path per entry in details, 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.