mcpField
T
telegram-bot-ui

telegram-bot-ui

github.com/agntdevDevelopment160 installsUpdated 17w ago
Repository

About telegram-bot-ui

telegram-bot-ui is a skill for building Telegram bot keyboards and handling their interactions. It explains when to use buttons, commands, and ForceReply, and provides reusable builders for menus, confirmations, and pagination.

Key features

  1. 01Explain inline and reply keyboards
  2. 02Choose between buttons and typed input
  3. 03Build menus and confirmation keyboards
  4. 04Create paginated keyboard controls
  5. 05Support URL, copy-text, and Web App buttons
  6. 06Cover grammY callback handling

Use cases

  1. 01Add a button-based main menu
  2. 02Collect free-form input with ForceReply
  3. 03Build confirmation and pagination flows
  4. 04Add copyable text or Mini App buttons

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/agntdev/skills --skill telegram-bot-ui

After installing, type/telegram-bot-uiin Claude Code to confirm it loaded.

Bundled filesreferences/grammY-keyboards.md

Details

From the project README

telegram-bot-ui Skill

How to wire Telegram keyboards — pure mechanics. For what the bot should say and how it should feel, see telegram-bot-ux.

⭐ BUTTON-FIRST — the default for reaching a feature

The owners shipping these bots are non-technical, and so are their users. People operate the bot by tapping buttons, not by memorising slash commands. So a feature is made reachable by adding a button to the /start main menu, NOT by minting a new bot.command(...).

In the bot-starter template this is one line — call registerMainMenuItem({ label, data }) at the top of your src/handlers/<slug>.ts and handle its data with .callbackQuery(...); the shipped /start renders the aggregate menu. The only slash commands a bot should expose are /start, /help, and the occasional free-form typed input below. Do NOT add a slash command per feature — that is how bots end up with 20+ cryptic, overlapping commands no human can use.

This does not mean "never use commands": see the buttons-vs-commands heuristic — commands and ForceReply are still the right tool for free-form typed input (a search query, note, address, date, time, amount). Buttons for navigation and choices; commands for typing the bot can't predict.

Built for the agntdev pipeline. See agnt-cli-builder for the discovery-and-claim loop. This skill teaches the inline-button, menu, paginate, confirmKeyboard, ForceReply, and copy_text patterns you use in your claimed task's implementation.


1. How Telegram Keyboards Work (Bot API)

Telegram has two keyboard types + several markup variants for special inputs. Different use cases, different JSON shapes.

InlineKeyboardMarkup

Buttons attached to a specific message. Tapping sends a callback_query back to your bot (no message to chat). Good for menus, confirmations, pagination.

// Attached to sendMessage reply_markup field
{
  "inline_keyboard": [
    [
      { "text": "Yes", "callback_data": "confirm:42:yes" },
      { "text": "No",  "callback_data": "confirm:42:no" }
    ],
    [
      { "text": "Open Site", "url": "https://example.com" }
    ]
  ]
}

Tap "Yes" → bot receives callback_query with data: "confirm:42:yes".

Limits (see telegram-bot-api-fundamentals §4):

  • callback_data ≤ 64 BYTES (UTF-8). Cyrillic / emoji = multi-byte.
  • ≤ 100 buttons per keyboard.
  • ≤ 8 rows (iOS scrolls at ~5).
  • Button text ≤ ~64 chars (truncated otherwise; keep ≤24 on mobile).
ReplyKeyboardMarkup

Persistent buttons that replace the user's keyboard. Tapping sends a regular text message. Good for persistent menus, quick replies.

{
  "keyboard": [
    [{ "text": "📅 Book" }, { "text": "📋 My bookings" }],
    [{ "text": "❌ Cancel" }]
  ],
  "resize_keyboard": true,
  "one_time_keyboard": false,
  "input_field_placeholder": "Type or tap…",
  "selective": false
}

Useful params:

  • resize_keyboard — make buttons small. Ignored on Telegram Desktop 5.3.2+ (it always uses 54px/button flat grid; 530px horizontal cap).
  • one_time_keyboard: true — hide keyboard after first tap. Good for one-shot prompts.
  • input_field_placeholder — text in the input field above the keyboard. Use it; users see this hint.
  • selective: true — show this keyboard only to specific users in a group (rare).
ForceReply

Asks the user to reply in the chat (with an inline "replying to" UI). Use for mandatory typed input the bot can't capture with buttons.

