Build a Form with the Forms SDK

An end-to-end walkthrough: create the form record, bind your own HTML, add a scheduler, validate, publish, and verify

This guide builds a demo-booking form as a page you host: two question steps, a branch that disqualifies small companies, a scheduler step, and a thank-you screen. The form record on Surface is the data model, your HTML is the presentation, and the SDK binds the two (overview).

Prerequisites

  • A Surface environment.
  • The SDK installed: npm install @surface-labs/forms-sdk.
  • Ideally the Surface MCP server connected to your AI assistant; without it, copy IDs from the dashboard.

Step 1: Create the form record

Ask your assistant, or call the tools yourself:

create_form with sdk: true
  name: "Demo booking"
  steps:
    1. "About you": IdentityInfo (first name, last name, work email)
    2. "Company": MultipleOptionsQuestion "How large is your team?"
       options: "1-10", "11-50", "51-200", "200+"
    3. "Book a time": CalendlyScreen (your Surface scheduler)
    4. "Thanks" (endStepKind: thank_you): Header "You're booked!"
    5. "Not a fit" (endStepKind: disqualified): Header "Thanks for your interest"
  • Give every question real question text; the responses table uses it as the column label.
  • Ending steps need at least one component; a Header is fine.
  • Link the scheduler's prefill to the identity step (linkNameToStep, linkEmailToStep).
  • Save the formId and previewToken from the reply.

The dashboard builds the same record by hand: Forms, Create New Form, Build SDK form.

Step 2: Get the binding map

get_sdk_binding_map formId: <your form>

The reply lists every step and question with questionId, componentType, fieldNames, option keys, and answerShape, plus environmentId, apiBaseUrl, and a ready-to-paste init snippet. Never retype IDs from memory.

Step 3: Write the page

<div id="demo-form" hidden>
  <!-- Step 1: identity -->
  <section data-step-id="STEP_ABOUT">
    <div data-question-id="Q_IDENTITY" 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>
    <button type="button" class="surface-next-button">Continue</button>
  </section>

  <!-- Step 2: company size, with a branch -->
  <section data-step-id="STEP_COMPANY" hidden>
    <fieldset data-question-id="Q_SIZE" data-question-type="MultipleOptionsQuestion">
      <label><input type="radio" name="size" value="1-10" /> 1-10</label>
      <label><input type="radio" name="size" value="11-50" /> 11-50</label>
      <label><input type="radio" name="size" value="51-200" /> 51-200</label>
      <label><input type="radio" name="size" value="200+" /> 200+</label>
    </fieldset>
    <button type="button" id="company-continue">Continue</button>
  </section>

  <!-- Step 3: scheduler -->
  <section data-step-id="STEP_BOOK" hidden>
    <div id="booking"></div>
  </section>

  <!-- Endings: revealed by the SDK, no buttons needed -->
  <section data-step-id="STEP_THANKS" hidden><h2>You're booked!</h2></section>
  <section data-step-id="STEP_NOT_FIT" hidden><h2>Thanks for your interest</h2></section>
</div>

Replace every STEP_* and Q_* placeholder with real IDs from the binding map, and keep the radio value attributes byte-identical to the record's option keys. Note: every container after the first is hidden; the choice question binds as a group (no data-field-name on options); the scheduler question has no binding; both endings have containers.

Step 4: Boot, branch, and mount the scheduler

import { SurfaceForms, mountScheduler } from "@surface-labs/forms-sdk";

const form = await SurfaceForms.init({
  environmentId: "ENV_ID",
  formId: "FORM_ID",
  apiBaseUrl: "API_BASE_URL", // from get_sdk_binding_map
  container: document.querySelector("#demo-form"),
  emailValidation: true, // annotate deliverability; never blocks
  preview: true, // remove before deploying
  previewToken: "PREVIEW_TOKEN",
});
document.querySelector("#demo-form").hidden = false;

// The branch: small teams get the disqualified ending.
document.querySelector("#company-continue").addEventListener("click", () => {
  const answer = form.state().answers["STEP_COMPANY"]?.["Q_SIZE"];
  const picked = answer?.choices?.find((c) => c.value)?.key;
  if (!picked) return; // require an answer
  if (picked === "1-10") form.disqualify();
  else form.next();
});

// Mount once, into the still-hidden step container.
mountScheduler({
  form,
  container: document.querySelector("#booking"),
  onEvent(e) {
    if (e.type === "booking_confirmed") form.submit();
  },
});

The branch is code-driven, so add data-surface-nav="js" to the <body>. Everything else (answer capture, partial saves, booking persistence, identity, journey tracking, busy state) is the SDK's job.

Step 5: Validate the markup

validate_form_html
  formId: <your form>
  html: <the rendered page>
  containerSelector: "#demo-form"

Fetch the page exactly as a visitor receives it; for a framework page, curl the dev server output. Fix every blocker and re-run until clean.

Step 6: Walk it in preview

With preview: true and the previewToken, the page serves the draft record and writes nothing: walk both branches, book a synthetic meeting, confirm both endings render.

Step 7: Publish and deploy

publish_form formId: <your form>

Remove preview: true and previewToken, then deploy.

Step 8: Verify end to end

Submit one real test response on the deployed page, then read it back:

list_responses formId: <your form>
get_response responseId: <the new one>

Confirm every answer landed, the choice answer carries the structured list, the booking has eventScheduled: true, and the metadata shows surfaceTagStatus: "sdk".

Where to go next

On this page