GramIO is a modern, type-safe Telegram Bot API framework for TypeScript. It runs on Node.js, Bun, and Deno with full Bot API coverage, a composable plugin system, and first-class TypeScript support.
-
Callback routing — NEVER parse callback data manually. CallbackData.pack() produces a 6-character hash prefix (sha1-base64url of the schema name) + serialized payload — NOT a literal prefix like "nav:". Checks like ctx.data.startsWith("nav:") always fail at runtime. Use one of these four patterns, picked by shape of data:
bot.callbackQuery("refresh", (ctx) => ctx.answer("Refreshed"));
bot.callbackQuery(/^user_(\d+)$/, (ctx) => {
const [, id] = ctx.match!;
ctx.answer(`User ${id}`);
});
import { CallbackData } from "gramio";
const nav = new CallbackData("nav").enum("to", ["home", "history", "admin"]);
bot.callbackQuery(nav, (ctx) => {
ctx.queryData.to;
});
const result = nav.safeUnpack(ctx.data!);
if (!result.success) return ctx.answer("Button expired");
result.data.to;
if (ctx.data?.startsWith("nav:")) {
const [, to] = ctx.data.slice(4).split(":");
}
See callback-data and middleware-routing for overlapping-handler behavior across plugins.
-
Method chaining — handlers, hooks, and plugins chain via .command(), .on(), .extend(), etc. Order matters: when two handlers match the same update, the first-registered one wins unless it calls next(). See middleware-routing.
-
Type-safe context — context is automatically typed based on the update type. Use context.is("message") for type narrowing in generic handlers. After .derive()/.decorate()/.extend(plugin), new fields appear on the inferred context type automatically — never cast with ctx as unknown as { myField }. Export the bot's context type and reuse it (see context → "Context typing after derive").
-
Context getters — always camelCase; never touch ctx.payload or ctx.update.* — every Telegram field is exposed as a camelCase getter (ctx.from, ctx.firstName, ctx.chatId, ctx.messageId, ctx.text, ctx.data, ctx.queryData). Both ctx.payload AND ctx.update are raw snake_case internal objects — treat them as off-limits in handler code.
-
Plugin system — new Plugin("name").derive(() => ({ ... })) adds typed properties to context. Register via bot.extend(plugin).
-
Hooks lifecycle — onStart → (updates with onError) → onStop. API calls: preRequest → call → onResponse/onResponseError.
-
Error suppression — bot.api.method({ suppress: true }) returns error instead of throwing.
-
Lazy plugins — async plugins (without await) load at bot.start(). Use await for immediate loading.
-
Derive vs Decorate — .derive() runs per-update (computed), .decorate() injects static values once.
-
Formatting — four critical rules (read formatting before writing any message text):
- Never use
parse_mode — format produces MessageEntity arrays, not HTML/Markdown strings. Adding parse_mode: "HTML" or "MarkdownV2" will break the message. GramIO passes entities automatically.
- Never use native
.join() on arrays of formatted values — it calls .toString() on each Formattable, silently destroying all styling. Always use the join helper: join(items, (x) => bold(x), "\n").
- **Always wrap styled content in
format\`** when composing or reusing — embedding a Formattablein a plain template literal (``${boldx}``) strips all entities. Useformat`${bold`x`}`` instead.
- Never call
.toString() on a FormattableString — pass it directly as the text:/caption: param to send, editMessageText, editMessageCaption, etc. Calling .toString() strips all entities. This is the #1 reason magic-links and formatted entities "stop working" after an edit.
-
Scenes — step semantics and update-type filtering — context.scene.step.go(N) and context.scene.step.next() run the scene's middleware chain immediately, but each .step(updateName, handler) filters by context.is(updateName). If your current context is callback_query and the next step is .step("message", …), it will not fire — you must either send the UI directly before return step.next(), use .step(["message", "callback_query"], …), or render the prompt in onEnter / the current step's callback handler. Prefer .ask("field", zodSchema, "prompt") for single-value validated input. See scenes.
-
InlineQueryResult builders — use InlineQueryResult.article(id, title, InputMessageContent.text(...)) and similar builder methods for inline results. bot.inlineQuery(/regex/, handler) routes inline queries. See triggers.
-
Composing screens — prefer @gramio/views over inline ctx.send / ctx.editText — for any bot with more than a couple of screens, use the @gramio/views plugin to define reusable view templates and render them with context.render(view, params). It auto-detects send-vs-edit based on update type, centralises text/keyboards/media (incl. JSON-driven templates with {{param}} / {{$global}} interpolation and i18n via adapter factory), and eliminates the common failure mode of duplicating message bodies across ctx.send(...) and ctx.editText(...) call sites. The plain render-function pattern is fine for trivial menu-only bots (2-3 screens, no media, no i18n) — anything more graduates to @gramio/views. @gramio/views is marked work-in-progress (helper/adapter details may evolve), but the core shape (initViewsBuilder, .render(fn), context.render(...)) is stable and is the recommended direction for view composition in GramIO. See views.
-
@gramio/prompt is in-memory only — NOT persistent. Use Scenes + .ask() for any multi-step / connect / onboarding flow. The Promise returned by context.prompt(...) / context.wait(...) lives in the current Node.js process. The moment the process restarts (deploy, crash, container reschedule, dyno cycling), the Promise is gone: the user's next message is not treated as an answer, no error is thrown, the bot silently "forgets" where the user was. This is fatal for OAuth-connect flows (Spotify/Last.fm/etc.), onboarding wizards, payment collection, any question-sequence that must survive a deploy. Scenes, by contrast, persist step index and collected answers in the configured storage (Redis / Cloudflare / custom) and rehydrate on the next update. Rule: @gramio/prompt is acceptable only for single-question, in-process prompts where losing the flow on restart is acceptable (e.g. throwaway confirmations, debug helpers). Every question-sequence that matters — and every "ask → validate → ask again" connect flow — belongs in a Scene with .ask("field", zodSchema, "prompt message"). .ask() kills the same firstTime branching and validation-retry boilerplate as prompt, plus it persists. Do not recommend @gramio/prompt as a simpler alternative to scenes for multi-step flows; that is the recommendation trap this concept exists to block. See scenes and prompt.
-
Subagent delegation — skills do not auto-activate inside subagent sessions. When spawning a subagent that will write bot code, explicitly pass the relevant reference-file paths (e.g. skills/references/callback-data.md, skills/plugins/scenes.md, skills/references/formatting.md, skills/references/middleware-routing.md) in the agent prompt, or include the key rules inline.
-
No any anywhere in examples — never write ctx: any, as any, <any>, or implicit-any parameters in any file under skills/ (examples, markdown code blocks, plugin docs). Skill examples are templates that AI copies verbatim into user bots; every any here multiplies into every downstream bot. Derive the proper type from ContextType<typeof bot, "update_name">, CallbackQueryShorthandContext<typeof bot, typeof schema>, or export a BotContext = typeof bot['_']['context'] alias. If a value is genuinely unknown at a system boundary, use unknown + narrowing. No exceptions, even in "what-not-to-do" snippets — use @ts-expect-error on the specific line with a comment instead of a broad any.
-
Button-first UX — users tap, they don't type. Navigation belongs to inline keyboards, not slash commands. /start should be a short hero (bold title + blockquote description) with an inline keyboard of primary actions — not a wall of text listing /help, /settings, /delete. Nested menus need breadcrumbs in the title (⚙️ Settings · home › settings), a ◀ Back button on every non-home screen, and 🏠 Home anywhere deeper than two levels. Navigation clicks edit the current message (ctx.editText), never send new ones — new sends are for events (results, notifications), not navigation. Toggle buttons carry their state in the label (✅ Notifications / ⬜ Notifications) and one handler flips the session field + rerenders. Destructive actions always go through a confirm screen with the safe default on the left. Every callback handler starts with ctx.answer() so the spinner stops immediately — empty is fine for navigation, short text for toast feedback, { show_alert: true } only for errors the user must acknowledge. Commands exist for discovery (register with setMyCommands so they appear in Telegram's menu button), not as the primary UI. See ux-patterns for the full playbook and examples/ux-menu.ts for a worked example covering hero /start, nested menu, toggles, and destructive confirm.
-
Run bun run check:skills before finishing any skill edit — any change to skills/**/*.ts or TypeScript code blocks in skills/**/*.md must typecheck cleanly against the currently installed gramio versions. The check:skills script runs tsc --noEmit over skills/examples/*.ts with strict mode. If it reports errors, fix them — don't ship. If a pre-existing example breaks because gramio's API evolved, update the example to match the current API (check node_modules/gramio/dist/index.d.ts and node_modules/@gramio/*/dist/index.d.ts for current signatures).
Each page contains: GramIO TypeScript examples, parameter details, error table with causes and fixes, tips & gotchas, and related links. When a user asks about a specific Telegram API method or type, you can fetch or reference the corresponding page for accurate GramIO-specific usage.
Load when the user wants to migrate an existing bot to GramIO.