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:
- Open your bot in the Landbot dashboard → Share → Embed.
- 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>
- Grab the
configUrlvalue — the…/index.jsonURL. 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/…orchats.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 renderedindex.htmlpage). 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.$readableSequenceis 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 noundefined-checking. - Dedupe on
msg.key. Pipelines can replay a message (init replay, hot reload). Keep aSetof seen keys and bail early on repeats. - Let the message tell you what input to show. A
dialogmeans render buttons; otherwise show a text box — unlessextra.hide_textboxis 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
Coreis a named export from the CDN build:import { Core } from "…/+esm". A default import (import Core from "…") fails — either aSyntaxErrororCore 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_typeis the recommended discriminator. You may see older code key off the numericsamuraifield (samurai === undefinedfor users) — that still works, butauthor_typeis semantic and needs noundefinedcheck. 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'tevalthem 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 barefile://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 freshCorefor 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 withextra.buttons.type === "rating"; questions carry input modality onextra.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