Polls, Forms & Menus
Higher-level interactive widgets: native Telegram polls, multi-step intake wizards, and first-class named menus.
send.poll
Send a native Telegram poll:
send.poll(
question: "What's your favorite season?",
options: ["Spring", "Summer", "Autumn", "Winter"],
is_anonymous: false,
allows_multiple_answers: false
)
| Param | Type | Notes |
|---|---|---|
question! | String | The poll question |
options! | Array | Answer options |
is_anonymous | Boolean | Hide voter identities (default true) |
allows_multiple_answers | Boolean | Multi-select |
poll_type | String | "regular" or "quiz" |
chat_id | ChatId | Target chat |
ui.form
A multi-step input form that collects several fields in one widget:
let { form_data } = ui.form(items: [
{ key: "name", label: "Your name", type: "text" },
{ key: "email", label: "Your email", type: "text" },
{ key: "age", label: "Your age", type: "number" }
])
send.text(`Thanks, ${form_data.name}!`)
| Param | Type | Notes |
|---|---|---|
items | Array | Field definitions |
Outputs: form_data (an object keyed by your field keys), error_text.
ui.menu
Present an interactive choice menu (list, paginated, or searchable). Returns the chosen item:
let { selected_item, selected_index, value } = ui.menu(items: [
{ label: "Coffee", value: "coffee" },
{ label: "Tea", value: "tea" },
{ label: "Juice", value: "juice" }
])
send.text(`You picked: ${value}`)
| Param | Type | Notes |
|---|---|---|
items | Array | Menu options |
Outputs: selected_index, selected_item, value, error_text.
For persistent navigation menus that re-render with current state, use the first-class menu declaration (described below) instead of ui.menu.
wizard — guided intake flows
wizard collects several answers in sequence, with built-in validation and retry handling, and saves all results into one object. It replaces a chain of ask.* calls and branch blocks.
wizard collect_order save_as flow.order {
step name: text("What's your name?")
step email: email("Your email address?")
step qty: number("How many would you like?")
validate value >= 1 && value <= 100 error "Please enter between 1 and 100."
on_complete {
send.text(`Thanks ${flow.order.name}! Order for ${flow.order.qty} confirmed.`)
}
on_cancel {
send.text("Order cancelled.")
}
}
After on_complete runs, flow.order contains one key per step name with the collected value.
Primitive step types
| Type | Collects |
|---|---|
text | Any string |
number | A number |
boolean | Yes/no |
location | A location object |
photo | A photo file |
document | A document file |
Field presets
Presets are named step types that carry built-in validation so you don't have to write the regex or check yourself.
| Preset | Underlying input | Built-in check |
|---|---|---|
email | text | Valid email format ([email protected]) |
phone | text | 7–15 digits, optional leading + |
date | text | ISO format YYYY-MM-DD |
money | number | Non-negative (value >= 0) |
choice(prompt, options) | text | Answer must be one of the listed options |
textarea | text | Long-form text (UI hint, no extra validation) |
wizard signup save_as flow.reg {
step email: email("What's your email?")
step phone: phone("Your phone number (e.g. +15551234567)?")
step bday: date("Date of birth (YYYY-MM-DD)?")
step budget: money("Monthly budget in USD?")
step plan: choice("Which plan?", ["starter", "pro", "enterprise"])
on_complete {
send.text(`Signed up: ${flow.reg.email} on the ${flow.reg.plan} plan.`)
}
}
Adding your own validate clause ANDs it with the built-in check:
step bday: date("Date of birth?")
validate value >= "1900-01-01" error "That date is too far back."
textarea is a UI hint only — it tells the editor to render a larger input field. It does not add validation beyond requiring a non-empty text response.
First-class menu — named reusable keyboards
Declare a menu once, attach it anywhere. Menus re-render from current state on every press, handle navigation automatically, and carry typed payloads without manual parsing.
Basic inline menu
menu mainMenu {
text: "👋 Welcome! What would you like to do?"
text "🎮 Play" -> startPlay()
text "📊 Stats" -> showStats()
url "❓ Help" "https://t.me/support"
}
flow start on command "/start" {
send.text("Welcome!", reply_markup: mainMenu)
}
Define a subflow for each button handler:
subflow startPlay() {
send.text("Let's play! 🎲")
}
subflow showStats() {
send.text(`Your score: ${user.score}`)
}
Parameterized menus
Pass typed scalar params (String, Number, Boolean) to customize the menu for the current context. Params are remembered across every press.
menu shopMenu(category: String) {
text: "🛍 Shop"
for p in array.where(shared.products, "category", category) {
text `${p.name} — $${p.price}` -> buyItem(pid: p.id, price: p.price)
row
}
submenu "🛒 Cart" -> cartMenu
back "⬅ Back"
}
flow shop on command "/shop" {
send.text("Browse:", reply_markup: shopMenu("all"))
}
Submenu navigation
menu mainMenu {
text: "🏠 Home"
submenu "🛒 Shop" -> shopMenu("all")
submenu "⚙️ Settings" -> settingsMenu
}
menu settingsMenu {
text: "⚙️ Settings"
text "🔔 Toggle notifications" -> toggleNotifs()
back "⬅ Back"
}
Reply keyboard menus
menu commandBar reply {
resize
persistent
button "📦 Orders"
button "🛒 Shop"
row
button "📞 Contact" request_contact
}
flow start on command "/start" {
send.text("Use the bar below:", reply_markup: commandBar)
}
Button forms summary
| Form | Use when |
|---|---|
text "Label" -> handler(arg: val) | You need a typed payload in the handler |
text "Label" callback "raw" | You need a specific literal callback_data string |
url "Label" "https://..." | Opens a URL — no handler needed |
submenu "Label" -> menu | In-place menu navigation (no args — context flows automatically) |
back "Label" | Navigate to the parent menu |
for item in expr { text item.field -> handler(id: item.id) row } | One button per item |
For a complete reference of all button forms, params, and reply keyboard options, see Keyboards & menus.