Forms SDK

The Binding Contract

The data attribute vocabulary that connects your HTML to the form record

The binding layer discovers your inputs through four data attributes, captures answers as visitors type, and shows one step at a time. Exact IDs and answer shapes come from the get_sdk_binding_map MCP tool; never invent them.

The four attributes

data-question-id

On the input itself or on a wrapper; every input, select, and textarea inside a wrapper binds to that question. Bind every answer-recording question; the SDK warns in the console about unbound ones. A control inside a nested wrapper belongs to the inner question only.

Schedulers (CalendlyScreen, DynamicScheduler) and FileUploader need no HTML binding; their answers are written in code, and validate_form_html skips them.

data-field-name

Which key of the component's answer object this control writes. Resolution: wrapper's attribute, control's attribute, then inference ("selected" for a <select> on a Dropdown, "email" for input[type=email], "input" otherwise), and inference only works when the control type matches the component. An email input bound to a ShortInput needs an explicit data-field-name="input"; every control of a multi-field component needs its own explicit name:

<div data-question-id="q_contact" data-question-type="IdentityInfo">
  <input data-field-name="firstName" placeholder="First name" />
  <input data-field-name="lastName" placeholder="Last name" />
  <input type="email" data-field-name="workEmailAddress" placeholder="Work email" />
</div>

IdentityInfo field names are a closed set: emailAddress and workEmailAddress (not email), phoneNumber (not phone), firstName, lastName, companyName, websiteUrl, title, numberOfEmployees. Bind only the keys the binding map lists.

data-question-type

The record's component type. Inferred only for ShortInput (the default) and EmailForm (email inputs); every other type must be set explicitly or the server rejects the answer.

data-step-id

One container per record step; the SDK shows exactly one at a time by setting hidden on the rest. Single-step records may omit containers. Author every container after the first with the hidden attribute; unhidden steps flash stacked while init resolves.

Value capture

  • Text controls contribute value; checkboxes contribute checked; radios follow the choice group rules below.
  • Values already in the DOM at bind time (server-side rendering, autofill) are captured immediately.
  • Multi-field components accumulate one answer object per question, e.g. { firstName, lastName, workEmailAddress }.

Choice questions bind as a group

Five types are group-built: MultipleOptionsQuestion, MultipleChoiceQuestion, MMChoiceQuestion, GraphicOptions, MultipleChoiceSingleAnswer. Their answers are structured objects built from the container's whole radio or checkbox group:

<fieldset data-question-id="q_role" data-question-type="MultipleOptionsQuestion">
  <label><input type="radio" name="role" value="Revenue / Sales Ops" /> Revenue / Sales Ops</label>
  <label><input type="radio" name="role" value="Sales leadership" /> Sales leadership</label>
</fieldset>
  • data-question-type on the container is required; without it the server rejects the answer.
  • Each control's value must equal the record's option key exactly (validate_form_html cross-checks).
  • Never set data-field-name on the options.
  • Author controls in record order. Multi-select uses checkboxes with the same markup.

The five types have different answer shapes (only MultipleOptionsQuestion carries an option index). In bound HTML the SDK builds them; in code, copy each question's answerShape from the binding map.

MultipleChoiceQuestion and MultipleChoiceSingleAnswer are legacy types. When porting, create a MultipleOptionsQuestion with isMultiSelect true or false respectively.

The "Other" option

The group captures the option selection only. Render an extra text control outside the group and mark the answer when used:

otherInput.addEventListener("input", () => {
  const current = form.state().answers["step_role"]?.["q_role"] ?? {};
  form.setAnswer("q_role", { ...current, selectedOther: true });
});

The typed text has no slot on the choice answer; if the owner wants it stored, add a ShortInput question to the record.

A Dropdown binds a <select> whose option values are the record's option keys; the state key (selected) is inferred:

<label>
  Company size
  <select data-question-id="q_size" data-question-type="Dropdown">
    <option>1-10</option>
    <option>11-50</option>
  </select>
</label>

Busy state during submit

While submit() or disqualify() wait on the finishing write, the binding layer marks the container with the surface-busy class, sets aria-busy="true" and a progress cursor, and disables the clicked button. Style .surface-busy for a spinner; nothing else is needed.

Validating your markup

Run the validate_form_html MCP tool on the markup visitors actually receive; it checks every rule on this page against the record. For a framework page, validate the rendered HTML from your dev server or build output, never a hand transcription. Details in Building with AI.

On this page