Status: alpha — phases 1–7 shipped (everything except the docs-site page + public skill examples). API is stable enough for real bots; semver resets to
0.xonce phase 8 lands.
Declarative user tutorials for GramIO bots. Walk a user through your bot's features one step at a time — advance on a "Next" button, or on the user actually doing the thing the step describes.
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." })
.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", async (ctx) => {
await ctx.send("Let's start!");
ctx.onboarding.welcome.start(); // fire-and-forget; bubble follows the reply
});
bot.on("message", async (ctx, next) => {
if (!/https?:\/\//.test(ctx.text ?? "")) return next();
await ctx.send("Downloading…");
ctx.onboarding.welcome.next({ from: "links" }); // no await; "All set!" follows
});
bot.start();Reply first, then hand off to the flow — ctx.onboarding.* never rejects, so there's no reason to await it. The { from: "links" } guard is idempotent: if the user somehow double-triggers the advance, only one step-change happens.
Writing an onboarding tutorial without a plugin means hand-rolling a tiny state machine per bot: a flag per user, a switch per step, stale-button guards, DM-vs-group checks, an opt-out path, and a way to layer "welcome" on top of "premium upgrade" without them stepping on each other. @gramio/onboarding is that state machine, done once, typed, and pluggable.
- Declarative steps that read like a script.
- Advance on real user actions — not just button clicks.
- Fire-and-forget API — calls never reject; failures forward to
bot.errorHandler. - Refusal ladder — skip → exit → dismiss →
disableAll, all opt-in via buttons. - Multi-flow concurrency — queue, preempt, or run in parallel.
- Cross-chat scoping — DM steps that wait for the user to appear in a group before rendering there.
- Optional views integration —
step.viewworks with@gramio/viewsfor rich content. - Storage-agnostic — any
Storagefrom the@gramio/storage-*ecosystem.
bun add @gramio/onboarding
# or npm / pnpm / yarnThe plugin peer-depends on gramio >= 0.7.0. @gramio/views is an optional peer — pull it in only if you want view-rendered steps.
A flow is a named, ordered list of steps. You build one with the chainable builder and extend your Bot with it. One bot can have any number of flows.
const welcome = createOnboarding({ id: "welcome", storage: memoryStorage() })
.step("hi", { text: "Hi!", buttons: ["next"] })
.step("done", { text: "Bye!" })
.build();
bot.extend(welcome);Every createOnboarding({ id }) returns a separate Plugin. Multi-flow bots simply .extend() each one; they coordinate through a shared ctx.onboarding namespace built by whichever plugin derives first.
A step is a piece of content (text or view) plus a handful of hooks and constraints:
interface StepConfig {
text?: string | ((ctx) => string);
view?: View | string | ((ctx) => View); // @gramio/views
args?: unknown | ((ctx) => unknown); // view args
media?: MediaSpec | ((ctx) => MediaSpec);
buttons?: Array<"next" | "skip" | "exit" | "dismiss">;
advanceOn?: (ctx) => boolean | Promise<boolean>;
passthrough?: boolean; // default true
renderIn?: "dm" | "group" | "any" | ((ctx) => boolean);
controls?: {
dm?: { next?, skip?, exit?, dismiss? };
group?: { next?, skip?, exit?, dismiss? };
};
onEnter?: (ctx) => unknown;
onLeave?: (ctx, { to, reason }) => unknown;
}text is the shortcut path — inline render with a built-in keyboard. view delegates to @gramio/views. You get both — pick per step.
Every flow has a per-user status:
null → active → completed | exited | dismissed
↕
paused (preempt mode only)
completed / exited / dismissed are terminal. dismissed sticks — users who "don't want to see this again" won't, even across force: true restarts (only explicit undismiss() or enableAll() clears it).
Once you extend your bot with a flow, every handler sees a typed ctx.onboarding namespace:
bot.command("start", (ctx) => {
ctx.onboarding.welcome.start(); // StartResult
ctx.onboarding.welcome.next(); // NextResult
ctx.onboarding.welcome.goto("links");
ctx.onboarding.welcome.skip();
ctx.onboarding.welcome.exit();
ctx.onboarding.welcome.dismiss();
ctx.onboarding.welcome.complete();
ctx.onboarding.welcome.status; // "active" | ...
ctx.onboarding.welcome.currentStep; // "hi"
ctx.onboarding.welcome.isActive;
ctx.onboarding.welcome.isDismissed;
ctx.onboarding.welcome.data; // step-shared scratchpad
ctx.onboarding.active; // { id, step } | null
ctx.onboarding.list; // ["welcome", "premium"]
ctx.onboarding.flow("premium"); // lookup by id
ctx.onboarding.disableAll(); // kill every flow
ctx.onboarding.enableAll();
ctx.onboarding.exitAll(); // dismiss all + disableAll
ctx.onboarding.allDisabled;
});"started" | "resumed" | "already-active" | "already-completed"
| "dismissed" | "opted-out" | "queued" | "preempted"
"advanced" | "completed" | "inactive" | "step-mismatch"
step-mismatch protects against races — pass { from: "links" } to assert you're advancing from a specific step; if the user already clicked Next, the second call is a no-op.
Three ways to advance — pick whichever fits:
The business handler owns the reply and the advance. Send your response first, then fire-and-forget the onboarding transition — the bubble follows the reply automatically.
bot.on("message", async (ctx, next) => {
if (!isLink(ctx.text)) return next();
await ctx.send("Downloading…");
ctx.onboarding.welcome.next({ from: "links" }); // no await
});The { from: "links" } guard makes it a no-op if the flow isn't on the "links" step — your handler stays valid even after a force-restart, a button click that already advanced, or a race with advanceOn.
.next() returns "advanced" | "completed" | "inactive" | "step-mismatch" if you do want to inspect the outcome:
const r = await ctx.onboarding.welcome.next({ from: "links" });
if (r === "completed") logAnalytics("welcome.completed", ctx.from?.id);.step("hi", { text: "Hi!", buttons: ["next", "exit"] })The built-in renderer ships Next / Skip / Exit / Don't show again buttons. Callback data is onb:<op>:<flowId>:<runId>:<stepId> — stale clicks after a force-restart safely no-op with "Already moving on". Buttons are the right pick for "intro" / "done" steps that aren't gated by a user action.
Drop the predicate on the step and the plugin installs a message middleware that runs it on every update while the flow is active:
.step("links", {
text: "Send me a link.",
advanceOn: (ctx) => /https?:\/\//.test(ctx.text ?? ""),
})By default the update still reaches your regular handlers (passthrough: true), so you don't have to duplicate the predicate. Set passthrough: false on the step to suppress forwarding after a match. advanceOn is concise but spreads the "what happens next" across two places — prefer next({ from }) when the same handler already produces the reply.
Steps can pin themselves to DM or group context:
const welcome = createOnboarding({ id: "welcome" })
.step("ask-group", { text: "Add me to your group!", buttons: ["next"] })
.step("in-the-group", { text: "Hi team!", renderIn: "group" })
.build();When the next step's renderIn doesn't match the current chat (user clicked Next in DM but the step wants "group"), the runner doesn't render — it stashes pendingStepId on the record. The moment the user sends anything in a group, the derive hook sees the pending step is now eligible and renders it there.
Group steps default to showing only the Exit button (no Next/Skip/Dismiss noise in a group chat). Override per flow or per step:
createOnboarding({
id: "welcome",
controls: { group: { next: true } }, // flow-level
})
.step("demo", {
text: "Here in your group!",
renderIn: "group",
controls: { group: { exit: false } }, // step-level
})renderIn also accepts a function — e.g. (ctx) => ctx.from?.id === ADMIN_ID for admin-only steps.
Multiple flows coexist. What happens when two flows want to start at once is per-flow policy:
const welcome = createOnboarding({
id: "welcome",
concurrency: "queue", // default — enqueue, auto-start on terminal
}).build();
const announce = createOnboarding({
id: "announce",
concurrency: "preempt", // pause welcome, run announce, then resume
}).build();
const tip = createOnboarding({
id: "tip",
concurrency: "parallel", // ignore coordination
}).build();
bot.extend(welcome).extend(announce).extend(tip);queue(default) —start()returns"queued"if another flow is live; the coordinator drains the FIFO queue on every terminal event.preempt—start()pauses every active flow (LIFO preempt stack), runs itself, then resumes the topmost paused flow.start()returns"preempted".parallel— no coordination; multiple flows render simultaneously.
State lives on a shared global:<scopeKey> record, so coordination survives restarts as long as your storage does.
Two levels, because "I've seen this one" and "stop all tutorials forever" aren't the same request.
// Per-flow — only this tutorial stops
ctx.onboarding.welcome.dismiss(); // onDismiss hook fires
ctx.onboarding.welcome.undismiss(); // reversible
// Namespace-wide — every flow is blocked
ctx.onboarding.disableAll(); // start() returns "opted-out" everywhere
ctx.onboarding.enableAll(); // reversible
ctx.onboarding.exitAll(); // dismiss active + disableAllFire-and-forget like every other ctx.onboarding.* method. Typical wiring:
bot.command("no_tutorials", async (ctx) => {
await ctx.send("Got it — I'll stop showing guides.");
ctx.onboarding.exitAll();
});Expose them in the UI by listing "dismiss" in buttons (rendered as Don't show again), or by emitting an exitAll token from a view. startImpl checks disabled before dismissed, so after exitAll every flow returns "opted-out".
The storage: option accepts any Storage<OnboardingStorageMap> — the same Storage interface every other GramIO plugin uses:
import { memoryStorage } from "@gramio/onboarding";
import { redisStorage } from "@gramio/storage-redis";
createOnboarding({ id: "welcome", storage: memoryStorage() });
createOnboarding({ id: "welcome", storage: redisStorage({ client }) });No wrapper needed — the plugin stores records under flow:<flowId>:<scopeKey> and global:<scopeKey>, where scopeKey is userId by default (or chatId, or your own resolver via scope: (ctx) => ...).
Ship any backend by implementing the Storage<T> interface and running the exported contract suite:
import { describe, it } from "bun:test";
import { getStorageContractCases } from "@gramio/onboarding";
import { myAdapter } from "./my-adapter.js";
describe("my adapter", () => {
for (const c of getStorageContractCases(() => myAdapter({ fresh: true }))) {
it(c.name, c.run);
}
});getStorageContractCases() is framework-agnostic — it returns { name, run }[], so you can wire it to bun:test, vitest, Jest, node:test, whatever. The suite pins get/set/has/delete semantics, overwrite behaviour, and nested-data round-trips.
If you pass step.view and @gramio/views is wired on the bot, the plugin delegates rendering to ctx.render(view, args) instead of using the built-in inline renderer. Inside the view, the current step's token bundle is available as this.onboarding:
interface OnboardingViewCtx {
flowId: string;
stepId: string;
data: Record<string, unknown>;
next: string | undefined; // callback_data; undefined when control is disabled
skip: string | undefined;
exit: string; // always defined
dismiss: string | undefined;
exitAll: string; // always defined (nuclear)
goto: (target: string) => string;
}Declare a Globals shape that includes onboarding, then register buildRender in a derive, wrapping your globals with withOnboardingGlobals():
import { Bot, InlineKeyboard } from "gramio";
import { initViewsBuilder } from "@gramio/views";
import {
createOnboarding,
withOnboardingGlobals,
type OnboardingViewCtx,
} from "@gramio/onboarding";
interface Globals {
greeting: string;
onboarding: OnboardingViewCtx | undefined;
}
const defineView = initViewsBuilder<Globals>();
const welcomeView = defineView().render(function () {
const tokens = this.onboarding;
const kb = new InlineKeyboard();
if (tokens?.next) kb.text("Continue", tokens.next);
if (tokens?.exit) kb.row().text("Skip tour", tokens.exit);
return this.response
.text(`${this.greeting}, ${tokens?.stepId ?? "?"}!`)
.keyboard(kb);
});
const welcome = createOnboarding({ id: "welcome" })
.step("hi", { view: welcomeView })
.step("done", { text: "All set!" })
.build();
const bot = new Bot(process.env.BOT_TOKEN!)
.extend(welcome)
.derive(["message", "callback_query"], (ctx) => ({
render: defineView.buildRender(
ctx,
withOnboardingGlobals({ greeting: "Hi" }),
),
}));withOnboardingGlobals({ greeting: "Hi" }) returns a thunk — () => ({ greeting, onboarding }) — consumed by buildRender's lazy-globals path (@gramio/views ≥ 0.2). The thunk is re-evaluated on every ctx.render(...), so if middleware advances the flow between .derive() and the render call, the view sees the new step's tokens — no stale snapshots.
Outside an onboarding-driven render (e.g. you call ctx.render(someOtherView) directly from a command handler), this.onboarding is simply undefined.
buildRender also accepts plain objects with getters — useful when other globals need the same fresh-per-render treatment:
.derive(["message", "callback_query"], (ctx) => ({
render: defineView.buildRender(ctx, withOnboardingGlobals({
userId: ctx.from!.id,
get locale() { return ctx.session.locale; }, // evaluated per render
})),
}));Because withOnboardingGlobals uses object spread, the getters survive to createContext — no Object.defineProperty gymnastics required.
Every ctx.onboarding.* method is fire-and-forget. They return a promise (so you can await the result code), but they never reject. Storage failures, render errors, user hooks that throw — all get forwarded to your bot.errorHandler with a structured payload:
bot.onError(({ error, context }) => {
// context: { source: "onboarding", flowId: "welcome", op: "next.render" }
logger.error(error, { source: context.source, flowId: context.flowId });
});Set errors: "throw" on a flow if you want the loud version — useful in tests.
bunx pkgroll # Build dist/
tsc --noEmit # Strict typecheck
bun test # bun:test — currently 76 tests across 8 files
bunx biome check # LintTests live next to the phase they exercise — tests/basic.test.ts, tests/advanceOn.test.ts, tests/concurrency.test.ts, tests/scope.test.ts, tests/optOut.test.ts, tests/storage-contract.test.ts, etc. They drive a real Bot through @gramio/test's TelegramTestEnvironment — no unit-test mocking; the tests are what a user would observe.
MIT