{
  "force_reply": true,
  "input_field_placeholder": "Send your booking address…",
  "selective": false
}

User sees a "Replying to your bot…" label above the input field. Bot must explicitly handle the next text message (filter by session step — see telegram-bot-ux §6 Linear wizard).

Buttons vs commands — the heuristic
User needs to…Use
Pick from a small fixed set (yes/no, choice A/B/C, page N)Inline button
Open a menu, confirm an action, paginateInline button
Send free-form text (a contract address, a note, a search query)/command + text input
Type a structured argument the bot parses (date, time, amount)/command + text input
Mandatory text reply (replying to the bot's question)ForceReply
Persistent shortcut the user wants to tap repeatedlyReply keyboard (use sparingly)

The old "default to inline buttons" rule is wrong. Buttons are right for choices; commands are right for free input. A bot that turns every input into a button is hostile to power users. A bot that uses commands for menus is hostile to mobile users.

When in doubt, ask: "Does the user know what to type?" If yes, use a command. If no (or if there are too many valid options to remember), use a button.

For what to write on the button (verb-first, sentence case, emoji budget), see telegram-bot-ux §1 Microcopy.

Edit vs send
# Edit existing message (for inline keyboards — user doesn't see new messages)
POST /bot<TOKEN>/editMessageText   { chat_id, message_id, text, reply_markup }

# Edit only the keyboard (keep text)
POST /bot<TOKEN>/editMessageReplyMarkup  { chat_id, message_id, reply_markup }

# Send new message (for reply keyboard flows)
POST /bot<TOKEN>/sendMessage  { chat_id, text, reply_markup }

2. grammY — Using Keyboards

The grammY mechanics for attaching keyboards, handling callbacks, and the edit-vs-new-message UX pattern live in references/grammY-keyboards.md. Read that file when you need the exact reply_markup shapes for inline / reply / ForceReply / request-contact / request-location / request-user / request-chat / request-managed-bot, the callback-data prefix routing rules, and the edit-in-place UX note from Telegram.


3. The toolkit (src/toolkit/) — UI Builders

The inlined toolkit (at src/toolkit/ in the bot-starter template) provides pure builders that return plain InlineKeyboardMarkup objects. No grammY import needed — they produce the exact JSON shapes grammY expects.

import {
  inlineButton, urlButton, copyTextButton, webAppButton,
  inlineKeyboard, menuKeyboard, confirmKeyboard, paginate,
} from "../src/toolkit/ui/buttons.js";
inlineButton(text, callbackData)
inlineButton("Yes", "confirm:42")
// → { text: "Yes", callback_data: "confirm:42" }
// Type: { text: string; callback_data: string }
urlButton(text, url)
urlButton("Docs", "https://agnt-gm.ai")
// → { text: "Docs", url: "https://agnt-gm.ai" }
// Type: { text: string; url: string }
copyTextButton(text, copyText) — one-tap copy
copyTextButton("📋 Copy order ID", "ORD-12345")
// → { text: "📋 Copy order ID", copy_text: { text: "ORD-12345" } }
// Type: { text: string; copy_text: { text: string } }

Use this for IDs, addresses, codes, links — anything users would otherwise long-press to copy. Saves 4 taps per interaction. See telegram-bot-ux §10 anti-pattern "no copy_text for IDs".

webAppButton(text, url) — open Mini App
webAppButton("🛒 Open shop", "https://shop.example.com/twa")
// → { text: "🛒 Open shop", web_app: { url: "https://shop.example.com/twa" } }
// Type: { text: string; web_app: { url: string } }

Bot API 9.4+. Use when the flow has grown past what inline keyboards can carry — see telegram-bot-ux §8 Mini App graduation for the 4 explicit thresholds.

inlineKeyboard(rows)

Wrap rows of buttons into valid InlineKeyboardMarkup:

const kb = inlineKeyboard([
  [inlineButton("A", "a"), inlineButton("B", "b")],
  [urlButton("Docs", "https://x.io")],
]);

await ctx.reply("Choose:", { reply_markup: kb });
menuKeyboard(items, columns?)

Grid layout from a flat list. Default 1 column.

const items = [
  { text: "📅 Book", data: "menu:book" },
  { text: "📋 My bookings", data: "menu:my" },
  { text: "❌ Cancel", data: "menu:cancel" },
  { text: "ℹ️ Help", data: "menu:help" },
];

menuKeyboard(items);     // vertical list, 1 per row
menuKeyboard(items, 2);  // 2-column grid
confirmKeyboard(actionPrefix, opts?)

Yes/No row. Callbacks: <prefix>:yes / <prefix>:no.

confirmKeyboard("delete:42");
// → [✅ Yes ("delete:42:yes")] [❌ No ("delete:42:no")]

confirmKeyboard("publish", { yes: "🚀 Publish", no: "🔙 Back" });

Handler:

bot.on("callback_query:data", async (ctx) => {
  const data = ctx.callbackQuery.data;
  if (data.startsWith("delete:")) {
    const [, id, action] = data.split(":");
    await ctx.editMessageText(action === "yes" ? `Deleted ${id}` : "Cancelled");
  }
  await ctx.answerCallbackQuery();
});
paginate(items, options)

Slice items into pages with prev/next controls.

const result = paginate(allItems, {
  page: 0,               // 0-based, auto-clamped
  perPage: 5,
  callbackPrefix: "pg",   // default: "page"
  prevLabel: "« Prev",
  nextLabel: "Next »",
});

// result.pageItems  — items for current page
// result.totalPages — how many pages
// result.page       — actual page number (may be clamped)
// result.controls   — InlineKeyboardMarkup with prev/next buttons

Single page → empty controls. First page → only Next. Last page → only Prev. Middle → both.

Full pagination handler:

async function showPage(ctx: BotContext, page: number) {
  const items = await loadItems();
  const { pageItems, controls } = paginate(items, { page, perPage: 5 });

  const rows = pageItems.map(item => [
    inlineButton(item.name, `select:${item.id}`),
  ]);

  const keyboard = inlineKeyboard([...rows, ...controls.inline_keyboard]);
  await ctx.editMessageText("Choose an item:", { reply_markup: keyboard });
}

bot.on("callback_query:data", async (ctx) => {
  const data = ctx.callbackQuery.data;
  if (data.startsWith("pg:next:")) await showPage(ctx, parseInt(data.split(":")[2]));
  if (data.startsWith("pg:prev:")) await showPage(ctx, parseInt(data.split(":")[2]));
  await ctx.answerCallbackQuery();
});

Callback format: <prefix>:prev:<n> / <prefix>:next:<n> where n = target page index.


4. Toolkit vs Pure grammY

TaskPure grammYToolkit
Inline button{ text: "Hi", callback_data: "x" }inlineButton("Hi", "x")
URL button{ text: "Link", url: "..." }urlButton("Link", "...")
Copy-text button{ text: "Copy", copy_text: { text: "..." } }copyTextButton("Copy", "...")
Web App button{ text: "Open", web_app: { url: "..." } }webAppButton("Open", "...")
Grid menuManual row chunkingmenuKeyboard(items, cols)
Confirm rowManual 2-button rowconfirmKeyboard("prefix")
PaginateManual slice + prev/next logicpaginate(items, opts)

Toolkit builders produce the same JSON shapes grammY expects. They're convenience, not lock-in. Use them when you want less boilerplate. Skip them when you need full control.


Common mistakes

  1. inline_keyboard in keyboard field — different objects. Inline keyboards go under reply_markup.inline_keyboard. Reply keyboards under reply_markup.keyboard.
  2. paginate() without handler — buttons generate pg:prev:X / pg:next:X data. Must route them in callback handler.
  3. Not catching unknown callbacks — always have fallback answerCallbackQuery for stray callback data.
  4. Using editMessageText on a new message — you need message_id from a previous reply. ctx.reply() for first message, ctx.editMessageText() for updates.
  5. 64-byte callback_data with non-ASCII — Telegram rejects with BUTTON_DATA_INVALID. It's BYTES, not chars. See telegram-bot-api-fundamentals §4.
  6. ForceReply without a step filter — every text message in the chat will trigger your "awaiting X" handler. Gate by ctx.session.step. See telegram-bot-ux §6.
  7. Reply keyboard + resize_keyboard: true then relying on it on Desktop — Telegram Desktop 5.3.2+ ignores resize_keyboard. Design for 4 columns max.
  8. Missing input_field_placeholder — users see an empty input field and don't know what to type. Always set it.
  9. one_time_keyboard: false (default) on one-shot prompts — keyboard stays around after the user taps. Set one_time_keyboard: true for "tap once, I'm done" flows.
  10. Sharing a custom keyboard for RequestContact after the user already shared — old keyboard still visible. Send a new message with remove_keyboard: true or update the keyboard.

Comments

Markdown supported