Build a conversational app: custom widgets per bot step

Turn a Landbot bot into an interactive app — render a bespoke widget (slider, picker, card grid) for each step of the flow, driven entirely by the conversation, using the Core SDK.

A chat doesn't have to be a stream of text bubbles. With the Core SDK you can treat the conversation as a controller and the page as a canvas: the bot decides what step you're on, and your app renders a purpose-built widget for it — a budget slider, a date picker, a card grid, a signature pad. The bot owns the flow and the data; you own the interface.

This recipe builds the smallest complete version of that pattern: a bot step that asks for a budget, rendered as a slider instead of a text box. Once the dispatch loop is in place, every other widget is just more of the same.

If you haven't wired up Core before, start with Build a custom chat UI — this recipe assumes that foundation (subscribe → init() → render → sendMessage) and adds the one new idea: per-step widget dispatch.

The one new idea: a dispatch signal

The bot needs to tell your app "render the budget widget now." The Core SDK does not expose the bot's custom fields as structured data on the message stream — there's no customer.ui to read. So the signal has to travel inside a message. The cleanest way, and the one this recipe uses, is a plain-text marker the bot writes into its own prompt:

[[ui:budget]] What's your budget range?

Your app reads each bot message, pulls the budget out of [[ui:…]], renders the matching widget, and strips the marker before showing the text. No JavaScript in the bot, nothing executable on the wire — just a convention you control.

Note Verified against a live bot: marker text in a prompt arrives on the Core stream verbatim in the message body ([[ui:budget]] …) — whether you type it literally or interpolate a field (@{ui_signal}), and a Set-a-field block (if you use one) emits nothing of its own on the stream. Put the marker on the input block that waits for the answer (see below). See Alternatives for the Code-block and inline-<script> variants.

Bot side (no code)

One Landbot mechanic shapes the whole design: a Send Message block doesn't pause the flow. The bot runs straight through consecutive blocks and only stops to wait for the user at an input block — Buttons, Ask a Question, or Multi-Question. So the marker belongs on the input block's own prompt (the block that waits), not on a standalone Send Message the bot would just run past.

For the budget step it's a single block: an Ask a Question that saves the answer to a budget field, with the marker typed at the start of the question text.

[[ui:budget]] What's your budget range?
Landbot

That's the whole bot side — the literal [[ui:budget]] rides along in the message body and your app reads it off. Repeat per step with a different value (location, property-type, results, …) on each input block's prompt. Plain Send Message blocks in between are fine — they arrive as ordinary messages and the bot keeps going; only a marked input block triggers a widget.

Tip Need the marker value to be dynamic (computed per user) instead of fixed? Set a field first (e.g. ui_signal) and interpolate it: [[ui:@{ui_signal}]] …. The field picker inserts @{ui_signal}, which Landbot resolves server-side before sending. (That interpolation path is the one we verified in the lab; a static literal arrives by the same message-body delivery.)

A complete minimal flow

