The widget.zip format
A release is one ZIP archive. You upload it to the release, Bito validates it before a single byte is inflated, and stores it only if validation passes. The report comes straight back to you — every failure names the rule it broke.
Directory layout
widget.zip
├── manifest.json required, at the archive root
├── script.js required, at the archive root
├── style.css optional
├── i18n/
│ ├── uz.json one file per language declared in the manifest
│ ├── ru.json
│ └── en.json
├── templates/ optional, any name
│ └── card.twig
└── images/ optional
├── logo.png
└── menu.png optional, left-menu iconmanifest.json and script.js must be at the root, not inside a folder. Zipping a directory rather than its contents is the single most common upload failure.
manifest.json
{
"widget": { "interface_version": 2 },
"bito": {
"schema_version": 1,
"locations": {
"surfaces": ["customer_profile", "card_phone", "list:customers"],
"persistent": false
},
"network": {
"connect_origins": ["https://api.example.com", "wss://events.example.com"]
},
"pipeline_endpoint": "https://api.example.com/bito/pipeline",
"left_menu": {
"widget_page": {
"title_key": "app_title",
"icon": "images/menu.png",
"submenu": {
"deals": { "title_key": "deals" },
"reports": { "title_key": "reports" }
}
},
"saleMain": {
"submenu": { "order_sync": { "title_key": "order_sync" } }
}
}
},
"languages": ["uz", "ru", "en"],
"default_language": "uz"
}| Field | Required | Rules |
|---|---|---|
widget.interface_version | Yes | Must be a number. 2 is the current in-page runtime. |
bito.schema_version | Yes | Must be a number. 1 is current. |
languages | Yes | Array of language codes. Every one needs a matching i18n/<lang>.json in the archive. |
default_language | Yes | String. Used when the viewer's language is not in languages. |
bito.locations.surfaces | No | Array of mount surfaces. Each entry must be a named surface, list:<page_name>, or global. Duplicates are rejected. |
bito.locations.persistent | No | Boolean. true opts into the always-mounted persistent slot. Listing global in surfaces opts in too — either form counts. |
bito.network.connect_origins | No | Array of exact origins. Disclosure only — see below. |
bito.pipeline_endpoint | No | Exact https:// URL. Where the sale-order pipeline widget_action posts. |
bito.left_menu | No | Pages in the admin panel's left menu: the widget's own section and/or submenu pages in existing modules. Requires the app's widget code; see left menu. |
Anything that does not parse, or does not match this shape, is a validation failure with a message, not a server error.
connect_origins is disclosure, never enforcement
DANGER
bito.network.connect_origins is shown to the tenant on the consent screen and never technically enforced. Nothing stops your widget — or a compromised version of it — from talking to a host that is not in this list.
It cannot be enforced. A page-level CSP applies to the whole document and cannot be scoped to one script, and your code runs in the host page. Treat this field as a declaration you are making to the tenant, not as a restriction Bito applies to you. Do not describe it to your users as a sandbox.
The shape is validated at upload, so the consent screen shows a clean list:
- exact
https://orwss://origin only; - no wildcards anywhere (
*in the string is an instant reject); - no bare scheme (
https:); - no path, query or fragment, and no trailing slash — the string must round-trip as its own origin.
pipeline_endpoint
If a tenant configures a sale-order pipeline stage action of type widget_action against your app, Bito resolves the endpoint from your consented release's manifest — the tenant never types a URL. It POSTs:
{
"event": "pipeline_action",
"db_name": "tenant_db",
"sale_order_id": "6712f0a9c4d1e87b3a0f1122",
"organization_id": "6712f0a9c4d1e87b3a0f1130",
"state_id": "6712f0a9c4d1e87b3a0f1140",
"client_id": "69c50a2d50f7b939923a469b"
}At upload the only check is the shape: it must be an exact https:// URL. At dispatch the destination is re-checked — non-HTTPS, loopback, private-range and link-local addresses are refused, and the call has a hard timeout.
Left menu
bito.left_menu gives the installed widget its own pages in the admin panel's left menu. It is modelled on amoCRM's left_menu and its initMenuPage callback. Bito draws the menu items and the page title; the page content is whatever your widget renders. Every such page mounts on the widget_page surface.
bito.left_menu replaces bito.settings_page and bito.menu. Both are removed: a manifest that still declares either one is rejected with a message pointing at left_menu.
Each key of left_menu is a block — widget_page or a module key:
| Block | Fields | Rules |
|---|---|---|
widget_page | title_key (required), icon, submenu | The widget's own top-level section. Without submenu it is one page; submenu holds at most 10 items. |
| module key | submenu (required) | 1 to 3 pages added to that module's Extensions group. title_key and icon are rejected — the module keeps its own title and icon. |
A module key must be one of the following; any other key is rejected. The prefix is the start of the page URL.
| Module key | URL prefix |
|---|---|
saleMain | /sale |
customerMain | /customer |
distributionMain | /distribution |
logisticsOrder | /logistics |
supplyMain | /supply |
warehouseMain | /warehouse |
financeMain | /finance |
hrMain | /hr |
integrationMain | /integration |
manufacturingMain | /manufacturing |
referenceMain | /reference |
toolsMain | /tools |
submenu is an object: the key is the subitem code, the value is { "title_key": "..." }. A subitem code must match ^[a-z][a-z0-9_]{1,31}$ — a lowercase Latin letter, then 1 to 31 lowercase letters, digits or underscores. Items are shown in the order you declare them.
Labels
Every title_key must exist in i18n/<default_language>.json, otherwise the upload is rejected. Labels are read from the archive at upload time, so the menu text cannot drift from the reviewed package. At display time each label is taken from the viewer's language bundle; a key missing there falls back to default_language.
Icon
icon is optional and allowed only on widget_page. The upload is rejected unless it is:
- a path to an archive entry under
images/ending in.png; - a PNG file of at most 16 KB (16,384 bytes);
- square, 48 to 128 px on each side;
- with an alpha channel (PNG colour type 4, grayscale + alpha, or 6, RGBA).
Bito uses only the icon's shape. The PNG's alpha channel is applied as a mask and filled with the menu's current theme colour, so the icon follows light and dark mode, hover and the active state; your own colours are never shown. Draw a single glyph on a transparent background. Without an icon the item shows Bito's standard extension glyph.
Widget code and URLs
{widget_code} in menu URLs is the app's widget code — the system_id you choose and claim in My apps (see the FAQ). An app without one (for example a private widget) uses its client_id instead: /widget/{client_id}/{subitem}.
| Page | URL | location |
|---|---|---|
widget_page without submenu | /widget/{widget_code} | widget_page |
widget_page submenu item | /widget/{widget_code}/{subitem} | widget_page |
| Module submenu item | {prefix}/widget/{widget_code}/{subitem}, e.g. /sale/widget/acme_crm/order_sync | the module key, e.g. saleMain |
A URL for a block or subitem the manifest does not declare shows a "not available" message instead of your widget. A module page always needs a subitem code. Your widget learns which page is open from initMenuPage and bito.context.menu.
Visibility and access
- The menu shows the
widget_pagesections of at most five widgets; further widgets' sections are not shown. Module items are not affected by this cap. - Menu items and pages are shown to every user of the tenant; Bito does not filter them by role. Your widget decides what each user may see and do.
- Bito RBAC still applies to every
bito.ajaxcall, so a page cannot read or change anything beyond the signed-in user's own rights. - Items appear once the app is installed. Your code runs on the page only after the tenant accepts consent for the release, as on every surface.
Differences from amoCRM
- The label field is
title_key(an i18n key). - There is no
sort: declaration order is display order. - There is no
is_hidden. - The icon is a PNG used as a shape and painted in Bito's theme colours, not an SVG shown as drawn.
- Module keys are Bito's own, listed above.
The i18n bundles
A flat JSON object of key → string. Bito resolves one bundle server-side at mount: the viewer's language if the manifest declares it, otherwise default_language. Your widget receives only that bundle, through bito.i18n().
{
"title": "Call history",
"empty": "Nothing yet",
"greeting": "Hello, {{name}}"
}A bundle that is not valid JSON is logged and treated as empty — your widget still mounts, with every key falling back to itself.
templates/
Every file under templates/ is inlined into the mount payload and addressed by its path after the templates/ prefix — templates/card.twig is card.twig. Rendering is plain double-brace substitution, nothing more: no loops, no conditionals, no filters. The .twig extension is allowed for familiarity; it is not a Twig engine.
style.css
If present, it is injected into the host document as a <link> while your widget is mounted, and removed on teardown. It is not scoped — a bare .card { ... } rule restyles the admin panel. Namespace every selector.
script.js
The AMD module. See the CustomWidget lifecycle.
Limits
Every one of these is checked against the ZIP's central directory before anything is decompressed, so a zip bomb is rejected without being inflated.
| Limit | Value |
|---|---|
| Compressed archive | 10 MiB (10,485,760 bytes) |
| Total uncompressed | 50 MiB (52,428,800 bytes) |
| Single file, uncompressed | 5 MiB (5,242,880 bytes) |
| Entry count | 1000 |
| Expansion ratio, per entry | 100:1 |
What gets an entry rejected
| Rule | Detail |
|---|---|
| Absolute paths | A leading /, or any absolute path |
| Traversal | Any .. path segment |
| Backslashes | \ anywhere in the entry name |
| Symlinks | Rejected unconditionally, whatever host the archive claims to come from |
| Encrypted entries | Password-protected entries are refused |
| Nested archives | .zip .rar .7z .tar .gz .tgz .bz2 |
| Executable bit | Any of the Unix 0o111 mode bits set |
| Duplicate paths | The same entry name twice |
| Unknown extension | Anything outside the allowlist below |
| Bad text encoding | A text file that is not valid UTF-8 |
| Image that is not one | PNG, JPEG and WebP are verified by magic bytes; the extension is not believed |
Allowed extensions: .js .json .css .html .twig .txt .png .jpg .jpeg .webp .woff2
Content types are assigned server-side from that extension, never from anything the archive or the upload declares.
The validation report
upload answers with the report, and it is stored on the release whether it passed or failed:
{
"ok": false,
"errors": [
"Extension not allowed: vendor/lib.map",
"Missing i18n/ru.json declared in manifest.json"
],
"entries": 14,
"uncompressed_bytes": 189342
}When ok is true the report also carries the parsed manifest. A release cannot be submitted for review until its stored report says ok.