umbot
    Preparing search index...

    Quick start: your first voice skill or chatbot with umbot

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

    This guide shows how to quickly build a multi-platform application for voice skills and chatbots with the umbot TypeScript framework.

    umbot is a universal framework for building voice skills and chatbots for many platforms. Key features:

    • One codebase for all platforms (Alice, Sber Salute, Telegram, VK and more)
    • Built-in user state management
    • Full type safety (TypeScript)
    • UI components: buttons, cards, images, sounds

    The fastest way to get a ready-made project is the CLI:

    npx umbot create echo
    cd echo
    npm i
    npm run build
    npm start

    The CLI creates a controller, configuration, package.json, tsconfig.json, .gitignore and a .env with empty token variables — fill in the tokens for the platforms you use. The --minimal flag creates a project without a controller class, --prod adds a Dockerfile and a deploy file. A project from the visual editor is generated with npx umbot create from-flow flow.json --output ./my-bot.

    If you prefer to set up the project yourself, install the package:

    npm install umbot
    

    Basic version (with a controller) Let's create a controller that describes how commands are handled.

    import { Bot, BotController, WELCOME_INTENT_NAME } from 'umbot';
    import { fullPlatforms } from 'umbot/plugins';
    import { join } from 'node:path';

    // A controller with the skill logic
    class MyController extends BotController {
    public action(intentName: string | null): void {
    switch (intentName) {
    case WELCOME_INTENT_NAME:
    this.text = 'Hi! I am a new skill.';
    this.buttons.addBtn('Help');
    break;

    case 'help':
    this.text = 'I can answer commands and show buttons';
    break;

    default:
    this.text = this.userCommand || 'You did not say anything';
    break;
    }
    }
    }

    // Create the application
    const bot = new Bot();
    // Connect all available platforms
    // If you only need voice platforms, use voicePlatforms, or a specific adapter if you need just one platform
    bot.use(fullPlatforms);

    // Configure commands
    bot.setPlatformParams({
    intents: [
    {
    name: 'help',
    slots: ['help', 'what can you do'],
    },
    ],
    });

    // Configure the application
    bot.setAppConfig({
    json: join(__dirname, 'data'),
    error_log: join(__dirname, 'logs'),
    isLocalStorage: true,
    });

    // Connect the controller
    bot.initBotController(MyController);

    bot.start('localhost', 3000);

    Minimal version (without a controller)

    You can also skip the BotController entirely and handle everything by adding commands dynamically. Note FALLBACK_COMMAND: its handler runs when no matching command is found. Instead of the constant you can simply pass "*" — it means the same thing.

    import { Bot, BotController, FALLBACK_COMMAND, HELP_INTENT_NAME, WELCOME_INTENT_NAME } from 'umbot';
    import { fullPlatforms } from 'umbot/plugins';
    import { join } from 'node:path';

    const bot = new Bot()
    .use(fullPlatforms)
    .setAppConfig({
    json: join(__dirname, 'data'),
    error_log: join(__dirname, 'logs'),
    isLocalStorage: true,
    })
    .addCommand(WELCOME_INTENT_NAME, ['hello'], (_: string, bc: BotController) => {
    bc.text = 'Hi! I am a new skill.';
    bc.buttons.addBtn('Help');
    })
    .addCommand(HELP_INTENT_NAME, ['help'], (_: string, bc: BotController) => {
    bc.text = 'I can answer commands and show buttons';
    })
    .addCommand(FALLBACK_COMMAND, [], (_: string, bc: BotController) => {
    bc.text = bc.userCommand || 'You did not say anything';
    })
    .start('localhost', 3000);

    The base class that gives access to the response API and to state.

    this.text = 'Reply to the user'; // Response text
    this.tts = 'Text for speech synthesis (if it differs from text)'; // TTS version (optional)
    this.buttons
    .addBtn('Simple button')
    .addBtn('Link', 'http://localhost')
    .addBtn('Button with data', null, {
    action: 'custom',
    value: 123,
    });
    this.card.addImage('image.jpg').setTitle('Title').setDescription('Description');
    
    // In TypeScript, declare an interface and pass it to BotController
    interface IUserState {
    counter?: number;
    }
    class MyController extends BotController<IUserState> {}

    // Inside the controller, userData now knows about counter
    this.userData.counter = 42;

    // Read the data
    const counter = this.userData.counter ?? 0;
    bot.setPlatformParams({
    intents: [
    {
    name: 'start_game',
    slots: ['start game', 'play', 'start'],
    },
    ],
    });
    bot.addCommand('greeting', ['hello', 'hi'], (_, controller) => {
    controller.text = 'Hello!';
    });
    src/
    ├── controller/ # Controllers with logic (if needed)
    ├── plugins/ # Additional plugins (if needed)
    ├── utils/ # Helper functions (if needed)
    ├── config/ # Configuration (if needed)
    └── index.ts # Entry point
    interface IGameState {
    score: number;
    level: number;
    lastAction?: string;
    }

    class GameController extends BotController<IGameState> {
    public action(intentName: string | null): void {
    // this.userData is now typed as IGameState
    this.userData.score = 100;
    }
    }
    try {
    // Your asynchronous logic (an API request, a database call, etc.)
    const result = await fetchExternalData();
    this.text = `Done: ${result}`;
    } catch (error) {
    console.error('Error:', error);
    this.text = 'Sorry, something went wrong';
    }
    // First launch check
    if (!this.userData.initialized) {
    this.userData.initialized = true;
    this.userData.score = 0;
    }

    // Resetting state — mutate, do not reassign
    if (intentName === 'restart') {
    Object.keys(this.userData).forEach((key) => delete this.userData[key]);
    this.text = 'The game has been restarted';
    }

    Important: do not write this.userData = {}; — the framework keeps a reference to the object, and reassigning it entirely can break change tracking. Mutate fields or delete them one by one instead.

    Alice caveat: in Alice's local storage, delete and = undefined do not remove a field — it comes back with the old value on the next request. To remove a field on Alice, assign null: this.userData.tempData = null.

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

    const bot = new BotTest();
    bot.use(fullPlatforms);

    // Starts an interactive console session: you type phrases, the application replies
    bot.test();

    BotTest checks the logic without a network. To see the bot in the messenger itself or in Alice, there are two ways.

    Telegram, VK and MAX — long polling, no tunnel. The bot requests updates from the platform itself, so no public address is needed. Instead of bot.start(...), run:

    await bot.startPolling();
    
    • Telegram delivers updates via polling only while the bot has no webhook. For development, create a separate bot with @BotFather. The webhook can be removed on start with new TelegramAdapter(token, { telegram_delete_webhook: true }) — never do this with a production bot token: it will stop receiving messages.
    • For VK, enable the Long Poll API in the community settings ("API usage" → "Long Poll API") and select the event types you need. A community token is required.
    • MAX recommends polling for development and testing, and a webhook in production.

    Tokens and webhook status can be checked with npx umbot doctor.

    Alice, Marusia, SmartApp and Viber — webhook only (tunnel). These platforms send requests to a public HTTPS address, not to localhost, so the local port is exposed to the internet through a tunnel. A tunnel also works for messengers when you need to test the webhook itself.

    ⚠️ Debug with a separate test bot. Registering a webhook on a tunnel redirects all of the bot's messages there: with a production bot token, users will stop getting replies from your server.

    1. Start the application on a local port — npm start in a CLI project, or bot.start('localhost', 3000).

    2. Start a tunnel to the same port (any of these tools):

    cloudflared tunnel --url http://localhost:3000
    
    ngrok http 3000
    

    The tunnel gives you a public address like https://xxxx.trycloudflare.com.

    3. Register the webhook on this address. For Telegram and MAX this is one command in the project folder. It takes the bot token from .env (TELEGRAM_TOKEN / MAX_TOKEN), registers the webhook with a secret right away and saves the secret to .env:

    npx umbot webhook telegram https://xxxx.trycloudflare.com/
    
    npx umbot webhook max https://xxxx.trycloudflare.com/
    

    Other platforms are configured in their developer consoles: the webhook address for Alice is set in Yandex Dialogs, for VK — in the community's Callback API settings, for SmartApp — in SmartApp Studio. Viber registers a webhook with a set_webhook request to its API (ViberRequest.setWebhook()).

    4. Restart the application so that it reads the secret. The application reads .env only if the configuration contains its path — bot.setAppConfig({ env: './.env' }). Projects created with npx umbot create and create from-flow do this already.

    A free tunnel's address changes on every start: repeat step 3 with the new address. The umbot webhook command reuses the secret already saved in .env, so no restart is needed. After debugging, deploy the application to a server with HTTPS and register the webhook on its address (see "Running in production").

    // In the controller
    console.log('Data:', this.userData);
    console.log('Command:', this.userCommand);

    // In the configuration
    bot.setAppConfig({
    error_log: './logs',
    });
    bot.setAppMode('dev');

    In production, use the strict_prod mode, set up a webhook and enable webhook signature verification.

    bot.setAppMode('strict_prod'); // enables strict security checks (rejects ReDoS-prone regular expressions)
    bot.start('0.0.0.0', 8080); // starts the HTTP server

    If the process runs with NODE_ENV=production, strict_prod is enabled even without setAppMode(); an explicit setAppMode() call always takes precedence.

    Until signature verification is enabled, anyone who learns your webhook URL can send the bot forged requests on behalf of any user — including bypassing authorization by userId and reading or overwriting other users' data. Webhook URLs leak easily (logs, domain registries), so set the secret before the first production request:

    bot.setAppConfig({
    tokens: {
    // The secret is set on both sides: when registering the webhook with the platform
    // (setWebhook in Telegram, VK group settings, MAX subscription) and here.
    telegram: { webhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET },
    vk: { secret_key: process.env.VK_SECRET_KEY },
    max_app: { webhookSecret: process.env.MAX_WEBHOOK_SECRET },
    // Viber verifies the signature automatically with the bot token itself — nothing else to configure.
    },
    });

    The framework picks up TELEGRAM_WEBHOOK_SECRET, MAX_WEBHOOK_SECRET and VK_SECRET_KEY by itself — from the process environment or from .env if it is connected via env: './.env'. The block above is only needed if the secrets are stored elsewhere. For Telegram and MAX, the easiest way to create a secret is the CLI: the command registers the webhook with a secret right away and writes it to .env:

    npx umbot webhook telegram https://your-domain/webhook
    
    npx umbot webhook max https://your-domain/webhook
    

    If the application reads variables from the environment (Docker, serverless), move the secret from .env there as well.

    What each platform's secret does and how to generate it — see configuration.md → Webhook signature verification. Alice, SmartApp and Marusia have no webhook signature at all (a platform limitation): do not treat the userId of these platforms as an authenticated identity.

    Make sure everything is done:

    • [ ] strict_prod mode — enabled with bot.setAppMode('strict_prod')
    • [ ] Webhook signature verification is enabled — webhookSecret (Telegram/MAX) or secret_key (VK) is set; on start, the log has no "WITHOUT signature verification" warning. Alice/SmartApp/Marusia have no signature — design your own verification for sensitive actions
    • [ ] intents are configured — bot.setPlatformParams({ intents: [...] }) if needed. Note: the array you pass replaces the built-in welcome/help intents, so either add them to your list or define your own slots for the greeting and help
    • [ ] Tokens are in .env — not in the code, not in git. Check .gitignore
    • [ ] MongoAdapter instead of FileAdapter — FileAdapter keeps the whole table in memory (OOM risk with large data) and is designed for a single process, so it is not suitable for production
    • [ ] Preload for media — all images and sounds are preloaded (the first media upload takes 200–1000 ms per file and can eat up a voice platform's response budget)
    • [ ] rateLimiter is connected — bot.use(rateLimiter()) to stay within platform limits
    • [ ] error_log is configured — bot.setAppConfig({ error_log: './logs' })
    • [ ] HTTPS is set up — platforms send webhooks to a public HTTPS address (Telegram — ports 443, 80, 88 or 8443, MAX — 443 only)
    • [ ] The webhook URL is registered — for Telegram and MAX with npx umbot webhook, for the others in the platform's developer console
    • [ ] npx umbot doctor reports no errors — tokens work, webhooks are registered, Telegram has no accumulated delivery errors, .env is in .gitignore

    Cause: letter case. controller.userCommand is automatically converted to lower case.

    // ❌ Wrong — the slot starts with a capital letter
    bot.addCommand('greet', ['Hello'], (_, bc) => {
    bc.text = 'Hello!';
    });

    // ✅ Right — the slot is in lower case
    bot.addCommand('greet', ['hello'], (_, bc) => {
    bc.text = 'Hello!';
    });

    Cause: your own welcome_text is not set in setPlatformParams — the framework replies with its default placeholder text.

    // ❌ Not configured — replies with the default greeting
    bot.setPlatformParams({ intents: [] });

    // ✅ Right — your own greeting
    bot.setPlatformParams({
    welcome_text: 'Hi! I can help you.',
    intents: [],
    });

    Cause: without a generic, userData is typed as IUserData with an index signature [key: string]: unknown — writing any field is allowed, but reading it in arithmetic (+= 10) is not: the value is of type unknown.

    // ❌ Wrong — TypeScript does not know about score
    bot.addCommand('play', ['play'], (_, bc) => {
    bc.userData.score += 10; // Error!
    });

    // ✅ Right — annotate the type
    bot.addCommand('play', ['play'], (_, bc: BotController<MyData>) => {
    bc.userData.score += 10; // OK
    });

    Cause: no DB adapter is connected and isLocalStorage: false.

    import { MongoAdapter } from 'umbot/plugins';

    // ❌ Wrong — the data is lost
    bot.setAppConfig({ isLocalStorage: false });

    // ✅ Option 1: local storage (for voice platforms)
    bot.setAppConfig({ isLocalStorage: true });

    // ✅ Option 2: a database (for chatbots)
    bot.use(new MongoAdapter({ host: '...', database: '...' }));
    bot.setAppConfig({ isLocalStorage: false });

    Cause: you use BotController instead of BaseBotController. empty_text is set automatically only by BaseBotController. If you extend BotController directly, set this.text in action(). Adapters do not invent a reply: Alice and Marusia keep the fields empty and log a warning, and chat platforms will not send an invalid empty message.

    More about this mechanism — in the "Dispatcher order" section of GUIDE.md.

    // Solution: handle the default case in action() yourself
    public action(intentName: string | null): void {
    switch (intentName) {
    case WELCOME_INTENT_NAME:
    this.text = 'Hi!';
    break;
    default:
    if (!this.text) this.text = 'I didn\'t get that. Say "help".';
    }
    }

    When regular expressions are used in commands (addCommand(..., isPattern: true)) or intents, the framework checks them for potential ReDoS vulnerabilities.

    ⚠️ By default (appMode: 'dev'), unsafe RegExps are still registered!
    This keeps development flexible, but is often unacceptable in production.

    ✅ For production, enable strict checking:

    const bot = new Bot();
    bot.setAppMode('strict_prod'); // ← be sure to enable it!

    With setAppMode('strict_prod'), any potentially dangerous RegExp is rejected at registration, and an attempt to use it produces an error in the logs. The check runs at registration time (addCommand, setPlatformParams), so set the mode right after creating the Bot — expressions registered earlier are not checked again.

    ⚠️ If you use slots with RegExp, make sure your expressions:

    • do not contain nested quantifiers ((a+)+);
    • do not contain overlapping alternatives under repetition ((a|aa)+);
    • are bounded in length ({1,10} instead of *).

    Create an adapter for the platform following the platform adapter guide and connect it to the application. If everything is done correctly, the framework will handle requests from the new platform and return data in the format that platform expects.

    Keep sensitive data in a .env file and pass its path:

    bot.setAppConfig({
    env: './.env', // path to the file
    });

    An example .env file:

    TELEGRAM_TOKEN=your-telegram-token
    VK_TOKEN=your-vk-token
    VK_CONFIRMATION_TOKEN=your-vk-confirmation-token
    VIBER_TOKEN=your-viber-token
    ALISA_TOKEN=your-alisa-token
    MARUSIA_TOKEN=your-marusia-token
    MAX_TOKEN=your-max-token
    SMARTAPP_TOKEN=your-smartapp-token
    
    # Webhook secrets: they enable signature verification. TELEGRAM_WEBHOOK_SECRET and MAX_WEBHOOK_SECRET
    # are written by `npx umbot webhook`, VK_SECRET_KEY comes from the community's Callback API settings.
    TELEGRAM_WEBHOOK_SECRET=
    MAX_WEBHOOK_SECRET=
    VK_SECRET_KEY=
    
    # Yandex SpeechKit — TTS for chat platforms (Telegram/VK/Max);
    # the value is automatically written to speech_kit_token of all three platforms.
    # A service account API key is recommended (sent as `Api-Key`); an IAM token `t1.…`
    # is also accepted (sent as `Bearer`), but it lives no longer than 12 hours.
    SPEECH_KIT_TOKEN=your-speechkit-api-key
    
    # MongoDB connection: host is a full connection string with the protocol
    DB_HOST=mongodb://localhost:27017
    DB_USER=user
    DB_PASSWORD=password
    DB_NAME=bot_db
    

    YANDEX_TOKEN for Alice is deprecated and kept only for backward compatibility — use ALISA_TOKEN (if both are set, it takes precedence). SMARTAPP_TOKEN is only needed for SmartApp (Sber); you can leave it unset if you do not use this platform.

    ⚠️ Do not commit .env to git! It is already in the .gitignore template generated by the CLI, but if you create the file manually, make sure it is excluded.

    If all required tokens are in process.env, you can pass local to the env property.

    bot.setAppConfig({
    env: 'local', // Read the data from process.env
    });

    Data in this.userData is saved between sessions automatically. Choose where it is stored with the isLocalStorage parameter:

    bot.setAppConfig({
    isLocalStorage: true, // Data is kept in the platform's local storage (supported by voice platforms)
    // or
    isLocalStorage: false, // data is kept in your database (connect an adapter: MongoAdapter, etc.)
    });

    Chat platforms (Telegram, VK, Max, Viber) have no local storage: with isLocalStorage: true and no DB adapter connected, their data will not be saved.

    this.buttons.addBtn('Yes').addBtn('No').addBtn('Not sure');
    
    this.card
    .addImage('image1.jpg', 'Title 1', 'Description 1')
    .addImage('image2.jpg', 'Title 2', 'Description 2')
    .setTitle('Image gallery');

    More questions and answers are in the FAQ section.