COMPAX

BOTS

WIDGETS
IN A CHAT

A keyboard is rows of buttons, so every question that is not which of these buttons becomes a conversation. A widget is the answer to that: a card the application draws from JSON the bot sends, in the same message, on the same callback channel. Below is every control there is, a constructor that writes the JSON for each, and the card it draws.

WHAT THEY ARE FOR

Four applications, in a chat

An ordering screen, a search, a booking, a fare. Every card below is built out of the nodes this page hands out, and each one is live — press it. Where a screen needed something the vocabulary did not have, that was the vocabulary being short a node: place, time, item and total are the four that came out of building these.

Two lines of a menu, the size, what goes on it, and what it comes to. The whole order is one message that keeps being edited.

itemitemchoicechecklisttotalbutton

9:41
tablebotbot
/book10:58 ✓✓
tablebot
MargheritaSan Marzano, fior di latte, basil£11.501+
Diavola'Nduja, chilli, honey£13.001+
Size
Regular+£3.00
Large
On top
Extra mozzarella+£1.50
Olives+£1.00
Anchovy+£1.50
Totaldelivery included£24.50
Send to the kitchen
+Write a message…🎤

THE IDEA

What a widget is

A keyboard is rows of buttons, so every question that is not which of these buttons becomes a conversation: one message per value, and no way at all to collect a set, a quantity or a day in place. A widget is the answer to that: a card the app draws from JSON the bot sends, in the same message, with the same callback channel underneath.

The state model is the one a keyboard already has, sharpened: the bot owns the state, the client holds nothing it was not given. Every tap is an ordinary event string; the bot updates its own copy and pushes the next spec with editMessageWidget, and the card re-renders in place. sendMessage takes a widget parameter, so the spec rides the send and the message’s text is the fallback for a client that cannot draw it.

A CARD BUILT FROM GENERAL NODES
POST /bot$KEY/sendMessage
{
  "chat_id": -1001,
  "text": "Order #4821 — packed",          // the fallback
  "widget": {
    "type": "card", "gap": "xs",
    "children": [
      { "type": "row", "gap": "sm", "children": [
          { "type": "icon",  "icon": "cart", "tone": "accent" },
          { "type": "text",  "text": "Order #4821", "weight": "bold" },
          { "type": "badge", "text": "PACKED", "tone": "muted" } ] },
      { "type": "progress", "value": 0.66 },
      { "type": "row", "gap": "sm", "children": [
          { "type": "button", "label": "Advance", "primary": true, "tap": "card:step" },
          { "type": "button", "label": "Reset", "tap": "card:reset" } ] }
    ]
  }
}

# the tap arrives as an ordinary callback_query with data "card:step",
# and the bot pushes the next state into the same message:
POST /bot$KEY/editMessageWidget
{ "chat_id": -1001, "message_id": 19, "widget": { …the next spec… } }

THE CONSTRUCTOR

Build one

Pick a control, turn its knobs, and press the card. The JSON is what your bot posts to sendMessage; the string under it is the whole of what comes back when somebody presses what you drew.

stepper asks how many, bounded.

Telegram’s answer is two buttons and a counter written into the text.

WHAT THE BOT SENDS
curl -X POST "$BASE/bot$KEY/sendMessage" \
  -H 'content-type: application/json' \
  -d '{
       "chat_id": -1001,
       "text": "People — open the app to answer",
       "widget": {
         "type": "card",
         "gap": "sm",
         "children": [
           {
             "type": "stepper",
             "label": "People",
             "min": 1,
             "max": 8,
             "value": 2,
             "tap": "seats:"
           }
         ]
       }
     }'
What comes back

Press the card in the phone. Whatever the control does, what reaches the bot is one string on cbq — nothing else fits on that channel.

9:41
tablebotbot
/book10:58 ✓✓
tablebot
People2+
+Write a message…🎤

SEVERAL AT ONCE

Put them in a card

One control answers one question, and almost nothing a bot asks is one question. A booking is a count, a day, a setting and a button. card is the node that holds them, and its children are a list — so the list is the layout. Start from one of these, or add rows to it.

Editing stepper — it asks how many, bounded.

WHAT THE BOT SENDS
curl -X POST "$BASE/bot$KEY/sendMessage" \
  -H 'content-type: application/json' \
  -d '{
       "chat_id": -1001,
       "text": "Open the app to answer",
       "widget": {
         "type": "card",
         "gap": "sm",
         "children": [
           {
             "type": "stepper",
             "label": "People",
             "min": 1,
             "max": 8,
             "value": 2,
             "tap": "seats:"
           },
           {
             "type": "date",
             "label": "Day",
             "placeholder": "Pick a day",
             "tap": "day:"
           },
           {
             "type": "switch",
             "label": "Window seat",
             "value": false,
             "tap": "window"
           },
           {
             "type": "button",
             "label": "Book the table",
             "tap": "book"
           }
         ]
       }
     }'
