Skip to main content

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
)
ParamTypeNotes
question!StringThe poll question
options!ArrayAnswer options
is_anonymousBooleanHide voter identities (default true)
allows_multiple_answersBooleanMulti-select
poll_typeString"regular" or "quiz"
chat_idChatIdTarget 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}!`)
ParamTypeNotes
itemsArrayField 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}`)
ParamTypeNotes
itemsArrayMenu options

Outputs: selected_index, selected_item, value, error_text.

tip

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

TypeCollects
textAny string
numberA number
booleanYes/no
locationA location object
photoA photo file
documentA 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.

PresetUnderlying inputBuilt-in check
emailtextValid email format ([email protected])
phonetext7–15 digits, optional leading +
datetextISO format YYYY-MM-DD
moneynumberNon-negative (value >= 0)
choice(prompt, options)textAnswer must be one of the listed options
textareatextLong-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."
note

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"))
}
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

FormUse 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" -> menuIn-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.

See also