Dynamic Keyboards
Named menus handle the common cases cleanly: param-driven queries, dynamic ranges, typed payloads, drill-down nav, toggle buttons, and confirm dialogs. This page shows each pattern with a worked example.
Pattern 1 โ Param + persistent-state queryโ
Pass a scalar param (category, filter, id) and read the full data from persistent state inside the menu body. The param is remembered across every press, so re-renders always produce the right layout.
shared tasks: Array<{ id: String, title: String, status: String }> = []
menu taskMenu(view: String) {
text: `Tasks โ ${view}`
for t in array.where(shared.tasks, "status", view) {
text t.title -> openTask(id: t.id)
row
}
text "๐ Open" -> switchView(v: "open")
text "โ
Done" -> switchView(v: "done")
}
flow start on command "/start" {
send.text("Your tasks:", reply_markup: taskMenu("open"))
}
subflow switchView(v: String) {
edit.text(message: callback.message, text: "Tasks:", reply_markup: taskMenu(v))
}
The view param is automatically carried through every button press. Tapping "Done" renders the menu again with view = "done" โ no manual state storage needed.
Pattern 2 โ Dynamic range + typed payloadโ
for item in expr { ... } generates one button per item. The payload arrives in the handler as a typed value โ no string parsing.
shared products: Array<{ id: String, name: String, price: Number, category: String }> = []
menu catalogMenu(cat: String) {
text: "๐ Catalog"
for p in array.where(shared.products, "category", cat) {
text `${p.name} โ $${p.price}` -> addToCart(pid: p.id, price: p.price)
row
}
submenu "๐ Cart" -> cartMenu
}
subflow addToCart(pid: String, price: Number) {
set { user.cart << [{ id: pid, price: price }] }
send.answer_callback(text: "Added to cart!")
edit.text(message: callback.message, text: "Catalog:", reply_markup: catalogMenu(flow.cat))
}
The pid and price arrive in addToCart as a String and Number respectively โ the runtime decodes them from the button payload automatically.
Pattern 3 โ Drill-down navigation (submenu / back)โ
submenu and back wire navigation without any extra flows or message ids. The message is always edited in place.
menu mainMenu {
text: "๐ Main Menu"
submenu "๐ Shop" -> shopMenu("all")
submenu "โ๏ธ Settings" -> settingsMenu
url "โ Help" "https://t.me/support"
}
menu shopMenu(cat: String) {
text: "๐ Shop"
for p in array.where(shared.products, "category", cat) {
text p.name -> viewProduct(pid: p.id)
row
}
back "โฌ
Back"
}
menu settingsMenu {
text: "โ๏ธ Settings"
text "๐ Notifications" -> toggleNotifs()
text "๐ Language" -> pickLanguage()
back "โฌ
Back"
}
back uses the parent menu recorded in the menu declaration chain automatically.
Pattern 4 โ Paginationโ
A page param slices the list and provides the next/prev navigation buttons. See Pagination & limits for the full worked example.
menu productList(page: Number) {
text: `๐ฆ Products (page ${page + 1})`
for p in array.slice(shared.products, page * 5, page * 5 + 5) {
text `${p.name} โ $${p.price}` -> viewProduct(pid: p.id)
row
}
text "โฌ
Prev" -> goPage(page: page - 1)
text "Next โก" -> goPage(page: page + 1)
}
subflow goPage(page: Number) {
edit.text(message: callback.message, text: "Products:", reply_markup: productList(page))
}
Pattern 5 โ Toggle buttonโ
Store a boolean in user.* and regenerate the menu on every press. The button label reflects the current state.
user notifs: Boolean = true
menu settingsMenu {
text: "โ๏ธ Settings"
text `${user.notifs ? "๐ Notifications: ON" : "๐ Notifications: OFF"}` -> toggleNotifs()
}
subflow toggleNotifs() {
set { user.notifs = !user.notifs }
edit.text(message: callback.message, text: "Settings:", reply_markup: settingsMenu)
}
Because the menu re-renders on every press from current user.* state, the label automatically reflects the new value after the toggle.
Pattern 6 โ Confirm flowโ
Pass the item id as a typed param so the confirmation screen knows what to act on.
menu confirmMenu(action: String, target_id: String) {
text: `Are you sure you want to ${action}?`
text "โ
Yes, confirm" -> doConfirm(action: action, id: target_id)
text "โ Cancel" -> doCancel()
}
subflow doConfirm(action: String, id: String) {
when action == "delete" {
set { user.items = array.reject(user.items, "id", id) }
edit.text(message: callback.message, text: "๐ Deleted.")
}
}
subflow doCancel() {
edit.text(message: callback.message, text: "Cancelled.")
}
// Trigger the confirm screen from another menu:
subflow startDelete(id: String) {
edit.text(message: callback.message, text: "Confirm?", reply_markup: confirmMenu("delete", id))
}
Pattern 7 โ Multi-arg typed payloadโ
Pass several values in one button press. All arrive typed in the handler subflow.
menu orderMenu {
text: "๐งพ Place an order"
for p in shared.products {
text p.name -> placeOrder(pid: p.id, name: p.name, price: p.price, qty: 1)
row
}
}
subflow placeOrder(pid: String, name: String, price: Number, qty: Number) {
set { user.cart << [{ id: pid, name: name, price: price * qty }] }
send.answer_callback(text: `Added ${qty}ร ${name} ($${price * qty})`)
}
Raw inline_keyboard โ for external data onlyโ
When a keyboard is built entirely from data arriving at runtime (webhook payload, scheduled broadcast) and no named menu can be referenced, the raw form is available:
// Only use raw inline_keyboard when the data arrives externally at runtime.
send.text("Approve this user?", chat_id: global.admin_id, reply_markup: {
inline_keyboard: [[
{ text: "โ
Approve", callback_data: `approve:${flow.uid}` },
{ text: "โ Decline", callback_data: `decline:${flow.uid}` }
]]
})
Handle the callback with a matching flow ... on callback "approve:" { ... }.
Inline mode (@yourbot <query>)โ
Inline mode lets anyone use your bot in any chat by typing @yourbot <query>. Telegram fires on inline_query; you read inline.query / inline.id and respond with the answer_inline node. Each result is { id, title, text, description }.
flow inline on inline_query {
set { flow.q = str.trim(inline.query) }
when str.len(flow.q) == 0 {
answer_inline(inline.id, [
{ id: "hint", title: "Type to use a tool", text: "Try: 100 EUR USD ยท 12 * 8", description: "fx ยท calc" }
])
stop
}
set { flow.results = [] }
set { flow.results << [{ id: "echo", title: "Send as-is", text: flow.q, description: "Echo your text" }] }
set { flow.results << [{ id: "upper", title: "UPPERCASE", text: str.upper(flow.q), description: "Shout it" }] }
answer_inline(inline.id, flow.results)
}
Enable inline mode for your bot in @BotFather first (/setinline). Keep inline handlers fast โ compute results locally rather than calling slow external APIs, since Telegram expects a quick answer.
Web App buttonsโ
Mix a web_app button into any menu or raw keyboard. It opens a Mini App instead of firing a callback.
menu loyaltyMenu {
text: "๐
Loyalty Club"
url "๐ Open App" global.webapp_url
text "๐
Check in" -> doCheckin()
}