Build a custom chat UI with @landbot/core

Run a Landbot bot headless in the browser and render the conversation in your own DOM — no widget, no build step — using the Core SDK.

The Landbot widget is a drop-in chat bubble. The Core SDK (@landbot/core) is the opposite: it gives you the raw conversation — messages in, messages out — and lets you decide how it looks. Same bot, same flow you built in the dashboard, but rendered in your own markup.

By the end you'll have a single HTML file that runs one of your bots as a normal chat transcript, with text replies and tappable buttons — no framework and no build step. It's the foundation for any bespoke front end: a conversational landing page, a chat panel that matches your design system, an in-app assistant.

Reach for Core when you want full control of the markup and behaviour. Reach for the widget when you just want a working chat bubble with minimal code.

Get your config URL

The Core SDK starts from a bot config URL — the same configUrl the widget uses. The easiest place to find it is your bot's Share / Embed snippet:

  1. Open your bot in the Landbot dashboard → ShareEmbed.
  2. Copy the snippet. It looks like this:
<script type="module" src="https://cdn.landbot.io/landbot-3/landbot-3.0.0.mjs"></script>
<script type="module">
  var myLandbot = new Landbot.Livechat({
    configUrl: 'https://storage.googleapis.com/landbot.pro/v3/H-3448554-ONJS9LMP6EK79HU6/index.json',
  });
