Skip to main content

Keyboards & Menus

Keyboards turn a flat chat into an app. Botgami supports menus (named, reusable inline keyboards), reply keyboards (persistent buttons below the input field), and raw inline_keyboard for special cases.

The recommended approach is the first-class menu declaration. It keeps your button layouts next to your bot definition, handles navigation automatically, and stays in sync as persistent state changes — no manual assembly required.

First-class menus

Declare a menu once at the top level (or inside a bot block), then attach it anywhere with reply_markup: menuName(args).

bot ShopBot {
shared products: Array<{ id: String, name: String, price: Number }> = []

menu shopMenu(category: String) {
text: "🛍 *Shop*"
parse_mode: "Markdown"
for p in array.where(shared.products, "category", category) {
text p.name -> openProduct(pid: p.id)
row
}
submenu "🛒 Cart" -> cartMenu
url "📖 Docs" "https://docs.botgami.app"
}

flow start on command "/start" {
send.text("Welcome!", reply_markup: shopMenu("all"))
}
}

How menus stay in sync

Every time a button is pressed, the menu re-renders from the current state of user.*, shared.*, and global.*. The category parameter is remembered across presses, so re-renders produce the correct filtered list even without any extra plumbing.

Parameters are typed scalars (String, Number, Boolean) bound when the menu is attached and carried through every button press. They let you pass context — a category, a page number, a filter — without storing it separately.

menu taskMenu(view: String, page: Number) {
text: `Tasks — ${view}`
for t in array.where(user.tasks, "status", view) {
text t.title -> openTask(id: t.id)
row
}
text "⬅ Prev" -> goPage(page: page - 1)
text "Next ➡" -> goPage(page: page + 1)
}

flow start on command "/start" {
send.text("Your tasks:", reply_markup: taskMenu("open", 0))
}

subflow goPage(page: Number) {
edit.text(message: callback.message, text: "Tasks:", reply_markup: taskMenu(flow.view, page))
}

Rule of thumb: pass a scalar key or page number and read the full data from persistent state inside the menu body. Never try to pass arrays or objects as parameters — use user.* or shared.* for those.

Button forms (inline menus)

SyntaxWhat it does
text "Label" -> handler(arg: expr)Calls the named handler subflow with typed payload
text "Label" callback "raw_data"Emits a literal callback_data (escape hatch — see below)
url "Label" "https://..."Opens a URL
submenu "Label" -> targetMenuNavigates to another menu, editing the message in place
back "⬅ Label"Navigates to the parent menu (label is optional)
rowStarts a new row
for item in expr { text item.name -> handler(id: item.id) row }Dynamic range — one row per item

Typed payload

When a button uses -> handler(arg: expr), the payload arrives in the handler subflow as a typed value — no manual string parsing.

menu productMenu {
for p in shared.products {
text p.name -> buyProduct(pid: p.id, price: p.price)
row
}
}

subflow buyProduct(pid: String, price: Number) {
set { user.cart << [{ id: pid, price: price }] }
send.answer_callback(text: "Added to cart!")
}

Drill-down navigation

submenu and back handle navigation automatically. The message is edited in place — no extra flows or message ids needed. To drill into a menu that takes params, pass them in the submenu arrow — submenu "🛒 Shop" -> shopMenu("all") — and they're remembered across presses.

menu mainMenu {
text: "Main Menu"
submenu "🛒 Shop" -> shopMenu("all")
submenu "⚙️ Settings" -> settingsMenu
url "❓ Help" "https://t.me/support"
}

menu shopMenu(category: String) {
text: "🛍 Shop"
for p in array.where(shared.products, "category", category) {
text p.name -> viewProduct(pid: p.id)
row
}
back "⬅ Back"
}

menu settingsMenu {
text: "⚙️ Settings"
text "🔔 Notifications" -> toggleNotifs()
back "⬅ Back"
}

flow start on command "/start" {
send.text("Welcome!", reply_markup: mainMenu)
}

Dynamic range

for item in expr { ... } generates one button per item. The item variable is available inside the block for labels and payloads.

menu categoryMenu {
text: "Pick a category"
for cat in shared.categories {
text cat.name -> browseCat(id: cat.id)
row
}
}

Reply keyboards

A reply keyboard replaces the user's text keyboard with persistent buttons. Declare it with the reply keyword.

menu mainKb reply {
resize
persistent
button "📦 Orders"
button "🛒 Shop"
row
button "📞 Support" request_contact
button "📍 My location" request_location
}

flow start on command "/start" {
send.text("Use the menu below:", reply_markup: mainKb)
}

Reply keyboard options (all optional):

OptionWhat it does
resizeShrinks the keyboard to fit the buttons
one_timeHides after first tap
persistentShows even when the keyboard is hidden
placeholder: "..."Sets placeholder text in the input field

Reply button types:

SyntaxWhat it does
button "Label"Sends the label text as a message
button "Label" request_contactAsks the user to share their phone number
button "Label" request_locationAsks the user to share their location
button "Label" request_pollAsks the user to create a poll
button "Label" web_app "https://..."Opens a Mini App

Attaching a menu to any message

Any send.* or edit.* call that accepts reply_markup can reference a menu:

send.text("Pick a product:", reply_markup: shopMenu("all"))
edit.text(message: callback.message, text: "Updated!", reply_markup: shopMenu(flow.cat))
send.photo(file: global.banner_url, caption: "Welcome", reply_markup: mainKb)

Removing a keyboard

To remove a reply keyboard, pass an empty inline keyboard or use edit.reply_markup:

edit.reply_markup(message: callback.message, reply_markup: { inline_keyboard: [[]] })

Raw inline_keyboard — escape hatch

For cases where a keyboard is built entirely at runtime from external data (webhook responses, scheduled jobs) and you can't reference a named menu, the raw form is still available:

// Escape hatch — prefer menus for anything you author by hand.
send.text("Actions:", reply_markup: {
inline_keyboard: [[
{ text: "✅ Approve", callback_data: `approve:${flow.uid}` },
{ text: "❌ Decline", callback_data: `decline:${flow.uid}` }
]]
})

A button with callback_data in the raw form fires a matching flow ... on callback "...". Prefix matching ("approve:") routes all approve buttons through one flow.

tip

If you're writing inline_keyboard by hand for something the user will tap more than once, it's almost always better as a named menu. Menus re-render automatically, handle navigation, and carry typed payloads.

Next