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
MargheritaSan Marzano, fior di latte, basil£11.50−1+
Diavola'Nduja, chilli, honey£13.00−1+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.
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.
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:" } ] } }'
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.
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.
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" } ] } }'
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.
THE VOCABULARY
The vocabulary is a whitelist
- layout —
column,row,wrap,box,card,space,divider - display —
text,heading,note,banner,icon,badge,progress,markdown,code,spinner,glossary,total - input —
field,check,switch,choice,select,checklist,stepper,slider,rating,date,time,place,button,item,gallery,pagination,tree - composites —
checkbox_group,payment_request,swap_panel,ticket_offer: whole cards the app draws from state the bot supplies - the window —
mini_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 itstapstring 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— sendstapwith the value appended:"tap": "form:seats"picking four arrives asform: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
tapis drawn inert. That is how a spec says “this is showing, not asking” — and a finished form sends its next spec with notapstrings at all, which is how it stops being pressable. callback_datais 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 send | What comes back on a tap |
|---|---|
{"type": "button", "label": "Book it", "primary": true, "tap": "form:place"} | form:placethe node is the value |
{"type": "switch", "id": "remind", "label": "Remind me", "on": false, "tap": "form:remind"} | form:remindbare — 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:4the value is appended |
{"type": "select", "id": "sitting", "value": "main", "options": [{"value": "early", "label": "Early — 18:00"}, …], "tap": "form:sitting"} | form:sitting:earlythe option’s value, never its label |
{"type": "checklist", "id": "extras", "values": ["window"], "options": […], "tap": "form:extras"} | form:extras:cakethe 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.comfree text is the last segment |
{"type": "text", "text": "Order #4821"} — no tap | nothing. 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.
# 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 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.
# 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.
# 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.
"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.