# Landbot Developers *Build with the Landbot API and SDKs.* Landbot is a chatbot platform. These docs cover everything you need to build on it programmatically: HTTP APIs for managing conversations, customers, and channels from your backend and for building bots from code, and two JavaScript SDKs for embedding and customising the chat experience in the browser. New here? Start with the quickstart, then pick the surface that fits your use case. - **[Quickstart](/guides/quickstart)** — Make your first authenticated API call in a few minutes. - **[Choosing an API](/guides/choosing-an-api)** — Platform API, APIchat, the Bots API, or the SDKs? Decide in a couple of questions. - **[Key concepts](/guides/key-concepts)** — Bots, channels, customers, agents, and fields — the shared vocabulary. - **[Authentication](/guides/authentication)** — The Platform API and APIchat use different tokens. Learn which is which. ## Build - **[API reference](/api-reference/introduction)** — Every Platform, APIchat and Bots API endpoint, with an interactive playground. - **[SDKs](/sdks/introduction)** — Embed a Landbot widget, or build a fully custom chat UI in the browser. - **[Cookbook](/guides/cookbook/overview)** — Complete, runnable recipes — custom chat UIs, AI-agent apps, channel bridges, and AI-assisted scaffolding. ## Using the Landbot app These are the **developer** docs — APIs and SDKs. For building bots in the dashboard, managing channels, your account and billing, the team inbox, and no-code integrations, head to the [**Landbot Help Center**](https://help.landbot.io/). ## For AI agents This site is built to be machine-readable. There's a docs **MCP server at `https://dev.landbot.io/mcp`** (HTTP transport, public — no token) so assistants can search and read these docs directly, plus `llms.txt` / `llms-full.txt` at the site root and a `.md` view of any page. Full orientation — endpoints, auth, and connect config — is on the [For AI agents](/guides/for-ai-agents) page. # Quickstart *Make your first Landbot API call in under a minute.* This page walks through making a single authenticated request against the Platform API and reading the response. If you haven't already, grab your agent token from [app.landbot.io/gui/settings/account](https://app.landbot.io/gui/settings/account). ## Prerequisites - A Landbot workspace — new to Landbot? The Help Center's [Getting started](https://help.landbot.io/category/ony6ww9ogi-getting-started) and [Build a bot](https://help.landbot.io/category/7n3iothsl4-build-a-bot) cover creating an account and your first bot. - Your **agent token** (in **Settings → Account** in the dashboard) ## Make the call The simplest non-trivial endpoint is `GET /channels/` — it returns the channels in your workspace, requires no path parameters, and is safe to call. ```bash curl curl https://api.landbot.io/v1/channels/ \ -H "Authorization: Token YOUR_AGENT_TOKEN" \ -H "Content-Type: application/json" ``` ```javascript Node.js const res = await fetch('https://api.landbot.io/v1/channels/', { headers: { 'Authorization': 'Token YOUR_AGENT_TOKEN', 'Content-Type': 'application/json', }, }); const data = await res.json(); console.log(data); ``` ```python Python import requests res = requests.get( 'https://api.landbot.io/v1/channels/', headers={ 'Authorization': 'Token YOUR_AGENT_TOKEN', 'Content-Type': 'application/json', }, ) print(res.json()) ``` ## Read the response A successful call returns `200 OK` with a body like this: ```json { "success": true, "total": 3, "channels": [ { "id": 8, "name": "Web chat", "type": "webchat", "active": true, "created_at": 1485789228.878516, "token": "H-8-GXQB4SYFQITIB9XZ" } ] } ``` The shape — `success`, `total`, plus a list keyed by the resource name — is consistent across every listing endpoint in the Platform API. See [Pagination](/guides/pagination) for how `total` and `offset`/`limit` work together. ## Make a write call Same auth, just a `POST` with a JSON body. To send a text message to a customer, swap `42` for a real customer ID from your `/customers/` list: ```bash curl curl https://api.landbot.io/v1/customers/42/send_text/ \ -X POST \ -H "Authorization: Token YOUR_AGENT_TOKEN" \ -H "Content-Type: application/json" \ -d '{"message": "Hello from the API"}' ``` ```javascript Node.js const res = await fetch('https://api.landbot.io/v1/customers/42/send_text/', { method: 'POST', headers: { 'Authorization': 'Token YOUR_AGENT_TOKEN', 'Content-Type': 'application/json', }, body: JSON.stringify({ message: 'Hello from the API' }), }); const data = await res.json(); console.log(data); ``` ```python Python import requests res = requests.post( 'https://api.landbot.io/v1/customers/42/send_text/', headers={ 'Authorization': 'Token YOUR_AGENT_TOKEN', 'Content-Type': 'application/json', }, json={'message': 'Hello from the API'}, ) print(res.json()) ``` You'll get a `200 OK` with `{"success": true}`. The same call reaches the customer on whatever channel they're on — webchat, WhatsApp, Messenger, APIchat. **One channel-specific caveat:** on WhatsApp, free-form messages like this are only delivered within 24 hours of the customer's last inbound message; outside that window you must send a pre-approved [WhatsApp template](/api-reference/platform) instead. If the call fails (`403`, `412`, `422`), the response body is `{"errors": {"field_name": ["…message…"]}}`. See [Errors](/guides/errors) for which codes are safe to retry. ## What's next - **[Authentication](/guides/authentication)** — The Platform API and APIchat use different tokens. Learn which is which. - **[API reference](/api-reference/introduction)** — Browse every endpoint with the interactive playground. - **[Rate limiting](/guides/rate-limiting)** — 10 req/sec cap, what `429` looks like, and how to back off. - **[Errors](/guides/errors)** — What each 4xx means and which are safe to retry. # Key concepts *The vocabulary used across these docs — bots, channels, customers, fields, code blocks — defined once and cross-referenced from everywhere else.* Read this once if Landbot is new to you. The same handful of terms show up across the Platform API, APIchat, Widgets SDK, and Core SDK docs, and they always mean the same thing — but they're easy to confuse the first time around (especially "Field" vs "customData" vs "Hidden Field", which are three names for largely the same primitive). ## How the pieces fit ``` ┌──────────────────────────────────────────────┐ │ YOUR WORKSPACE │ │ │ │ ┌────────┐ ┌──────────┐ │ │ │ Bot │───▶│ Channel │◀── Webhook ──▶ Your server │ │ (flow) │ │ (e.g. │ │ │ └────────┘ │ webchat) │ │ │ └──────────┘ │ │ │ │ │ ▼ │ │ ┌────────────────┐ │ │ │ Customer │ │ │ │ ── Fields ── │ │ │ │ email: … │ │ │ │ utm_source: … │ │ │ └────────────────┘ │ │ │ │ │ assigned to │ │ │ ▼ │ │ ┌────────┐ │ │ │ Agent │ (you, a human) │ │ └────────┘ │ └──────────────────────────────────────────────┘ ``` A **bot** runs in one or more **channels**. Each conversation is between a **customer** and either the bot or an **agent**. Every customer has a bag of **Fields** that the bot can read and write. A **webhook** lets your server be notified when messages move through the channel. ## Terms ### Agent A human team member in your workspace. Each agent has an **agent token** ([Authentication](/guides/authentication)) that grants the same access via the Platform API as their dashboard login. A customer can be assigned to an agent (`PUT /customers/{id}/assign/`) so that human gets the conversation instead of the bot. The agent identifier shows up on customer records as `agent_id`. ### Agent token The credential the Platform API uses. One per agent. Sent as `Authorization: Token ` against `api.landbot.io`. Find yours at [app.landbot.io/gui/settings/account](https://app.landbot.io/gui/settings/account). Distinct from the [**channel token**](#channel-token). ### Bot A conversation flow you built in the [Landbot builder](https://help.landbot.io/category/7n3iothsl4-build-a-bot) — the visual blocks, branches, and Code blocks that decide what to ask and how to respond. Bots have [two IDs](#bot-id): a numeric one the Platform API uses, and an `H-…` one the browser SDKs use. A single bot can be exposed through multiple **channels** (webchat + WhatsApp + APIchat, say) — same flow, different surfaces. In the Platform API, a customer can be assigned to a bot via `PUT /customers/{id}/assign_bot/{bot_id}/`. Two optional body parameters make this far more useful than "start a bot": - **`node`** — a reference to the block to start from, instead of the flow's `start` node. Get one by right-clicking a block in the builder and choosing **Copy reference**. - **`launch`** — `true` (the default) runs the bot immediately; `false` waits until the customer sends a message. Together they let you **jump a customer to any point in a flow at any time**, including a flow they're already in. That's the basis for handing a conversation to your own backend and resuming it later — see [Park a conversation and resume it](/guides/cookbook/park-and-resume). A bot exists in two states. The **draft** is the builder canvas, autosaved as you work; preview runs the draft. The **published** version is what live channels serve. Editing a flow changes the draft only — real conversations keep hitting the last published version until you hit **Publish**. That distinction bites when you test through the API. Because preview creates no API-addressable customer (see [Customer](#customer)), API testing has to happen on a published channel — which means you are exercising the *published* flow, not the edits you just made. If a change doesn't seem to take effect, check that you published it. Likewise, a node reference copied from a block that exists only in your draft isn't part of what live channels serve yet, so publish before pointing `assign_bot` at it. **Flows are built in the builder or through the Bots API.** The Platform API's entire relationship to a bot is *assignment* — pointing a customer at a flow, optionally at a specific node — plus the conversation that results. Creating a bot and reading or changing its blocks, branches and logic belongs to the [Bots API](/api-reference/bots/introduction), which is in alpha: it writes the same draft the builder edits, deploys it for testing and publishes it. If you'd rather not build the flow in code, the builder's AI features — [Build it for me](https://help.landbot.io/article/a6l84s29zv-build-it-for-me) and Copilot — take a natural-language description of the flow and assemble it for you. ### Bot ID An integer — e.g. `4043569` — and the only thing the Platform API means by `bot_id`. Read it out of the builder URL: ``` https://app.landbot.io/gui/bot/4043569/builder ▲ the bot_id the API wants ``` It's typed as an integer throughout: `PUT /customers/{id}/assign_bot/4043569/`. The [Bots API](/api-reference/bots/introduction) does not use this id: it identifies a bot by a uuid, which `GET /v0-alpha/bots` lists. > **Note** > The `H-XXXXXX-YYYYYYY` string in a bot's hosted URL is **not** the bot ID. It identifies the **channel** the bot is served on — see [Channel ID](#channel-id) — and the Platform API won't accept it where a `bot_id` belongs. If `assign_bot` is 404ing, that swap is the first thing to check. ### Three IDs, and which is which The identifiers get confused constantly, so: | ID | Looks like | Where you get it | Used for | | --- | --- | --- | --- | | **Customer ID** | `529021057` | the `@id` system Field, or `GET /customers/` | every `/customers/{id}/…` call | | **Bot ID** | `4043569` | the builder URL | `assign_bot/{bot_id}/` | | **Channel ID** | `3440710` | `GET /channels/` — or, on web channels, the middle segment of the `H-…` string | `/channels/{id}/message_hooks/`, and the `channel_id` on every customer | ### Channel A connection between Landbot and a messaging surface — `webchat`, `whatsapi` (WhatsApp), `facebook`, `apichat`, etc. Channels have an integer ID and a `type`. Every **customer** belongs to exactly one channel; that's how Landbot knows which surface to deliver messages on. Channels are managed via the [Channels endpoints](/api-reference/platform). Setting up a channel itself is a dashboard task — see the Help Center for [WhatsApp](https://help.landbot.io/category/lqcfhrswo8-whatsapp-mainchannel) and [API Chat](https://help.landbot.io/category/ux5s5ixba5-api-chat). #### What happens when a flow is waiting on your server If a flow hands off to your backend and waits — a Webhook block that leads nowhere while you do slow work — what the user can do to break it depends on the channel: | Channel | User types while the flow waits | | --- | --- | | **Web** (webchat) | Nothing happens. There's no persistent connection driving the flow, so it simply sits until you resume it. | | **APIchat, Messenger, WhatsApp** | The submitted message **restarts the flow from the beginning** — silently dropping the user back to the first block, mid-wait. | On anything but web, a bare wait is unsafe. Park the user in an **Ask a Question** block that loops back to itself, so any message they send is absorbed by the holding loop rather than restarting the flow, and break them out of it with `assign_bot` when your work is done. See [Park a conversation and resume it](/guides/cookbook/park-and-resume). ### Channel ID The integer identifying a **channel**. Every customer carries one as `channel_id`, and it's the path parameter for channel-scoped endpoints — `POST /channels/{channel_id}/message_hooks/` and friends. List them with `GET /channels/`. On a **web channel**, it's also the middle segment of the `H-…` string in the bot's hosted URL — the same string that appears in the [configuration URL](#configuration-url-configurl): ``` H-3440710-YQ702MGG35NCYIND ▲ the channel ID, on web channels ``` That string identifies the channel the bot is served on, not the bot itself, which is why it's no use to `assign_bot`. **Prefer `GET /channels/` anyway.** Reading the ID out of the URL only works for web channels, and it's a string format rather than a contract. The endpoint works for every channel type, hands you the `type` alongside the ID, and won't break if the hosted-URL format changes. ### Channel token The credential **APIchat** uses (not the Platform API). Distinct per APIchat channel — if you have three APIchat channels, you have three independent tokens. Sent as `Authorization: Token ` against `chat.landbot.io`. Find it in the APIchat channel's settings panel. See [Authentication](/guides/authentication). ### Code block A flow block that runs JavaScript inline in the conversation. In web channels, the code executes in the bot's render context (the iframe, in most widget formats). Inside a Code block, `this` is the widget instance — you can call `this.setCustomData(...)`, read `@{field}` interpolations, and reach the parent page via `window.fn()`. See [JavaScript execution](/sdks/widgets/advanced/javascript-execution). Not available in WhatsApp or Messenger flows — those run server-side without a JS runtime. ### Configuration URL (`configUrl`) The JSON representation of a bot, fetched at: ``` https://chats.landbot.io/u/H-XXXXXX-YYYYYYY/index.json ``` or (newer hosting) ``` https://landbot.pro/v3/H-XXXXXX-YYYYYYY/index.json ``` The `H-XXXXXX-YYYYYYY` segment identifies the **channel** the bot is served on — see [Channel ID](#channel-id) — not the bot's API `bot_id`. Replace `index.json` with `index.html` to get the rendered hosted page. The Widgets and Core SDKs both take this URL — either by passing it to the constructor (`new Landbot.Popup({ configUrl: '...' })`) or by fetching the JSON manually and handing the parsed object to `new Core(config)`. ### Conversation The message stream between a customer and whoever is currently assigned (bot or agent). **Not a first-class API resource** — there's no `/conversations/` endpoint. Every message-related API endpoint addresses the **customer**, not the conversation. A customer who's been reassigned between bots and agents has had multiple "logical" conversations, but the API surfaces them as a single message history per customer. ### `customData` An object passed at widget construction (or set later via `setCustomData(...)`) that populates **Fields** on the customer. Same primitive as a Field, just a different on-ramp. See [Setting variables](/sdks/widgets/advanced/setting-variables). ### Customer A person who has spoken with the bot through one channel. Customers have: - An integer **`id`** (unique in your workspace). - A **`channel_id`** — exactly one channel per customer. - Optionally an **`agent_id`** if assigned to a human. - A bag of **Fields** (`custom_fields` in API responses). - Meta state: `archived`, `unread`, `last_message`, etc. Customers are managed via the [Customers endpoints](/api-reference/platform). **Preview and test sessions don't create customers.** A customer record exists only for a conversation on a *published* channel. When you run a bot from the builder's preview or **Test this AI agent** mode, the flow itself executes normally — logic evaluates, Webhook blocks fire, integrations call out — but no API-addressable customer is created. The direction that breaks is *inbound*: with no `customer_id` in existence, nothing outside can reach into that conversation, so `send_text`, `assign_bot`, field writes and the rest have nothing to target. Test anything that calls **into** Landbot against a published channel. ### Field (Landbot Field) A typed variable stored on a **customer**. Field types: `string`, `integer`, `float`, `boolean`, `date` (ISO string), `datetime` (Unix timestamp), and `object`. `object` holds arbitrary JSON — either an object or an array — and round-trips as real structure rather than as an escaped string. It's the type the builder canvas presents as **array**/**list**. Reach for it whenever a Field holds anything that isn't a scalar. Five ways to set a Field: 1. **Inside the flow**, with a Set-Variable block. 2. **From a Code block**, with `this.setCustomData({...})`. 3. **From the parent page** (Widgets SDK), with `myLandbot.setCustomData({...})` or passing `customData` at construction. 4. **From a URL query parameter** ([Hidden Field](#hidden-fields)). 5. **From the Platform API**, with `POST/PUT/DELETE /customers/{id}/fields/{name}/` — see [Creating vs updating a Field](#creating-vs-updating-a-field-over-the-api), because neither verb is an upsert. Read from the flow with `@{field_name}` interpolation in Send Message blocks; read from a Code block with the same syntax (literal substitution before the JS runs). #### The system Field that holds the customer ID Every conversation carries a system Field, **`@id`**, holding that customer's `customer_id` — the same integer the Platform API addresses in `/customers/{customer_id}/…`. That's what lets a flow tell your backend who it's talking about: interpolate `@id` into a Webhook block's body and your server receives the identifier it needs to call back in. ```json { "customer_id": "@id", "email": "@email" } ``` Without it, a Webhook block's request is anonymous and there's no way to answer it. See [Park a conversation and resume it](/guides/cookbook/park-and-resume). `@id` is numeric, so it arrives as a JSON **number** even if you quote it in the body template — `"customer_id": "@id"` is delivered as `"customer_id": 529094641`. That's [type-aware interpolation](#field-type-decides-the-shape-of-interpolated-json) doing its job, but it catches out receivers that expect a string. #### Creating vs updating a Field over the API **Neither verb is an upsert.** `POST` creates and refuses to overwrite; `PUT` updates and refuses to create: | Verb | Field exists | Field doesn't exist | | --- | --- | --- | | `POST` | `412` — `{"errors": {"field_name": ["The field already exists"]}}` | `200` — creates it | | `PUT` | `200` — updates the value, and may also change the `type` | `404` — **served as an HTML page, not JSON** | | `GET` | `200` | `404` — HTML page | | `DELETE` | `204` | `404` — HTML page | A `PUT` returning `404` therefore means *this Field was never created*, not *this endpoint is broken*. Because that `404` arrives as HTML with `Content-Type: */*`, a client that calls `.json()` on it gets a parse error instead of a useful message — which makes the endpoint look far more broken than it is. See [Errors](/guides/errors). To set a Field without knowing whether it exists, try `POST` first and fall back to `PUT` on `412`: ```js async function setField(customerId, name, payload) { const url = `https://api.landbot.io/v1/customers/${customerId}/fields/${name}/`; const opts = { headers: { Authorization: `Token ${TOKEN}`, "Content-Type": "application/json" }, body: JSON.stringify(payload), }; const created = await fetch(url, { method: "POST", ...opts }); if (created.status !== 412) return created; // created, or a real failure return fetch(url, { method: "PUT", ...opts }); // already existed — update instead } await setField(529021057, "preferences", { type: "object", value: { plan: "pro" }, extra: {} }); ``` `PUT` can also migrate a Field's type in place — a `string` Field carrying escaped JSON can be `PUT` as `"type": "object"` and will round-trip as structure from then on. #### Field type decides the shape of interpolated JSON When a Webhook block interpolates a Field into a JSON request body, **the Field's declared type determines whether the value arrives as a string or as structure.** The same value, `{"a":1}`, stored in two Fields of different types and sent in one body: ```json { "from_a_string_field": "{\"a\":1}", "from_an_object_field": {"a": 1} } ``` The string Field arrives as a JSON **string containing JSON** — your receiver must parse it a second time to reach `a`. The object Field arrives as a real nested object. Neither case is double-encoding: the request body itself is always a well-formed JSON object with `Content-Type: application/json`. Only the *value* is escaped, and only when the Field holding it is typed as text. If structured data is arriving at your server as an escaped string, the fix is on the sending side — store it in an object Field — not a second `JSON.parse()` on the receiving side. So the fix for an escaped-string value is to store it in an `object` Field rather than a text one — in the builder canvas that's the **array**/**list** type, and over the Platform API it's `"type": "object"`. > **Warning** > Unrecognised type strings are **not rejected**. `POST`ing `"type": "array"`, `"list"`, or `"json"` returns `200`, but the value is silently coerced and stored as a `string` — `[1,2,3]` comes back as `"[1, 2, 3]"`, and `{"a":1}` as `"{'a': 1}"`, which isn't even valid JSON. The only type that preserves structure is `object`. Read the `type` in the response to confirm what was actually stored. ### Hidden Fields A declarative way to capture URL query parameters into named Fields. Declared in the bot's **Settings → Hidden Fields** panel. When a user lands on the bot URL with `?name=Jane&utm_source=newsletter`, those values populate Fields named `name` and `utm_source` before the first flow block runs. See [Setting variables → From URL query params](/sdks/widgets/advanced/setting-variables#from-url-query-params-hidden-fields). Iframe-embedded widgets don't inherit the parent page's query params — that's the most common gotcha. ### Message hook (Webhook) A URL Landbot POSTs to when messages move through a **channel**. Registered via `POST /channels/{channel_id}/message_hooks/` on the Platform API. The payload shape is documented at [Webhook payload](/api-reference/message-hooks/webhook-payload). Optionally signed with a shared `token` Landbot echoes in the `Authorization` header so your endpoint can verify the request. Distinct from APIchat's webhook, which delivers from-bot messages to your middleware in a slightly different shape — see the [APIchat webhook payload](/api-reference/apichat/webhooks/payload). ### Question block (Ask a Question) A flow block that pauses and waits for user input. Comes in flavours: text input, buttons (with optional payloads), buttons-multiple, rating, date, file upload, etc. Each Question block can save the answer to a Field by name. The most common building block when you need data from the user. ### `samurai` field The discriminator that distinguishes bot messages from user messages on the receive side. Bot messages carry `samurai` (usually a small negative integer identifying the bot or persona); user messages don't. See [Messages → bot vs user messages](/sdks/core/api/messages). ### Send Message block A flow block where the bot says something — pre-written text (with `@{field}` interpolation), an image, a card, a media block. No pause for user input — execution flows straight through. The other half of the most common pair: Send Message → Question → Send Message → Question. ### Webhook See [Message hook](#message-hook-webhook). # Choosing an API *Five surfaces — Platform API, APIchat, Bots API, Widgets SDK, Core SDK. Pick the right one for the job before you write code.* Landbot exposes five developer surfaces. Most integrations only use one. Picking the right one first will save you re-architecting later. ## At a glance | Surface | Runs in | Talks to | Auth | Token type | |------------------------|--------------------------|-------------------------------------------|--------------------------|---------------| | **Platform API** | Your server | Workspace control plane (customers, channels, hooks, messages out) | `api.landbot.io/v1/` | Agent token | | **APIchat** | Your middleware (server) | A specific APIchat channel (push messages in from external platforms) | `chat.landbot.io/v1/` | Channel token | | **Bots API** (alpha) | Your server | The bots themselves (create, edit, test, publish a flow) | `api.landbot.io/v0-alpha/` | Agent token | | **Widgets SDK** | The end user's browser | A bot — drops a chat into a website | Public `configUrl` | None | | **Core SDK** | The end user's browser | A bot — same engine the Widgets SDK uses, but you build the UI | Public `configUrl` | None | The first three are **server-to-server APIs**. The last two are **browser SDKs** that ride directly on a public bot config — no token, no proxy, runs entirely on the client. ## Decide in one question each ### Platform API — "Do I need to do something **about** customers from a server?" Use it when your server needs to: - Send a message into an existing conversation (`send_text`, `send_image`, `send_template`, etc.) - List, search, or paginate customers - Read or write custom Fields on a customer - Assign or unassign a customer to/from an agent or bot - Archive, block, opt-out - Register webhooks to be notified about incoming messages Don't use it when you want to *show* the bot to a user — that's the SDKs' job. → [Platform API reference](/api-reference/introduction) ### APIchat — "Am I bridging Landbot to a messaging platform Landbot doesn't natively support?" Use it when you're building a middleware that: - Receives messages from an external platform (Telegram, Slack, SMS, custom mobile app, IVR, …) - Pushes those messages into Landbot via `POST /v1/send/{customer_token}/` - Listens to Landbot's webhook for bot replies - Forwards bot replies back out to the external platform APIchat is what lets you ship a Landbot-powered bot on a surface Landbot's UI doesn't natively render on. It's *not* a general-purpose admin API — for that, use the Platform API. → [APIchat reference](/api-reference/apichat/introduction) ### Bots API — "Do I need to create or change a bot's flow from code?" Use it when your server needs to: - Create a bot, list yours, change its settings, move it to the trash - Read or rewrite its diagram, or add, change and remove single blocks - Deploy the draft to a test channel and try it - Publish a version and read the published history It pairs with the AI Agents API (the agent an `ai_agent` block runs) and the Channels API (where the bot is reached and what it looks like). It describes bots; it does not run conversations, so talking to a published bot is still the SDKs', APIchat's or the Platform API's job. The Bots API is in alpha: its contract can change without notice. → [Bots API overview](/api-reference/bots/introduction) ### Widgets SDK — "Do I want to embed Landbot in a website with minimal code?" Use it when you want one of these six embed formats: - **Fullpage** — full-screen takeover - **Popup** — launcher bubble + popup chat - **Container** — embedded into a `
` of your choice - **Livechat** — launcher + expandable chat panel - **Native** — renders directly into the parent DOM (no iframe) - **ContainerPopup** — embed-into-container that opens as a popup The SDK gives you a constructor (`new Landbot.Popup({ configUrl: '...' })`), parent-page communication via `customData` and `window.fn()` bridges, and lifecycle methods (`open`, `close`, `setCustomData`, `destroy`). You don't write UI code. → [Widgets SDK overview](/sdks/widgets/introduction) ### Core SDK — "Do I want to build my own UI on top of the bot?" Use it when none of the Widgets formats fits — you're rendering chat into a Three.js scene, a terminal, a voice-only interface, a non-rectangular layout, a mobile-app webview with native gestures, anything custom. You get: - A `Core` instance constructed from a `configUrl` - Subscriptions to three pipelines (`$sequence`, `$readableSequence`, `$typingSequence`) for delivering messages with different pacing - `sendMessage` for the user's side - An events bus for custom signals - Pagination methods for backlog scroll You write the UI — every pixel. → [Core SDK overview](/sdks/core/introduction) ## Common combinations Real integrations often blend two surfaces. The common pairings: ### Widgets SDK + Platform API The most common combination. Embed a widget in your marketing site, and on the server side use the Platform API to: - Send proactive messages to customers (re-engagement, follow-ups) - Sync collected Fields into your CRM - Listen for new-message webhooks and trigger workflows Each side has its own auth: the widget uses a public `configUrl` (no token, ships in client code), the Platform API uses your agent token (server-only, never expose it). ### Core SDK + Platform API Same as above but with a custom-UI front end. The split is identical — public config URL on the client, agent token on the server. ### APIchat + Platform API Building a bridge to a non-native platform but you also want server-side automation? Both APIs work on the same workspace. APIchat handles the message flow on the bridged channel; the Platform API handles workspace-wide operations (customers across all channels, hooks, agent assignment). Different tokens, different hosts — see [Authentication](/guides/authentication). ### Widgets SDK alone You're embedding a bot on a marketing site and don't need server-side workflows. Just the SDK, the bot's config URL, and a ` ``` 3. 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](https://help.landbot.io/category/8hvvv26zm0-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. ```html My custom Landbot chat
``` ## 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](/sdks/core/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](/sdks/core/api/messages). **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](/sdks/core/api/messages). **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 `fetch`es 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()`](/sdks/core/api/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()`](/sdks/core/api/core#getlastmessages) 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 `dialog`s with `extra.buttons.type === "rating"`; questions carry input modality on `extra.textarea.type`). The [Messages reference](/sdks/core/api/messages) 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`](/sdks/core/api/pipelines#advanced-typing-sequence), rating widgets, file uploads. For a fully art-directed take on the same SDK, see the [cinematic demo](/sdks/core/example) — a scene-per-message landing page built on exactly this foundation. **Reference:** [Install](/sdks/core/install) · [Landbot.Core](/sdks/core/api/core) · [Pipelines](/sdks/core/api/pipelines) · [Messages](/sdks/core/api/messages) # 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](/sdks/core/introduction) 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](/guides/cookbook/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](#alternative-signals) for the Code-block and inline-`\nprompt`. | Legacy. Parse `setCustomData(...)` from `msg.message`; strip the tag. | > **Warning** > For the Code-block and inline-` ``` For each widget format, swap `Landbot.Fullpage` for the constructor of the format you want. The constructor blocks below show what changes per format — the loader stays the same. > **Tip** > The `setTimeout(..., 500)` after `load` is defensive — the SDK assigns `window.Landbot` during module evaluation, and 500 ms is enough to safely handle browsers where module-load timing varies. The official Landbot examples ship with it; we recommend keeping it. ## Constructors per format ### Fullpage ```javascript new Landbot.Fullpage({ configUrl: '...', }); ``` ### Popup A launcher bubble in the bottom-right corner; opens a fullpage chat when clicked. ```javascript new Landbot.Popup({ configUrl: '...', }); ``` ### Embed (Container) Renders into an existing element on your page. Pass the element's selector as `container`. ```html
``` ```javascript new Landbot.Container({ container: '#myLandbot', configUrl: '...', }); ``` ### Livechat ```javascript new Landbot.Livechat({ configUrl: '...', }); ``` ### Native Renders into the parent DOM directly (no iframe). ```javascript new Landbot.Native({ configUrl: '...', }); ``` ### ContainerPopup ```javascript new Landbot.ContainerPopup({ configUrl: '...', }); ``` ## See it in action [**bidirectional-examples.vercel.app**](https://bidirectional-examples.vercel.app/) — a live demo site showing four real-world patterns: pre-filled queries from a search box, multiple CTA buttons opening the bot with different questions, per-product context via `customData`, and a bot that fills a form on the page. The HTML and JavaScript of each example are good starting points for your own integration. # Landbot.Widget *Base class shared by every Landbot widget. Constructor, properties, methods, and events.* Base class. All landbot widgets inherit from `Landbot.Widget`. ## Constructor ### Landbot.Widget **Landbot.Widget(config)** Parameters: - config: *Object* This is not the common way to create a Landbot Widget, but all widgets extend from this class, so they can call its methods. ## Properties ### config Type: *Object* The config object is initially passed with `configUrl` and populated remotely to get the full customization from the builder platform. ### core Type: *Landbot.Core* All landbot widgets use a Landbot.Core instance internally doing all the "backend stuff". ### window Type: *Object* The window object associated to the widget frame. If not embedded, `window.top === this.window`. ### document Type: *Object* The document object associated to the widget frame. window and document are exposed here to get access to the DOM and other features in landbot javascript execution. ## Methods ### sendMessage **sendMessage(message)** Parameters: - message: *Object* Returns: - *Promise* Sends a message to the bot from the client side. See [Messages](/sdks/core/introduction) for more info about message structures. ### setCustomData **setCustomData(data)** Parameters: - data: *Object* Set variables. Go to [Setting variables](/sdks/widgets/advanced/setting-variables) to see an example. ### onLoad **onLoad(func)** Parameters: - func: *Function* Pass a function to be executed just after the widget initialization and rendering. ```plaintext var myLandbot = new Landbot.Native({ ... }); myLandbot.onLoad(function() { console.log('Load complete!'); }) ``` ### destroy **destroy()** Destroy widget instance and remove all of its DOM elements and listeners. ## Events ### "load" Listening to the "load" event has the same effect as using the "onLoad" method from the widget instance. ### "new_message" New message received event. It's a [core event](/sdks/core/introduction) ### "proactive_open" Show proactive event. ### "proactive_close" Closed proactive event. ### "send_message" Sending message event. ### "widget_open" Widget opening (livechat, popup) event. ### "widget_close" Widget closing (livechat, popup) event. ## Examples of widget events ```javascript var myLandbot = new Landbot.Livechat({ ... }); myLandbot.core.events.on('load', function() { console.log('Load complete!'); }); myLandbot.core.events.on('widget_open', function() { console.log('Landbot livechat was opened!'); }); myLandbot.core.events.on('proactive_close', function() { console.log('Livechat proactive message was closed!'); }); ``` # Landbot.Fullpage *Full-page Landbot widget.* This class extends `Landbot.Widget`. See [Widget](/sdks/widgets/api/widget) methods and properties. ## Constructor ### Landbot.Fullpage **Landbot.Fullpage(config)** Parameters: - config: *Object* Create a Landbot Fullpage widget with given config. The main difference between Native and Fullpage is the last one generates an iframe to create a different scope than the container site. # Landbot.Popup *Popup widget anchored to a launcher bubble.* This class extends `Landbot.Widget`. See [Widget](/sdks/widgets/api/widget) methods and properties. ## Constructor ### Landbot.Popup **Landbot.Popup(config)** Parameters: - config: *Object* Create a Landbot Popup widget with given config. ## Methods ### open **open()** Open popup widget. ### close **close()** Close popup widget. ### showProactive **showProactive(messages)** Parameters: - messages: *Array* Show a series of proactive messages (array of [Messages](/sdks/core/introduction)). ```plaintext myLandbot.showProactive([ { type: 'text', samurai: -1, message: 'Hi there!', } ]); ``` ### hideProactive **hideProactive()** Close proactive messages. A common pattern: dismiss the proactive automatically after a few seconds. ```javascript myLandbot.onLoad(function () { setTimeout(function () { myLandbot.hideProactive(); }, 15000); }); ``` ## Customising the launcher and proactive UI The launcher bubble is the parent-page wrapper `.LandbotPopup` (with `.LandbotPopup.is-open` while the chat is visible). The proactive teaser is `.LivechatProactive` inside the iframe. Both are documented with the rest of the launcher classes in [Styling → Widget launcher and bubble](/sdks/widgets/advanced/styling#widget-launcher-and-bubble). Because the launcher sits *outside* the iframe and the chat content sits *inside*, real launcher tweaks usually inject rules into both stylesheets. See [Embedding → Injecting CSS at runtime](/sdks/widgets/advanced/embedding#injecting-css-at-runtime) for the two-scope pattern. # Landbot.Container *Embedded widget wrapped inside an element of your page (the Embed format).* This class extends `Landbot.Widget`. See [Widget](/sdks/widgets/api/widget) methods and properties. ## Constructor ### Landbot.Container **Landbot.Container(config)** Parameters: - config: *Object* Create a Landbot Container widget with given config. `container` property is required to let the widget know where to render its content. ```plaintext var myLandbot = new Landbot.Container({ configUrl: '...', container: '#landbot-container', }) ``` ## Properties ### container Type: *HTMLElement* Container element. # Landbot.Livechat *Livechat widget with a launcher and proactive messages.* This class extends `Landbot.Widget`. See [Widget](/sdks/widgets/api/widget) methods and properties. ## Constructor ### Landbot.Livechat **Landbot.Livechat(config)** Parameters: - config: *Object* Create a Landbot Livechat widget with given config. ## Methods ### open **open()** Open livechat widget. ### close **close()** Close livechat widget. ### showProactive **showProactive(messages)** Parameters: - messages: *Array* Show a series of proactive messages (array of [Messages](/sdks/core/introduction)). ```plaintext myLandbot.showProactive([ { type: 'text', samurai: -1, message: 'Hi there!', } ]); ``` ### hideProactive **hideProactive()** Close proactive messages. A common pattern: dismiss the proactive automatically after a few seconds. ```javascript myLandbot.onLoad(function () { setTimeout(function () { myLandbot.hideProactive(); }, 15000); }); ``` ## Customising the launcher and proactive UI The launcher bubble is the parent-page wrapper `.LandbotLivechat` (with `.LandbotLivechat.is-open` while the chat is visible). The proactive teaser is `.LivechatProactive` inside the iframe. Both are documented with the rest of the launcher classes in [Styling → Widget launcher and bubble](/sdks/widgets/advanced/styling#widget-launcher-and-bubble). Because the launcher sits *outside* the iframe and the chat content sits *inside*, real launcher tweaks usually inject rules into both stylesheets. See [Embedding → Injecting CSS at runtime](/sdks/widgets/advanced/embedding#injecting-css-at-runtime) for the two-scope pattern. ### Hiding the launcher on specific pages ```javascript var landbotScope = this; this.onLoad(function () { if (window.location.href.includes('/pricing')) { landbotScope.document.styleSheets[0].insertRule( '.LandbotLivechat { display: none !important }' ); } }); ``` # Landbot.Native *Native widget that renders directly in the parent DOM (no iframe).* This class extends `Landbot.Widget`. See [Widget](/sdks/widgets/api/widget) methods and properties. ## Constructor ### Landbot.Native **Landbot.Native(config)** Parameters: - config: *Object* Create a Landbot Native widget with given config. This widget doesn't generate an iframe, so it gets the best performance in terms of UX. # Landbot.ContainerPopup *Embedded widget that opens as a popup inside a container.* This class extends `Landbot.Widget`. See [Widget](/sdks/widgets/api/widget) methods and properties. ## Constructor ### Landbot.ContainerPopup **Landbot.ContainerPopup(config)** Parameters: - config: *Object* Create a Landbot ContainerPopup widget with given config. `container` property is required to let the widget know where to render its content. ContainerPopup behaves as a Container widget while navigating in desktop but it behaves as Fullpage when using on mobile. ```plaintext var myLandbot = new Landbot.ContainerPopup({ configUrl: '...', container: '#landbot-container', }) ``` ## Properties ### container Type: *HTMLElement* Container element. # Embedding in your app *Patterns for hosting a Landbot widget inside a real website or single-page app — controlling it from the parent page, sharing data, injecting CSS at runtime, and tearing down cleanly.* The widget snippets in the [intro](/sdks/widgets/introduction) get you on screen. This page covers what comes after: making the widget cooperate with the page that hosts it. ## Two scopes Almost every parent-page integration touches two scopes: - **`siteScope`** — the parent window. This is where `myLandbot` (the value returned by the widget constructor) lives. - **`landbotScope`** — `this` inside a bot Custom JS block. This is the widget instance, equivalent to `myLandbot` but from inside the iframe. `landbotScope.window` and `landbotScope.document` reach the widget frame's DOM. The parent's `window` and `document` reach the host page's DOM. Many launcher/proactive customisations need rules inserted in **both** stylesheets to take effect across the bubble and the open chat. ## Mounting after page load The canonical embed snippet on the [Widgets overview](/sdks/widgets/introduction#embed-snippet) loads the SDK as an ES module from a dynamically-inserted ` ``` See [JavaScript execution](/sdks/widgets/advanced/javascript-execution) for the read/write patterns in detail. ## Controlling the widget from parent JS The instance you got back from the constructor exposes the full [`Landbot.Widget`](/sdks/widgets/api/widget) API plus widget-type-specific methods: ```javascript var myLandbot = new Landbot.Popup({ configUrl: '...' }); document.getElementById('chat-with-us').addEventListener('click', function () { myLandbot.open(); }); document.getElementById('book-a-demo').addEventListener('click', function () { myLandbot.sendMessage({ type: 'button', message: 'Book a demo', payload: '#demo', }); }); ``` For event-driven UIs (analytics, telemetry, state machines), subscribe to widget events: ```javascript myLandbot.core.events.on('new_message', function (msg) { analytics.track('bot_message_received', { type: msg.type }); }); myLandbot.core.events.on('widget_open', function () { analytics.track('bot_opened'); }); ``` The full event list is on [`Landbot.Widget`](/sdks/widgets/api/widget#events). ## Injecting CSS at runtime Four patterns, ordered from simplest to most robust. ### From inside the bot, into a known stylesheet If you've put **any** custom CSS in the builder's **Design → Custom Code** panel, Landbot creates a `' ); ``` ### From the parent page, via a bot-side bridge To push a complete CSS string from the parent at load time, expose a hook from the bot's Custom JS: ```html ``` Then call it from the parent page after the widget is ready: ```javascript let newStyle = `.input-button{background-color:yellow}`; myLandbot.onLoad(function () { window.changeCSS(newStyle); }); ``` `landbotScope.config.style` accepts a full CSS string and overrides whatever was set in **Design → Custom CSS**. ### From both scopes (launcher + chat) Launcher-bubble rules (`.LivechatLauncher`, `.LandbotLivechat`, `.LandbotPopup`) need to be inserted in the **parent page's** stylesheet, because the launcher is rendered outside the iframe. Chat-content rules need to be inserted in the **bot's** stylesheet. Many real customisations do both: ```javascript // In the bot's Custom JS var siteScope = window; var landbotScope = this; // Parent: widen the launcher container var sheet = siteScope.document.createElement("style"); siteScope.document.head.appendChild(sheet); sheet.sheet.insertRule('.LandbotLivechat { min-width: 200px !important }', 0); // Bot: widen the launcher inside the iframe this.onLoad(function () { var landbotSheet = landbotScope.document.createElement("style"); landbotScope.document.head.appendChild(landbotSheet); landbotSheet.sheet.insertRule('.LivechatLauncher { width: 200px !important }', 0); }); ``` > **Note** > `document.styleSheets[0]` is brittle — if the host page has its own stylesheet, index `0` won't be Landbot's. Create your own ` ``` The same `.is-open` pattern works for `.LandbotPopup`. ## Mobile-only rules Use a media query inside the inserted rule, or detect with `window.innerWidth`: ```javascript var landbotScope = this; this.onLoad(function () { landbotScope.document.styleSheets[0].insertRule( '@media only screen and (max-device-width: 767.9px) { .LivechatLauncher{display:none} }' ); }); ``` ## SPA and framework hosts Single-page apps make the embed snippet's "script-at-the-bottom-of-``" advice fall apart. Two things to remember: 1. **Mount once, after the route is rendered.** The widget creates an iframe and attaches to `body`; calling the constructor in a component's mount lifecycle (React `useEffect`, Vue `onMounted`, etc.) is fine, but make sure you only do it once per page session — multiple instances will stack iframes. 2. **Tear down before navigation.** Call `myLandbot.destroy()` in the cleanup phase. It removes the iframe and all DOM listeners — without it, navigating away and back creates duplicate widgets. A complete `Landbot.Container` component in React, mounting into a `useRef`-managed div and destroying on unmount: ```jsx import React, { useRef, useEffect } from 'react'; export default function MyLandbot({ url }) { const containerRef = useRef(null); useEffect(() => { const _landbot = new Landbot.Container({ container: containerRef.current, configUrl: url, }); return () => _landbot.destroy(); }, [url, containerRef]); return
; } ``` The `[url, containerRef]` dependency array re-creates the widget when the bot URL changes — handy for route-aware embeds. For other widget formats (Popup, Livechat, …) that mount themselves into `body` rather than a target element, drop the `containerRef` and the wrapping `
`; the cleanup pattern is otherwise identical. If you need a stable singleton across route changes, hold the instance at app scope and *don't* destroy on unmount — re-use it on the next render. ## Modals and overlays inside the bot You can drop a `
` (and CSS) into the [Custom JS panel](/sdks/widgets/advanced/javascript-execution) to render a modal that opens in response to a Code block — useful for booking widgets, third-party iframes, image lightboxes. Register the open/close handlers on `_landbot.window` in `onLoad`, then trigger them from a flow Code block as `this.window.openModal()`. > **Warning** > The modal renders **inside the bot's iframe**. It can only overlay the bot's footprint, not the host page. This is fine for **Fullpage**, **Popup** (while open), and **Livechat** in expanded mode — the bot covers enough of the viewport for the modal to feel native. It is **not** suitable for a small **Container** or collapsed Livechat, where the modal would be clipped to a tiny region. > > If you need a modal that overlays the host page itself, render it on the parent page and have the bot call a `window.openModal()` function you define there (see [JavaScript execution → Calling parent-page functions](/sdks/widgets/advanced/javascript-execution#calling-parent-page-functions-from-the-bot)). ### Worked example: Google Calendar booking modal Open a Google Calendar appointment-scheduling page in a modal when the user taps a CTA. We skip Google's official button (it injects itself next to its own ` ``` **3. In the flow** ``` [Message] "Want to grab a slot on my calendar?" ↓ [Buttons] • "Book an appointment" ← the CTA ↓ [Code] this.window.openBookingModal() ↓ [Message] "Take your time — I'll be here when you're done. 👋" ``` The Code block opens the modal as soon as the user taps the button. The flow continues to the next message immediately, so the post-booking message is already sitting in the chat when the user closes the modal. > **Note** > **Things to watch for** > > - **Iframe-blocking risk.** The `?gv=true` URL is what Google's own scheduling button loads in its popup, so it iframes fine today. If Google ever sets `X-Frame-Options: DENY` on it, the fallback is to drop the original `` + ` ``` ```javascript // Later, in any Code block in the flow this.setCustomData({ price_label: this.window.formatPrice("@{price_cents}"), welcome: this.window.greet("@{name}"), }); ``` This is the canonical place for shared utilities (date formatting, validation, opening custom UI elements) — define them once in the Custom JS panel; call them from any Code block as the flow progresses. > **Note** > `_landbot.window` here is the **bot iframe's** window, not the host page's. Functions registered on it are visible to other Code blocks inside the same bot session, but not to the parent page. To call functions on the host page, use the parent-page `window` — see [Calling parent-page functions from the bot](#calling-parent-page-functions-from-the-bot) below. ## Reading Landbot Fields Inside a Code block, use `@{fieldname}` to interpolate the current value of a Landbot Field. Landbot substitutes the literal value before the JavaScript runs. ```javascript var welcome = "@{welcome}"; console.log(welcome); ``` For values that may contain line breaks, quotes, or boolean literals, use template literals to avoid escaping problems: ```javascript var message = `@{message}`; ``` > **Note** > `@{field}` is replaced when the Code block executes, which only happens after the user has provided input to the bot. If you need a value available at the very first block, set it at construction with `customData`. See [Setting variables](/sdks/widgets/advanced/setting-variables). ## Writing Landbot Fields From a Code block, write fields with `this.setCustomData`: ```javascript this.setCustomData({ total: (Math.round("@{value}" * 100) / 100).toFixed(2), }); ``` From the parent page, write fields with `myLandbot.setCustomData` — same shape: ```javascript myLandbot.setCustomData({ email: 'visitor@example.com', plan: 'pro', }); ``` For the full lifecycle and naming rules, see [Setting variables](/sdks/widgets/advanced/setting-variables). ## Calling parent-page functions from the bot `window` inside a Code block resolves to the page that hosts the widget. Any function you defined globally on the parent page is callable from the bot: ```html ``` ```javascript // in a Code block, after the user picks a colour window.changeBackgroundColor("@{color}"); ``` This is the pattern for any UI side-effect that lives outside the widget: open a modal, navigate, log analytics, fire a tracking event. ## Calling widget methods from the parent page The widget instance you got back from the constructor exposes the full [`Landbot.Widget`](/sdks/widgets/api/widget) API plus widget-type-specific methods. Use it to drive the widget from your page's UI: ```javascript var myLandbot = new Landbot.Popup({ configUrl: '...' }); document.getElementById('chat-with-us').addEventListener('click', function () { myLandbot.open(); }); document.getElementById('start-flow').addEventListener('click', function () { myLandbot.sendMessage({ type: 'button', message: 'Get a quote', payload: '#quote', }); }); ``` Common methods: [`open`](/sdks/widgets/api/popup#open) and [`close`](/sdks/widgets/api/popup#close) on Popup/Livechat, [`sendMessage`](/sdks/widgets/api/widget#sendmessage) on every widget, [`destroy`](/sdks/widgets/api/widget#destroy) to tear down. ## Recipe: bot fills a webpage form A common pattern: the bot interviews the user and pushes the collected answers into a form on the host page. Three pieces wire together. **1. The form on the parent page.** ```html
``` **2. A global function on the parent page** that knows how to populate the form. The bot will call this. ```javascript window.fillContactForm = function (data) { if (data.name) document.getElementById('fullName').value = data.name; if (data.email) document.getElementById('email').value = data.email; if (data.phone) document.getElementById('phone').value = data.phone; if (data.company) document.getElementById('company').value = data.company; if (data.message) document.getElementById('message').value = data.message; }; var myLandbot = new Landbot.Container({ container: '#myLandbot', configUrl: '...', }); ``` **3. A Code block in the bot's flow** that calls the function with the field values it collected. ```javascript window.fillContactForm({ name: "@{name}", email: "@{email}", phone: "@{phone}", company: "@{company}", message: "@{message}", }); ``` As soon as the Code block runs, the form fields on the host page are populated. The user reviews and submits the form themselves — the bot doesn't submit it. ## Limitations - **Web only.** WhatsApp and Messenger flows can't run JavaScript Code blocks. - **No ` ``` > **Warning** > **`Core` is a named export, not a default export.** Use `import { Core } from "…"` (curly braces). The CJS-style `import LandbotCore from "…"` you'd write in a bundler doesn't survive the trip through browser-native ESM: > > - `import Core from "https://esm.sh/@landbot/core"` → `SyntaxError: ... does not provide an export named 'default'`. > - `import Core from "https://cdn.jsdelivr.net/npm/@landbot/core/+esm"` → loads, then throws `TypeError: Core is not a constructor` because the default export is `null`. > > `import { Core } from "https://cdn.jsdelivr.net/npm/@landbot/core/+esm"` is the form that works. ### Picking a CDN Both jsDelivr (`+esm`) and unpkg (`?module`) build ESM bundles. We use jsDelivr in the [cinematic](/sdks/core/example), departures, and composer demos. # Landbot.Core *The core class — constructor, properties, and methods for sending and reading messages.* The `Landbot.Core` class is the entry point to the SDK. Construct it with a bot config object (see [Install](/sdks/core/install) for how to fetch one), then use the instance to send and read messages. ## Constructor ### Landbot.Core **Landbot.Core(config)** Parameters: - config: *Object* Create a Landbot.Core instance. ## Properties ### config Type: *Object* The config object the instance was created with. ### events Type: *Object* Event emitter instance. See [Events](/sdks/core/api/events). ### pipelines Type: *Object* Pipelines instance. See [Pipelines](/sdks/core/api/pipelines). ## Methods ### init **init()** Returns: - *Promise* Core initialization. Returns a Promise that resolves with `data.messages` — the conversation history for this bot session (welcome bundle + any previous conversation if persistent storage is enabled). ```javascript core.init().then(function (data) { console.log(data.messages); }); ``` > **Note** > **`data.messages` can be empty.** On bots without persistent storage, or on fresh sessions where the welcome arrives via pipelines rather than as a history bundle, `data.messages` resolves to `[]`. Don't rely on `init()` alone to seed your UI — subscribe to [`$readableSequence`](/sdks/core/api/pipelines) (or another pipeline) **before** calling `init()` so the welcome messages are caught by your subscription regardless of whether the Promise carries them. ### destroy **destroy()** Destroy the `Landbot.Core` instance and all its listeners. Call this when the host UI is being torn down to free resources. > **Warning** > **Don't reuse a destroyed instance.** Post-destroy calls don't throw — `sendMessage` silently resolves and `pipelines.*.subscribe` accepts the call without delivering any messages. Looks like it worked, but nothing happens. To start a new conversation, construct a fresh `Core` with the same config. > > A fresh `Core` does **not** continue the previous conversation — it re-runs the welcome flow with a new message key. Sessions are not sticky across instances. ### sendMessage **sendMessage(message)** Parameters: - message: *Object* Returns: - *Promise* Sends a message to the bot from the client side. See [Messages](/sdks/core/api/messages) for the supported message structures. ```javascript core.sendMessage({ message: 'Hey bro!' }); ``` ### getLastMessages **getLastMessages(amount)** Parameters: - amount: *Number* Returns: - *Promise* Return the last `amount` messages stored in the database. ```javascript core .getLastMessages(25) .then(function (data) { console.log(data); }); ``` > **Warning** > **Requires persistent storage on the bot.** If the bot was configured with storage disabled (the `storage_off` flag in its config), both `getLastMessages` and `getMoreMessages` throw: > > ``` > TypeError: Cannot read properties of null (reading '_query') > ``` > > The error is the SDK reaching for a Firestore reference that was never initialised. Before relying on these methods, verify the fetched config has `storage_off: false` — or wrap calls in `try { … } catch { … }` and degrade gracefully when storage isn't available. ### getMoreMessages **getMoreMessages(amount)** Parameters: - amount: *Number* Returns: - *Promise* Return `amount` messages stored in the database. An internal increasing counter lets this function be called repeatedly to retrieve older and older messages — a typical infinite-scroll back-pagination pattern. ```javascript core .getMoreMessages(25) .then(function (data) { console.log(data); }); ``` Same persistent-storage requirement applies as [`getLastMessages`](#getlastmessages) — see that warning. # Messages *Wire format, message shapes, and the four kinds of speakers — bot, user, system, agent — flowing through the Core SDK's pipelines.* A message is **received** via a [pipeline subscription](/sdks/core/api/pipelines) or a `new_message` [event listener](/sdks/core/api/events). A message is **sent** via the [`sendMessage`](/sdks/core/api/core#sendmessage) method. The receive-side wire format is richer than what hand-written examples typically suggest. This page documents what's actually on the wire, derived from empirical traces across multiple bot configurations. ## Wire format Every received message carries some subset of the fields below. Most consumers only need a handful; the rest are documented so you know what you're seeing in raw logs. ### Identity and origin | Field | Description | | --- | --- | | `key`, `uuid`, `id` | Three identifier fields, typically holding the same string. Use `key` as canonical. | | `type` | The message rendering shape: `"text"`, `"dialog"`, `"image"`, `"iframe"`, `"multi_question"`, `"hidden"`, or `"event"`. | | `author_type` | The sender role: `"bot"`, `"user"`, `"sys"`, or `"agent"`. The cleanest discriminator across all message types. | | `author_uuid` | UUID string identifying the specific bot persona, agent, or user. Persistent across messages from the same speaker. | | `samurai` | Numeric form of the author role — see [Bot vs user vs system vs agent](#bot-vs-user-vs-system-vs-agent) for the sign semantics. | | `chat` | Integer ID of the conversation. | | `channel` | Integer ID of the channel. | ### Body and rendering | Field | Description | | --- | --- | | `message` | User-visible body string for `text` messages. For `dialog`, the prompt + button labels concatenated (prefer `title` for the bare prompt). For `image` / `iframe`, empty string or the URL. For `event`, can be a *number* (e.g. an agent ID). | | `title` | Prompt text on `dialog` messages. Cleaner extraction than `message` for dialogs. | | `rich_text` | HTML version of the message body. **Unreliable** — can be a `

`-wrapped HTML string, an unwrapped string, `null`, or absent entirely. Treat `message` as canonical; use `rich_text` only when populated and you specifically need formatted output. | | `url` | Media URL on `image` and `iframe` messages. | | `buttons`, `payloads`, `urls`, `attachments` | Parallel arrays on `dialog` messages: button labels, routing payloads (e.g. `"$0"`), optional URL targets, and optional attachments. Empty positions appear as `null`. | ### Embedded ` Got it. What type of property? ``` **These scripts execute when the message is rendered by the Landbot widget. They do *not* execute when the same message is delivered through `$sequence` or `$readableSequence` to a Core SDK consumer.** The SDK gives you the raw message — rendering, including script execution, is the consumer's responsibility. For cross-consumer state changes (widget + Core SDK + webhook all seeing the same source of truth), use a real **Code block** in the flow instead — its `this.setCustomData(...)` call runs server-side during flow execution, and the resulting field propagates through the customer object on every subsequent message: ```text [Code block] this.setCustomData({ ui: "property-type" }) ↓ [Buttons block] Got it. What type of property? ``` If you're consuming a bot that was authored with inline `