Here's a whole bot you can build in a couple of minutes and point the app at. Three input-bearing steps and a close — enough to see the dispatcher switch between a default widget and a custom one:

  1. Buttons block — the opener (it doubles as the greeting; see the note below). Question text: [[ui:property-type]] Welcome! What type of property are you after? — buttons Apartment / House / Studio. Save answer to: property_type. App renders the default widget (buttons), since property-type has no entry in WIDGETS.
  2. Ask a Question block. Question text: [[ui:budget]] What's your budget range? Save answer to: budget. App renders the custom budget slider.
  3. Send a Message (Thanks — we'll be in touch! 👋) → Close chat. Close chat emits the hidden / finish message your app shows as "All done".

Note The first block of a web bot must be an input block (Buttons / Ask a Question) — Landbot won't let a flow open on a silent block. That's why step 1 carries the greeting in its prompt rather than using a separate Send Message welcome. Any step whose ui value isn't in your WIDGETS map falls back to the default widget, so you can grow the flow before you've built every widget.

For a full-scale version of this pattern — eight steps, a bespoke component each — see the property-search demo and its block-by-block build guide (realtor/BOT_SETUP.md in that project).

App side

Because the bot can fire several messages before it pauses, the app has two surfaces: a transcript (#log) where every message appears as it arrives, and a canvas (#canvas) that holds the active step's widget. Informational messages just stream into the transcript; the widget is rendered — and the input lives — only when the bot actually stops to wait. That's the same split the property-search demo uses (chat sidebar + canvas).

// Pull the dispatch marker out of a bot message body. Returns e.g. "budget".
const UI_MARKER = /^\s*\[\[ui:([^\]]+)\]\]\s*/;
const readUi  = (text) => (("" + (text || "")).match(UI_MARKER) || [])[1] || null;
const stripUi = (text) => ("" + (text || "")).replace(UI_MARKER, "");

// The bot WAITS only at an input block: Buttons (dialog), a Question
// (carries extra.textarea), or a Form (multi_question). Everything else is
// informational — the bot keeps running, so don't show an input for it.
const isWaitingStep = (msg) =>
  msg.type === "dialog" || msg.type === "multi_question" || !!msg.extra?.textarea;

// One renderer per step, mounted into the canvas. Each collects input and
// sends it back to advance the flow.
const WIDGETS = {
  budget: (msg, mount) => {
    mount.innerHTML = "";
    const slider = document.createElement("input");
    Object.assign(slider, { type: "range", min: "500", max: "5000", step: "100", value: "1500" });
    const out = document.createElement("output");
    out.textContent = `€${slider.value}`;
    slider.oninput = () => (out.textContent = `€${slider.value}`);
    const send = document.createElement("button");
    send.textContent = "Set budget";
    send.onclick = () => { send.disabled = true; core.sendMessage({ message: slider.value }); };
    mount.append(slider, out, send);
  },

  // Default affordance for a waiting step with no bespoke widget:
  // buttons for a dialog, a text box for a question.
  default: (msg, mount) => {
    mount.innerHTML = "";
    if (msg.type === "dialog" && msg.buttons?.length) {
      msg.buttons.forEach((label, i) => {
        const b = document.createElement("button");
        b.textContent = label;
        b.onclick = () => core.sendMessage({ type: "button", message: label, payload: msg.payloads?.[i] ?? `$${i}` });
        mount.append(b);
      });
    } else {
      const input = document.createElement("input");
      input.placeholder = "Type a reply…";
      input.onkeydown = (e) => {
        if (e.key === "Enter" && input.value.trim()) { input.disabled = true; core.sendMessage({ message: input.value.trim() }); }
      };
      mount.append(input);
      input.focus();
    }
  },
};

Wire it into the message handler — same author_type / dedupe logic as the custom-chat-ui recipe, but now we always add a transcript bubble and render a widget only at a waiting step:

const log = document.getElementById("log");        // transcript (every message)
const canvas = document.getElementById("canvas");  // active step widget
const seen = new Set();
let currentUi = "default";
let core = null;

const bubble = (text, who) => {
  const el = document.createElement("div");
  el.className = `bubble ${who}`;
  el.textContent = stripUi(text);   // never let the marker reach the screen
  log.append(el);
  log.scrollTop = log.scrollHeight;
};

function onMessage(msg) {
  if (seen.has(msg.key)) return;
  seen.add(msg.key);
  if (msg.author_type === "user") { bubble(msg.message, "user"); return; }
  if (msg.author_type === "sys") return;             // system events
  if (msg.type === "hidden") {
    if (msg.action === "finish") canvas.textContent = "All done 🎉";
    return;                                           // never eval other hidden/script messages
  }

  const ui = readUi(msg.message);
  if (ui) currentUi = ui;            // a marker selects the active step
  bubble(msg.message, "bot");        // show what the bot said, marker stripped

  // Render the widget ONLY when the bot is actually waiting for input.
  if (isWaitingStep(msg)) (WIDGETS[currentUi] || WIDGETS.default)(msg, canvas);
}

(async () => {
  const config = await fetch(CONFIG_URL).then((r) => r.json());
  core = new Core(config);
  core.pipelines.$readableSequence.subscribe(onMessage); // before init()
  await core.init();
})();

That's the whole pattern. To add a calendar picker, a card grid, or a signature pad, you write one more entry in WIDGETS and one more ui_signal value in the bot. The dispatch loop never changes.

Alternative signals

The marker is the cleanest option, but two others work if your bot is already built differently. All three carry the value inside the message — Core never exposes it as a structured field.

Approach How it arrives on the stream When to use
Interpolation marker (this recipe) One text message: [[ui:budget]] prompt. Set-a-field block is silent on the stream. Default. No bot-side code, nothing executable.
Code block A separate type: "hidden", action: "script" message with the call in msg.script (this.setCustomData({ ui: "budget" })), then the clean prompt. Your bot already uses Code blocks. Parse msg.script; correlate with the next prompt.
Inline <script> One text message whose body is <script>this.setCustomData({ui:"budget"})</script>\nprompt. Legacy. Parse setCustomData(...) from msg.message; strip the tag.

Warning For the Code-block and inline-<script> variants, the message carries a setCustomData(...) string. Parse the value out with a regex — never eval it. Executing message-borne script is a code-injection vector against your page. The interpolation marker avoids this entirely: it's plain text, never code.

Production notes

  • The bot owns the data. When the user moves a slider and you sendMessage(value), the bot saves it to its field and advances. Your app doesn't need to persist answers — read them back from later prompts (interpolate them into a summary step) or track them locally for display.
  • Default gracefully. Always have a default widget for steps with no marker (and for the welcome message, which carries none). An unknown ui value should fall back, not break.
  • Strip before you render. The marker must never reach the screen — stripUi() every body you display.
  • Pair it with the basics from Build a custom chat UI: subscribe before init(), discriminate on author_type, dedupe on msg.key.

Next steps

This is exactly the architecture behind the property-search assistant demo — eight steps, each a bespoke React component (location search, budget slider, results grid, contact form) dispatched off the bot's signal. Build your widget set, style it to your product, and the chat becomes a genuine application rather than a form.

Reference: Build a custom chat UI · Core overview · Pipelines · Messages