What comes back

Press any row of the card in the phone. However many nodes a card holds, what reaches the bot is one string — which is why every addressable node carries its own tap.

9:41
tablebotbot
/book10:58 ✓✓
tablebot
People2+
DayPick a day
Window seat
Book the table
+Write a message…🎤

THE VOCABULARY

The vocabulary is a whitelist

  • layoutcolumn, row, wrap, box, card, space, divider
  • displaytext, heading, note, banner, icon, badge, progress, markdown, code, spinner, glossary, total
  • inputfield, check, switch, choice, select, checklist, stepper, slider, rating, date, time, place, button, item, gallery, pagination, tree
  • compositescheckbox_group, payment_request, swap_panel, ticket_offer: whole cards the app draws from state the bot supplies
  • the windowmini_app: the one node that draws something this application did not write, and deliberately the last in the table. It opens over the conversation rather than instead of it.

A name absent from the list draws a box that names it, rather than nothing — a form with a silently missing row is one somebody submits believing it complete. Appearance is a token ("tone": "accent", "size": "lg"), resolved against the app’s palette: there is no colour property, no pixel property and no icon outside the whitelist, which is what stops a bot drawing something the shape of this app’s own confirmation sheet inside a conversation somebody already trusts. A raw "color" is ignored, and there is a test that says so.

How a value gets back on a channel that carries one string

  • A control whose value is the node — button, check, switch — sends its tap string unchanged. The bot holds the old state and knows what the new one must be.
  • A control that carries a value — select, choice, checklist, stepper, slider, rating, date, field — sends tap with the value appended: "tap": "form:seats" picking four arrives as form:seats:4. Free text is the last segment (todo:rename:2:Buy oat milk), so split it off rather than splitting on every colon.
  • A node with no tap is drawn inert. That is how a spec says “this is showing, not asking” — and a finished form sends its next spec with no tap strings at all, which is how it stops being pressable.
  • callback_data is 64 bytes in the Bot API, so a picked value has to be short: a list of asset codes is fine, a list of sentences is not.

Every control, and what comes back

One table, because the whole protocol is in it. Send the node on the left; the tap on the right is what arrives as callback_query.data. Every one of these is lifted from examples/widgetbot.rs, which is a bot you can run.

The node you sendWhat comes back on a tap
{"type": "button", "label": "Book it", "primary": true, "tap": "form:place"}form:place
the node is the value
{"type": "switch", "id": "remind", "label": "Remind me", "on": false, "tap": "form:remind"}form:remind
bare — you hold the old bit and know the new one
{"type": "stepper", "id": "seats", "label": "Seats", "value": 2, "min": 1, "max": 8, "tap": "form:seats"}form:seats:4
the value is appended
{"type": "select", "id": "sitting", "value": "main", "options": [{"value": "early", "label": "Early — 18:00"}, …], "tap": "form:sitting"}form:sitting:early
the option’s value, never its label
{"type": "checklist", "id": "extras", "values": ["window"], "options": […], "tap": "form:extras"}form:extras:cake
the item touched, not the new set — you flip it
{"type": "slider", "id": "tip", "value": 10, "min": 0, "max": 30, "step": 5, "suffix": "%", "tap": "form:tip"}form:tip:15
{"type": "rating", "id": "stars", "value": 4, "max": 5, "tap": "form:stars"}form:stars:5
{"type": "date", "id": "when", "value": "", "tap": "form:when"}form:when:2026-09-14
{"type": "field", "id": "email", "label": "E‑mail", "placeholder": "you@example.com", "tap": "signup:email"}signup:email:ada@example.com
free text is the last segment
{"type": "text", "text": "Order #4821"} — no tapnothing. Drawn inert: this is showing, not asking

So a handler splits twice and no more: once for the verb, once for the field, and whatever is left is the value.

READING A TAP
# data = "form:seats:4"
let (verb, arg)   = data.split_once(':').unwrap_or((data, ""));   # form | seats:4
let (field, value) = arg.split_once(':').unwrap_or((arg, ""));    # seats | 4

# Free text can hold colons, so it is the remainder — never a third split.
# "todo:rename:2:Buy oat milk, 2 tins" is rename, item 2, and the rest.

The loop, end to end

Three calls, and the middle one is the app’s. This is the whole of it — a card that asks something, a tap, and the card redrawn with the answer in it.

The number does not move until the bot moves it
9:41
tablebotbot
tablebot
People23+
DayFri, 8pm
Book
edited
seats:3 → tablebot
+Write a message…

