@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

terminal
pnpm add @yaebal/scenes

a 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.

bot.ts
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.

step-model.ts
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.

ask.ts
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

navigation.ts
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.

bot.ts
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.

sub-scenes.ts
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 question

hooks 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.

hooks.ts
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.

routing.ts
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".

persistence.ts
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 all

api

exportdescription
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 / StepDefa 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> }
SceneSnapshotthe persisted shape — what a custom StorageAdapter stores

ctx.scene inside a step (ActiveScene)

memberdescription
state / paramsthe typed state bag (mutate freely — persisted automatically) and enter params
step / stepName / firstTime / namewhere 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)

memberdescription
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 / activethe active scene's (typed) name, and whether one is active

ScenesOptions

fielddefaultdescription
storageMemoryStorageany StorageAdapter<SceneSnapshot> — see @yaebal/sklad
getKeychat.id:from.idper user per chat: group-safe, and a wizard doesn't follow the user across chats. undefined disables scenes for the update
passthroughtrueunclaimed updates fall through (true), are swallowed (false), or a predicate exempts updates from the scene entirely
passCommandstruecommands bypassing the scene: all, an allowlist array, or none
ttlms of inactivity before an abandoned scene expires (lazily, on the next update)
nowDate.nowclock override, mainly for tests
steps claim fresh messages only. edited messages, channel posts, reactions and the rest never re-enter a wizard unless a step opts in via 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.