Skip to main content

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

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

See alsoโ€‹