Build a Form with the Forms SDK

Create the form record, bind your own HTML, add a scheduler, validate, publish, and verify

You will build a demo-booking form on 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 package 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. 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, and both endings have their own container.

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

Ready to Get Started?

See it on your own site, or open the app and build the first form.

Need Help?

Setup problems, blocked domains and CRM push failures live in the Help Center.

Feature Requests?

Tell us what is missing. We would rather hear it than guess.

On this page