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
Headeris fine. - Link the scheduler's prefill to the identity step (
linkNameToStep,linkEmailToStep). - Save the
formIdandpreviewTokenfrom 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".