</script>
  1. Grab the configUrl value — the …/index.json URL. That string is all you need; ignore the rest of the snippet (it loads the widget, which you're replacing).

Note The config URL host varies — you'll see storage.googleapis.com/landbot.pro/v3/… or chats.landbot.io/u/… depending on the bot. Either is fine; what matters is that it ends in /index.json, which is the parsed bot config (not the rendered index.html page). For more on the Share / Embed panel, see the Help Center's Share & embed section.

How it works

Four ideas carry the whole integration. Keep them in mind while reading the code:

  • Subscribe before you call init(). The bot's welcome messages fire during init. $readableSequence is forgiving, but make the subscription first and you never miss them.
  • One stream, both speakers. Every pipeline emits bot and user messages. Discriminate with author_type ("bot", "user", "agent", "sys") — it's the recommended field: semantic, and no undefined-checking.
  • Dedupe on msg.key. Pipelines can replay a message (init replay, hot reload). Keep a Set of seen keys and bail early on repeats.
  • Let the message tell you what input to show. A dialog means render buttons; otherwise show a text box — unless extra.hide_textbox is set, which means "no free-text here."

The complete page

Save this as index.html, drop in your config URL, and open it in a browser (or serve it — a file:// page can't always fetch). That's the whole app.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>My custom Landbot chat</title>
    <style>
      body { font-family: system-ui, sans-serif; margin: 0; background: #f5f5f7; }
      #chat { max-width: 480px; margin: 0 auto; height: 100vh; display: flex; flex-direction: column; background: #fff; }
      #log { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 8px; }
      .bubble { max-width: 80%; padding: 10px 14px; border-radius: 16px; line-height: 1.4; }
      .bot { align-self: flex-start; background: #ececed; }
      .user { align-self: flex-end; background: #2b6cff; color: #fff; }
      #choices { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 16px 8px; }
      #choices button { padding: 8px 14px; border: 1px solid #2b6cff; background: #fff; color: #2b6cff; border-radius: 20px; cursor: pointer; }
      #composer { display: flex; padding: 12px 16px; border-top: 1px solid #e5e5e7; }
      #composer[hidden] { display: none; }
      #composer input { flex: 1; padding: 10px 14px; border: 1px solid #d0d0d5; border-radius: 20px; font-size: 15px; }
      #composer input:disabled { background: #f0f0f2; }
    </style>
  </head>
  <body>
    <main id="chat">
      <div id="log"></div>
      <div id="choices"></div>
      <form id="composer" hidden>
        <input id="input" type="text" placeholder="Type a message…" autocomplete="off" />
      </form>
    </main>

    <script type="module">
      // Core is a NAMED export from the CDN ESM build — note the curly braces.
      import { Core } from "https://cdn.jsdelivr.net/npm/@landbot/core/+esm";

      const CONFIG_URL = "https://storage.googleapis.com/landbot.pro/v3/H-3448554-ONJS9LMP6EK79HU6/index.json";

      const log = document.getElementById("log");
      const choices = document.getElementById("choices");
      const composer = document.getElementById("composer");
      const input = document.getElementById("input");

      const seen = new Set();   // dedupe by message key — pipelines can replay
      let core = null;

      // --- rendering ----------------------------------------------------
      function bubble(text, who) {
        const el = document.createElement("div");
        el.className = `bubble ${who}`;
        el.textContent = text;
        log.appendChild(el);
        log.scrollTop = log.scrollHeight;
      }

      function renderChoices(msg) {
        choices.innerHTML = "";
        msg.buttons.forEach((label, i) => {
          const btn = document.createElement("button");
          btn.type = "button";
          btn.textContent = label;
          btn.onclick = () => {
            choices.innerHTML = "";
            // payload (e.g. "$0") is what routes the bot's flow — send it back verbatim.
            core.sendMessage({ type: "button", message: label, payload: (msg.payloads && msg.payloads[i]) || `$${i}` });
          };
          choices.appendChild(btn);
        });
      }

      // What can the user do after this bot message?
      function setAffordance(msg) {
        if (msg.type === "dialog") {
          composer.hidden = true;          // buttons drive the next step
          renderChoices(msg);
        } else if (msg.extra && msg.extra.hide_textbox) {
          composer.hidden = true;          // no free-text expected here
        } else {
          choices.innerHTML = "";
          composer.hidden = false;
          input.disabled = false;
          input.focus();
        }
      }

      // --- the message stream (bot AND user flow through here) ----------
      function onMessage(msg) {
        if (seen.has(msg.key)) return;
        seen.add(msg.key);

        if (msg.author_type === "user") { bubble(msg.message, "user"); return; } // your own reply, echoed back
        if (msg.author_type === "sys") return;                                   // system events (e.g. agent assigned)

        // bot or human agent from here on
        if (msg.type === "hidden") {
          if (msg.action === "finish") {                                         // Close Chat block ended the flow
            composer.hidden = true;
            choices.innerHTML = "";
            bubble("— conversation ended —", "bot");
          }
          return;                                                                // ignore "script" + other hidden actions
        }

        if (msg.type === "text") bubble(msg.message, "bot");
        else if (msg.type === "dialog") bubble(msg.title, "bot");
        else if (msg.type === "image" || msg.type === "iframe") bubble(msg.url, "bot");
        else return;                                                             // multi_question, etc. — see the Messages reference

        setAffordance(msg);
      }

      // --- sending ------------------------------------------------------
      composer.addEventListener("submit", (e) => {
        e.preventDefault();
        const value = input.value.trim();
        if (!value) return;
        input.value = "";
        input.disabled = true;             // re-enabled when the next bot message invites input
        core.sendMessage({ message: value }); // type "text" is the default; appears via its echo above
      });

      // --- boot ---------------------------------------------------------
      (async () => {
        try {
          const config = await fetch(CONFIG_URL).then((r) => {
            if (!r.ok) throw new Error(`HTTP ${r.status} fetching bot config`);
            return r.json();
          });
          core = new Core(config);
          core.pipelines.$readableSequence.subscribe(onMessage); // SUBSCRIBE before init()
          await core.init();
        } catch (err) {
          bubble(`Couldn't start the bot: ${err.message}`, "bot");
          console.error(err);
        }
      })();
    </script>
  </body>
</html>

Walking through the key parts

Boot order (the one mistake to avoid). In the boot block we fetch the config, construct new Core(config), subscribe, then init() — in that order. The welcome messages are emitted during init(); subscribing first guarantees you catch them. $readableSequence also paces messages at a human reading speed, so bursts arrive one at a time instead of all at once.

Warning Core is a named export from the CDN build: import { Core } from "…/+esm". A default import (import Core from "…") fails — either a SyntaxError or Core is not a constructor. In a bundler (Vite/webpack) the package is instead a default export: import LandbotCore from "@landbot/core". See Install.

onMessage — one handler for both speakers. The same subscription delivers the user's own messages (echoed back a moment after sendMessage) and the bot's. We branch on author_type: render "user" messages on the right, ignore "sys" events, and treat everything else ("bot" / "agent") as content to render. This "single stream is the source of truth" model is why we don't render the user's message optimistically on send — it shows up when its echo arrives.

Note author_type is the recommended discriminator. You may see older code key off the numeric samurai field (samurai === undefined for users) — that still works, but author_type is semantic and needs no undefined check. See Messages → bot vs user vs agent.

Message types. We handle text (msg.message), dialog (prompt in msg.title, buttons in the parallel msg.buttons / msg.payloads arrays), and media (image / iframe, URL in msg.url). Other types — multi_question, event — are no-ops here; the full catalog is in the Messages reference.

Sending. A typed reply is core.sendMessage({ message }) (type "text" is the default). A button reply is core.sendMessage({ type: "button", message: label, payload }) — the payload (e.g. "$0") is the value from the dialog's payloads array and is what actually routes the bot's flow, so send it back unchanged.

Warning Bots can emit type: "hidden", action: "script" messages carrying JavaScript the bot author wrote in a Code block. Don't eval them unless you control the bot end to end — it's a code-injection vector against your page. This recipe ignores them.

Production notes

  • Serve over HTTP(S). The page fetches its config, which won't work from a bare file:// open in some browsers. Use any static server (npx serve, etc.).
  • Tear down when you're done. Call core.destroy() when unmounting the UI to free listeners. A destroyed instance can't be reused — construct a fresh Core for a new conversation.
  • History & resume. If the bot has persistent storage enabled, core.getLastMessages() / getMoreMessages() back-paginate older messages for an infinite-scroll transcript. They throw on storage-disabled bots — guard accordingly.
  • Render the rest of the type union as your bot uses it (ratings are dialogs with extra.buttons.type === "rating"; questions carry input modality on extra.textarea.type). The Messages reference documents each shape.

Next steps

Style it to match your product, then layer on whatever the bot's flow needs — a typing indicator via $typingSequence, rating widgets, file uploads. For a fully art-directed take on the same SDK, see the cinematic demo — a scene-per-message landing page built on exactly this foundation.

Reference: Install · Landbot.Core · Pipelines · Messages