umbot
    Preparing search index...

    umbot — a guide to building voice skills and chatbots

    This page is machine-translated from the Russian original. If something reads oddly, the Russian version is the source of truth — open an issue.

    About this guide The guide takes you from installation to a working bot: how the framework is organized, how to write commands and steps, where user data is stored, how to reply with buttons, cards and sounds, and how to avoid the typical pitfalls. Full signatures and tables are in the API reference, and details on specific topics are in the dedicated sections (links at the end of each chapter and in the table below).

    Framework version: umbot@3.1.x Repository: https://github.com/max36895/umbot npm: https://www.npmjs.com/package/umbot

    You need Section
    Create your first project in 5 minutes Quick start
    Full signatures of Bot, BotController, components, constants API reference
    Tokens, .env, modes, webhook signature verification Configuration and security
    The features and limits of each platform Platform integration
    Middleware and the built-in rateLimiter, authGuard, ipFilter Middleware
    BotTest, simulate(), Jest Testing
    HTTPS, Docker, PM2, serverless, scaling Deployment
    Ready-made solutions for common tasks Recipes


    umbot is a TypeScript framework for building voice skills (Alice, Sber SmartApp, Marusia) and chatbots ( Telegram, VK, MAX, Viber). The main idea: write the logic once — run it on any supported platform.

    The framework is focused on voice platforms: all voice functionality (TTS, sounds, SSML effects, nature sounds, pauses) is fully supported. For chatbots (Telegram, VK, Viber, Max) the same feature set is supported as for voice platforms — cards, buttons, audio messages. Messenger-specific features (polls, payments, message editing) that have no equivalents on voice platforms are not part of the unified API — they are available via controller.api and the platform API clients (TelegramRequest, VkRequest, MaxRequest, ViberRequest).

    Key properties:

    • A single business logic. The same code works on all registered platforms at once. The framework takes care of the differences in request/response formats.
    • Performance. Request processing inside the framework takes less than 30 ms even with 1000 commands. This is critical for voice platforms: for example, Alice's network limit is about 4.5 seconds, but the framework warns after 2 seconds of processing already and logs an error after 2.9 — treat ~3 seconds as the practical ceiling.
    • RegExp safety. Built-in protection against ReDoS attacks. re2 is used optionally (2–15 times faster).
    • Media caching. Images and sounds are uploaded to the platform once, and the tokens are cached in the database — repeated replies spend no time on uploads.
    • TypeScript-first. Full typing, strict mode, autocompletion.
    • CLI. npx umbot create <name> sets up a ready project in a minute.
    • Extensibility. You can add your own platform (via an adapter) or your own database (via a DB adapter).
    • Voice skill developers (Alice, Sber SmartApp, Marusia) — the main audience.
    • Teams that maintain a bot on several platforms at once (voice + chatbots).
    • Those who want to start with one platform but lay down an architecture for the future.
    • A visual dialog editor is a separate service, Umbot Flow: it exports flow.json, from which the CLI generates a project (npx umbot create from-flow).
    • It does not train its own NLU models — for Alice, intents are configured in Yandex Dialogs.
    • It does not host the skill — you need your own server or a serverless function.
    • It does not work with streaming audio responses (only TTS via SpeechKit or ready-made sounds).

    ┌──────────────────────── An HTTP request from a platform (Alice/TG/VK/...) ────────────────────────────────────┐
    │ │
    │ 1. webhookHandle() accepts the request, parses the JSON, validates the signature/token │
    │ 2. Bot.#getAppType() — automatic platform detection by the request body/headers │
    │ 3. platformAdapter.setQueryData(query, controller) — the adapter fills the controller: │
    │ controller.userCommand, userId, messageId, payload, nlu, state, isScreen ... │
    │ 4. Loading userData (from the database) or state (from the platform's local storage) │
    │ 5. Running the NLU plugin (if installed) — enriching controller.nlu │
    │ 6. Running the middleware chain: │
    │ global → platform │
    │ if a middleware did not call next() — the chain is broken (or execution stops), action() does not run │
    │ 7. controller.run() — the dispatcher: │
    │ 0) bot.addEvent handlers by controller.eventType (photo, callback, ...) │
    │ a) if oldIntentName is registered as a step → call the step │
    │ b) otherwise look for a command: an exact match → the rest in registration order │
    │ c) otherwise — a lookup by the intents from platformParams.intents │
    │ d) otherwise — FALLBACK_COMMAND ('*'), if registered │
    │ e) built-in: 'welcome' (the greeting), 'help' (help) │
    │ f) at the end action(intentName, isCommand, isStep) is ALWAYS called │
    │ 8. Saving userData / state │
    │ 9. platformAdapter.getContent(controller) — building the response in the platform format │
    │ 10. Sending the response (for Alice — JSON in the HTTP body, for TG — a POST to api.telegram.org) │
    │ │
    └───────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
    Entity Role Who writes it
    Bot The orchestrator. Accepts requests, routes them, manages the lifecycle. Used by the developer
    AppContext The application state storage: config, tokens, plugin registry, logger. Created inside Bot
    BotController The base class for business logic. Contains text, buttons, card, userData, state, nlu. Extended by the developer
    PlatformAdapter Translates the universal response into a specific platform's format. Built in or the developer
    DatabaseAdapter Saves userData between requests. Built in or the developer
    Middleware Intercepts the request before/after action(). The developer
    Plugin An extension: NLU, i18n, a custom RegExp engine. The developer

    The framework automatically:

    1. Creates an instance (new MyController(appContext)) for every request.
    2. Fills the request fields (userCommand, userId, ...).
    3. Loads userData / state.
    4. Calls run() — the internal dispatcher.
    5. run() determines what fired (event → step → command → intent → fallback → welcome/help), and at the end calls action() (in detail — "Dispatcher order").
    6. Calls platformAdapter.getContent(controller) — builds the response (state is saved inside this method via setLocalStorage; userData is saved by the framework later — in #runApp after the response is built).
    7. Resets the transient fields (text, tts, buttons, card, nlu) — userData is kept.

    # Install the framework and create a project with one command
    npx umbot create my-skill
    cd my-skill
    npm install
    npm run build
    npm run start

    After starting, the server listens on 0.0.0.0:3000 (the CLI template sets hostname: '0.0.0.0') and is ready to accept webhooks. When starting manually with bot.start() without arguments, the server listens on localhost:3000 — to accept external webhooks, pass the host explicitly: bot.start('0.0.0.0', 3000). npm run start runs the built code from dist/, so npm run build is needed after any change to the sources.

    For Telegram, VK and MAX the bot can run without a public HTTPS address: bot.startPolling() instead of bot.start(). The bot requests updates from the platform itself — handy for local development.

    const bot = new Bot();
    bot.use(new TelegramAdapter(process.env.TELEGRAM_TOKEN));
    bot.addCommand('hello', ['hello'], (_text, ctx) => {
    ctx.text = 'Hi!';
    });
    await bot.startPolling(); // { platforms: ['telegram'] } — only the selected platforms

    Platform restrictions (the webhook in Telegram, setting up the Long Poll API in VK) are in platform-integration.

    Besides create, the CLI can create a project from the visual editor (create from-flow), validate flow.json (validate), register a webhook with a secret (webhook), check tokens and webhooks (doctor), add a Dockerfile and CI (add docker, add deploy), and create a scaffold of your own platform adapter, DB adapter or middleware with a ready test (add platform, add db, add middleware). The full list of commands, flags and the JSON config format for create is in the CLI description.

    npm install umbot
    # optional (recommended for production):
    npm install re2 # speeds up RegExp 2-15 times
    npm install mongodb # if you use MongoDB instead of the file database

    Files are named after the project (the CLI substitutes it into the templates): for npx umbot create mybot the configs are mybotConfig.ts / mybotParams.ts, and the controller is MybotController.ts. Non-alphanumeric characters in the name are replaced with _ (my-bot → my_bot).

    Inside src/ the folders go from specific to general: first the domain modules (controller, plugins, models, config), and at the very bottom index.ts, which assembles everything. This way the IDE tree shows the project logic, and index.ts serves as the "exit" from it.

    my-bot/                        # the directory: named my_bot (hyphens and special characters → _)
    ├── .env # tokens (do not commit!)
    ├── .gitignore # generated by the CLI, .env is already in it
    ├── media/ # images and sounds for preloading
    ├── json/ # database files (with FileAdapter)
    ├── logs/ # error logs (the default error_log is the logs/ folder)
    ├── src/
    │ ├── controller/
    │ │ └── My_botController.ts # extends BotController (if you use a controller)
    │ ├── plugins/ # logical modules with commands (game.ts, shop.ts, ...)
    │ ├── config/
    │ │ ├── my_botConfig.ts # a function (): IAppConfig
    │ │ └── my_botParams.ts # a function (): IAppParam
    │ ├── models/ # custom database models (optional)
    │ └── index.ts # the entry point — the bot is assembled here
    ├── package.json
    └── tsconfig.json

    If you use isLocalStorage: true without a database, you do not have to create the json/ and logs/ folders (they appear automatically when needed). The logs folder is configured with error_log; by default the framework writes to logs/ next to the process working directory.


    A minimal skill that can greet (via welcome_text), show help, repeat after the user and end the dialog on the "bye" command.

    // src/index.ts
    import { Bot, WELCOME_INTENT_NAME, HELP_INTENT_NAME, FALLBACK_COMMAND } from 'umbot';
    import { fullPlatforms } from 'umbot/plugins';

    const bot = new Bot()
    .use(fullPlatforms)
    .setAppConfig({ isLocalStorage: true })
    .setAppMode('strict_prod');

    // The greeting command
    bot.addCommand(WELCOME_INTENT_NAME, ['hello'], (_, bc) => {
    bc.text = 'Hi! I repeat after you. Say "help" or "bye".';
    bc.buttons.addBtn('Help');
    });

    // The "help" command
    bot.addCommand(HELP_INTENT_NAME, ['help'], (_, bc) => {
    bc.text = 'I repeat after you. Say something, and I will repeat it.';
    bc.buttons.addBtn('Exit');
    });

    // Ending the dialog — isEnd = true closes the session.
    // Supported by voice platforms (Alice, SmartApp, Marusia); chat platforms
    // (Telegram, VK, Viber, MAX) do not read the flag — there the session ends by itself on a timeout.
    bot.addCommand('bye', ['bye', 'exit', 'goodbye'], (_, bc) => {
    bc.text = 'Goodbye!';
    bc.isEnd = true;
    });

    // Fallback — repeat after the user everything that did not match the commands above.
    bot.addCommand(FALLBACK_COMMAND, [], (userCommand, bc) => {
    bc.text = `You said: ${userCommand}`;
    bc.buttons.addBtn('Help').addBtn('Exit');
    });

    bot.start('localhost', 3000);

    To run it: ts-node src/index.ts, or after building, node dist/index.js.

    To test locally without publishing on a platform, replace Bot with BotTest and start with test:

    import { BotTest } from 'umbot/test';
    import { fullPlatforms } from 'umbot/plugins';

    const bot = new BotTest()
    .use(fullPlatforms)
    .setAppConfig({ isLocalStorage: true })
    .setPlatformParams({
    welcome_text: 'Hi! I repeat after you.',
    intents: [],
    });

    await bot.test(); // starts an interactive dialog in the console —
    // type text, get a reply; to exit, type "exit"

    Entry points and imports

    Import path What is inside
    umbot Bot, BotController, components (Buttons, Card, Sound, Nlu, Navigation), models, constants, types
    umbot/plugins Platform and DB adapters (fullPlatforms, TelegramAdapter, MongoAdapter, …), T_* constants, API clients
    umbot/middleware rateLimiter, authGuard, requestId, maintenance, ipFilter
    umbot/test BotTest — a dialog in the console and simulate() for tests
    umbot/preload Preload — upload images and sounds to the platforms in advance
    umbot/build run() — start a bot with a single function
    umbot/utils Text, loadEnvFile, working with files and regular expressions

    The full list of exports and constant values (WELCOME_INTENT_NAME, T_TELEGRAM, …) is in the API reference.


    umbot supports two ways of describing the application logic. They do not exclude each other — they can (and often should) be combined.

    The logic is described with bot.addCommand(...) and bot.addStep(...). It is a simple, declarative way: one command — one handler function. It suits any project — from small prototypes to large skills with dozens of commands.

    import { Bot, WELCOME_INTENT_NAME, HELP_INTENT_NAME, FALLBACK_COMMAND } from 'umbot';
    import { fullPlatforms, FileAdapter } from 'umbot/plugins';

    const bot = new Bot();

    bot.use(fullPlatforms)
    .use(new FileAdapter())
    .setAppConfig({ json: './data', isLocalStorage: false })
    .setAppMode('strict_prod');

    bot.addCommand(WELCOME_INTENT_NAME, ['hello', 'hi'], (_, bc) => {
    bc.text = 'Hi! How can I help?';
    bc.buttons.addBtn('Help').addBtn('Exit');
    });

    bot.addCommand(HELP_INTENT_NAME, ['help', 'what can you do'], (_, bc) => {
    bc.text = 'I can repeat after you. Just say something.';
    });

    // A command with a RegExp slot
    bot.addCommand('num', [/^\d+$/], (userCommand, bc) => {
    bc.text = `You said a number: ${userCommand}`;
    });

    // Fallback — called if nothing matched
    bot.addCommand(FALLBACK_COMMAND, [], (userCommand, bc) => {
    bc.text = `You said: ${userCommand}`;
    });

    bot.start('0.0.0.0', 3000);

    When there are many commands, do not keep them all in index.ts. Move related commands into separate modules and connect them with bot.use(pluginFn):

    // src/plugins/game.ts
    import { Bot, AppContext, BotController, IUserData, createPlugin } from 'umbot';

    // Describe the userData type once — it is used in several commands
    interface GameData extends IUserData {
    score: number;
    }

    export const gamePlugin = createPlugin((appContext: AppContext, bot: Bot): void => {
    // Pass GameData as a generic parameter and annotate bc
    bot.addCommand('game_start', ['play', 'start game'], (_, bc: BotController<GameData>) => {
    bc.userData.score = 0;
    bc.text = 'The game has started! What is 2+2?';
    bc.buttons.addBtn('3').addBtn('4').addBtn('5');
    bc.thisIntentName = 'game_answer';
    });

    bot.addStep('game_answer', (bc: BotController<GameData>) => {
    if (bc.userCommand === '4') {
    bc.userData.score = (bc.userData.score || 0) + 1;
    bc.text = 'Correct!';
    } else {
    bc.text = 'Wrong.';
    }
    bc.thisIntentName = null;
    });

    bot.addCommand('game_score', ['score', 'my score'], (_, bc: BotController<GameData>) => {
    bc.text = `Your score: ${bc.userData.score || 0}`;
    });
    });
    // src/plugins/shop.ts
    import { Bot, AppContext, createPlugin } from 'umbot';

    export const shopPlugin = createPlugin((appContext: AppContext, bot: Bot): void => {
    bot.addCommand('catalog', ['catalog'], (_, bc) => {
    /* ... */
    });
    bot.addCommand('order', ['order'], (_, bc) => {
    /* ... */
    });
    bot.addStep('order_email', (bc) => {
    /* ... */
    });
    });
    // src/index.ts
    import { Bot } from 'umbot';
    import { fullPlatforms, FileAdapter } from 'umbot/plugins';
    import { gamePlugin } from './plugins/game';
    import { shopPlugin } from './plugins/shop';

    const bot = new Bot();
    bot.use(fullPlatforms);
    bot.use(new FileAdapter());
    bot.use(gamePlugin); // registers the commands from game.ts
    bot.use(shopPlugin); // registers the commands from shop.ts
    bot.setAppConfig({ json: './data' });
    bot.setAppMode('strict_prod');
    bot.start('0.0.0.0', 3000);

    Why this is good:

    • Each module is responsible for its own domain (game, shop, auth, ...).
    • index.ts stays a clean assembly point — you can see which modules are connected.
    • Modules can be reused in other projects.
    • Commands can be tested independently.

    ⚠️ Attention! A plugin function must have the isPlugin = true marker. Without it bot.use(fn) treats the function as middleware (a global request interceptor) rather than a plugin — and the commands inside it are not registered. This is a common and non-obvious mistake: the code looks correct, there is no error, but the commands do not work. To avoid setting the flag manually and forgetting it, use the createPlugin() helper — it does this automatically (see the examples above).

    A controller (BotController) is a class with an action(intentName, isCommand?, isStep?) method that the framework calls always last, after the commands and steps have run. It is a convenient place for post-processing shared by all commands.

    When a controller is really useful: when there is logic that must run after any command. For example:

    • Every screen needs an "About us" / "Help" / "Exit" button.
    • Analytics must be written after every command.
    • Long text has to be trimmed or a standard footer added.

    The controller can be kept very compact — the shared logic is written once at the start of action(), and the specific cases (welcome/help) go into a switch:

    import { BotController, WELCOME_INTENT_NAME } from 'umbot';

    export class FooterController extends BotController {
    public action(intentName: string | null, isCommand?: boolean, isStep?: boolean): void {
    // Shared post-processing for ALL responses — add the "About us" button
    this.buttons.addBtn('About us');

    // If a command or a step fired, they have already filled text,
    // nothing else needs to be done.
    if (isCommand || isStep) return;

    // Handling intents (only if no command/step fired)
    switch (intentName) {
    case WELCOME_INTENT_NAME:
    // welcome_text has already been set by the framework —
    // you can override or extend it
    break;
    case 'about':
    this.text = 'This skill was made to demonstrate umbot.';
    break;
    default:
    if (!this.text) this.text = 'I didn\'t get that. Say "help".';
    }
    }
    }
    // index.ts
    bot.initBotController(FooterController);

    // All commands keep working as usual — after each command
    // action() is called with isCommand=true, and the "About us" button is added to the response.
    bot.addCommand('weather', ['weather'], (_, bc) => {
    bc.text = 'It is sunny today.';
    });

    The main rule: do not try to put all the logic into action(). If you have 30 commands, action() will grow into an unreadable 300-line switch. Use addCommand for each command, and action() only for shared post-processing.

    In real projects, usually:

    1. The logic is described with addCommand / addStep (or plugins with them).
    2. Shared post-processing (shared buttons, analytics) goes into the controller.
    import { BotController, WELCOME_INTENT_NAME } from 'umbot';

    // The controller: adds the "Help" button to all responses and writes analytics
    bot.initBotController(
    class extends BotController {
    // action() can also be async — the framework awaits the promise
    // before building the response.
    action(intentName: string | null, isCommand?: boolean, isStep?: boolean): void {
    // A shared button for all responses — written once
    this.buttons.addBtn('Help');

    // If a command/step fired, it has already filled text — exit
    if (isCommand || isStep) return;

    switch (intentName) {
    case WELCOME_INTENT_NAME:
    // welcome_text has already been set by the framework —
    // additionally count the user's visits
    this.userData.visits = Number(this.userData.visits ?? 0) + 1;
    break;
    default:
    if (!this.text) this.text = 'I didn\'t get that. Say "help".';
    }

    // Analytics — fire-and-forget: the request goes in the background and does not block the response.
    // Always limit the time so that a slow analytics
    // endpoint does not "hang" the outgoing request forever.
    const ac = new AbortController();
    const timer = setTimeout(() => ac.abort(), 8000);
    timer.unref();
    fetch('https://analytics.example.com/event', {
    method: 'POST',
    signal: ac.signal,
    body: JSON.stringify({
    intent: intentName,
    platform: this.appType,
    userId: this.userId,
    isCommand,
    isStep,
    }),
    headers: { 'Content-Type': 'application/json' },
    })
    .catch(() => {
    // analytics errors must not affect the user
    })
    .finally(() => clearTimeout(timer));
    }
    },
    );

    // Commands describe specific logic
    bot.use(gamePlugin);
    bot.use(shopPlugin);
    bot.addCommand('about', ['about us'], (_, bc) => {
    bc.text = '...';
    });

    The configuration is split into two objects:

    • setAppConfig(IAppConfig) — infrastructure: the logs and data folders, the database connection, local storage, the path to .env, platform tokens.
    • setPlatformParams(IAppParam) — business parameters: the greeting, help and "didn't get that" texts, intents. The intents field is required, even if empty: intents: [].
    bot.setAppMode('strict_prod'); // the mode — before registering intents and commands
    bot.setAppConfig({
    env: './.env', // tokens: TELEGRAM_TOKEN, VK_TOKEN, ALISA_TOKEN, ...
    isLocalStorage: true, // the platform storage instead of a database (Alice, SmartApp, Marusia)
    error_log: './logs',
    });
    bot.setPlatformParams({
    welcome_text: 'Hi! I can count.',
    help_text: 'This is a math game.',
    empty_text: 'I didn\'t get that. Say "help".',
    intents: [{ name: 'bye', slots: ['bye', 'goodbye'] }],
    });

    The operating mode (dev / prod / strict_prod) is set with setAppMode(); without the call it comes from NODE_ENV (production → strict_prod, otherwise dev). strict_prod drops dangerous regular expressions at registration, so call it before addCommand and setPlatformParams.

    All fields, environment variables, token priority and webhook signature verification are in Configuration and security.


    bot.addCommand(
    name: string, // the name (unique)
    slots: TSlots, // (string | RegExp)[]
    cb: (userCommand: string, controller: TBotController) => void | string | Promise<void | string>,
    isPattern?: boolean, // treat strings as regex
    ): this;

    Slot behavior:

    Slot type Behavior
    string, isPattern=false (the default) userCommand.includes(slot) — a substring. An utterance that matches the slot entirely is found in O(1) through the exact match index. A partial match from 16 such commands is looked up in a substring index in time that depends on the length of the utterance, not on the number of commands. The slot must be in lower case, since userCommand is already lowercased.
    string, isPattern=true Compiled as a regex and checked with .test().
    RegExp .test(userCommand). isPattern is ignored.

    About case: controller.userCommand is the user's text converted to lower case. String slots must also be in lower case: 'hello', not 'Hello'. For RegExp use the i flag if you want case-insensitive matching.

    Re-registration: addCommand with a name that is already taken fully replaces the command (a warning is logged): the old slots stop firing, while the command's place in the registration order (its priority) is kept.

    Asynchrony: the callback can be synchronous (void | string) or asynchronous (Promise<void | string>) — the framework automatically waits for the result with await. This lets you make HTTP requests, read from the database, etc. right inside the command handler:

    bot.addCommand('weather', ['weather'], async (userCommand, bc) => {
    const city = userCommand.replace('weather', '').trim() || 'moscow';
    // Always set a timeout — an external API can hang and eat
    // the platform's whole response time budget (more in [recipe 7](https://www.maxim-m.ru/docs/umbot/en/v-3.1/guides/recipes#recipe-7-an-http-request-to-an-external-api))
    const res = await fetch(`https://api.weather.example.com/current?city=${city}`, {
    signal: AbortSignal.timeout(3000),
    });
    const data = (await res.json()) as { temp: number };
    bc.text = `It is ${data.temp}°C now`;
    });

    If the callback returns a string (or Promise<string>), it becomes controller.text. This works for commands (addCommand), events (addEvent) and steps (addStep).

    About typing userData in a command: addCommand is a generic method with the signature addCommand<TBotController>(name, slots, cb, isPattern): the TBotController parameter is inferred from the callback annotation. For TypeScript to know about your fields in bc.userData, annotate the second argument: (_, bc: BotController<MyUserData>) => {...}. A detailed description of all the ways to type it (in a command, in a step, in a controller) is in the "Typed userData" section.

    import { FALLBACK_COMMAND } from 'umbot';

    bot.addCommand(FALLBACK_COMMAND, [], (userCommand, bc) => {
    bc.text = `I didn't get that: "${userCommand}". Say "help".`;
    });

    FALLBACK_COMMAND is '*'. It fires if:

    • Neither a step, nor a command, nor an intent from platformParams.intents matched. Intents are looked up before the fallback: an utterance that matches an intent slot goes to action() with the intent name, not to the fallback.
    • Regardless of messageId: if a fallback is registered, it fires on the first message without a matching intent too — welcome for messageId === 0 is substituted only when no fallback is registered (see "Dispatcher order"). To greet the user with a fallback as well, check bc.messageId === 0 inside the fallback handler.

    A step is a mechanism for building multi-step scenarios: registrations, questionnaires, ordering a product, a game with a series of questions. Each step is a separate handler function that is called at the right moment.

    bot.addStep(
    stepName: string,
    cb: (controller: TBotController) => void | false | string | Promise<void | false | string>,
    ): this;

    Everything is built on two controller fields:

    • controller.thisIntentName — where to go after the current request.
    • controller.oldIntentName — where we came into the current request from.

    Whatever you wrote to controller.thisIntentName in the current request, the framework automatically saves and passes to you in controller.oldIntentName in the next request from this user. No manual saving — the framework itself carries one into the other between requests.

    1. In the current request you set controller.thisIntentName = 'step_name'. This means: "the user's next request must go to the step_name step".
    2. The framework saves this value to userData.oldIntentName (or to state.oldIntentName with isLocalStorage: true) — it survives between requests.
    3. In the next request the framework loads oldIntentName from the storage and puts it into controller.oldIntentName.
    4. The dispatcher checks: if oldIntentName matches the name of a registered step, it calls that step's callback instead of looking up commands.
    5. Inside the step you must manage thisIntentName explicitly:
      • Set thisIntentName = 'next_step' — go to another step.
      • Set thisIntentName = null — leave the scenario (the next request takes the regular path: commands → intents → fallback).
      • Set thisIntentName = 'current_step' — stay on the step if the user's answer has not been accepted yet.
      • Leave thisIntentName untouched (it is null by default in a new request) — the step ends: at the end of the request null is written to oldIntentName, and the next request takes the regular path. To ask for the input again, be sure to assign thisIntentName = '<step name>' again.
    • If the step callback returns false, the step is skipped and the dispatcher moves on (commands → intents → fallback). This is useful when, during a multi-step scenario, the user suddenly asks an "urgent" question that must be handled by a separate command rather than as an answer to the current step.
    • If the step callback returns a string (or Promise<string>), the string becomes the response text, as with addCommand: bot.addStep('ask_name', (ctx) => `Hi, ${ctx.originalUserCommand}!`).
    • If the step is chosen by the platform's NLU intents and several intents match registered steps, the first of them fires.
    • If oldIntentName matches no step, steps are ignored and the dispatcher looks up commands right away.
    • If the user closed the skill and opened it again, oldIntentName may remain in userData, but messageId === 0 (a new session). In such cases you often need to return false to start over.

    A real example: we are on the ask_phone step (waiting for a phone number), but instead of a number the user says "what is the weather in moscow" — this is not an answer to the step but a separate request:

    import { BotController, IUserData } from 'umbot';

    interface PhoneData extends IUserData {
    phone?: string;
    }

    // A separate command — answers an "urgent" request during the scenario.
    // It fires after the step, because step.cb returns false.
    bot.addCommand('weather', ['weather'], async (userCommand, bc) => {
    const city = userCommand.replace('weather', '').trim() || 'moscow';
    const res = await fetch(`https://api.weather.example.com/current?city=${city}`, {
    signal: AbortSignal.timeout(3000), // the timeout is mandatory (see the anti-patterns)
    });
    const data = (await res.json()) as { temp: number };
    bc.text = `It is ${data.temp}°C now. `;
    // Restart the step explicitly: the command fired after the step returned false,
    // and thisIntentName is null by default. Without this line the scenario would end.
    bc.thisIntentName = 'ask_phone';
    });

    bot.addStep('ask_phone', (bc: BotController<PhoneData>) => {
    // Did the user send something that looks like the weather? Skip the step —
    // let the weather command above fire.
    if (bc.userCommand?.includes('weather')) {
    return false;
    }

    // Otherwise — regular step handling
    if (!bc.userCommand || bc.userCommand.length < 5) {
    bc.text = 'That does not look like a number. Enter your phone:';
    // IMPORTANT: thisIntentName is null by default — for the step to fire again,
    // it must be assigned again explicitly.
    bc.thisIntentName = 'ask_phone';
    return;
    }
    bc.userData.phone = bc.userCommand;
    bc.text = 'Done! The phone is saved.';
    bc.thisIntentName = null;
    });

    The same goes for a new session:

    bot.addStep('ask_name', (bc) => {
    // If this is a new session, do not continue the old scenario, start over
    if (bc.messageId === 0) {
    return false; // the step is skipped, the dispatcher moves on → welcome
    }
    // ... the regular step logic
    });

    The scenario: the user says "register" → we ask for the name → save it → ask for the age → save it → finish.

    import { BotController, IUserData } from 'umbot';

    // Describe the userData type — it is used in the steps
    interface RegData extends IUserData {
    name?: string;
    age?: number;
    }

    // Step 0: the trigger command that starts the scenario.
    // userData is not touched here — no typing needed
    bot.addCommand('register', ['register', 'sign up'], (_, bc) => {
    bc.text = 'What is your name?';
    bc.thisIntentName = 'reg_name'; // the next request goes to the reg_name step
    });

    // Step 1: waiting for the name — typed through the generic parameter
    bot.addStep('reg_name', (bc: BotController<RegData>) => {
    if (!bc.userCommand || bc.userCommand.length < 2) {
    bc.text = 'The name is too short. Please try again.';
    // IMPORTANT: to stay on the step, thisIntentName must be reassigned explicitly —
    // in a new request it is null by default, and without the assignment the scenario ends
    bc.thisIntentName = 'reg_name';
    return;
    }
    bc.userData.name = bc.originalUserCommand ?? ''; // save it with the original case
    bc.text = `Nice to meet you, ${bc.userData.name}! How old are you?`;
    bc.thisIntentName = 'reg_age'; // go to the reg_age step
    });

    // Step 2: waiting for the age
    bot.addStep('reg_age', (bc: BotController<RegData>) => {
    const age = parseInt(bc.userCommand || '', 10);
    if (isNaN(age) || age < 1 || age > 120) {
    bc.text = 'That does not look like an age. Enter a number from 1 to 120.';
    bc.thisIntentName = 'reg_age'; // stay on the step
    return;
    }
    bc.userData.age = age;
    bc.text = `Got it: you are ${age} years old. Registration is complete!`;
    bc.thisIntentName = null; // leave the scenario — the next request takes the regular path
    });

    What happened in this example, request by request:

    Request oldIntentName on entry What it calls thisIntentName after
    "register" null the register command 'reg_name'
    "John" 'reg_name' the reg_name step 'reg_age'
    "25" 'reg_age' the reg_age step null (exit)
    "hello" null a regular command lookup —
    public action(intentName: string | null, isCommand?: boolean, isStep?: boolean): void {
    if (intentName === 'back') {
    // Going back to the previous step
    switch (this.oldIntentName) {
    case 'reg_age':
    this.text = 'How old are you?';
    this.thisIntentName = 'reg_age';
    break;
    case 'reg_name':
    this.text = 'What is your name?';
    this.thisIntentName = 'reg_name';
    break;
    default:
    this.text = 'There is nowhere to go back to.';
    }
    }
    }

    controller.run() checks in the following order (until the first match):

    1. An event — bot.addEvent handlers by controller.eventType (called first, before steps and commands; a handler can return false — then the event is "not its own" and the pipeline continues).
    2. A step — if oldIntentName is registered as a step.
    3. A command — the command registered first wins (the order of addCommand calls):
      • exact string matches are checked first (O(1) through a hash index);
      • then string slots as substrings and regexes (.test()) — in registration order. Large command sets are sped up without changing the order: a partial match from 16 string commands is looked up in a substring index, and a regex from 16 of them runs only if the utterance contains its mandatory part (/order_\d+/ — only with "order_" in the text). The regexes of commands registered after the 300th are combined into RegExp groups (setCommandGroupMode).
    4. An intent — from platformParams.intents (the intent's slots are compared with userCommand).
    5. FALLBACK_COMMAND — if registered and no intent was found. A fallback that fired ends the lookup: welcome is not substituted after it, even with messageId === 0.
    6. Welcome — if no intent was found, no fallback is registered and messageId === 0, 'welcome' is substituted → the framework sets controller.text = platformParams.welcome_text.
    7. Built-in intents:
      • 'help' → the framework sets controller.text = platformParams.help_text
      • otherwise → controller.text = platformParams.empty_text (only if you extend BaseBotController)
    8. action(intentName, isCommand, isStep) — always called at the end.

    About welcome/help: the framework sets controller.text = platformParams.welcome_text (or help_text) before calling action(). If you also set this.text in action(), your value overrides the automatically set one. This is useful for a dynamic greeting (for example, a different greeting for a returning user).

    ⚠️ About BaseBotController and empty_text: setting controller.text = platformParams.empty_text automatically (when nothing matched) happens only if you extend BaseBotController. If you extend BotController directly, set this.text manually in action(). Platform adapters do not invent a user reply: Alice and Marusia keep an empty response with a warning, while Telegram and MAX do not send an invalid empty message to the external API.

    So in action() always handle default: in the switch or add a check at the end:

    if (!this.text) this.text = 'I didn\'t get that. Say "help".';
    

    umbot has two fields for storing the dialog state: controller.userData and controller.state. Let's see what goes where and why.

    To avoid confusion, use the following rule:

    If a DB adapter is connected (FileAdapter, MongoAdapter or your own), userData always comes from the database. If NO DB adapter is connected and isLocalStorage: true, userData comes from the platform's local storage, and on platforms without one (Telegram, VK, MAX, Viber) — from an in-process memory session.

    That is:

    DB adapter connected? isLocalStorage Where userData comes from
    ✅ Yes (any) any value from the database (the adapter reads/writes itself)
    ❌ No true from the platform's local storage (Alice/SmartApp/Marusia)
    ❌ No true Telegram/VK/MAX/Viber: from an in-process memory session — see below
    ❌ No false userData stays empty — a mode without persistence: valid, but data does not survive between requests

    This makes sense: a database is a full persistent storage that always works. Local storage is a lightweight option for simple skills on voice platforms only, without a database. If you connected a database, it is what gets used.

    state is the platform's local storage (for example, Alice's session_state). It is a storage that the platform itself carries between requests in the request/response body — without a database, without servers.

    state is filled only when isLocalStorage: true AND the platform supports it (Alice, SmartApp, Marusia). On Telegram/VK/Viber/Max there is no local storage — state is always null, and userData without a database is kept in process memory.

    The relation between userData and state depends on whether a DB adapter is connected:

    Configuration userData state
    A DB adapter is connected + isLocalStorage: true from the database (the adapter reads/writes) from the platform's local storage — this is a different object
    NO DB adapter + isLocalStorage: true from the platform's local storage the same object as userData (a reference)
    A DB adapter is connected + isLocalStorage: false from the database null
    NO DB adapter + isLocalStorage: true, Telegram/VK/MAX/Viber from an in-process memory session null
    NO DB adapter + isLocalStorage: false empty null (data is not kept between requests)

    The key difference between the first and the second case:

    • When a database is connected + isLocalStorage: true → you have two independent storages: userData (the database, heavy data) and state (local, light temporary data). They are written separately, but if state turned out empty, userData is sent to the platform as the state (a fallback) rather than an empty object.
    • When no database is connected + isLocalStorage: true → userData and state point to the same object of the local storage. Write to userData.foo — and you see the same in state.foo. This is done for convenience: work with whichever field you like better.
    bot.setAppConfig({ isLocalStorage: true });
    // Do NOT connect a DB adapter
    • userData and state are the same object from Alice's state (the adapter takes the longest-lived of the levels present: the user, the application or the session).
    • The limit is 1024 bytes per chosen state level (session_state and each of the others separately). If exceeded, the framework logs an error and does not send this field to the platform (the data in userData/state is not cleared, it just does not get into the response).
    • No database server is needed.
    • The data is bound to the device/user on the Yandex side.
    bot.use(new MongoAdapter({ host: '...', database: '...' }));
    bot.setAppConfig({ isLocalStorage: false });
    • userData always comes from the database.
    • state is not used (null).
    • A record is identified by the userId + platform pair: Telegram user 42 and VK user 42 are different records. To link the accounts of one person on different platforms, store the link yourself (your own model).
    • There is no 1 KB limit.
    bot.use(new MongoAdapter({ host: '...', database: '...' }));
    bot.setAppConfig({ isLocalStorage: true });
    • userData — from the database (heavy data: settings, history).
    • state — a separate object from the local storage (light temporary data of the current dialog).
    • Rarely used, when you clearly need to separate "long-lived" and "short-lived" data.

    If isLocalStorage: true is enabled, the platform does not support local storage (Telegram, VK, MAX, Viber), and no DB adapter is connected, userData is kept in process memory — the same as grammY's MemorySessionStorage. Dialog steps (addStep) and counters in userData work without a database.

    bot.setAppConfig({
    isLocalStorage: true,
    // Optional. Defaults: up to 10 000 users, 24 hours since the user's last request.
    memorySession: { maxSize: 50_000, ttl: 60 * 60 * 1000 },
    });

    The limitations are the same as for any in-memory session:

    • data is lost when the process restarts (a deployment, a crash, a container restart);
    • data is not shared between processes: a cluster, several replicas behind a load balancer, serverless (Yandex Cloud Functions — each call may land on a new instance);
    • when maxSize is exceeded, the user who has not written to the bot the longest is evicted; after ttl without requests the user's data is deleted;
    • an empty userData is not kept in memory.

    Updating, evicting and cleaning up cost O(1) regardless of the number of users: the entries are linked in a list by update recency, and a maxSize of tens of thousands does not slow requests down.

    The framework logs a warning once per platform about where the data lives. For reliable storage, connect a DB adapter (FileAdapter, MongoAdapter) — then the in-memory session is not used. memorySession: false disables it: userData is not kept between requests (the behavior before 3.1.0).

    When you mutate controller.userData and/or controller.state, after action() the framework decides where to save it:

    What is filled Where it is saved
    Only userData The database (if connected) or local storage (if isLocalStorage=true and no database is connected; on Telegram/VK/MAX/Viber — process memory)
    Only state The platform's local storage
    Both userData and state (different objects) userData → the database, state → local storage

    You do not need to call any "save" methods — the framework does it automatically.

    Data type Where to store it
    Game progress, score userData.score, userData.level
    User settings (language, theme) userData.preferences
    An authorization token userData.token
    The current scenario step Do not store it manually! Use controller.thisIntentName — the framework saves it itself: to userData.oldIntentName, and with isLocalStorage: true without a database and an empty userData — to state.oldIntentName.
    Temporary data of the current dialog (a message draft, the selected product) state.draft, state.selectedItemId (only with isLocalStorage=true)

    Alice's local storage works so that a missing field does not mean it is deleted — the platform ignores its absence and keeps the old value. So delete this.userData.foo or this.userData.foo = undefined do not work: on the next request the field comes back with the old value.

    To delete a field, set it to null:

    this.userData.tempData = null; // the field is deleted on the Alice side
    // And NOT:
    // delete this.userData.tempData; // will NOT work — the field comes back
    // this.userData.tempData = undefined; // will NOT work — the field comes back

    The base IUserData interface contains only one field — oldIntentName?: string | null (the framework saves it automatically for multi-step dialogs). You add all other fields in your own derived interface.

    At runtime the userData object can be empty on the user's first request (especially if you use isLocalStorage: true and the user opened the skill for the first time). So always initialize fields with ??=.

    import { IUserData } from 'umbot';

    interface MyUserData extends IUserData {
    score: number;
    name?: string;
    lastVisit?: string;
    preferences?: {
    language: 'ru' | 'en';
    theme: 'light' | 'dark';
    };
    }

    Typing is enabled differently depending on whether you write through BotController or through addCommand / addStep. If you do not do this, bc.userData.score += 1 in a command gives a type error — TypeScript does not know about the score field.

    Option A — in addCommand (through the generic parameter):

    import { Bot, BotController, IUserData } from 'umbot';

    // 1. Annotate bc as BotController<MyUserData>
    bot.addCommand('play', ['play'], (_: string, bc: BotController<MyUserData>) => {
    bc.userData.score ??= 0; // ✅ TypeScript knows that score: number
    bc.userData.score += 10;
    bc.userData.lastVisit = new Date().toISOString();
    bc.text = `Score: ${bc.userData.score}`;
    });

    // ❌ Without typing — a TS error occurs when the value is used:
    // bot.addCommand('play', ['play'], (_, bc) => {
    // bc.userData.score += 10; // ← 'score' is of type 'unknown': writing is allowed
    // // (IUserData has an index signature), but arithmetic is not
    // });

    Option B — in addStep (also through the generic):

    bot.addStep('game_answer', (bc: BotController<MyUserData>) => {
    bc.userData.score ??= 0;
    bc.userData.score += 1;
    bc.text = `Correct! Score: ${bc.userData.score}`;
    });

    Option C — in a controller (through the class generic parameter):

    import { BotController, IUserData } from 'umbot';

    export class MyController extends BotController<MyUserData> {
    public action(intentName: string | null): void {
    // this.userData is already typed as MyUserData
    this.userData.score ??= 0;
    this.userData.score += 1;
    this.userData.lastVisit = new Date().toISOString();
    this.text = `Score: ${this.userData.score}`;
    }
    }

    Tip: declare the MyUserData interface in a separate file (src/types.ts or src/models/userData.ts) and import it where needed. This avoids duplication.

    • The bot runs on Telegram, VK, MAX or Viber — there is no local storage there, and without a database userData lives only in process memory (it is lost on restart).
    • The state size is > 1 KB (the limit of Alice's local storage).
    • Several bot instances (load balancing) — FileAdapter is not safe for multi-process.
    • A prototype.
    • An Alice-only skill with isLocalStorage: true.
    • A personal bot for a small team (< 100 users).
    • Data size up to ~250 MB (the adapter logs a warning at 270 MB — stay well below the threshold).

    All components are available through BotController getters: this.buttons, this.card, this.sound, this.nlu. Initialization is lazy. They are reset between requests automatically.

    // An interactive button (sends text/payload back to the bot)
    this.buttons.addBtn('Help');
    this.buttons.addBtn('Buy', '', { action: 'buy', id: 42 }); // with a payload

    // A link button (opens a URL)
    this.buttons.addLink('Website', 'https://example.com');
    this.buttons.addLink('Documentation', 'https://docs.example.com', '', {
    utmSource: 'bot',
    utmCampaign: 'welcome',
    });

    // Chaining
    this.buttons.addBtn('Yes').addBtn('No').addLink('More', 'https://example.com/help');
    Method hide flag Purpose
    addBtn(title, url?, payload?, options?) true (B_BTN) Interactive — sends the payload when pressed
    addLink(title, url, payload?, options?) false (B_LINK) A link / suggestion chip

    payload is arbitrary data attached to a button that comes back in controller.payload when the button is pressed. The framework normalizes the payload: pass an object and you get an object back, whatever the platform.

    // Register a button with a payload object
    this.buttons.addBtn('Buy', '', { action: 'buy', id: 42 });

    // When the button is pressed, the controller receives the same object:
    // controller.payload === { action: 'buy', id: 42 }

    The type of controller.payload is Record<string, unknown> | string | null | undefined. If you passed an object, you get an object. Check that the field you need exists before using it: the payload may be missing if the user did not press a button.

    An example of handling a button press with a payload in middleware (checked before commands to avoid collisions):

    // Check the payload in middleware BEFORE the regular command handling:
    bot.use(async (ctx, next) => {
    const data = ctx.payload as Record<string, unknown> | null;
    if (data?.action === 'buy') {
    ctx.text = `The purchase of item #${data.id} has started.`;
    return; // do NOT call next() — this breaks the chain, the regular handling does not run
    }
    await next(); // continue the regular command/intent handling
    });

    Tip: check the payload before intentName. On voice platforms (Alice, Marusia) a button sends its title as text: a "Play" button gives userCommand = 'play' — and without a payload check an intent fires instead of the button handler. On Telegram/VK/MAX the payload 'buy' or {"command":"buy"} of callback buttons is normalized to userCommand = 'buy' — check the payload to tell a press from a command with the same name.

    Each platform has its own maximum number of buttons, but the adapters trim the extra ones automatically — you do not need to track this manually. The current adapter limits: Alice, Marusia, VK — 10 buttons; Telegram — 40; Viber — 6; SmartApp — 8; MAX — 30. Buttons over the limit are dropped with a warning in the log. How many buttons fit in one row is set by buttons.row() (below).

    UX recommendation: do not overload the interface with buttons. For voice platforms and most chat bots the optimum is 3–5 buttons per screen. A user (especially a voice one) cannot quickly say 10 options, and on a screen more than 5 buttons start to blur together.

    Platform-specific options (through options):

    Platform Options in options
    VK _group (a string or a number) — buttons of one group go into one row (like buttons.row()); color: 'primary' | 'secondary' | 'positive' | 'negative'
    Telegram request_contact / request_location (bool) — request a contact/location; style — the inline button style (TG_STYLE_PRIMARY/TG_STYLE_SUCCESS/TG_STYLE_DANGER, Bot API 9.4+; Telegram rejects other values — the adapter skips them with a warn); inline (bool) — show a button without a payload and url as an inline button under the message
    Viber ActionType: 'reply' | 'open-url' | 'location-picker' | 'share-phone'

    Examples:

    // VK: grouping into a row and a color
    this.buttons.addBtn('A', '', '', { _group: 1, color: 'primary' });
    this.buttons.addBtn('B', '', '', { _group: 1, color: 'secondary' });

    // Telegram: requesting a contact/location
    this.buttons.addBtn('Send phone', '', '', { request_contact: true });
    this.buttons.addBtn('Send location', '', '', { request_location: true });

    // Telegram: the inline button style (Bot API 9.4+; the constants come from 'umbot/plugins')
    this.buttons.addBtn('Buy', '', 'buy', { style: TG_STYLE_SUCCESS });

    // Telegram: a regular button shown as an inline button under the message
    this.buttons.addBtn('Catalog', '', '', { inline: true });

    // Viber: a custom type
    this.buttons.addBtn('Location', '', '', {
    ActionType: 'location-picker',
    ActionBody: 'loc_payload',
    });

    Three things are worth knowing about the inline option:

    • it is needed only for a button without payload and url — such buttons go into a regular reply keyboard by default; a button with a payload or a link becomes an inline button anyway;
    • the press comes to the bot as the button text, so the regular commands fire, not addAction;
    • the option has no effect on request_contact / request_location buttons: Telegram accepts them only in a regular keyboard.

    Telegram does not combine two keyboard types in one message, so if the response has at least one inline button, the adapter shows inline and the other text buttons as well — otherwise they would simply disappear. Projects generated by npx umbot create from-flow set inline: true on all Telegram buttons.

    By default chat platforms show each button on a separate line. row() ends the current row: the buttons added before the call are shown in one line, the following ones — on a new line.

    this.buttons.addBtn('Yes').addBtn('No').row().addBtn('Help');
    // Telegram / VK / MAX / Viber:
    // [ Yes ] [ No ]
    // [ Help ]
    • Buttons per row limits: Telegram — 8, VK — 5 (a location/vkpay/open_app button takes a whole row), MAX — 7 (3 if the row has a link, open_app, a location or contact request), Viber — 6 (the row width is split between the row's buttons, an explicit Columns in the options is kept). Extra buttons are moved to the next line with a warning in the log.
    • A row is a shared options._group: buttons with an explicitly set group keep it, buttons of one group are shown in one line on all four platforms.
    • Voice platforms (Alice, SmartApp, Marusia) do not support button layout — row() has no effect there.

    On Telegram (the reply keyboard) and VK the keyboard "sticks" to the dialog and lives until it is explicitly replaced — an empty button list is not sent to the platform, so an empty buttons.clear() cannot remove it. There is an explicit call for this:

    this.buttons.remove(); // ask the platform to remove the previously shown keyboard
    

    In Viber, MAX, Alice, SmartApp and Marusia the keyboard is bound to the message and disappears by itself — the call is safe there and changes nothing. Whether removal was requested can be checked with the buttons.isRemove getter. A Telegram requirement: a message that removes the keyboard must have text, otherwise the keyboard is not removed (the framework warns in the log).

    // One image with a title and a description
    this.card
    .addOneImage('https://example.com/img.jpg', 'Title', 'Description')
    .addButton({ title: 'Open', url: 'https://example.com' });

    // A list (gallery) — up to 5 items on Alice
    this.card
    .setTitle('Product catalog')
    .addImage('https://example.com/p1.jpg', 'Product 1', '99 ₽', {
    title: 'Buy',
    payload: { id: 1 },
    })
    .addImage('https://example.com/p2.jpg', 'Product 2', '199 ₽', {
    title: 'Buy',
    payload: { id: 2 },
    })
    .addButton({ title: 'To the catalog', url: 'https://shop.example.com' });

    // A gallery (images only, up to 10 on Alice)
    this.card.isUsedGallery = true;
    this.card
    .addImage('https://example.com/1.jpg', 'Wedding')
    .addImage('https://example.com/2.jpg', 'Graduation');
    What is set Card type
    addOneImage() or isOne=true Single (BigImage on Alice)
    images.length > 1, isUsedGallery=false List (ItemsList on Alice, ≤ 5)
    isUsedGallery=true Gallery (images only, without descriptions or buttons)

    The adapters also handle the limits on the number of card elements (the title, the description, the number of images) — the extra is trimmed.

    If you pass a URL or a path to an existing file, the framework uploads the image to the platform (the first time) and caches the token in the database (the ImageTokens model). Subsequent requests use the token — without an upload delay.

    // A URL — uploaded on first use
    this.card.addImage('https://example.com/img.jpg', 'Title');

    // A local file — uploaded
    this.card.addImage('/abs/path/to/file.png', 'Title');

    // An already known token (for example, after Preload) — not uploaded
    this.card.addImage('image_hash_xxx', 'Title');
    // Or explicitly:
    getImage(appContext, 'image_hash_xxx', 'Title', ' ', null, true); // isToken=true
    import { SoundConstants } from 'umbot';

    // The standard win sound (Alice/Marusia only)
    this.tts = `Congratulations! ${SoundConstants.S_AUDIO_GAME_WIN} You are great!`;

    // A 1-second pause
    this.tts = `One moment${SoundConstants.getPause(1000)}done!`;

    // The "hamster" effect (the voice becomes high-pitched)
    this.tts = `${SoundConstants.S_EFFECT_HAMSTER}Hi!${SoundConstants.S_EFFECT_END}`;

    // A custom sound (uploaded from a file on first use; Alice and Marusia —
    // <speaker audio="..."> in TTS, chat platforms — as an audio message)
    this.sound.sounds = [{ key: '#bell#', sounds: ['/audio/bell.mp3'] }];
    this.tts = 'Attention! #bell# An announcement.';
    • S_AUDIO_GAME_WIN — a game win
    • S_AUDIO_GAME_LOSS — a loss
    • S_AUDIO_GAME_8_BIT_COIN — a coin (note the 8_BIT in the name!)
    • S_AUDIO_GAME_BOOT — game loading
    • S_AUDIO_GAME_PING — a ping
    • S_AUDIO_GAME_8_BIT_FLYBY — a flyby
    • S_AUDIO_GAME_8_BIT_MACHINE_GUN — a machine gun
    • S_AUDIO_GAME_8_BIT_PHONE — a phone
    • S_AUDIO_GAME_POWERUP — a power-up
    • S_AUDIO_NATURE_WIND — wind
    • S_AUDIO_NATURE_THUNDER — thunder
    • S_AUDIO_NATURE_JUNGLE — jungle
    • S_AUDIO_NATURE_RAIN — rain
    • S_AUDIO_NATURE_FOREST — forest
    • S_AUDIO_NATURE_SEA — sea
    • S_AUDIO_NATURE_FIRE — a campfire
    • S_AUDIO_NATURE_STREAM — a stream
    • S_AUDIO_THING_CHAINSAW — a chainsaw
    • S_AUDIO_NATURE_ANIMALS — animals
    • S_AUDIO_NATURE_HUMAN — a human
    • S_AUDIO_MUSIC — music

    The full list is in src/components/sound/constants.ts. The names of some constants contain 8_BIT ( S_AUDIO_GAME_8_BIT_COIN, S_AUDIO_GAME_8_BIT_FLYBY, S_AUDIO_GAME_8_BIT_MACHINE_GUN, S_AUDIO_GAME_8_BIT_PHONE) — do not lose this part of the name.

    • S_EFFECT_BEHIND_THE_WALL — a voice behind the wall
    • S_EFFECT_HAMSTER — a hamster (a high voice)
    • S_EFFECT_MEGAPHONE — a megaphone
    • S_EFFECT_PITCH_DOWN — a low voice
    • S_EFFECT_PSYCHODELIC — psychedelic
    • S_EFFECT_PULSE — pulsing
    • S_EFFECT_TRAIN_ANNOUNCE — a train station announcement
    • S_EFFECT_END — the end of the effect
    Platform Standard sounds Custom sounds S_EFFECT_* effects Pauses
    Alice ✅ ✅ (through <speaker audio="...">) ✅ ✅
    Marusia ✅ ✅ ❌ ✅
    SmartApp ❌ ❌ ❌ ❌
    Telegram/VK/MAX ❌ ✅ (uploaded as audio) ❌ (TTS through SpeechKit, as a separate message) ❌
    Viber ❌ ❌ ❌ (with an empty text, tts is sent as text) ❌

    ⚠️ SmartApp supports neither standard nor custom sounds nor TTS effects: sound markers are stripped from tts, and the tts text itself is spoken by the assistant.

    Important. On Telegram/VK/MAX TTS needs a Yandex SpeechKit token. Set it in appConfig.tokens[platform].speech_kit_token or in the SPEECH_KIT_TOKEN environment variable.

    About SSML. The framework substitutes the ready-made effect constants (S_EFFECT_*, the section above) only for Alice. Raw SSML tags <speaker ...> written into tts manually are passed to TTS as is in Marusia, and in SmartApp they are sent with the application/ssml type — but support for specific tags and effects depends on the platform's own TTS.

    // Text — for word endings, Nlu — for the static methods below
    import { Nlu, Text } from 'umbot';

    // Full name (Alice/Marusia)
    const fio = this.nlu.getFio();
    if (fio.status) {
    const p = fio.result![0];
    this.text = `Hi, ${p.first_name} ${p.last_name}!`;
    }

    // Date/time (Alice/Marusia)
    const dt = this.nlu.getDateTime();
    if (dt.status) {
    const d = dt.result![0];
    if (d.day_is_relative) {
    // Russian plural forms of "day": Text.getEnding(5, ['день', 'дня', 'дней'])
    const days = Text.getEnding(d.day ?? 0, ['день', 'дня', 'дней']) || 'дней';
    this.text = `Через ${d.day} ${days}`;
    } else {
    this.text = `${d.day}.${d.month}.${d.year}`;
    }
    }

    // A number (Alice/Marusia)
    const num = this.nlu.getNumber();
    if (num.status) {
    this.text = `You said the number ${num.result![0]}`;
    }

    // Geo (Alice)
    const geo = this.nlu.getGeo();
    if (geo.status) {
    const g = geo.result![0];
    this.text = `City: ${g.city}, street: ${g.street}`;
    }

    // The user name (Telegram, VK, Viber, MAX — the adapters fill thisUser)
    const user = this.nlu.getUserName();
    if (user?.first_name) {
    this.text = `Hi, ${user.first_name}!`;
    }

    // Built-in intents (work on all platforms through userCommand)
    if (this.nlu.isIntentConfirm(this.userCommand || '')) {
    this.text = 'You agreed!';
    }
    if (this.nlu.isIntentReject(this.userCommand || '')) {
    this.text = 'You declined.';
    }

    // Static methods — work on any platform through a regex
    const phones = Nlu.getPhone(this.originalUserCommand || '');
    if (phones.status) {
    this.userData.phone = phones.result![0];
    }

    const emails = Nlu.getEMail(this.originalUserCommand || '');
    if (emails.status) {
    this.userData.email = emails.result![0];
    }

    const links = Nlu.getLink(this.originalUserCommand || '');
    if (links.status) {
    this.userData.url = links.result![0];
    }

    // Custom intents (Alice and Marusia — configured in the platform console)
    const myIntent = this.nlu.getIntent('ORDER_PIZZA');
    if (myIntent) {
    const slot = Array.isArray(myIntent.slots) ? myIntent.slots[0] : myIntent.slots;
    // ...
    }
    Feature Alice Marusia SmartApp Telegram VK Viber Max
    FIO, GEO, DateTime, Number ✅ ✅ ❌ ❌ ❌ ❌ ❌
    Custom intents (nlu.getIntent) ✅ ✅ ❌* ❌ ❌ ❌ ❌
    getUserName() ❌ ❌ ❌ ✅ ✅ ✅ ✅
    isIntentConfirm/Reject (through userCommand) ✅ ✅ ✅ ✅ ✅ ✅ ✅
    getLink/getPhone/getEMail (regex, static) ✅ ✅ ✅ ✅ ✅ ✅ ✅

    * In SmartApp the intent comes in payload.intent and goes to controller.oldIntentName, not to nlu.intents — getIntent() always returns null for SmartApp. Define your intents in SmartApp Code and handle them by oldIntentName.

    import { BotController, Navigation } from 'umbot';

    interface Product {
    id: number;
    name: string;
    price: number;
    }

    class ShopController extends BotController {
    // In a real bot — keep it between requests through this.userData.nav = { page: N }
    nav = new Navigation<Product>(3); // 3 items per page

    public action(intentName: string | null): void {
    const products: Product[] = [
    { id: 1, name: 'Apple', price: 50 },
    { id: 2, name: 'Pear', price: 70 },
    { id: 3, name: 'Banana', price: 40 },
    { id: 4, name: 'Orange', price: 80 },
    { id: 5, name: 'Mango', price: 200 },
    { id: 6, name: 'Kiwi', price: 90 },
    { id: 7, name: 'Lemon', price: 30 },
    ];

    // Get the current page (the method itself moves thisPage on "дальше"/"назад" — "next"/"back")
    const page = this.nav.getPageElements(products, this.userCommand || '');

    // Render it as a list card
    this.card.setTitle('Choose a product');
    for (const p of page) {
    this.card.addImage(`https://shop.example.com/img/${p.id}.jpg`, p.name, `${p.price} ₽`, {
    title: 'Buy',
    payload: { action: 'buy', id: p.id },
    });
    }

    // Pagination buttons
    for (const caption of this.nav.getPageNav()) {
    this.buttons.addBtn(caption);
    }

    // Page information
    const info = this.nav.getPageInfo();
    if (info) this.buttons.addBtn(info);

    // Did the user choose an item?
    const selected = this.nav.selectedElement(products, this.userCommand || '', ['name']);
    if (selected) {
    this.text = `You chose: ${selected.name} for ${selected.price} ₽`;
    }
    }
    }
    Method Purpose
    getPageElements(elements, text) Returns the items of the current page. Mutates thisPage on the Russian "next"/"back" words
    selectedElement(elements, text, keys) Picks an item by the text (by number or by text similarity)
    getPageNav(isNumber?) Returns the pagination button captions: ['👈 Назад', 'Дальше 👉'] or ['1', '[2]', '3']. "Back" is not returned on the first page, "Next" — on the last
    getPageInfo() Returns "N страница из M" ("page N of M") or an empty string
    getMaxPage(elements) The number of pages
    numberPage(text) Recognizes a page reference like "2 страница" / "N страни…" ("page N"; the digit is required) and goes there. Negative values are silently turned into page 0

    Important: Navigation is purely in-memory. Keep thisPage (and the item list if needed) in userData between requests.

    On chat platforms a lazy facade to the platform API is available from the controller: ctx.api. It is created on first access and reset between requests; on voice platforms (Alice, SmartApp, Marusia) it is null. There is always one recipient — the user of the current request, so chatId/userId is not passed to the methods.

    bot.addCommand('photo', ['photo'], async (_, ctx) => {
    if (ctx.api?.can('sendPhoto')) {
    // Sending a photo directly through the platform API (Telegram/MAX — all methods,
    // VK — sendPhoto/sendDocument/answerCallback)
    await ctx.api.sendPhoto('https://example.com/cat.png', { caption: 'Here is a cat!' });
    }
    });
    Method Telegram VK MAX Viber
    sendPhoto ✅ ✅ ✅ — (warn + null)
    sendDocument ✅ ✅ ✅ — (warn + null)
    sendAudio ✅ — ✅ — (warn + null)
    sendVideo ✅ — ✅ — (warn + null)
    answerCallback ✅ ✅ ✅ — (warn + null)
    • answerCallback(text, showAlert?) — a notification for a callback button press (outside a callback request — a warn and null).
    • can(method) — checks whether the platform supports the method (returns false in Viber).
    • A custom platform connects the facade by overriding the adapter method createApi(controller) — see how in platform-integration.md, the "Platform API" section.
    • The full matrix and the signatures are in api-reference.md, the "Platform API" section, and in platform-integration.md.

    7 platforms are supported out of the box: Alice, SmartApp (Sber), Marusia, Telegram, VK, Viber, Max. You can connect them in any of the ways below.

    // Option 1: all platforms at once — the most common choice
    bot.use(fullPlatforms);

    // Option 2: voice platforms only (Alice, SmartApp, Marusia)
    bot.use(voicePlatforms);

    // Option 3: chat bots only (Telegram, VK, Viber, Max)
    bot.use(botPlatforms);

    // Option 4: one by one (if you want to limit the set of platforms)
    bot.use(new AlisaAdapter('YANDEX_OAUTH_TOKEN'));
    bot.use(new TelegramAdapter('TELEGRAM_BOT_TOKEN'));
    bot.use(
    new VkAdapter('VK_TOKEN', {
    vk_confirmation_token: 'CONFIRMATION_STRING', // required for VK
    vk_api_version: '5.199', // optional
    }),
    );
    bot.use(
    new ViberAdapter('VIBER_TOKEN', {
    viber_sender: 'MyBotName', // required for Viber, ≤ 28 characters
    viber_api_version: '8', // optional
    }),
    );
    bot.use(new MaxAdapter('MAX_TOKEN'));
    bot.use(new MarusiaAdapter('MARUSIA_TOKEN'));
    bot.use(new SmartAppAdapter()); // no token — authentication through the Sber ecosystem

    Need your own platform (Discord, Slack, WhatsApp, a corporate messenger)? umbot supports adding custom adapters through BasePlatformAdapter. A detailed guide is in the official documentation.

    Bot itself detects which platform a request came from — one webhook endpoint accepts requests from all platforms. The adapters follow the platform limits (text length, number of buttons, state size) themselves: the extra is trimmed with a warning in the log, so the code stays the same for all platforms. Your responsibility is the response time for voice platforms: the framework warns after 2 s of processing and logs an error after 2.9 s.

    What each platform supports (a summary table), how to override platform detection (setPlatformResolver) and the specifics of each platform are in Connecting platforms.


    If you use controller.userData for persistent data (and not only isLocalStorage: true), you need to connect a DB adapter. Two are available out of the box; for others (PostgreSQL, Redis, ...) you can write your own through BaseDbAdapter.

    Uses JSON files in the appConfig.json folder. Suitable for prototypes, personal skills, small teams (< 100 users).

    import { FileAdapter } from 'umbot/plugins';

    bot.use(new FileAdapter());
    bot.setAppConfig({ json: './data' }); // the folder for JSON files

    Limits: up to ~250 MB of data (logs a warning at 270 MB, an error at 360 MB, a crash is possible at ~400 MB — the whole file is loaded into memory). One process (not safe for multi-process). Only strict equality in where (no operators like $gt, $in).

    import { MongoAdapter } from 'umbot/plugins';

    // Option 1: options in the constructor
    bot.use(
    new MongoAdapter({
    host: 'mongodb://localhost:27017',
    database: 'umbot',
    user: 'root',
    pass: 'secret',
    options: { maxPoolSize: 100 },
    }),
    );

    // Option 2: through appConfig.db + .env
    bot.use(new MongoAdapter());
    bot.setAppConfig({
    db: {
    host: process.env.DB_HOST!,
    user: process.env.DB_USER,
    pass: process.env.DB_PASSWORD,
    database: process.env.DB_NAME!,
    },
    env: '.env',
    });

    Specifics: pool size 50, timeouts of 2–3 s (serverSelection/connect/socket — 2000 ms, the overall timeoutMS — 3000 ms), support for query operators ($gt, $in, $or, aggregations), multi-process safe. Suitable for production loads.

    Need another database? umbot supports custom adapters through BaseDbAdapter — implement 5 methods (_select, _insert, _update, _remove, isConnected) and register it with bot.use(new MyAdapter()). An example implementation is in the official documentation and in examples/skills/userDbConnect/ of the repository.

    • userData (through the UsersData model) — the main user state.
    • ImageTokens / SoundTokens — the token cache of uploaded media. You do not manage this cache manually — the framework itself uploads images/sounds to the platform on first use and reuses the tokens afterwards.

    Direct access to ImageTokens / SoundTokens is needed only to inspect or invalidate the cache (to force a re-upload of the media). In 99% of cases you will not need it.

    If besides userData you need a separate table (records, a catalog, logs), create a model through Model<TState>. For simple skills userData is usually enough. A model example and its methods are in the API reference.


    Middleware are functions that receive the request before the handlers (commands, steps, action()): authentication, filtering, rate limiting, tracing.

    import { T_ALISA } from 'umbot/plugins';
    import { rateLimiter, requestId } from 'umbot/middleware';

    // Global — for all platforms
    bot.use(async (ctx, next) => {
    ctx.appContext.log(`[${ctx.appType}] ${ctx.userId}: ${ctx.userCommand}`);
    await next(); // without next() the processing ends here
    });

    // Only for Alice
    bot.use(T_ALISA, async (ctx, next) => {
    if (!ctx.userData.authorized) {
    ctx.text = 'Please sign in';
    return; // next() is not called — the commands do not run
    }
    await next();
    });

    // Built-in
    bot.use(requestId());
    bot.use(rateLimiter());

    The order: first the whole global chain (including the code after await next()), then the platform chain, and only then the handler. So after await next() the response is not built yet — reading ctx.text there is pointless; the whole response is available in responseCb of bot.start() / bot.webhookHandle(). The core logs an exception in middleware and answers the platform with 200, but the commands do not run.

    The built-in middleware (rateLimiter, authGuard, requestId, maintenance, ipFilter), their options and the rules for writing your own are in Middleware.


    The first time an image or a sound is sent, the file is uploaded to the platform (200–1000 ms per file), which may not fit into the voice platform's limit. Preload does this at startup: the tokens are saved to the database (ImageTokens / SoundTokens), and the first user gets a response as fast as everyone else.

    import { Preload } from 'umbot/preload';
    import { T_ALISA } from 'umbot/plugins';

    const preload = new Preload(bot.getAppContext());
    await Promise.all([
    ...preload.loadImages(['./media/img1.jpg'], [T_ALISA], { alisaSkillId: 'your-skill-id' }),
    ...preload.loadSounds(['./media/win.mp3'], [T_ALISA], { alisaSkillId: 'your-skill-id' }),
    ]);
    bot.start('0.0.0.0', 3000);

    Alice needs alisaSkillId, Telegram — telegramUseId (the user who receives the file to get the file_id). The methods, return values and deleting media are in the API reference.


    BotTest is the same Bot, but with a dialog in the console: replace Bot with BotTest and start() with test(), type utterances and see the responses without publishing the skill.

    import { BotTest } from 'umbot/test';
    import { fullPlatforms } from 'umbot/plugins';

    const bot = new BotTest();
    bot.use(fullPlatforms);
    bot.setPlatformParams({ welcome_text: 'Hi!', intents: [] });
    await bot.test({ isShowResult: true, isShowStorage: true });

    For Jest simulate() is more convenient: it builds a valid platform request itself and returns the response.

    const res = await bot.simulate('hello', { platform: 'alisa' });
    

    The test() and simulate() parameters, tests through run(), mocks of the HTTP client and the database are in Testing.


    • The built-in server — bot.start('0.0.0.0', 3000): accepts webhooks on POST /, serves GET /health, shuts down by itself on SIGTERM/SIGINT.
    • Your own server (Express, Fastify) — app.post('/webhook', (req, res) => bot.webhookHandle(req, res)); do not add express.json(), webhookHandle reads the body itself.
    • Serverless (Yandex Cloud Functions) — bot.webhookEvent(body, headers, clientIp) returns a ready { statusCode, body }; a project with the handler is generated by npx umbot create from-flow flow.json --usecloud.
    • Long polling (Telegram, VK, MAX) — bot.startPolling(), no public address needed.

    HTTPS and nginx, Docker, PM2, CI/CD, several processes and the pre-launch checklist are in Deployment.


    The platform limits (text length, number of buttons, card size) are handled by the adapters — more in Connecting platforms. You do not need to remember them: the framework trims the extra itself.

    The only things you are responsible for:

    • Response time — the framework logs a warning when processing takes 2000 ms or more and an error at 2900 ms or more; check the voice platform limits against their current documentation. This is a limitation of the platform itself, and the framework cannot "trim" your business logic. Keep action() fast.
    • The size of userData with isLocalStorage=true — Alice's local storage is limited to 1 KB per state type. If the data is large, use a database.
    • The HTTP request size — the built-in server accepts up to 2 MB in the body. Platforms send much less, so this is rarely a problem.
    1. Long synchronous operations in action() or in a command — they block the event loop and hit the voice platform timeout.

      • ❌ JSON.parse(fs.readFileSync(hugeFile))
      • ✅ await fs.promises.readFile()
    2. Complex RegExp without ReDoS protection — setAppMode('strict_prod') checks them, but do not take the risk.

      • ❌ /(a+)+b/ (catastrophic backtracking)
      • ✅ /a+b/
    3. Making HTTP requests without a timeout — an external API can hang and exhaust the response time limit.

      • ❌ await fetch(url)
      • ✅ AbortController with setTimeout(() => controller.abort(), 3000)
    4. Storing large data in userData with isLocalStorage=true — the platform-side limit is 1 KB.

      • ❌ userData.history = [1000 messages]
      • ✅ Use MongoAdapter
    5. Using delete this.userData.field — on Alice a missing field does not mean it is deleted, the platform returns the old value.

      • ❌ delete this.userData.tempData
      • ✅ this.userData.tempData = null
    6. Forgetting intents in setPlatformParams — the field is required.

      • ❌ bot.setPlatformParams({ welcome_text: 'Hi' })
      • ✅ bot.setPlatformParams({ welcome_text: 'Hi', intents: [] })
    7. Logging secrets — secret masking in logs works in all modes (dev, prod, strict_prod), but do not log sensitive data on purpose. Masking can be disabled only explicitly, by passing a custom logger with maskSecrets: false — do not do this in production.

    Internal request processing takes less than 30 ms even with 1000 commands; the numbers, the measurement method and advice for large command sets are in Performance and guarantees.


    The umbot framework handles errors at several levels. Understanding these levels helps you write reliable code.

    If a command callback throws an exception, the framework catches it, logs the error and returns a standard message to the user (in Russian): "Could not run the command. Please try again.". For dialog steps the text is similar: "Could not run the dialog step. Please try again.".

    // The framework automatically wraps this code in try/catch:
    bot.addCommand('risk', ['risk'], async (_, bc) => {
    const res = await fetch('https://external-api.com/data', {
    signal: AbortSignal.timeout(3000), // may fail or hang
    });
    const data = await res.json();
    bc.text = data.answer;
    });

    If you need to handle the error yourself (for example, to show the user a clear message), use try/catch inside the callback:

    bot.addCommand('risk', ['risk'], async (_, bc) => {
    try {
    const res = await fetch('https://external-api.com/data', {
    signal: AbortSignal.timeout(3000),
    });
    const data = await res.json();
    bc.text = data.answer;
    } catch (error) {
    bc.text = 'The service is temporarily unavailable. Please try again later.';
    bc.appContext.logError('Error calling the external API', { error });
    }
    });

    Middleware functions can also throw exceptions. If a middleware did not call next() and did not set text, action() is not called, and the user gets an empty response.

    bot.use(async (ctx, next) => {
    try {
    const allowed = await checkAccess(ctx.userId);
    if (!allowed) {
    ctx.text = 'Access denied.';
    return; // next() is not called — action() does not run
    }
    await next();
    } catch (error) {
    ctx.appContext.logError('Error in middleware', { error });
    ctx.text = 'Something went wrong. Please try again later.';
    }
    });

    The framework catches an exception (or a rejected promise) in action(): it logs the error, and if text is still empty, it answers "Could not run the command. Please try again." (in Russian). Set your own error text with try/catch:

    class SafeController extends BotController {
    public action(intentName: string | null): void {
    try {
    switch (intentName) {
    case WELCOME_INTENT_NAME:
    this.text = 'Hi!';
    break;
    default:
    if (!this.text) this.text = 'I didn\'t get that. Say "help".';
    }
    } catch (error) {
    this.appContext.logError('Error in action()', { error });
    this.text = 'Sorry, something went wrong. Please try again.';
    }
    }
    }

    All errors are logged through appContext.logError():

    // Anywhere in the code:
    this.appContext.logError('Error description', { additionalData: '...' });

    In the dev mode errors go to the console and to a file (if error_log is set). In the prod and strict_prod modes without a custom logger the error is written to a file, and its text (without the stack and metadata, with masked secrets) is duplicated as a [umbot] ... line in stderr — so errors are visible in docker logs and in the serverless function log.

    Scenario What happens Recommendation
    An error in a command A standard message + a log entry Wrap it in try/catch for a custom response
    An error in middleware A log entry, the commands do not run Log it and set text
    An error in fetch The promise is rejected Use try/catch + timeouts
    A database error The method returns false Check the result of save()
    A platform timeout (~3 s; the framework warns after 2 s, logs an error after 2.9 s) The platform drops the connection Use Preload for media

    The framework measures the command and intent lookup, action(), middleware, database and platform API requests. Metrics are collected only if the logger has a metric() method:

    bot.setLogger({
    metric: (name: string, value: unknown, labels?: Record<string, unknown>) => {
    console.log(`[METRIC] ${name}: ${value}`, labels);
    },
    });

    The list of metrics (EMetric) and what each one measures are in the API reference.


    Causes:

    • A string with a capital letter in slots — userCommand is already lowercased, so the slot must be lowercase too.
    • The slot contains special characters — escape them or use isPattern=true.
    • The command is registered after the start (start()). Technically this works — commands are read at request time — but register them before start() to avoid a race condition in the first seconds after the launch.
    • Another command with the same name is registered — addCommand overwrites it.
    • userCommand is null (the platform sent not text but, for example, a callback_query without text).

    This is the expected platform behavior. The ready-made effect constants (S_EFFECT_*) and <speaker effect="..."> work only on Alice. In Marusia raw SSML tags in tts are passed as is; the SmartApp adapter strips the sound markers and sends the text with the application/ssml type only when there are real SSML tags — support for specific effects depends on the platform's TTS. On Telegram/VK/MAX TTS is synthesized through SpeechKit — a separate subscription and a token are needed.

    The cause: the first use of an image/sound → an upload to the platform (200–1000 ms each).

    The solution: Preload at startup.

    Causes:

    • isLocalStorage: false and no DB adapter is connected → the data is not saved.
    • isLocalStorage: true on Telegram/VK/MAX/Viber without a DB adapter → the data is in process memory: it is lost on restart and is not visible to other processes/replicas or to neighboring calls of a serverless function. Connect a DB adapter.
    • memorySession: false and no DB adapter is connected.
    • The field is undefined → Alice does not save it. Use null to delete it.
    • The size exceeded 1 KB (Alice's state) → the framework logs an error and does not send the field to the platform, so the data is not saved (the values in userData themselves are not cleared).

    Causes:

    • The request came with unknown headers.
    • Several platforms have similar markers (Alice/Marusia — the same body format).
    • No adapter is registered for the platform.

    The solution: bot.setPlatformResolver((query, headers, detect) => { ... }).

    The solution: connect bot.use(rateLimiter()), or reduce the load on the platform.

    The adapter returned false from setQueryData — the actual framework message (in Russian) says that the platform adapter "X" could not parse the request (where X is the platform identifier). Most likely the request does not match the platform format: an unrelated request came to this endpoint, or the webhook is configured on another platform. Check the webhook URL and the secret.

    In the strict_prod mode dangerous regexes are rejected. Simplify the pattern:

    • ❌ /(a+)+b/
    • ✅ /a+b/ or /^(a+?)b$/

    1. Start with the CLI. npx umbot create my-skill gives you a working template in a minute.
    2. Use BotTest for development. The REPL in the console saves hours — you do not need to publish the skill and test it through Yandex Dialogs.
    3. Always setAppMode('strict_prod') in production (or NODE_ENV=production). It drops dangerous regular expressions; secret masking in logs works in all modes.
    4. Enable Preload for media. The first user should not wait for an upload.
    5. Keep state in userData, not in local controller variables. The controller is recreated for every request.
    6. Test on all target platforms. The logic is the same, but the limits and specifics differ.
    7. Watch the response time. The END_WEBHOOK metric in your logger shows slow requests before the voice platform notices them.
    8. Use MongoAdapter for production. FileAdapter is for prototypes only.
    9. Read the source code. It is well documented with JSDoc in Russian.