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.
Menu parameters
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)
| Syntax | What 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" -> targetMenu | Navigates to another menu, editing the message in place |
back "⬅ Label" | Navigates to the parent menu (label is optional) |
row | Starts 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):
| Option | What it does |
|---|---|
resize | Shrinks the keyboard to fit the buttons |
one_time | Hides after first tap |
persistent | Shows even when the keyboard is hidden |
placeholder: "..." | Sets placeholder text in the input field |
Reply button types:
| Syntax | What it does |
|---|---|
button "Label" | Sends the label text as a message |
button "Label" request_contact | Asks the user to share their phone number |
button "Label" request_location | Asks the user to share their location |
button "Label" request_poll | Asks 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.
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
- Dynamic keyboards — params, ranges, toggle, confirm
- Pagination & limits — paged lists with menu params
- Polls, forms & menus — polls, wizards, and menu reference