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 or a new_message event listener. A message is sent via the 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 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 <p>-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 <script> tags don't auto-execute

A bot's message text can contain HTML, including <script> tags. The most common pattern is a one-line dispatcher set on a custom field from within the message body:

<script>this.setCustomData({ui:"property-type"});</script>
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:

[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 <script> tags and you can't change the flow, you can parse the intent out of the message body client-side:

function extractUiFromScript(text) {
  return text?.match(/setCustomData\s*\(\s*\{\s*ui\s*:\s*['"]([^'"]+)['"]/)?.[1];
}

core.pipelines.$readableSequence.subscribe((msg) => {
  const ui = extractUiFromScript(msg.message ?? msg.rich_text);
  if (ui) applyUiIntent(ui);
});

This shim makes widget-authored flows work in an SDK consumer, but it's a compatibility layer — prefer Code blocks for new builds.

State and ordering

Field Description
timestamp Unix epoch milliseconds with sub-millisecond fractional precision. Welcome messages may have timestamp: 0.
seq Sequence number within a single bot speech turn — increments across all bot messages from the moment the bot starts talking until the user speaks again, then resets. null on agent messages and events.
read, readed_at Read-receipt state (spelling is intentional).
ui_key Usually null. Internal SDK use.

The extra family

The extra object carries flow-side metadata. The fields you'll actually use:

Field Description
extra.id The originating block's identifier in the bot flow, formatted as "{blockId}" or "{blockId}_{emissionIndex}". See Multi-emission blocks.
extra.welcome true on every message from the bot's welcome block.
extra.hide_textbox true when the user shouldn't see a free-text input after this message — either because the next affordance is a question with its own widget, or because the conversation is ending.
extra.textarea Input modality config on Question blocks. See Question modalities.
extra.buttons Button-block config on dialog messages — including the discriminator that turns a "plain dialog" into a rating widget.

Bot vs user vs system vs agent

The same pipeline delivers messages from four kinds of speakers. The cleanest discriminator is author_type:

switch (msg.author_type) {
  case "bot":   /* the bot persona */    break;
  case "user":  /* the end user */       break;
  case "sys":   /* system event */       break;
  case "agent": /* a human team member */ break;
}

The samurai field encodes the same information numerically:

author_type samurai
"bot" negative integer (persona ID)
"user" undefined (field absent entirely)
"sys" 0
"agent" positive integer (the agent's user ID)

Either field works as a discriminator. author_type is recommended — it's semantic, doesn't require checking for undefined, and survives any future expansion of speaker categories.

Received message types

text

The most common type. Plain text from the bot's Send Message blocks, and the wire shape of any single-input Question block (date, file, location, …) regardless of input modality.

{
  "type": "text",
  "message": "Select a date, please",
  "rich_text": "Select a date, please",        // may be null
  "samurai": -1,
  "author_type": "bot",
  "extra": {
    "id": "welcome",
    "welcome": true,
    "textarea": {                              // present when it's a Question block
      "type": "date",
      "field": "date",
      "dateOptions": {}
    }
  }
}

When the bot block is a Question, extra.textarea carries the input modality config — text, date, file, or location. See Question modalities.

dialog

A prompt with button options. The prompt lives in title; message concatenates the prompt with button labels (useful for accessibility/logging, not for rendering).

{
  "type": "dialog",
  "title": "Pick a brand colour.",
  "message": "Pick a brand colour.\n\nPink\nPurple\nEmerald",
  "buttons":  ["Pink", "Purple", "Emerald"],
  "payloads": ["$0", "$1", "$2"],              // sent back as `payload` on the button reply
  "urls":     [null, null, null],              // populated for URL-type buttons
  "attachments": null,
  "samurai": -3973119,
  "author_type": "bot",
  "extra": {
    "id": "NGFNRlhBW_0",
    "buttons": { /* discriminator config — see Rating below */ }
  }
}

Rating questions are dialogs in disguise. A Rating block sends a dialog message with identical button labels (the star emoji repeated) and an extra.buttons config that signals the renderer to draw a rating widget:

{
  "type": "dialog",
  "title": "Create an evaluation",
  "buttons": ["⭐️", "⭐️", "⭐️"],
  "payloads": ["$0", "$1", "$2"],
  "extra": {
    "id": "NCLVF_z-G",
    "buttons": {
      "type": "rating",            // ← discriminator
      "ratingType": "star-3",      // also "star-5", etc.
      "cumulative": true
    }
  }
}

Check msg.extra.buttons.type === "rating" to decide whether to render plain buttons or a star widget.

image

Media messages — images, GIFs, and (per receive-side normalisation) file uploads from users.

{
  "type": "image",
  "url": "https://.../image.png",
  "message": "",                              // empty string, not absent
  "samurai": -3973119,
  "author_type": "bot",
  "extra": { "id": "Np6XCv-oX_0", "hide_textbox": true }
  // no `rich_text`, no `title`
}

message is "", not absent or null. rich_text and title are absent. Extract via msg.url.

When a user uploads a file through a file-upload Question, the upload echoes back on the pipeline as type: "image" — regardless of whether the file was actually an image. See the asymmetry note under Sending message types.

iframe

URLs that should be embedded as iframes — currently the bot's YouTube media block emits this type.

{
  "type": "iframe",
  "url":     "https://www.youtube.com/watch?v=...",
  "message": "https://www.youtube.com/watch?v=...",   // duplicated from url
  "samurai": -3973119,
  "author_type": "bot",
  "extra": { "id": "N6OweM1fC_0", "hide_textbox": true }
}

The URL appears in both url and message. No rich_text, no title.

multi_question

The Form block — a multi-input form rendered as a single message.

{
  "type": "multi_question",
  "message":   "*Form title*\nAnswer the following questions",   // markdown
  "rich_text": "<h4>Form title</h4><p>Answer the following questions</p>",
  "text":      "*Form title*\nAnswer the following questions",   // duplicate of message
  "rows": [
    {
      "disposition": "1",
      "inputs": [
        {
          "type": "text",
          "label": "label",
          "name": "label",
          "help": "help",
          "required": false,
          "extra": {
            "textarea": {
              "type": "text", "field": "text", "size": "short",
              "pattern": "", "errorMessage": "Please enter a valid value"
            }
          }
        }
        // …more inputs
      ]
    }
    // …more rows
  ],
  "send_label": "Send",
  "skip_label": "Skip",
  "extra": {
    "id": "welcome",
    "errorMessage":     "This field is required",
    "markRequired":     true,
    "responsiveLayout": false
  }
}

Each entry in rows[].inputs[] carries its own extra.textarea config — the same modality structure used by single-question blocks (see Question modalities). send_label and skip_label are the form's action button copy.

event

System signals — currently emitted on agent assignment / unassignment.

{
  "type": "event",
  "action": "assign",            // or "unassign"
  "agent_id": 40684,
  "message": 40684,              // ⚠ a NUMBER, not a string — the agent_id
  "samurai": 0,
  "author_type": "sys",
  "author_uuid": null,
  "extra": {},
  "seq": null
}

Warning msg.message on event messages is a number, not a string. Code that does msg.title || msg.message to extract a body returns the bare agent ID for events. Special-case type === "event" and handle action instead.

After an assign event, subsequent messages from the assigned agent arrive with author_type: "agent" — see Agent messages.

hidden

Messages the SDK delivers but that aren't meant to render in the chat UI. The top-level action field tells you what they're for.

{
  "type": "hidden",
  "action": "finish",            // also "script", potentially others
  "extra": {},
  "samurai": -3973119,
  "author_type": "bot"
}

Two action values are confirmed in the wild.

action: "finish"

Emitted by the Close Chat block at the end of a flow. Use as the "conversation is done" signal — disable input, show a completion screen, fire analytics.

core.pipelines.$readableSequence.subscribe((msg) => {
  if (msg.type === "hidden" && msg.action === "finish") {
    showCompletionScreen();
  }
});

action: "script"

Emitted by Code blocks in the flow. Carries JavaScript source the bot author wrote in the Builder — under msg.script (and duplicated into msg.message).

{
  "type": "hidden",
  "action": "script",
  "script":  "console.log('hello world');",
  "message": "console.log('hello world');",
  "samurai": -3973119,
  "author_type": "bot"
}

In the Widgets SDK context (where the bot runs in an iframe), Landbot's renderer auto-evaluates these scripts. In a Core SDK integration you decide what to do with them. Safe defaults are log-only during development, ignore in production.

Warning Don't eval script messages unless you trust the bot author. Arbitrary JavaScript coming through your pipeline is a code-injection vector against your host page. Only execute Code block scripts if you control the bot end-to-end and audit every Code block it contains.

Note Wire-format change from older docs. The original Storybook examples showed hidden messages as { type: 'hidden', data: { action: '...', body: '...' } }. The current wire format is flatter — action and script at the top level, no data envelope. If you find code or examples using the data.action shape, treat them as out of date.

Question modalities

msg.type describes the rendering shape, not the question modality. A "Type your date" question and a "Type your name" question both arrive as type: "text". The actual input modality is on extra.textarea.type:

extra.textarea.type Question modality Additional fields
"text" Free-text input size ("short" or "long"), pattern, errorMessage
"date" Date picker dateOptions
"file" File upload multiple (boolean)
"location" Address / location (sparse — usually only type)

A renderer choosing which input widget to show should branch on extra.textarea.type, not on the top-level msg.type:

function renderInput(msg) {
  const modality = msg.extra?.textarea?.type ?? "text";
  switch (modality) {
    case "text":     return renderTextarea(msg);
    case "date":     return renderDatePicker(msg);
    case "file":     return renderFilePicker(msg);
    case "location": return renderLocationInput(msg);
    default:         return renderTextarea(msg);
  }
}

Rating is the exception: it arrives as type: "dialog" and the discriminator lives on extra.buttons.type === "rating" (see dialog).

Multi-emission blocks

A single block in the Landbot builder can emit multiple wire messages. Each emission carries extra.id formatted as "{blockId}_{emissionIndex}", with the emission index starting at 0:

extra.id = "welcome_0"     ← welcome block, 1st emission
extra.id = "welcome_1"     ← welcome block, 2nd emission
extra.id = "welcome_2"     ← welcome block, 3rd emission

Different emissions from the same block can have different types. A Buttons block, for example, emits a text "body" followed by a dialog with the buttons — both share the same {blockId} prefix:

extra.id = "NwwuRqbr0_0"   type = "text"      "Text body"
extra.id = "NwwuRqbr0_1"   type = "dialog"    "Click one" + buttons

Within the same bot speech turn, seq increments across all emissions globally (not per block). Use extra.id to identify which block produced a message; use seq to order multiple bot messages within a single turn when timestamps tie.

State-machine dispatch

extra.id is the recommended anchor for state machines that need to react to specific blocks. Counting user messages works in linear flows but breaks the moment a bot branches, loops, or adds optional blocks. Dispatching on extra.id survives any flow restructure that preserves block identifiers.

core.pipelines.$readableSequence.subscribe((msg) => {
  if (msg.author_type !== "bot") return;
  const blockId = (msg.extra?.id ?? "").replace(/_\d+$/, "");   // strip emission index
  switch (blockId) {
    case "welcome":   handleWelcome(msg);    break;
    case "NCLVF_z-G": handleRating(msg);     break;
    // …
  }
});

Validation error retries

When a user's reply fails a Question block's validation (wrong date format, file too large, etc.), the bot fires a regular text message with the question's configured error copy:

{
  "type": "text",
  "message": "I'm afraid I didn't understand, could you try again, please?",
  "rich_text": null,
  "samurai": -3973119,
  "author_type": "bot",
  "extra": {
    "id": "welcome_error",                  // original block id + "_error"
    "textarea": { "type": "date", ... }     // same modality config as the original question
  }
}

Two signals:

  • extra.id ends with _error — flags this as a retry on the previous question. The user is still on the same logical step.
  • extra.textarea repeated — same modality config the user just tried. Re-render the same input widget.

If you're driving a wizard from extra.id, the _error suffix tells you whether to advance state or stay on the current step.

Agent messages

When a conversation gets handed off from the bot to a human agent (via the bot's Send to Agent block, which fires an event message with action: "assign"), subsequent messages from the agent arrive with:

{
  "type": "text",
  "message": "hi",
  "rich_text": null,                  // typically null on agent messages
  "samurai": 40684,                   // POSITIVE — the agent's user ID
  "author_type": "agent",             // distinct from "bot"
  "author_uuid": "17fa298d-...",
  "extra": {},                        // empty — no flow block
  "seq": null
}

Render agent messages alongside bot messages in the conversation, but consider distinguishing them visually (different avatar, "Live agent" badge). The author_type === "agent" check is the cleanest way to differentiate.

To identify which agent is speaking, use samurai (the agent's user ID, an integer) or author_uuid (a UUID persistent across an agent's messages within a session).

Sending message types

Three send-side types are accepted by Core's sendMessage:

text

Default. Plain user input.

core.sendMessage({
  type: "text",
  message: "Hi!",
});

button

Used to answer a dialog question. The payload is required to follow the bot's specific flow — it's the value from the corresponding entry in the received message's payloads array.

core.sendMessage({
  type: "button",
  message: "Nice",
  payload: "$0",
});

file

Image or file message.

core.sendMessage({
  type: "file",
  url: "https://...",
});

Note Send/receive name asymmetry. Sending type: "file" produces a user-side echo on the pipeline with type: "image". The send vocabulary (text, button, file) and the receive vocabulary (text, dialog, image, iframe, multi_question, event, hidden) are deliberately different — file is a category you send into, image is the category Landbot's storage normalises media to.

The same normalisation applies when a user uploads through a bot's file-upload Question block — the upload arrives on the pipeline as type: "image" regardless of the actual file content.

Warning These three are the only sendable types via Core. sendMessage({ type: "image", ... }), sendMessage({ type: "location", ... }), and any unrecognised type all reject with an undefined rejection reason. The Core SDK's send vocabulary is narrower than the Platform API's — there's no Core equivalent of POST /customers/{id}/send_location/. To send a location from a Core integration, encode it as a text message and parse it in the bot's flow.

What we haven't observed yet

The shapes on this page are derived from empirical traces across five bot configurations and ~25 distinct interaction patterns. The following block types haven't been exercised on the wire, so their precise shapes aren't (yet) in this page:

  • Dynamic Data block (cards / carousels) — likely a distinct top-level type with an array of card objects.
  • Document / Video / Audio media — may all flatten into type: "image" (same as the file-upload normalisation), or may be distinct top-level types.
  • Note messages — agent-private comments visible in the Platform API webhook payload but never observed via Core's pipelines.
  • Code-block field interpolation timing — when a Set a field block sets a field immediately before a Send Message that references it, @{field} does resolve to the just-set value in the same turn (verified 2026-06-04 — it arrives already interpolated in the message body). The equivalent timing for a field set via a Code block (rather than a Set-a-field block) in the same position is not yet separately confirmed.

If you connect a Core integration to a bot exercising any of these blocks and see shapes that aren't documented here, please report what you observed.