This page is machine-translated from the Russian original. If something reads oddly, the Russian version is the source of truth — open an issue.
The full specification of the
flow.jsonJSON format (an export from Umbot Flow) for thenpx umbot create from-flowproject generator. Based on this description you can build a generator for any platform (Telegram, Alisa, etc.).
If you downloaded a JSON file from the visual editor, follow three steps:
flow.json into any foldernpx umbot create from-flow flow.json --output ./my-bot
The finished TypeScript project appears in the my-bot folder. Install the dependencies, build and run it:
cd my-bot
npm install
npm run build
npm start
A project generated with
from-flowhas no dev server with hot reload — there is nonpm run devscript. For development, use the classic cycle: change the code →npm run build→npm start. Thedefault/quiztemplates created by the CLI do not provide hot reload either (they run the same way:npm run build && npm start), but when created in thedevmode, the entry point usesBotTest— interactive console debugging without an HTTP server.
More about the CLI: umbot CLI documentation
{
"schemaVersion": "1.0",
"name": "my-bot",
"version": "1.0.0",
"description": "Bot description",
"platforms": ["telegram", "alisa"],
"database": { "type": "file", "config": {} },
"isLocalStorage": true,
"nodes": [],
"edges": [],
"fallback": { "text": "I didn't get that" },
"welcome": { "text": "Hi! I am a bot.", "buttons": [] },
"helpText": { "text": "Bot help" },
"variables": { "userName": "User name", "score": "Player score" }
}
| Field | Type | Description |
|---|---|---|
schemaVersion |
string | The format version. Defaults to "1.0". |
name |
string | The project name (used in package.json and the title). |
version |
string | The project version (semver). |
description |
string | The bot description. |
platforms |
string[] | Platforms: "telegram", "alisa", "marusia", "vk", "smart_app", "max_app", "viber". |
database |
object | The database configuration (see below). |
isLocalStorage |
boolean | Store userData in the platform's local storage (voice platforms) instead of a database. |
nodes |
array | All graph nodes (commands, steps, conditions, actions, responses). |
edges |
array | Connections between nodes. |
fallback |
object | The response to unrecognized input: { "text": "..." }. |
mode |
string | The application mode: "dev", "prod", "strict_prod" — bot.setAppMode(...) is generated. Without the field or with an unknown value — strict_prod. |
welcome |
object | The greeting from the settings: { "text": "...", "buttons": [] }. Used if the scenario has no welcome node: fires on /start, the Russian "hi" and at the start of a dialog. |
helpText |
object | The help text (optional): { "text": "..." }. Used if there is no help node: answers the Russian "help"/"what can you do". |
tokens |
object | Platform tokens: { "telegram": "..." }. They are moved to the project's .env; you can leave them empty and fill in .env after generation. |
variables |
object | Registered variables: { "name": "comment", ... }. A visual editor field — the generator ignores it. |
umbot has only 2 types of handlers:
bot.addCommand(name, slots, handler) — reacts to trigger words (slots).bot.addStep(name, handler) — activated via ctrl.thisIntentName = 'stepName'.Actions, conditions and responses are INLINE CODE inside the handlers. Standalone blocks (response/action/condition)
connected via edges are generated as separate reusable __<name>(ctrl: BotController) functions
called from commands/steps (or from other blocks). A node without incoming edges does not get into the code at all.
A command reacts to trigger words.
{
"type": "command",
"id": "node_123",
"name": "greeting",
"slots": ["hello", "hi"],
"isPattern": false,
"saveTo": "userName",
"actions": [],
"conditions": [],
"response": {
"text": "Hi, {{userName}}!",
"tts": "Hi!",
"emotion": "good",
"isEnd": false,
"buttons": [],
"card": null,
"sounds": []
}
}
| Field | Type | Required | Description |
|---|---|---|---|
type |
"command" |
yes | The node type |
id |
string | yes | A unique ID |
name |
string | yes | The command name (in the generated code) |
slots |
string[] | yes | Trigger words (case-insensitive, see below) |
isPattern |
boolean | no | If true, the slots are regular expressions (case-sensitive) |
saveTo |
string | no | Save the input to userData |
varComment |
string | no | A variable comment (an editor field, the generator ignores it) |
actions |
ActionBlock[] | no | Inline actions |
conditions |
FlowCondition[] | no | Inline conditions |
response |
FlowResponse | yes | Response settings |
Slot case. The framework compares slots with the user's utterance, already converted to lower case, so
the generator converts string slots to lower case: "Weather" from the editor becomes 'weather' in the code and matches
"weather", "Weather" and "WEATHER". Regex slots (isPattern: true) stay as they are — \D and \d mean different things.
Write them for lowercase text: ^code\d+$, not ^Code\d+$.
A step asks for input and saves it.
{
"type": "step",
"id": "node_456",
"name": "ask_name",
"prompt": {
"text": "What is your name?",
"tts": "",
"emotion": "",
"buttons": [],
"card": null
},
"saveTo": "userName",
"saveAs": "original",
"actions": [],
"conditions": []
}
| Field | Type | Required | Description |
|---|---|---|---|
type |
"step" |
yes | The node type |
id |
string | yes | A unique ID |
name |
string | yes | The step name (for thisIntentName) |
prompt |
FlowPrompt | yes | The question text, TTS, buttons, card |
saveTo |
string | yes | The userData field to save to |
saveAs |
"original" | "lowercase" |
no | The case of the saved input |
varComment |
string | no | A variable comment (an editor field, the generator ignores it) |
actions |
ActionBlock[] | no | Inline actions |
conditions |
FlowCondition[] | no | Inline conditions |
Navigation between steps happens via edges (connections), not via a
nextfield.
A condition checks a variable and leads along the True/False branches via edges.
{
"type": "condition",
"id": "node_789",
"name": "check_age",
"variable": "age",
"operator": "gte",
"value": 18
}
| Field | Type | Description |
|---|---|---|
type |
"condition" |
The node type |
id |
string | A unique ID |
name |
string | The node name (for thisIntentName) |
variable |
string | The variable name from userData |
operator |
string | The comparison operator (see the table below) |
value |
string|number | The value to compare with (can be a variable name) |
responseTrue/responseFalse are not stored in a standalone condition node — they are defined via branch_true/branch_false edges.
Operators:
| Operator | Code | Description |
|---|---|---|
eq |
a === b |
Equal |
neq |
a !== b |
Not equal |
gt |
Number(a) > Number(b) |
Greater than |
gte |
Number(a) >= Number(b) |
Greater than or equal |
lt |
Number(a) < Number(b) |
Less than |
lte |
Number(a) <= Number(b) |
Less than or equal |
contains |
String(a).includes(String(b)) |
Contains |
isEmpty |
!a |
Empty / undefined |
isNotEmpty |
!!a && a !== '' |
Not empty |
isSayTrue |
Text.isSayTrue(String(a)) |
The user said "yes" (in Russian) |
isSayFalse |
Text.isSayFalse(String(a)) |
The user said "no" (in Russian) |
isUrl |
Text.isUrl(String(a)) |
The user entered a URL |
The isSayTrue, isSayFalse and isUrl operators require importing
Textfromumbot.
An action runs code (setting variables, numbers, HTTP).
{
"type": "action",
"id": "node_101",
"name": "generate_numbers",
"actions": [
{ "type": "random_number", "field": "num1", "min": 1, "max": 10 },
{ "type": "set_variable", "field": "answer", "value": "num1 + num2" },
{
"type": "http_request",
"url": "https://api.com",
"method": "GET",
"saveResponseTo": "data"
}
],
"text": "Your number: {{num1}}",
"buttons": []
}
| Field | Type | Description |
|---|---|---|
type |
"action" |
The node type |
id |
string | A unique ID |
name |
string | The node name (for thisIntentName) |
actions |
ActionBlock[] | Action blocks |
text |
string | The text after the actions run (supports {{}}) |
buttons |
FlowButton[] | Buttons after the actions run |
A response shows text, buttons and cards without asking for input.
{
"type": "response",
"id": "node_202",
"name": "show_help",
"response": {
"text": "This is the help.",
"tts": "Bot help.",
"isEnd": false,
"buttons": [{ "title": "Back", "type": "action" }],
"card": null,
"sounds": []
}
}
⚠️ Not supported by the generator. An
endnode can appear in the editor export, butcreate from-flowignores it — no code for ending the dialog (isEnd = true) is generated. If you need to end the dialog, add an action/response that setsisEndmanually in the generated code.
{ "type": "end", "id": "node_303" }
{
"text": "Response text",
"tts": "Text for speech",
"emotion": "good",
"isEnd": false,
"shuffleButtons": false,
"buttons": [],
"card": null,
"sounds": []
}
The
emotionandsoundsfields are visual editor fields; the generator ignores them.
{
"text": "Question text",
"tts": "",
"emotion": "",
"shuffleButtons": false,
"buttons": [],
"card": null
}
{
"title": "Button text",
"type": "action",
"targetNodeId": "node_id",
"url": "https://..."
}
| Field | Description |
|---|---|
type: "action" |
An action button. The generator currently ignores targetNodeId — only addBtn(title) is generated. |
type: "link" |
A link button. url is the URL. |
{
"type": "gallery",
"title": "Title",
"images": [
{
"src": "https://example.com/photo.jpg",
"title": "Name",
"description": "Description",
"button": { "title": "Buy", "type": "action", "targetNodeId": "..." }
}
]
}
type |
Description |
|---|---|
single |
A single image |
list |
A list of images (vertical) |
gallery |
Horizontal scrolling |
The generator ignores the
typefield: for each element ofimagesit generatesctrl.card.addImage(src, title, description)(if the element has a button, its text is added as the fourth argument), and each platform chooses the final card view (BigImage, ItemsList, ImageGallery, etc.) itself based on the number of images.
{ "type": "set_variable", "field": "name", "value": "userName", "fieldComment": "User name" }
{ "type": "random_number", "field": "num", "min": 1, "max": 100, "fieldComment": "A random number" }
{ "type": "http_request", "url": "https://api.com", "method": "GET", "headers": "{ \"Auth\": \"token\" }", "body": "{\"key\": \"{{var}}\"}", "saveResponseTo": "data" }
| Field | Type | Description |
|---|---|---|
type |
string | "set_variable" / "random_number" / "http_request" |
field |
string | The variable name in userData |
fieldComment |
string | A variable comment (an editor field, the generator ignores it) |
value |
string | The expression for set_variable (supports {{var}}) |
min |
number | The minimum for random_number (1 by default) |
max |
number | The maximum for random_number (10 by default) |
url |
string | The URL for http_request: {{var}} in the path and parameters (encoded), {{env.NAME}} anywhere |
method |
string | The HTTP method: "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS" |
headers |
string | object | Headers: a JSON string or an object; values support {{var}} and {{env.NAME}} |
body |
string | object | The request body: a JSON string or an object; strings support {{var}} and {{env.NAME}} |
saveResponseTo |
string | Save the response to userData |
{
"variable": "score",
"operator": "gte",
"value": 100,
"responseTrue": { "text": "Victory!", "buttons": [] },
"responseFalse": { "text": "Try again.", "buttons": [] }
}
Used for inline conditions in a command/step. Standalone condition nodes use edges.
{ "text": "Text", "buttons": [] }
targetNodeIdis set at the top level of the condition object ({ "variable": "...", "targetNodeId": "step_id" }), not inside the response: the generator reads it only there and only for the true branch (navigation viathisIntentName).
{
"from": "source_node_id",
"to": "target_node_id",
"type": "next",
"label": ""
}
| Type | Description |
|---|---|
next |
A sequential transition |
branch_true |
The "yes" branch of a condition |
branch_false |
The "no" branch of a condition |
slot_match |
A slot match (reserved: the generator does not use this connection type currently) |
Command → Step (next)
Step → Condition (next)
Condition → Response (branch_true)
Condition → Response (branch_false)
Response → Step (next) — for loops
Command → Response (next) — Response is generated as a __name(ctrl) function called from the command
Command → Action (next) — Action is generated as a __name(ctrl) function called from the command
Command → Condition (next) — generated by one of two mechanisms: a standalone Condition node
(connected via an edge) becomes a separate __name(ctrl) function; inline conditions from the
cmd.conditions array are embedded right in the command body (without a separate function)
In JSON: {{variableName}}
In the generated code: `${ctrl.userData.variableName}`
"Hi, {{userName}}!" → setText(ctrl, `Hi, ${ctrl.userData.userName}!`)
{{var}}substitution works in all text fields that go through textExpr:text(response, prompt, fallback, welcome, helpText) and the texts of action blocks. Invalue(set_variable), a value with{{}}is also substituted as a template string; arithmetic such asnum1 + num2(names without{{}}) is evaluated by a separate expression parser. The rules forurl,headersandbodyof anhttp_requestare in the "HTTP requests: variables and secrets" section.{{env.NAME}}is not expanded in response texts: an environment variable must not reach the user.
{
"type": "file",
"config": {}
}
| Type | Description | Generated code |
|---|---|---|
file |
File storage | import { FileAdapter } from 'umbot/plugins'; bot.use(new FileAdapter()); — without arguments; the generator does not create a separate configuration file, the data path is set in the project's own appConfig.json section via bot.setAppConfig({ json: ... }), the config field is ignored here |
mongo |
MongoDB | import { MongoAdapter } from 'umbot/plugins'; bot.use(new MongoAdapter({ host: '...', database: '...' })); — from config.host/config.database |
none |
No database | Does not import an adapter |
For
mongo:user/passfromdatabase.configare not written to the source code — the generator moves them to the generated project's.env(theDB_USER/DB_PASSWORDvariables), where the framework reads them from. An existing.envis not overwritten: only missing variables are appended.MongoAdapterconnects to MongoDB byhost/database.
JSON:
{
"type": "command",
"id": "n1",
"name": "greeting",
"slots": ["hello"],
"response": { "text": "Hi!" }
}
Generated code:
bot.addCommand('greeting', ['hello'], (cmd: string, ctrl: BotController): void => {
setText(ctrl, 'Hi!');
});
JSON:
[
{
"type": "command",
"id": "n1",
"name": "start",
"slots": ["start"],
"response": { "text": "What is your name?" }
},
{
"type": "step",
"id": "n2",
"name": "enterName",
"prompt": { "text": "What is your name?" },
"saveTo": "userName"
}
]
(between the nodes — an edge { "from": "n1", "to": "n2", "type": "next" })
Generated code:
bot.addCommand('start', ['start'], (cmd: string, ctrl: BotController): void => {
setText(ctrl, 'What is your name?');
ctrl.thisIntentName = 'enterName';
});
bot.addStep('enterName', (ctrl: BotController): void => {
setText(ctrl, 'What is your name?');
ctrl.userData.userName = ctrl.originalUserCommand ?? ctrl.userCommand ?? '';
});
JSON:
{
"type": "condition",
"id": "n3",
"name": "check",
"variable": "score",
"operator": "gte",
"value": 100
}
Generated code (a block function; called from a command/step connected by a next edge):
/** Condition: check that score is greater than or equal to 100 */
function __check(ctrl: BotController): void {
if (Number(ctrl.userData.score) >= Number(100)) {
__win(ctrl); // the block connected by the branch_true edge
} else {
__lose(ctrl); // the block connected by the branch_false edge
}
}
JSON:
{
"type": "command",
"id": "n4",
"name": "gallery",
"slots": ["gallery"],
"response": {
"text": "Choose:",
"card": {
"type": "gallery",
"images": [
{ "src": "https://example.com/1.jpg", "title": "iPhone", "description": "999₽" }
]
}
}
}
Generated code:
bot.addCommand('gallery', ['gallery'], (cmd: string, ctrl: BotController): void => {
setText(ctrl, 'Choose:');
ctrl.card.addImage('https://example.com/1.jpg', 'iPhone', '999₽');
});
JSON:
{
"type": "command",
"id": "n5",
"name": "menu",
"slots": ["menu"],
"response": {
"text": "Choose:",
"buttons": [{ "title": "Help", "type": "action", "targetNodeId": "help_step" }]
}
}
Generated code:
bot.addCommand('menu', ['menu'], (cmd: string, ctrl: BotController): void => {
setText(ctrl, 'Choose:');
ctrl.buttons.addBtn('Help');
});
targetNodeIdof buttons is currently ignored: onlyaddBtn(title)is generated (oraddLinkfortype: "link").
JSON:
{
"type": "command",
"id": "n6",
"name": "bye",
"slots": ["bye"],
"response": { "text": "Goodbye!", "isEnd": true }
}
Generated code:
bot.addCommand('bye', ['bye'], (cmd: string, ctrl: BotController): void => {
setText(ctrl, 'Goodbye!');
ctrl.isEnd = true;
});
JSON:
{
"type": "command",
"id": "n7",
"name": "tts_demo",
"slots": ["say it"],
"response": { "text": "Text on the screen", "tts": "Text for speech" }
}
Generated code:
bot.addCommand('tts_demo', ['say it'], (cmd: string, ctrl: BotController): void => {
setText(ctrl, 'Text on the screen');
setTTS(ctrl, 'Text for speech');
});
JSON:
{
"type": "action",
"id": "n8",
"name": "fetch_data",
"actions": [
{
"type": "http_request",
"url": "https://api.example.com/data",
"method": "GET",
"saveResponseTo": "apiResult"
}
],
"text": "Received: {{apiResult}}"
}
Generated code (a block function; called with await from a command/step connected by a next edge):
/** Action: an HTTP request to https://api.example.com/data */
async function __fetch_data(ctrl: BotController): Promise<void> {
// fetchWithTimeout is a wrapper with a 2000 ms timeout, generated automatically in ./utils
try {
const response = await fetchWithTimeout('https://api.example.com/data');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const responseText = await response.text();
let data: unknown = null;
if (responseText.trim()) {
try {
data = JSON.parse(responseText);
} catch {
data = responseText;
}
}
ctrl.userData.apiResult = data;
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
setText(ctrl, `Request error: ${errorMessage}`);
}
setText(ctrl, `Received: ${ctrl.userData.apiResult}`);
}
JSON:
{
"type": "action",
"id": "n9",
"name": "roll",
"actions": [{ "type": "random_number", "field": "dice", "min": 1, "max": 6 }],
"text": "You rolled: {{dice}}"
}
Generated code:
/** Action: generate a random number into dice */
function __roll(ctrl: BotController): void {
ctrl.userData.dice = rand(1, 6);
setText(ctrl, `You rolled: ${ctrl.userData.dice}`);
}
JSON:
{
"type": "action",
"id": "n10",
"name": "set_score",
"actions": [{ "type": "set_variable", "field": "score", "value": "0" }]
}
Generated code:
/** Action: set the score variable */
function __set_score(ctrl: BotController): void {
ctrl.userData.score = 0;
}
JSON:
{ "type": "response", "id": "n11", "name": "show_help", "response": { "text": "This is the help." } }
Generated code (a block function; called from a command/step connected by a next edge):
/** Response: "This is the help." */
function __show_help(ctrl: BotController): void {
setText(ctrl, 'This is the help.');
}
JSON:
{
"type": "http_request",
"url": "https://api.com",
"method": "POST",
"body": "{\"user\": \"{{userName}}\", \"score\": \"{{score}}\"}",
"saveResponseTo": "result"
}
Generated code:
// body is valid JSON: the generator parses it and rebuilds it with
// JSON.stringify, substituting the variables; a template literal is only a fallback for invalid JSON
const response = await fetchWithTimeout('https://api.com', {
method: 'POST',
body: JSON.stringify({ user: `${ctrl.userData.userName}`, score: `${ctrl.userData.score}` }),
headers: { 'Content-Type': 'application/json' }, // added when there is a body, unless headers has its own Content-Type
});
// The response is read as text and parsed, falling back to the raw string for invalid JSON
const responseText = await response.text();
let data: unknown;
try {
data = JSON.parse(responseText);
} catch {
data = responseText;
}
ctrl.userData.result = data; // of type unknown, not a Promise
Two substitutions work in the url, headers and body of an http_request:
{{var}} — a scenario variable (or the system {{__currentDate}}). In url the value is encoded with
encodeURIComponent, so user input cannot add its own path or parameter to the request. A scenario variable
is allowed only after the server address — in the path and parameters: https://api.example.com/weather/{{city}}.
https://{{host}}/… or {{url}} is a validate error: otherwise the bot user would choose the server the bot
talks to.{{env.NAME}} — an environment variable (the name: Latin letters, digits, _). The value is substituted as is, including in
the server address: {{env.API_BASE}}/items/{{id}}. In code it is env('NAME') from ./utils: first the process environment
(Docker, serverless), then the project's .env. The variable is added to .env as an empty string if it is not there.Secrets do not get into the code. If the value of a header, a URL parameter or a JSON body key with a "secret" name
(Authorization, Cookie, token, secret, password, api_key, X-Api-Key, key, etc.) is written in
flow.json in plain text, the generator moves it to .env as HTTP_<NAME> (HTTP_AUTHORIZATION,
HTTP_API_KEY), and writes env('HTTP_…') in src/index.ts — with a warning in the console. Identical values
get one variable, different ones get the _2, _3 suffix. A secret in a non-JSON body is not detected — use
{{env.NAME}} explicitly.
{
"type": "http_request",
"url": "https://api.example.com/weather/{{city}}?key={{env.WEATHER_KEY}}",
"method": "GET",
"headers": { "Authorization": "Bearer sk-live-123" },
"saveResponseTo": "weather"
}
// .env: HTTP_AUTHORIZATION=Bearer sk-live-123, WEATHER_KEY=
const response = await fetchWithTimeout(
`https://api.example.com/weather/${encodeURIComponent(String(ctrl.userData.city ?? ''))}?key=${env('WEATHER_KEY')}`,
{ headers: { Authorization: `${env('HTTP_AUTHORIZATION')}` } },
);
import { Bot, BotController, FALLBACK_COMMAND } from 'umbot';
import { fullPlatforms } from 'umbot/plugins'; // if 7 platforms
import { Text } from 'umbot'; // only with isSayTrue/isSayFalse/isUrl
import { setText, setTTS, fetchWithTimeout } from './utils'; // conditionally
import { FileAdapter, MongoAdapter } from 'umbot/plugins'; // by database.type
| Condition | Import |
|---|---|
| Any node has TTS | import { setText, setTTS } from './utils' |
| No TTS | import { setText } from './utils' |
| There are http_request actions | import { fetchWithTimeout } from './utils' (generated by the CLI) |
Requests use {{env.NAME}} |
import { env } from './utils' (reads the environment and .env) |
| There is isSayTrue/isSayFalse/isUrl | import { Text } from 'umbot' |
| There are random_number actions | import { rand } from 'umbot/utils' |
| database.type === 'file' | import { FileAdapter } from 'umbot/plugins' |
| database.type === 'mongo' | import { MongoAdapter } from 'umbot/plugins' |
| All 7 platforms (or an empty list) | import { fullPlatforms } from 'umbot/plugins' |
| Voice platforms only | import { voicePlatforms } from 'umbot/plugins' |
| Chat platforms only | import { botPlatforms } from 'umbot/plugins' |
| A mixed set of platforms | import { TelegramAdapter, VkAdapter, ... } from 'umbot/plugins' |
__ + name is generated: non-ASCII characters are replaced with _
(replace(/[^a-zA-Z0-9_$]/g, '_')), a name starting with a digit gets the _ prefix; on name collisions
the _2, _3, ... suffix is added; an empty name is replaced with _block. To be safe,
use ASCII identifiers without spaces.ctrl.userData['123field']Full reference — API v-3.1 · all versions.