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.xRepository: 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 isIAppConfig and IAppParamaddCommand / addSteprateLimiterBotTest and Jeststart, webhookHandle, Docker, Expressumbot isumbot 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:
re2 is used optionally (2–15 times faster).npx umbot create <name> sets up a ready project in a minute.umbot does NOT doflow.json, from which the CLI generates a project (npx umbot create from-flow).┌──────────────────────── 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:
new MyController(appContext)) for every request.userCommand, userId, ...).userData / state.run() — the internal dispatcher.run() determines what fired (event → step → command → intent → fallback → welcome/help), and at the end
calls action() (in detail — "Dispatcher order").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).# 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: truewithout a database, you do not have to create thejson/andlogs/folders (they appear automatically when needed). The logs folder is configured witherror_log; by default the framework writes tologs/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"
| 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);
index.ts from growing — the "logical module as a plugin" patternWhen 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:
index.ts stays a clean assembly point — you can see which modules are connected.⚠️ Attention! A plugin function must have the
isPlugin = truemarker. Without itbot.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 thecreatePlugin()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:
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. UseaddCommandfor each command, andaction()only for shared post-processing.
In real projects, usually:
addCommand / addStep (or plugins with them).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 = '...';
});
IAppConfig and IAppParamThe 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.
addCommand / addStepbot.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.userCommandis the user's text converted to lower case. String slots must also be in lower case:'hello', not'Hello'. For RegExp use theiflag if you want case-insensitive matching.
Re-registration:
addCommandwith 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 withawait. 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
userDatain a command:addCommandis a generic method with the signatureaddCommand<TBotController>(name, slots, cb, isPattern): theTBotControllerparameter is inferred from the callback annotation. For TypeScript to know about your fields inbc.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 "TypeduserData" 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:
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.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.
controller.thisIntentName = 'step_name'. This means: "the user's next request
must go to the step_name step".userData.oldIntentName (or to state.oldIntentName with
isLocalStorage: true) — it survives between requests.oldIntentName from the storage and puts it into controller.oldIntentName.oldIntentName matches the name of a registered step, it calls that step's callback
instead of looking up commands.thisIntentName explicitly:
thisIntentName = 'next_step' — go to another step.thisIntentName = null — leave the scenario (the next request takes the regular path: commands →
intents → fallback).thisIntentName = 'current_step' — stay on the step if the user's answer has not been accepted yet.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.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.Promise<string>), the string becomes the response text, as with
addCommand: bot.addStep('ask_name', (ctx) => `Hi, ${ctx.originalUserCommand}!`).oldIntentName matches no step, steps are ignored and the dispatcher looks up commands right away.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):
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).oldIntentName is registered as a step.addCommand calls):
.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).platformParams.intents (the intent's slots are compared with userCommand).messageId === 0.messageId === 0, 'welcome' is substituted →
the framework sets controller.text = platformParams.welcome_text.'help' → the framework sets controller.text = platformParams.help_textcontroller.text = platformParams.empty_text (only if you extend BaseBotController)action(intentName, isCommand, isStep) — always called at the end.About welcome/help: the framework sets
controller.text = platformParams.welcome_text(orhelp_text) before callingaction(). If you also setthis.textinaction(), your value overrides the automatically set one. This is useful for a dynamic greeting (for example, a different greeting for a returning user).
⚠️ About
BaseBotControllerandempty_text: settingcontroller.text = platformParams.empty_textautomatically (when nothing matched) happens only if you extendBaseBotController. If you extendBotControllerdirectly, setthis.textmanually inaction(). 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 handledefault: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.
userData comes fromTo avoid confusion, use the following rule:
If a DB adapter is connected (
FileAdapter,MongoAdapteror your own),userDataalways comes from the database. If NO DB adapter is connected andisLocalStorage: true,userDatacomes 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 and how it relates to userDatastate 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:
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.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).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).bot.use(new MongoAdapter({ host: '...', database: '...' }));
bot.setAppConfig({ isLocalStorage: false });
userData always comes from the database.state is not used (null).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).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).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:
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;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.
userData / state| 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
userDataThe 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';
};
}
userDataTyping 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
MyUserDatainterface in a separate file (src/types.tsorsrc/models/userData.ts) and import it where needed. This avoids duplication.
userData lives only in
process memory (it is lost on restart).isLocalStorage: true.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.payloadisRecord<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 touserCommand = '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:
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;addAction;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.
buttons.row()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 ]
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.options._group: buttons with an explicitly set group keep it, buttons of one group
are shown in one line on all four platforms.row() has no effect there.buttons.remove()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.';
SoundConstants constants)S_AUDIO_GAME_WIN — a game winS_AUDIO_GAME_LOSS — a lossS_AUDIO_GAME_8_BIT_COIN — a coin (note the 8_BIT in the name!)S_AUDIO_GAME_BOOT — game loadingS_AUDIO_GAME_PING — a pingS_AUDIO_GAME_8_BIT_FLYBY — a flybyS_AUDIO_GAME_8_BIT_MACHINE_GUN — a machine gunS_AUDIO_GAME_8_BIT_PHONE — a phoneS_AUDIO_GAME_POWERUP — a power-upS_AUDIO_NATURE_WIND — windS_AUDIO_NATURE_THUNDER — thunderS_AUDIO_NATURE_JUNGLE — jungleS_AUDIO_NATURE_RAIN — rainS_AUDIO_NATURE_FOREST — forestS_AUDIO_NATURE_SEA — seaS_AUDIO_NATURE_FIRE — a campfireS_AUDIO_NATURE_STREAM — a streamS_AUDIO_THING_CHAINSAW — a chainsawS_AUDIO_NATURE_ANIMALS — animalsS_AUDIO_NATURE_HUMAN — a humanS_AUDIO_MUSIC — musicThe full list is in
src/components/sound/constants.ts. The names of some constants contain8_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 wallS_EFFECT_HAMSTER — a hamster (a high voice)S_EFFECT_MEGAPHONE — a megaphoneS_EFFECT_PITCH_DOWN — a low voiceS_EFFECT_PSYCHODELIC — psychedelicS_EFFECT_PULSE — pulsingS_EFFECT_TRAIN_ANNOUNCE — a train station announcementS_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 thettstext 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_tokenor in theSPEECH_KIT_TOKENenvironment variable.About SSML. The framework substitutes the ready-made effect constants (
S_EFFECT_*, the section above) only for Alice. Raw SSML tags<speaker ...>written intottsmanually are passed to TTS as is in Marusia, and in SmartApp they are sent with theapplication/ssmltype — 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.intentand goes tocontroller.oldIntentName, not tonlu.intents—getIntent()always returnsnullfor SmartApp. Define your intents in SmartApp Code and handle them byoldIntentName.
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} ₽`;
}
}
}
Navigation methods| 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:
Navigationis purely in-memory. KeepthisPage(and the item list if needed) inuserDatabetween requests.
controller.apiOn 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).createApi(controller) — see how in
platform-integration.md, the "Platform API" section.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)?
umbotsupports adding custom adapters throughBasePlatformAdapter. 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.
rateLimiterMiddleware 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 and JestBotTest 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.
start, webhookHandle, Docker, Expressbot.start('0.0.0.0', 3000): accepts webhooks on POST /, serves GET /health,
shuts down by itself on SIGTERM/SIGINT.app.post('/webhook', (req, res) => bot.webhookHandle(req, res)); do not
add express.json(), webhookHandle reads the body itself.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.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:
action() fast.userData with isLocalStorage=true — Alice's local storage is limited to 1 KB per state type. If
the data is large, use a database.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()Complex RegExp without ReDoS protection — setAppMode('strict_prod') checks them, but do not take the risk.
/(a+)+b/ (catastrophic backtracking)/a+b/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)Storing large data in userData with isLocalStorage=true — the platform-side limit is 1 KB.
userData.history = [1000 messages]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.tempDatathis.userData.tempData = nullForgetting intents in setPlatformParams — the field is required.
bot.setPlatformParams({ welcome_text: 'Hi' })bot.setPlatformParams({ welcome_text: 'Hi', intents: [] })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:
slots — userCommand is already lowercased, so the slot must be lowercase too.isPattern=true.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.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.
userData is not savedCauses:
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.undefined → Alice does not save it. Use null to delete it.userData themselves are not cleared).Causes:
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.
ReDoS detected in productionIn the strict_prod mode dangerous regexes are rejected. Simplify the pattern:
/(a+)+b//a+b/ or /^(a+?)b$/npx umbot create my-skill gives you a working template in a minute.BotTest for development. The REPL in the console saves hours — you do not need to publish the skill and test it
through Yandex Dialogs.setAppMode('strict_prod') in production (or NODE_ENV=production). It drops dangerous
regular expressions; secret masking in logs works in all modes.Preload for media. The first user should not wait for an upload.userData, not in local controller variables. The controller is recreated for every
request.END_WEBHOOK metric in your logger shows slow requests before
the voice platform notices them.MongoAdapter for production. FileAdapter is for prototypes only.Full reference — API v-3.1 · all versions.