The same three calls as the block below, played. Press the plus and watch the count stay where it is: the app has sent seats:3 and has nothing else to go on. It changes when the next spec arrives — which is the whole state model in one control, and the reason there are no deltas anywhere in this protocol.

ASK → TAP → REDRAW
# 1. the bot asks. `text` is the fallback for a client that cannot draw widgets.
POST /bot$KEY/sendMessage
{
  "chat_id": -1001,
  "text": "Table for two — 2 seats, no day yet",
  "widget": {
    "type": "card", "gap": "xs",
    "children": [
      { "type": "row", "gap": "sm", "children": [
          { "type": "icon", "icon": "cart", "tone": "accent" },
          { "type": "text", "text": "Table for two", "weight": "bold" } ] },
      { "type": "stepper", "id": "seats", "label": "Seats",
        "value": 2, "min": 1, "max": 8, "tap": "form:seats" },
      { "type": "date", "id": "when", "label": "Day", "value": "", "tap": "form:when" },
      { "type": "note", "text": "2 seats, no day yet" },
      { "type": "button", "label": "Book it", "primary": true, "tap": "form:place" }
    ]
  }
}

# 2. somebody moves the stepper to four — this arrives from getUpdates
{ "update_id": 43, "callback_query": {
    "id": "cb-9", "data": "form:seats:4",
    "from": { "id": 5501, "first_name": "Ada" },
    "message": { "message_id": 19, "chat": { "id": -1001 } } } }

# 3. answer first — the control is waiting on it — then redraw with the new state
POST /bot$KEY/answerCallbackQuery
{ "callback_query_id": "cb-9", "text": "4 at the table." }

POST /bot$KEY/editMessageWidget
{ "chat_id": -1001, "message_id": 19, "widget": { …the same card, "value": 4… } }

Refusing is the same three calls with the third left out: answer "One to eight." and send no new spec, and the card stays as it was. There is no way to fail silently, because the control is drawn waiting until the answer lands.

Asking for something typed

A field is the one control that carries words. What comes back is what was typed, as the last segment — and what makes it a form rather than four messages is that the shape is yours too: a spec can grow a row that was not there a moment ago.

A FIELD, AND A VERDICT ON IT
# the bot asks
{ "type": "field", "id": "email-1", "label": "E-mail",
  "placeholder": "you@example.com", "value": "", "tap": "signup:email" }

# what arrives
"data": "signup:email:ada@exmaple"

# the bot judges — nothing on the device knows what an address looks like —
# and sends the same field back wearing the verdict
{ "type": "field", "id": "email-2", "label": "E-mail",
  "value": "ada@exmaple", "error": "That address has no dot after the @.",
  "tap": "signup:email" }

# …with a banner above it, so the card says how many things need fixing
{ "type": "banner", "tone": "danger", "text": "One thing needs fixing." }

Two things about a field are worth knowing before you write one. It reports when it is left, not on every keystroke — so the tap arrives once, with what was typed. And its value is only what the box starts with: to clear a field, or to bring it back empty for a second attempt, give it a new id. That is why the ids above carry the round number (email‑1, email‑2) — a new identity is how a box comes back empty.

Three whole cards, for the price of a state machine

todo_list, swap_panel and ticket_offer are composites: the app draws the card, the bot supplies the state. The tap vocabulary is the card’s.

A LIST THE APP DRAWS
"widget": {
  "type": "todo_list", "title": "Shopping",
  // the two the bot decides. A full list shows no Add at all,
  // rather than an Add that answers no.
  "can_add": true, "can_edit": true,
  "items": [
    { "id": 0, "text": "Oat milk", "done": true },
    { "id": 1, "text": "Coffee", "done": false }
  ]
}

# There is no "tap" on a composite: the card's vocabulary is the card's,
# and the app sends these of its own accord.
#   ticking the second line  → "todo:toggle:1"
#   renaming it             → "todo:rename:1:Coffee, the good one"
#   the Add button          → "todo:add"      (bare — you name the new item)
#   retitling the card      → "todo:title:This week"

Validation is yours, and it is not a rule about tidiness

Every input node takes hint and error, and both are the bot’s words. Nothing in the renderer knows what an email address looks like or that a checklist has a maximum: the value goes up, the bot judges, and the next spec carries the verdict. A client that judged for itself would be a second opinion about rules that live somewhere else, and the two would disagree the day yours changed — quietly, in the direction of letting something through. Re-check on the way in, too: hiding a button does not stop a replayed event.

SEEING IT WITHOUT WRITING A BOT

Settings → Dynamic widgets in the app runs the whole loop with a mock at the other end: the same specs, the same event strings, and a panel under each card showing both halves of the wire verbatim. Then cargo run -p botapi --example widgetbot is the same six cards driven by a real bot — which is the version that can also show a tap being refused, arriving twice, or timing out.