@yaebal/scenes
durable, step-by-step wizards. every step is self-contained — it asks its own question on a firstTime pass, then processes each answer — so wizards can navigate
(next/previous/go), nest (enterSub), validate
(ask), and resume mid-flow after a restart from any persistent storage adapter.
install
pnpm add @yaebal/scenesa first wizard
declare scenes with defineScene (pinning the context, the typed state bag and enter
params), register them with scenes({ name: def }), and start one from any
handler with ctx.scene.enter(name) — scene names are typed, a typo is a compile
error.
import { createBot, type Context } from "yaebal";
import { ask, defineScene, scenes } from "@yaebal/scenes";
const register = defineScene<Context, { name: string; age: number }>({
steps: [
ask("name", { question: "what is your name?" }),
ask("age", {
question: (ctx) => `nice, ${ctx.scene.state.name}! how old are you?`,
parse: (text) => (/^\d+$/.test(text) ? Number(text) : undefined),
invalid: "age is a number — try again",
}),
],
onLeave: (ctx, info) =>
info.reason === "finish" &&
ctx.send(`saved ${ctx.scene.state.name}, ${ctx.scene.state.age} ✨`),
});
const bot = createBot(process.env.BOT_TOKEN!)
.install(scenes({ register }));
bot.command("register", (ctx) => ctx.scene.enter("register"));
bot.start();the step model
each step runs in two kinds of passes. on the question pass (ctx.scene.firstTime === true, right after entering or navigating) it asks its
question and returns. on every processing pass it receives an update the step
claims, reads the answer, and navigates — or doesn't, which keeps the user on the step (that is
the validation loop). running past the last step finishes the scene.
import { defineScene } from "@yaebal/scenes";
const echo = defineScene({
steps: [
async (ctx) => {
if (ctx.scene.firstTime) return ctx.send("say something");
await ctx.send(`you said: ${ctx.text}`);
return ctx.scene.next(); // past the last step → the scene finishes
},
],
});ask(): one-liner question steps
ask(key, options) builds the whole question-validate-store-advance step. the parsed
answer lands in ctx.scene.state[key]; the step is named after the key, so go("email") jumps back to it. parse accepts any standard-schema validator (zod,
valibot, arktype — typed structurally, no dependency) or a plain function.
import { z } from "zod"; // any standard-schema library: zod, valibot, arktype
import { ask, defineScene } from "@yaebal/scenes";
const signup = defineScene<Context, { email: string; age: number }>({
steps: [
ask("email", {
question: "your email?",
parse: z.string().email(), // schema issues become the error message
}),
ask("age", {
question: (ctx) => `thanks! how old are you?`,
// or a plain function: undefined = invalid, stays on the step
parse: (text) => (/^\d+$/.test(text) ? Number(text) : undefined),
invalid: "age is a number — try again",
}),
],
});navigation and named steps
const flow = defineScene<Context, { a: string }>({
steps: [
ask("a", { question: "A?" }),
{
name: "review", // named steps: ctx.scene.go("review")
handler: async (ctx) => {
if (ctx.scene.firstTime) return ctx.send(`review: ${ctx.scene.state.a}`);
if (ctx.text === "back") return ctx.scene.previous(); // re-asks A
if (ctx.text === "edit") return ctx.scene.go("a");
return ctx.scene.leave();
},
},
],
});steps over buttons
a step claims fresh messages by default. give it on filter queries — the same
mini-language as bot.on(...) — to build inline-keyboard wizards; anything the step
doesn't claim falls through to your normal handlers.
import { createBot, type Context } from "yaebal";
import { defineScene, scenes } from "@yaebal/scenes";
const quest = defineScene<Context, { klass?: string }>({
steps: [
{
on: ["callback_query:data"], // this step consumes button presses, not text
handler: async (ctx) => {
if (ctx.scene.firstTime)
return ctx.send("choose a class:", {
reply_markup: { inline_keyboard: [[
{ text: "builder", callback_data: "class:builder" },
{ text: "mage", callback_data: "class:mage" },
]] },
});
ctx.scene.state.klass = ctx.update.callback_query?.data?.split(":")[1];
return ctx.scene.next();
},
},
],
onLeave: (ctx, info) =>
info.reason === "finish" && ctx.send(`class locked: ${ctx.scene.state.klass}`),
});
const bot = createBot(process.env.BOT_TOKEN!)
.install(scenes({ quest }));
bot.command("quest", (ctx) => ctx.scene.enter("quest"));
bot.start();sub-scenes
enterSub suspends the current scene on a stack and runs another; when the sub-scene
finishes (or calls exitSub(merge)), the parent resumes at its current step, re-asks
its question, and merge lands in the parent's state bag. reusable fragments — an
address form, a quantity picker — become scenes of their own.
const qty = defineScene<Context, { qty: number }>({
steps: [ask("qty", { question: "how many?", parse: parseQty })],
onLeave: async (ctx, info) => {
// hand the result back to whoever suspended us
if (info.reason === "finish") await ctx.scene.exitSub({ qty: ctx.scene.state.qty });
},
});
// inside a parent step:
// ctx.scene.enterSub("qty") → suspends the parent, runs qty
// when qty exits, the merge lands in the parent's state and the
// parent's current step re-asks its questionhooks and typed context
the def's context parameter declares plugin dependencies: a scene over Context & { session: Profile } type-errors unless the session plugin is
installed first (core invariant #4), and ctx.session is typed inside every step.
const quest = defineScene<Context & { session: Profile }, QuestState>({
initial: () => ({ tries: 0 }), // the state bag before enter({ state }) merges
onEnter: (ctx) => track("quest_start"),
onLeave: (ctx, info) => {
// info.reason: "finish" | "leave" | "switch" | "reenter" | "expired"
if (info.cancelled) return ctx.send("quest cancelled");
if (info.reason === "finish") Object.assign(ctx.session, ctx.scene.state);
},
beforeStep: (ctx) => console.log("step", ctx.scene.step, ctx.scene.stepName),
steps: [/* … */],
});routing: passthrough and commands
scenes are polite by default. updates the current step doesn't claim fall through to normal
handlers, and /commands bypass the scene entirely — a global /cancel or /help keeps working mid-wizard with zero per-step boilerplate.
bot.install(scenes(defs, {
// updates the current step doesn't claim fall through to normal handlers
passthrough: true, // default; false = swallow, predicate = exempt
// /commands bypass an active scene, so global handlers keep working
passCommands: ["cancel", "help"], // default true (all commands)
}));
// a global /cancel that works mid-wizard — no per-step checks needed
bot.command("cancel", (ctx) =>
ctx.scene.active ? ctx.scene.leave({ cancelled: true }) : ctx.send("nothing to cancel"));persistence and ttl
the whole wizard — scene, step, state bag, params, sub-scene stack — is one json snapshot in a StorageAdapter<SceneSnapshot>. the default is in-memory; pass any @yaebal/sklad adapter (redis, sqlite, cloudflare kv, json file) and restarts resume
users exactly where they were. ttl expires abandoned wizards, firing onLeave with reason "expired".
import { scenes } from "@yaebal/scenes";
import { redisStorage } from "@yaebal/sklad";
import Redis from "ioredis";
bot.install(scenes(defs, {
storage: redisStorage(new Redis()), // any StorageAdapter<SceneSnapshot>
ttl: 15 * 60_000, // expire abandoned wizards after 15 min
}));
// a restart now resumes users mid-wizard: scene, step, state bag and allapi
| export | description |
|---|---|
scenes(defs, options?) | the plugin. adds ctx.scene; scene names, enter state and params are typed from defs |
defineScene<C, S, P>(def) | identity helper that pins a def's context, state bag and params types |
ask(key, options) | ready-made question step: asks, validates, stores to state, advances |
SceneDef / Step / StepDef | a def: initial, onEnter, onLeave, beforeStep, afterStep, steps. a step is a bare
handler or { name?, on?, handler } |
SceneContext<C, S, P> | the context steps receive: C & { scene: ActiveScene<S, P> } |
SceneSnapshot | the persisted shape — what a custom StorageAdapter stores |
ctx.scene inside a step (ActiveScene)
| member | description |
|---|---|
state / params | the typed state bag (mutate freely — persisted automatically) and enter params |
step / stepName / firstTime / name | where the user is: index, step name, question-pass flag, scene name |
next() / previous() / go(step, opts?) | move and run the target's question pass now. go takes an index or a step name;
past the last step = finish |
leave(opts?) / reenter(opts?) | leave({ cancelled, silent }) ends everything (sub-stack included); reenter restarts from step 0 |
enter(name, opts?) / enterSub(name, opts?) / exitSub(merge?) | switch scenes, or suspend into a sub-scene and come back with data |
ctx.scene everywhere else (SceneControl)
| member | description |
|---|---|
enter(name, opts?) | start a wizard. opts.state seeds the bag, opts.params is typed
per scene, opts.silent defers the first question. throws on unknown names and
keyless updates |
leave(opts?) | end the active scene (no-op without one) — what a global /cancel calls |
current / active | the active scene's (typed) name, and whether one is active |
ScenesOptions
| field | default | description |
|---|---|---|
storage | MemoryStorage | any StorageAdapter<SceneSnapshot> — see @yaebal/sklad |
getKey | chat.id:from.id | per user per chat: group-safe, and a wizard doesn't follow the user across chats. undefined disables scenes for the update |
passthrough | true | unclaimed updates fall through (true), are swallowed (false), or
a predicate exempts updates from the scene entirely |
passCommands | true | commands bypassing the scene: all, an allowlist array, or none |
ttl | — | ms of inactivity before an abandoned scene expires (lazily, on the next update) |
now | Date.now | clock override, mainly for tests |
on. that also means a
step's on: ["callback_query:data"] is all it takes to consume button presses. self-healing snapshots. a snapshot pointing at a scene or step that no longer exists (a deploy shrank the wizard) is deleted on the next update instead of shadowing the user forever;
enter() with an unknown name throws instead of persisting garbage. state must stay json-serializable. the state bag round-trips through the storage adapter — keep it data, not class instances.
concurrency. snapshots are read-modify-write per update. the built-in long poll is sequential and
@yaebal/runner's default per-chat lanes align with the default
key; on webhooks, serialize updates per chat yourself or two simultaneous answers can race.testing
wizards test end-to-end with @yaebal/test actors: send the trigger command, answer
with user.sendMessage(...), press buttons with user.click(...), and
assert on env.callsTo("sendMessage") and the storage contents. packages/scenes/src/index.test.ts covers every behavior on this page and doubles as
a cookbook.
related
@yaebal/conversation — the coroutine alternative
(await cv.wait()) for flows that don't need durable snapshots · @yaebal/prompt — a one-shot "ask once, handle the reply" · @yaebal/session — long-lived per-chat state the wizard can
write its results into.