Bot API 9.6, gramio 0.9, and a New Onboarding Plugin
March 2 – May 8, 2026
The biggest cycle since the framework's 0.5 launch. Telegram Bot API 9.6 lands across the ecosystem with managed bots, richer polls, and new entity helpers. gramio 0.9 ships bot.syncCommands() for automatic Telegram command-menu sync, full Plugin shorthand methods, and an allowed_updates builder that auto-derives from registered handlers. The brand-new @gramio/onboarding plugin provides declarative tutorials with multi-flow concurrency, scope-aware rendering, and pluggable storage. @gramio/scenes 0.6 finally stops eating your global /cancel and /help commands. @gramio/views 0.2 adds lazy globals that re-evaluate per render. And wrappergram v2 is a complete middleware-based rewrite that powers it all underneath.
gramio v0.9.0 — Command Sync, Plugin Shorthands, Smart Allowed Updates
bot.syncCommands() — Telegram menu sync without ceremony
Declaring a /command is half the battle — the other half is keeping Telegram's command menu in sync. bot.command() now accepts optional CommandMeta (description, locales, scopes, hide), and a single bot.syncCommands() call flushes everything to Telegram with hash-based caching so unchanged metadata doesn't burn rate-limit budget.
import { Bot } from "gramio";
const bot = new Bot(process.env.BOT_TOKEN!)
.command("start", { description: "Start the bot" }, (ctx) => ctx.send("Hello!"))
.command(
"help",
{ description: "Show help", locales: { ru: "Помощь", uk: "Допомога" } },
(ctx) => ctx.send("Help!"),
)
.command(
"admin",
{ description: "Admin panel", scopes: [{ type: "chat_administrators" }] },
adminHandler,
)
.command("debug", { hide: true }, debugHandler);
bot.onStart(() => bot.syncCommands());
await bot.start();The meta argument sits between the command name and the handler — bot.command(name, meta, handler). The plain two-arg form bot.command(name, handler) still works for commands you don't want in the menu.
syncCommands() groups commands by scope, deduplicates by hash, and skips entire scopes when nothing changed. Pair it with the new localesFor() helper from @gramio/i18n to drive locales straight from your translation files.
Plugin shorthand methods — Plugin().command(...) works directly
Until now, encapsulating a feature as a Plugin meant routing through the plugin's internal composer just to register handlers. Plugins now expose command, callbackQuery, hears, reaction, inlineQuery, chosenInlineResult, and startParameter as direct methods, sharing the same implementation as Bot and Composer:
import { Plugin } from "gramio";
const adminPlugin = new Plugin("admin")
.command("ban", banHandler)
.command("unban", unbanHandler)
.callbackQuery(adminAction, callbackHandler);
bot.extend(adminPlugin);composer.extend(plugin).command(...) chains correctly without losing accumulated TMethods typings (77f8c02). Plugin.extend(plugin) now propagates middleware, hooks, decorators, error definitions, groups, and dependencies — previously it carried only types.
AllowedUpdatesFilter — derive allowed_updates from registered handlers
chat_member, message_reaction, and message_reaction_count are the three update types Telegram excludes from getUpdates/setWebhook unless explicitly listed in allowed_updates — silently dropping them is an evergreen footgun. GramIO 0.9 fixes this in two ways:
import { Bot, AllowedUpdatesFilter } from "gramio";
const bot = new Bot(token)
.on("chat_member", chatMemberHandler)
.on("message_reaction", reactionHandler);
// 1. Default — no arg. GramIO scans your handlers and auto opt-ins to
// chat_member / message_reaction / message_reaction_count when registered.
await bot.start();
// 2. Strict mode — only request the update types your handlers register for,
// nothing else. Pass the literal string "strict".
await bot.start({ allowedUpdates: "strict" });
// 3. Explicit fluent builder — immutable Array<AllowedUpdateName>.
await bot.start({
allowedUpdates: AllowedUpdatesFilter.only("message", "callback_query"),
});
// 4. Default set with extras / exclusions.
await bot.start({
allowedUpdates: AllowedUpdatesFilter.default
.add("chat_member")
.except("poll", "poll_answer"),
});Available factories: AllowedUpdatesFilter.all / .default / .only(...types), with .add(...) and .except(...) chaining on any instance.
AnyBot no longer collapses ctx.isPM() / isGroup() / isChannel() to never
A long-standing TypeScript bug (gramiojs/gramio#28, fixed in @gramio/contexts 0.5.1): on Bot/Composer command and message handlers, ctx.isPM() and friends would narrow ctx to never instead of the expected branch. Fixed upstream in contexts and pulled into gramio 0.8.3+.
Bot instance available in onStart / onStop hooks
Both lifecycle hooks now receive the bot instance, so you can call bot.api.* during startup/shutdown without capturing a closure:
bot.onStart(({ bot, info }) => bot.api.sendMessage({ chat_id: ADMIN, text: `Started as @${info.username}` }));Updated packages in this release: gramio v0.9.0, @gramio/contexts v0.6.1, @gramio/types v9.6.1, @gramio/files v0.4.0, @gramio/format v0.7.0, @gramio/keyboards v1.4.0, @gramio/composer v0.4.1, @gramio/test v0.7.0.
Bot API 9.6 — Managed Bots, Richer Polls, New Entities
Managed bots: ManagedBotCreated, ManagedBotUpdated, and the new managed_bot update
Telegram's new managed-bot model lets a parent bot programmatically manage child bots. GramIO surfaces this through new contexts (managed_bot, managed_bot_created), new structures (ManagedBotCreated, ManagedBotUpdated), and User.canManageBots(). The ChatMemberControlMixin gains getManagedBotToken() and replaceManagedBotToken(), so token rotation lives next to the chat admin APIs. @gramio/keyboards 1.4.0 ships the requestManagedBot button for picking a managed bot from a Telegram dialog.
Polls overhauled — option add/remove, revoting, descriptions, persistent IDs
Polls in 9.6 are no longer immutable. Two new updates — poll_option_added and poll_option_deleted — fire as users mutate poll structure. Poll gains allowsRevoting, description, and descriptionEntities; correctOptionId is now correctOptionIds (array). PollOption gets persistentId, addedByUser, addedByChat, and additionDate. PollAnswer adds optionPersistentIds. Message gets replyToPollOptionId, managedBotCreated, pollOptionAdded, pollOptionDeleted.
Mojibake guard for emoji enums
@gramio/types 9.6.0 briefly shipped corrupted SendDiceEmoji values ("рџЋІ") when the upstream HTTP response was decoded as windows-1251. 9.6.1 throws on detection of the "рџ" byte sequence and the regenerated package has clean unicode again.
@gramio/onboarding v0.1.0 — New Official Plugin
Declarative user tutorials with multi-flow concurrency
A brand-new official plugin for walking users through your bot's features one step at a time. Steps advance on a "Next" button, on the user actually completing the action (advanceOn), or programmatically from a real handler (ctx.onboarding.<flow>.next({ from })). Multiple flows compose independently — welcome, premium-upsell, new-feature — with three concurrency modes (queue, preempt, parallel).
import { Bot } from "gramio";
import { createOnboarding } from "@gramio/onboarding";
const welcome = createOnboarding({ id: "welcome" })
.step("hi", { text: "Hi! I'll show you around.", buttons: ["next", "exit"] })
.step("links", { text: "Send me any link — I'll download it.", buttons: ["next", "dismiss"] })
.step("done", { text: "All set!" })
.onComplete((ctx) => ctx.send("Welcome aboard! /help is always available."))
.build();
const bot = new Bot(process.env.BOT_TOKEN!).extend(welcome);
bot.command("start", (ctx) => {
ctx.onboarding.welcome.start();
return ctx.send("Let's start!");
});Highlights:
- Refusal ladder —
next → skip → exit → dismiss → disableAll, all opt-in via buttons. - Scope-aware rendering —
renderIn: "dm" | "group" | "any"defers a step when the current chat doesn't fit and re-renders on the next eligible update. - Fire-and-forget API — every
ctx.onboarding.*call swallows errors and forwards them tobot.errorHandler, never throws into your business logic. - Storage-agnostic — pluggable
Storage<OnboardingStorageMap>via@gramio/storage(memory / redis / sqlite / cloudflare). A framework-agnosticgetStorageContractCases()helper lets adapter authors verify their adapter against the full contract. - Optional
@gramio/viewsintegration — passstep.viewand use the new lazy-globals support so views always see the live onboarding tokens.
Full reference: /plugins/official/onboarding.
@gramio/scenes v0.6.0 — Stop Eating Global Commands
Passthrough: non-matching updates flow to outer handlers
Previously, while a user was inside a scene, any update that didn't match the current step was silently swallowed. A user stuck mid-form couldn't run /cancel or /help registered outside the scene. 0.6.0 flips the default: non-matching updates now propagate to the rest of the bot chain, while the scene preserves its firstTime state so the user doesn't lose their place. Opt out with passthrough: false to restore the legacy greedy behavior.
import { Bot } from "gramio";
import { scenes } from "@gramio/scenes";
const bot = new Bot(token)
.extend(scenes([signupScene])) // passthrough: true by default
.command("cancel", (ctx) => ctx.scene?.exit()); // now actually fires!Sub-scenes and enterSub() / exitSub()
Scenes can now nest. ctx.scene.enterSub(otherScene, params) pushes the current scene onto a parent stack, runs the sub-scene to completion, then automatically returns to the caller's next step. The stack is persisted on the storage record, so a process restart resumes correctly. Use .exitData<T>() on a sub-scene to type the data it returns to the parent:
const pickAddress = new Scene("pick-address")
.exitData<{ address: string }>()
.step("ask", (ctx) => ctx.send("Send your address as text"))
.step("save", (ctx) => ctx.scene.exitSub({ address: ctx.text! }));
const checkout = new Scene("checkout")
.step("address", async (ctx) => {
await ctx.scene.enterSub(pickAddress); // pauses checkout, runs pickAddress
})
.step("confirm", (ctx) => {
// pickAddress resolved — its exitData is on ctx.scene.state via the parent merge
return ctx.send("Confirm order?");
});scene.reenter(params) and typed scene.enter() params
reenter() now accepts params, and scene.enter() properly type-checks the params tuple at the call site instead of falling back to any (c5b2dc5, closes #6).
scenesDerives uses Plugin for proper deduplication
Fixes #5 — context.scene was undefined when scenesDerives was extended via Composer because gramio couldn't dedupe an unnamed Composer. Switching to Plugin resolves the bug and unlocks shared storage between bot-level handlers and scene steps.
@gramio/format v0.7.0 — Block Separator Fix, Regenerated for Bot API 9.6
Markdown: preserve newlines between adjacent block tokens
A subtle but critical bug for any LLM-driven bot: marked stores a block's trailing newlines on its own raw field, so joining top-level tokens with an empty separator glued adjacent blocks together. "Agenda:\n- one\n- two" rendered as "Agenda:- one\n- two" — every enumerated assistant reply was wrong in production. The fix generalizes the previous heading-only workaround into normalizeBlockSeparators, covering paragraph+list, paragraph+blockquote, paragraph+code, heading+anything.
Mutator regenerated on @gramio/schema-parser
Replaces the old tg-bot-api/custom.min.json pipeline with @gramio/schema-parser's getCustomSchema(). The generator walks method parameters recursively, deduplicates transforms, and now covers 32 methods including sendMessageDraft, sendPaidMedia, sendPoll.description, sendChecklist, and editMessageChecklist.
formatMiddleware for the wrappergram v2 chain
@gramio/format/middleware now exports a ready-to-use middleware that decomposes FormattableString values into text + entities before each Telegram API call — dropped into the new wrappergram v2 middleware chain (or any other) without going through the gramio plugin path.
@gramio/views v0.2.0 — Lazy Globals via Thunk
buildRender now accepts Globals | (() => Globals). When a function is passed, it's invoked per render so views see fresh state from mutating sources — session, scene, onboarding snapshot, locale, role escalation. The adapter factory is also re-invoked per render with the resolved globals, so per-locale adapter selection keeps working when locale changes mid-handler:
bot.derive(["message", "callback_query"], (ctx) => ({
render: defineView.buildRender(ctx, () => ({
user: { id: ctx.from!.id, name: ctx.from!.firstName },
// captured fresh per render — locale flip in middleware "just works"
i18n: ctx.t,
// onboarding tokens for the @gramio/onboarding plugin
onboarding: getCurrentOnboardingTokens(),
})),
}));Plain-object form is unchanged. Property getters on plain-object globals already resolved per-render, so a getter mix is also valid.
@gramio/test v0.7.0 — Bubble Tracking, Telegram Payments, Type-Safe ApiCall
env.lastBotMessage() — bubble that tracks edits
A MessageObject mirror of the bot's last sendMessage, kept in sync with editMessageText / editMessageCaption / editMessageReplyMarkup eagerly in the proxy, so references captured before an edit stay current. user.on(bubble).clickByText(...) now works across multiple edits on the same reference. reply_markup Builder instances (e.g. InlineKeyboard) are normalized via .toJSON() before recording, so no more JSON.parse(JSON.stringify(...)) roundtrips in tests:
import { TelegramTestEnvironment } from "@gramio/test";
import { bot } from "./bot.js";
const env = new TelegramTestEnvironment(bot);
const user = env.user(1, { firstName: "Alice" });
await user.command("start");
const bubble = env.lastBotMessage(); // first send
await user.on(bubble).clickByText("Next →"); // bot edits the same message
// `bubble` now reflects the edited state — no manual refresh
expect(bubble.payload.text).toBe("Step 2 of 3");withReplyMarkup and where predicate filters
const bubble = env.lastBotMessage({ withReplyMarkup: true }); // skip status/confirmation messages
const found = env.lastBotMessage({ where: (call) => /Agenda/.test(call.params.text) });Telegram Payments support
PreCheckoutQueryObject and ShippingQueryObject builders, plus user.sendPreCheckoutQuery(), user.sendShippingQuery(), and user.sendSuccessfulPayment() for full payment flow simulation including bot pre-checkout approval verification.
Type-safe ApiCall<Method> and filterApiCalls(method)
ApiCall<Method> types params/response via APIMethodParams/APIMethodReturn. lastApiCall("sendMessage") returns a typed result, and the new filterApiCalls("sendMessage") returns ApiCall<"sendMessage">[] with full narrowing.
@gramio/composer v0.4.1 — registeredEvents() and EventContextOf
registeredEvents() — introspect what's wired up
Returns a Set<string> of event names registered via .on() and event-specific .derive(), including parsed composite events ("message|callback_query") and entity patterns ("message:text"). Powers the auto-derived allowed_updates in gramio 0.9 above.
EventContextOf<T, E> — global + per-event derives in one type
When writing custom methods that target a specific event type, EventContextOf<T, E> extracts TOut & TDerives[E] from the composer instance, so per-event derives are visible without manual intersection:
import { Bot, EventContextOf } from "gramio";
const bot = new Bot(token)
.derive(() => ({ session: { count: 0 } })) // global derive
.derive("callback_query", (ctx) => ({ payload: ctx.queryData })); // per-event
// Custom helper typed for callback_query — sees session AND payload
function bumpCounter(ctx: EventContextOf<typeof bot, "callback_query">) {
ctx.session.count += 1;
return ctx.answer({ text: `Got payload: ${ctx.payload}` });
}Documented alongside the existing ContextType and BotContext patterns.
Framework-agnostic commandsMeta storage
commandsMeta is now an unknown-valued Map instead of holding Telegram-specific CommandMeta/ScopeShorthand types. The Telegram-specific shape moved into gramio core where it belongs (2429013).
guard() predicate ctx no longer collapses to any after derive()
The union of type-guard and boolean-predicate overloads caused TS to fall back to any. Splitting into two overloads restores proper contextual typing — fixes #1.
@gramio/i18n v1.5 — localesFor() Bridges i18n Keys to syncCommands
A new method on the defineI18n() instance that returns Record<string, string> of all non-primary translations for a key — designed to drop straight into CommandMeta.locales:
import { defineI18n } from "@gramio/i18n";
const i18n = defineI18n({
languages: { en, ru, uk },
primaryLanguage: "en",
});
bot.command("help", {
description: i18n.t("en", "cmd.help"), // primary-locale string
locales: i18n.localesFor("cmd.help"), // { ru: "Помощь", uk: "Допомога" }
}, helpHandler);localesFor iterates Object.keys(languages), skips the primary, runs the same t(lang, key, ...args) translator that powers ctx.t(), and drops keys that resolve to null/missing — so partial language coverage is fine.
@gramio/auto-answer-callback-query v0.0.3 — Always Answers, Even on Throw
Previously, a thrown handler would skip the auto-answer, leaving the user with a stuck spinner on the button. The middleware now wraps the handler in try/finally so answerCallbackQuery always runs.
@gramio/jsx — <date-time> Element
Supports the new dateTime entity from @gramio/format 0.5+ with unixTime and optional format props (r, w, d, D, t, T, wDT, Dt, etc.):
<>Today is <date-time unixTime={Date.now() / 1000} format="D" /></>wrappergram v2 — From Bare Proxy to Middleware Pipeline
The minimal Bot API wrapper that powers gramio's bot.api got a full rewrite. Previously, wrappergram shipped just a Telegram class with no extension points — every call ran a hardcoded convertJsonToFormData → fetch → response.json() pipeline, with @gramio/files as a mandatory dependency. v2 turns that pipeline into a middleware chain you can plug into:
// Before — no extension points, @gramio/files hard-coded
import { Telegram } from "wrappergram";
const tg = new Telegram(token);
const response = await tg.api.sendMessage({ chat_id, text });
// After — explicit middleware chain, files/format opt-in
import { Wrappergram, TelegramError } from "wrappergram";
import { filesMiddleware } from "@gramio/files/middleware";
import { formatMiddleware } from "@gramio/format/middleware";
const tg = new Wrappergram({
token,
middlewares: [
async (ctx, next) => {
const start = Date.now();
await next();
console.log(`${ctx.method} took ${Date.now() - start}ms`);
},
formatMiddleware,
filesMiddleware,
],
});
// Suppress errors at the call site instead of try/catch
const result = await tg.sendMessage({ chat_id, text }, { suppress: true });
if (result instanceof TelegramError) {
console.error("send failed:", result.code, result.payload);
}Highlights:
- Single
Middlewaretype —(context, next) => unknown. No 4-hook split, no callback soup. - First-class
TelegramErrorcarryingmethod,code,payload, pluscaptureStackTraceso you get a real stack pointing at the call site. suppress: truepattern — returnsTelegramError | Resultinstead of throwing.SuppressedAPIMethodsinfers the right return type at the type level via theIsSuppressedgeneric.- Per-request fetch options ride along as the second argument to every API method.
@gramio/filesis no longer a hard dependency — opt-in via the newfilesMiddlewareexport from@gramio/files/middleware. Same pattern for@gramio/format/middleware. Bundle size drops for users who don't need them.
Other Improvements
@gramio/schema-parser v1.1.0 — Shared-Sibling FormattableString Detection
InputTextMessageContent drops the key prefix from its entity siblings (bare parse_mode / entities instead of message_text_parse_mode), so the existing per-field check missed it. The new shared-sibling fallback promotes the sole unmarked string field to semanticType: "formattable" when the object has both bare parse_mode and a bare entities array. Closes a long-standing gap that needed a manual workaround in @gramio/format's mutator generator.
@gramio/contexts v0.5.1 — Type Narrowing Through AnyBot
When Bot generic was AnyBot, __Derives resolved to any, which collapsed GetDerives and Context.is() type narrowing. Fix from external contributor @ttempaa (PR #3).
create-gramio v2.2.0 — Scoped Composer + Scene Step Inheritance
Generated projects now split plugins/ into base.ts (named scoped composer with i18n / session / render) plus a thin assembly file. Scenes .extend(baseComposer) so step handlers get typed access to ctx.t, ctx.render, ctx.session, etc. Registration-time dedup on name: "base" keeps the middleware running exactly once per update. Also bumps gramio 0.5 → 0.9, scenes 0.3 → 0.6, views 0.0.5 → 0.2, test 0.3 → 0.7, and 8 other dependency lines.
ecosystem-ci — New Cross-Repo Compatibility Pipeline
Brand-new internal infrastructure: a CLI orchestrator (resolve-matrix, run-suite, run-all) with a full dependency graph of 21 @gramio/* packages across 5 layers. Clones repos, applies dependency overrides, runs install → build → type-check → tests per package. 9 production-like flow tests (38 cases) cover bot assembly, middleware composition, scenes lifecycle, callback+keyboard roundtrip, error handling, macros, format integration, inline queries, webhooks. Nightly schedule + manual dispatch + repository_dispatch trigger for cross-repo CI on package publish.
Documentation & Skills
A massive cycle for the docs and AI tooling too:
/gramio-pick-usernameskill — generates Telegram bot username candidates, validates against BotFather's rules, batch-checks t.me availability via the bundledcheck-usernames.mjsscript.- Skill verification gates —
bun run check:skillsandbun run test:skillsnow run on every CI push that touchesskills/**. Every example exports{ bot }and has a matching runtime test driving it through@gramio/test. - UX patterns reference — a button-first design playbook covering hero
/start, edit-in-place navigation, breadcrumbs, toggle buttons, destructive confirm flows, loading/empty states, and the ship checklist. - Introspection CLI tools — four scripts under
skills/tools/parse installed@gramio/*packages to return token-efficient signatures for 169 Bot API methods, 329 Telegram types, context getters, and plugin shapes. - grammY migration guide — comprehensive side-by-side code comparisons.
- Package-manager switcher — VitePress nav now has a global pm switcher; all
::: code-groupinstall blocks moved to::: pm-addshorthand.