Build desktop apps for Odoo.
In an afternoon.
A .vapp is a web app that runs in a real desktop window on the Vizum Window Manager,
stores its data in the customer's Odoo database and talks to Odoo through a
permission-gated SDK. Start with sdk-next: vzm new,
TypeScript, Vite and a typed ORM. Plain ES modules with no build step still work.
No Odoo module. No server deploy.
Start here
What a vApp is, your first one in TypeScript, and the tools you build it with.
Concepts
The manifest, permissions, where data lives and how a vApp stays compatible.
SDK & server
The browser SDK, server-side code, routes, and porting an Odoo module to a vApp.
Examples
Complete apps, explained file by file, from a sticky note to a realtime board.
Ship it
Package, install and update a vApp, publish it on the Store, and fix what goes wrong.
Start here
What is a .vapp?
One zip file. One window. The full Vizum desktop experience for free.
Drag, resize, snap-tiling, Spaces, tabs, Mission Control β your app inherits all of it.
A per-user (or shared) key-value store in the customer's own Odoo database.
Read/write exactly the models and fields your manifest declares β nothing else.
Live theme tokens: accent color and dark mode follow the user's desktop automatically.
How it works
Why a vapp instead of a native Odoo module?
A native module is the right tool when you need to change Odoo's own core behaviour. For everything else β a department tool, a customer-facing mini-app, a dashboard, an integration β a vapp gets you there in an afternoon, with none of the server risk.
| Native Odoo module | Vizum .vapp | |
|---|---|---|
| Install | Filesystem access to the server, add to the addons path, restart the service | Upload one .zip in the browser β live instantly |
| Server restart | Required on every change (a window of downtime) | Never β apply updates while users keep working |
| Risk to the database | Arbitrary Python β a bug can corrupt or take down the whole DB | Sandboxed + capability-gated; the manifest can only narrow access |
| Odoo 17 / 18 / 19 / 20 | Port & test the code against each version separately | Write once β the SDK runs it on every version |
| Updates | Redeploy files + restart | Publish to the store β open desktops pick it up |
| Uninstall / rollback | Migrations, leftover tables, manual cleanup | Tracked install β clean, reversible uninstall |
| Who can ship it | A developer with server / SSH access | Anyone β a ZERO-RISK vapp needs no admin at all |
| Distribution | Package for the Odoo Apps Store + review | The in-platform Vizum store β instant, public or private |
| Desktop UX | Build your own UI from scratch | Windows, dock, Spaces, Mission Control, mobile β for free |
HTML + JS + a manifest. No scaffolding, no server, no review queue.
Sandboxed and double-checked β the worst a vapp can do is what its approved permissions allow.
The same .vapp runs on Odoo 17, 18, 19 and 20 β no per-version ports.
Publish and the desktops refresh. No redeploy, no restart, no maintenance window.
Start here
Quickstart β a TypeScript vApp with sdk-next SDK-next
Scaffold, build and pack a TypeScript vApp with sdk-next, the Vizum developer kit. It contains the typed SDK (@vizum/vapp-types), the UI tokens, charts, forms, the vzm CLI and the templates, as one pnpm workspace. You need Node 22.13+ and pnpm.
1. Scaffold from a template
From the root of the kit (sdk-next/):
node packages/vapp-cli/bin/vzm.mjs new vanilla-ts examples/my-app
| Template | What you get |
|---|---|
vanilla-ts | Vite + TypeScript, no framework. The smallest starting point. |
react-ts | React 18 + TypeScript + Tailwind (Vizum preset) + Zustand + TanStack Query. |
vanilla-multi | No build step at all: ES modules across js/ and css/. |
A template's manifest.json already declares "min_platform": "5.67", the highest. Lower it only to install on an older platform, and only if your app uses nothing above that version: vzm check names the version each native declaration needs (see Platform capabilities).
2. Build and pack
pnpm install --filter ./examples/my-app...
cd examples/my-app
pnpm run build
pnpm run pack
pnpm run pack runs vzm pack dist and writes <id>.vapp: a zip of your built files plus the manifest.
3. Install
On the desktop, open the dock's apps menu, choose + Install .vapp, pick the file, then Open. An app that asks only for its own private data installs for any user. Anything more (Odoo records, server-side code, routes) needs an administrator's approval.
What the template does
import { connect, model, applyTheme, V } from "@vizum/vapp-types";
interface IResPartner { id: number; name?: string; email?: string }
const ResPartner = model<IResPartner>("res.partner");
await connect(); // the handshake, before any other SDK call
applyTheme(); // follow the desktop's accent and dark mode
const rows = await ResPartner.searchRead([["is_company", "=", true]], ["name", "email"], { limit: 8 });
V.ui.notify(`Loaded ${rows.length} partners`, "success");
Next:
- The toolchain: the
vzmCLI, generating typed models from Odoo, and the packages. - Server-side code: hooks, triggers and guards.
- Quickstart without a build step: three hand-written files.
Start here
The toolchain β vzm, TypeScript, Vite and the packages
Build vApps as visual and maintainable as a modern SaaS app β TypeScript,
a typed ORM, a UI kit, charts, forms and maps β without changing how a vApp installs
or runs. A compiled vApp is still the same .vapp the
platform serves to the sandboxed iframe.
Two ways to build β same installable .vapp
"Compiled" only affects the developer's machine (a Vite build step). The output is
the same zip of static files β installed by the store / api_install
exactly as before. Hand-written and AI-generated single/multi-file vApps need no build.
TypeScript & a typed ORM
The SDK ships TypeScript types for every namespace, plus a generator that turns live Odoo metadata into typed models β autocomplete, compile-time safety, and far more reliable AI codegen.
// 1. generate typed interfaces from real Odoo fields
$ vzm introspect --from fields.json --out src/models.ts
// 2. use them β searchRead is typed; create() returns a SCALAR id (no number[] footgun)
import { SaleOrder } from "./models";
const rows = await SaleOrder.searchRead(
[["state", "=", "sale"]], ["name", "amount_total"], { limit: 10 });
const id = await SaleOrder.create({ name: "From my vApp" }); // number, not number[]
The vzm CLI
| Command | What it does |
|---|---|
vzm introspect | Odoo fields_get / Vizum.meta.fields → typed TS interfaces + accessors (the crown jewel). |
vzm pack <dir> | Zips a built (or hand-written) folder + manifest into a validated .vapp β pure JS, no deps. |
vzm new <template> | Scaffolds from a template (vanilla-ts, vanilla-multi, react-ts, offline-field-app). |
vzm check | Manifest and code against the platform contract: min_platform too low for what you use (V.tr 5.77, group_ops 5.78, V.identity 5.79, offline 5.80, x_company_id uniques and jobs with user data 5.81, V.vector.shared_namespaces 5.83), a declared language without its catalogue, untranslated native strings (--strict-i18n makes them errors), host limits. Every code is in the CLI reference. |
vzm i18n import-po | Turns a native module's i18n/<lang>.po into the package's i18n/<lang>.json (field labels, selections, menus, views, code strings), writing @native: keys where one English text has two translations (SDK 5.84). Never overwrites without --overwrite. |
Multi-file β no single huge app.js, no build
A .vapp can hold as many files/folders as you like. The platform
serves every file with Access-Control-Allow-Origin: *, so
ES-module imports across files (incl. subfolders) work in the sandboxed iframe with no
build step β keep big apps tidy either way.
my-app/
index.html <!-- <script type="module" src="js/main.js"> -->
js/main.js // import { renderHome } from "./views/home.js"
js/sdk.js // local model()/connect() β no npm
js/views/home.js
css/style.css
The SDK-next packages β the full kit
Nine tree-shaken packages under the @vizum/vapp-* scope. Import
only what you use; every one bundles its assets and fetches nothing at runtime (a hard
requirement inside the opaque-origin vApp iframe), and every one reads the host --vzm-*
design tokens so it follows light/dark mode automatically.
@vizum/vapp-types β TypeScript types for every window.Vizum namespace plus a tiny typed runtime: connect(), model<T>() (generic, footgun-free ORM β create() returns a scalar id, not number[]) and applyTheme(). The foundation the rest builds on.
vzm CLI@vizum/vapp-cli β pure-Node, zero-dependency CLI: introspect (typed-ORM generator), pack (β validated .vapp, pure-JS zip) and new (scaffold a template).
@vizum/vapp-ui β host-theme-aware CSS variables (accent + dark mode) and a Tailwind preset, so every app looks modern and consistent.
@vizum/vapp-charts β an ECharts wrapper themed from the Vizum tokens; mountChart(el, option) with auto-resize.
@vizum/vapp-forms β a declarative form built from its own compact FormSchema (not JSON Schema) (char/selection/many2one-picker/date/monetary/emailβ¦) with validation. The biggest multiplier for AI-generated apps.
@vizum/vapp-query β query-spec factories for TanStack Query over the typed ORM: searchRead, read, readGroup, count and keyFor, ready to spread into useQuery({...searchRead(β¦)}). Caching, refetching and optimistic updates are TanStack Query's own β this package builds the specs and the keys.
@vizum/vapp-3d β hand-rolled Three.js primitives for WOW 3D reports: createReportScene (a SceneHandle that owns its loop + dispose(), like mountChart), an extruded-GeoJSON Map3D choropleth, FlyingLines arcs, instanced DataBars, count-up KpiPanel and a ready Dashboard3D shell.
@vizum/vapp-game β the shared floor under the Vizum Arcade: fixed-timestep loop (auto-pause on tab hide), input (keyboard/pointer/touch/gamepad), procedural WebAudio audio, pooled particles, juice (shake/count-up/hit-stop), in-code pixel sprites, a token-derived theme, a Vizum.db-backed leaderboard (local fallback) and an ArcadeShell (menu, HUD, pause, results and board in dark glass; since 0.2 a per-game accent, api.run to tell a new run from a resume, shellText() for its Spanish copy, and onResize() on the 2D renderer).
@vizum/vapp-vision β MediaPipe hand-tracking processed entirely in the browser (nothing uploaded): a pure, unit-tested recognize() gesture reader (pinch/point/open-palm/fist/swipe, hand-size-relative thresholds) + createHandTracker(). Model + WASM are self-hosted via the CORS route /vizum/vision (no CDN). Needs permissions.media: ["camera"]; always ship a non-camera fallback.
Maps & external data
A vApp iframe can load CORS-enabled external resources directly β verified with
MapLibre styles + OpenStreetMap tiles + Nominatim geocoding, no platform change. For APIs
that don't send CORS headers, use the server-proxied Vizum.net.fetch.
Offline, PWA & native mobile
A vApp iframe runs on an opaque origin, so localStorage/IndexedDB
are blocked inside it β a vApp can't keep its own offline queue. That's exactly why the
host (the desktop page, with normal storage) owns the outbox behind
Vizum.offline:
// only APPROVED manifest actions can be queued β never arbitrary writes:
// "actions": [{ "name": "ticket.ack", "model": "helpdesk.ticket", "method": "action_acknowledge" }]
await Vizum.offline.status(); // { online: boolean }
Vizum.offline.on((s) => render(s.online)); // live connectivity flips
const r = await Vizum.offline.run("ticket.ack", { ids: [42] });
// online β runs live now, returns { ok, result }
// offline β the HOST queues it, returns { id, queued: true }
await Vizum.offline.pending(); // what's waiting in the host outbox
await Vizum.offline.flush(); // replay on reconnect (also automatic)
Vizum.offline.onFlushed((r) => toast(`synced ${r.sent}`));
Since 5.80 the host also keeps the datasets your manifest declares on the device and queues
Vizum.offline.create / write: read, write, and
resolve conflicts with no coverage (Offline data; the
offline-field-app template). Vizum Solo opens with no network after one online visit, and the Android
app sends queued actions, creates and changes with the app closed (sdk-next/docs/offline-pwa-mobile.md).
A service worker and install prompt for the desktop are not built; a vApp iframe can't register one.
Example gallery
15 example apps ship with the toolchain β every one verified running in the desktop.
Live revenue KPIs + an SVG revenue chart, CRM pipeline, recent orders, top customers. Β· vanilla Β· typed ORM
Revenue trend, invoices donut, top-salespeople & pipeline β real ECharts; click a bar to drill in. Β· React Β· ECharts
Search a customer, drill into orders / invoices / opportunities; click anything to open it. Β· vanilla Β· master-detail
Drag CRM opportunities between stages β optimistic write, host-confirmed. Β· React Β· DnD Β· TanStack
Project tasks by stage with a project switcher; drag to move. Β· React Β· DnD
Create leads/contacts from declarative JSON-Schema forms (validation + many2one pickers). Β· vanilla Β· forms
A βK global search across contacts/orders/leads/invoices/products, keyboard nav. Β· vanilla
On-hand KPIs, top products, stock-by-location, low-stock table; bar β drill. Β· React Β· ECharts
Headcount by department/position + a searchable employee directory. Β· React Β· ECharts
Today's meetings + my activities + my open tasks, overdue in red. Β· vanilla
A mini-Notion notebook with a TipTap rich-text editor + autosave. Β· vanilla Β· TipTap
A real WebGL warehouse β each location a bin sized/colored by stock; orbit, hover, click. Β· vanilla Β· Three.js
Triage tickets β stage filters, list + detail, related tickets. Β· vanilla Β· master-detail
Customers plotted on a real MapLibre/OpenStreetMap map (geocoded); click a pin to open. Β· vanilla Β· MapLibre
Start here
Quickstart without a build step
Three files, one zip, one click to install.
manifest.json{
"schema": 1,
"id": "com.acme.hello",
"name": "Hello Vizum",
"version": "1.0.0",
"entry": "index.html",
"icon": "icon.png",
"window": { "w": 360, "h": 300 },
"permissions": { "data": "user" }
}
index.html
<!DOCTYPE html>
<html><head>
<meta charset="utf-8">
<link rel="stylesheet" href="/vizum_apps_platform/static/sdk/vizum-glass.css">
</head>
<body style="padding:16px">
<div class="vz-card">
<h2>Hello!</h2>
<p>Opened <b id="n">β¦</b> times by you.</p>
<button id="reset" class="vz-btn">Reset</button>
</div>
<script src="/vizum_apps_platform/static/sdk/vizum-sdk.js"></script>
<script src="app.js"></script>
</body></html>
app.js
(async function () {
await Vizum.connect(); // handshake with the desktop
const n = ((await Vizum.data.get("opens")) || 0) + 1;
await Vizum.data.set("opens", n); // persisted per user, in Odoo
document.getElementById("n").textContent = n;
document.getElementById("reset").onclick = async () => {
await Vizum.data.set("opens", 0);
Vizum.ui.notify("Counter reset", "success");
Vizum.window.close();
};
})();
Zip it (plus any 256Γ256 icon.png) and install:
cd hello-vizum/
zip -r ../com.acme.hello.vapp . -x '.*'
On the desktop: dock π§© β β+ Install .vappβ β pick the file β Open. Because this manifest only asks for private data, any user can install it instantly β no admin needed.
Start here
Package anatomy
A .vapp is a renamed zip with one required contract: the manifest.
my-app.vapp
βββ manifest.json // REQUIRED β identity + permissions
βββ index.html // REQUIRED β your entry point
βββ app.js // your code, any structure you like
βββ style.css assets/β¦ // css, images, fonts β vendor everything
βββ icon.png // REQUIRED β square PNG, 256Γ256
| Limit | Value |
|---|---|
| Zipped size | 5 MB |
| Unpacked size | 20 MB |
| File count | 200 |
| Paths | relative only β no .., no leading / |
permissions.net and call Vizum.net.fetch() β
everything else is blocked. Libraries and assets still belong inside the zip.
Concepts
The manifest, field by field
Your app's identity, window preferences and β most importantly β its permission contract.
{
"schema": 1,
"id": "com.yourcompany.appname",
"name": "App Name",
"version": "1.0.0",
"min_platform": "5.49",
"entry": "index.html",
"icon": "icon.png",
"window": { "w": 420, "h": 560, "singleton": true },
"permissions": {
"data": "user",
"orm": [
{ "model": "sale.order",
"ops": ["search", "read"],
"fields": ["name", "amount_total", "state"],
"domain": [["user_id", "=", "uid"]] }
],
"ui": ["notify", "openRecord"],
"events": { "emit": ["myapp.ping"], "listen": ["otherapp.pong"] }
}
}
| Field | Required | Meaning |
|---|---|---|
schema | yes | Manifest schema version β always 1 today. |
id | yes | Reverse-DNS, lowercase. Your app's permanent identity β never change it between versions. |
name | yes | Display name (window title, launcher, store). |
version | yes | Semver x.y.z. Re-installing the same version is rejected β bump it. |
min_platform | no, but set it | The platform capability your app needs, e.g. "5.64". The installer refuses the app on an older platform instead of letting a feature fail at runtime. Set it to the highest row of Platform capabilities whose feature you use; the vzm new templates start at "5.64", the highest; vzm check names the version each native declaration needs. |
entry | yes | The HTML file loaded into your window. |
icon | yes | PNG path inside the zip. |
window.w / h | no | Initial window size in pixels. |
window.singleton | no | true (default): opening again focuses the existing window. |
permissions | no | The contract β see the next section. |
offline | no | The offline data layer (min_platform 5.80): {datasets, writes, fallback, max_mb} β named slices of the user's own Odoo data the device keeps, and the creates and writes that may be queued with no network. Privileged; any edit asks for approval again. See Offline data. |
i18n | no | The languages the package translates, in Odoo codes (["es", "es_419"]). Each one needs its i18n/<lang>.json: vzm pack, vzm check (I18N_CATALOGUE_MISSING), the installer and the Store refuse a declared language with no file. The same file translates the UI (Vizum._t), server tr() (5.73), V.tr in hooks and jobs (5.77) and the strings your app puts in Odoo's backend (5.84). See the SDK tour. |
Concepts
Permissions & install tiers
Declare exactly what you need. Admins approve exactly what you declared. The server enforces it on every call.
| Key | What it grants |
|---|---|
data | "user" (default): a private key-value store per user.
"global": one store shared by all users of the database.
Jobs with "user" (SDK 5.81): an app with jobs may declare
"data": "user"; the platform writes no shared tick for its jobs and the job's own
V.data is the app's service user's, never another user's private data. Before 5.81 jobs
required "global". Set min_platform 5.81. |
orm | A list of rules, one per model: model, ops
(search Β· read Β· read_group Β· write Β· create Β· unlink Β· call:<method>),
optional fields (reads return only these + id) and an optional
domain ANDed into every search β the literal "uid" becomes the
current user id server-side.
Group-scoped ops (SDK 5.78): group_ops: [{groups: [...], ops: [...]}]
gives extra ops (create Β· write Β· unlink Β· call:<method>) only to the calling user
who is in one of the groups — "@name" (your own native.groups),
"<app-key>/@name" (an app you list in requires) or a real xmlid, never an
administrative group. Keep ops read-only for everyone and put the writes of an
owner app's administrators in group_ops: Solo builds its shared access group from
ops only, so it no longer hands every Solo user the writes. An op sits in
ops or in group_ops, not both; moving one into group_ops
narrows the grant (no new approval), adding one widens it. Set min_platform 5.78.
Closed models: the platform's own models (vizum.*) take a grant only
from a short allowlist — vizum.app is read +
call:<api_*> only (use call:api_list and the other helpers,
never write); ir.actions.* is read-only (except
ir.actions.report); ir.cron accepts write on its
scheduling fields only (active, nextcall,
interval_number, interval_type, priority), never
create/unlink, and call:method_direct_trigger alone.
base.automation, ir.server.object.lines, ir.default
and ir.filters are read-only, and vizum.licence* /
vizum.store.* belong to the store's own back-office app.
The installer, the store and vzm check all refuse anything else.
Rows that are code: a create must also satisfy the grant's
domain. An app may write only its own ir.ui.view (key starting
vzm_<app id as a slug>.: every character of the id outside
a-z0-9 becomes _, so com.thelounge.lounge is
vzm_com_thelounge_lounge.) and its own ir.actions.report
(report_name under the same prefix; fields
name, model, report_type, report_name,
report_file, paperformat_id, binding_model_id,
binding_type, print_report_name only). The platform records
every view, report and mail template an app creates as that app's own, and only those can
be changed or deleted. The QWeb expressions an app writes are vetted on every write, as
they are at install (no .sudo(), .env or private attributes;
a static t-call may name only Odoo's report layouts or the app's own
templates). An inherit_id must be the app's own view or an Odoo report
template. A plain <style> (text only, no t-* attribute, no url(, @import, expression(, behavior, -moz-binding, javascript:, </ or backslash) is allowed in the app's own QWeb report templates (the view a report of the app names, or one it t-calls; create the report first), where a margin reset makes a label exact; anywhere else <style> is refused. An expression survives only if the same record already carried it (never one
with sudo, with_user or env), or if it is
byte-identical to one in a standard Odoo view or report the app READ through the bridge
in the last hour (call:get_combined_arch, or a read of
arch, arch_db, arch_base or
print_report_name): read the standard document, then write your copy. There
is no key to name the source; the platform records what you read, and which standard
record a copy came from. Odoo's own reports use o.sudo()..., so a copied
expression may too, but the copy is refused if it redefines, with
t-set or t-foreach/t-as, a name such an
expression uses (the error names the variable); a definition identical to the standard
one (<t t-set="o" t-value="o.with_context(lang=lang)"/>) is fine.
mail.template writes touch only the app's own templates, with the same
vetting; model_id may only name a model the app can read, and
attachment_ids, report_template_ids and
email_layout_xmlid are refused. A view, report or template is never created,
edited or linked through another record's one2many / many2many commands, and only
call:get_combined_arch (view), call:send_mail (template) and
call:report_action (report) run on them. ir.model.data,
website.page and website.controller.page are read-only. |
ui | Which desktop UI calls you may make: notify,
openRecord, openList, setBadge,
addPaletteCommand. (confirm is always available β it only
asks the user.) |
events | Event names you may emit / listen to on the desktop bus. |
apps | App ids you may open with Vizum.apps.open() (intents). Zero-risk: it only opens apps the user already installed. |
net | External hosts Vizum.net.fetch() may reach:
exact hostnames ("api.frankfurter.app") or wildcards
("*.openweathermap.org"). Proxied server-side β redirects are not
followed, internal/IP hosts are blocked, responses cap at 2 MB. Since 5.74
"*" means any public website, download-only (GET/HEAD, never a
private address, never a $secret), and Vizum.net.fetchImage()
downloads one image with redirects re-checked β see
Network & images. A named host is reached on ports 80 and 443
only; for another port write "host:port" (5.74; an older
min_platform keeps the old behaviour and vzm check warns
NET_PORT_UNDECLARED). |
file_limits | Raises Vizum.files per app:
{"max_mb", "count", "total_mb"} (defaults 64 MB, 50 files, 128 MB;
up to 200 MB a file since 5.70, up to 512 MB since 5.71). Above
200 MB the platform derives a separate large_files grant the administrator
approves on its own, and needs an app storage cap. Privileged; raising any value asks again.
See Files. |
device_routes | Derived by the platform from
native.routes with auth: "device" (5.70): the pages and routes that
paired screens, kiosks and time clocks reach without an Odoo login. Shown on its own line of
the approval card; adding a route or changing its embeds, model or fields asks again. See
Paired devices. |
media | ["camera"] and/or ["microphone"] β
delegates the device to your sandboxed iframe so the standard
getUserMedia() works. The browser still prompts the user. |
secrets | Named API keys/tokens the app may use
(max 16, snake_case). Values live encrypted in Odoo, are typed by an
admin into a host dialog, can never be read back by the app, and are injected
server-side into net.fetch via $secret:name. |
oauth | OAuth2 providers (max 8) the app may connect
through Vizum.oauth. Providers are configured once per database
by an admin; tokens are per-user, encrypted, refreshed automatically and
never visible to the app β requests are proxied with the token injected. |
sessions | Device/session controls the app may call via
Vizum.sessions: revoke Β· revokeUser (end sessions) and the
policy setters getPolicy Β· setLoginAlerts Β· setSessionTimeout Β· setActivityAudit Β·
setActivityAuditGlobal. Admin-gated (base.group_system) with a
host confirmation; session revoke needs Odoo 18+ for res.device. |
audit | true to read the field-level change history
through Vizum.audit.activity(). Pairs with native.audit (which
models/fields to change-log) and the admin's master switch. Captures real oldβnew diffs;
opt-in, prunes after 180 days. |
assistant | true to use the AI Assistant bridge
(Vizum.assistant). A server-side agent runs a tool-loop as the real
user (no sudo): broad read (ACL-enforced) + confirm-gated writes, with per-user
chat history. Admin-approved, high-risk. |
The two install tiers
| Tier | Manifest asks for⦠| Who can install |
|---|---|---|
| ZERO-RISK | nothing beyond data: "user" |
Any desktop user, instantly. |
| PRIVILEGED | orm, net, file_limits, device_routes or data: "global" |
Lands in βneeds approvalβ: an administrator reviews the exact scopes. Updates that widen permissions re-enter approval β the old version keeps running meanwhile. |
fields is also marketing.Write actions ask the user
Every mutating ORM call (create, write,
unlink, call: methods) pops a
host-rendered confirmation the sandboxed app cannot fake or skip:
βAppName wants to create 1 account.move record β Allow / Denyβ.
Creates, updates and method calls ask once per app + operation + model and are
then remembered; deletions ask every time. Users reset an app's remembered
grants from Vizum Apps (βΊ on the card). Denied calls reject with
Error("UserDenied") β handle it gracefully.
Concepts
Version compatibility β write once, run on every Odoo
A .vapp keeps working when the customer upgrades Odoo
(17 β 18 β 19 β 20 β β¦). You don't repackage it. Here's why, and the handful of rules that keep it true.
Why your app survives upgrades
- The SDK is shared, never bundled. Your package does not contain a copy of
vizum-sdk.jsβ it loads the platform's single shared copy. Upgrading the platform upgrades the SDK for every installed app at once; there are no stale copies. - The
window.Vizumcontract is version-stable. The transport (postMessage) and every namespace below are the same across Odoo versions. The platform absorbs the Odoo internals. - Renamed core fields are translated for you. Odoo occasionally renames a field
(e.g.
res.users.groups_idbecamegroup_idsin 19, and 20 keeps it). Theormproxy transparently maps the known renames in both directions, so an app written against either name keeps reading/writing/searching it on any version β the result comes back under the name you asked for.
Three rules to stay future-proof
- Never read group fields off
res.usersdirectly. UseVizum.user/Vizum.roles(below) β they speak stable group XMLIDs, which never change between versions. - Stick to standard fields.
id, name, login, lang, tz, company_id, currency_id, create_date, write_date, display_nameare stable everywhere. - Branch on the version only if you truly must β
Vizum.session.odoo_versiongives the running major (17/18/19/20).
Vizum.user β version-safe identity & groups
No permission required (it only ever reveals the current user's own info).
await Vizum.connect();
Vizum.user.id; // 12 (sync, from the handshake)
Vizum.user.name; // "Jane Doe"
Vizum.user.isAdmin; // true if ERP-manager/admin
Vizum.user.isSystem; // true if in base.group_system
// is the user in an Odoo group? (stable XMLID β works on every version)
if (await Vizum.user.inGroup("sales_team.group_sale_manager")) showApproveButton();
const xmlids = await Vizum.user.groups(); // the user's OWN group XMLIDs
const full = await Vizum.user.info(); // {id,name,login,isAdmin,isSystem,company,lang,tz}
Vizum.scope β am I per-user or global?
Tells your app which storage scope it runs in, so you can label the UI honestly (βshared with everyoneβ vs βyour dataβ). Derived from your approved manifest.
const s = await Vizum.scope();
// { data: "user" | "global", // your Vizum.data / key-value scope
// tables: { notes: {shared: true} }, // per Vizum.db table
// install_scope: "user" | "admin",
// default_access: "all" | "restricted" }
if (s.data === "global") banner("Notes are shared with your whole company.");
Declaring scope is done in the manifest, not at runtime:
permissions.data: "global" makes the KV store company-wide (privileged);
a tables[].shared: true table gives each row an owner + per-row sharing
(Vizum.db.share()), versus the default per-user isolation. See
Permissions & tiers and The manifest.
Vizum.roles β declarative app roles
Map your own role names to Odoo groups in the manifest; the platform evaluates them
as the real user (stable has_group(xmlid)) so the answer is trustworthy.
"roles": [{"name": "manager", "label": "Approver",
"groups": ["sales_team.group_sale_manager", "base.group_system"]}]
if (await Vizum.roles.has("manager")) enableApprovals();
await Vizum.roles.mine(); // ["manager", ...] the roles the user has
Concepts
Platform capabilities and min_platform GENERATED
Current platform capability: 5.86.
A vApp declares the capability it needs in its manifest, "min_platform": "x.y". The installer refuses the app on an older platform instead of installing it and letting the feature fail at runtime (often silently: an ignored key, a missing V.* name, a guard that never runs). Set it to the highest row whose feature your app uses; a higher value only locks out instances for nothing.
Versions compare numerically per part: 5.9 < 5.10 < 5.54.
Summary
| Capability | What it adds |
|---|---|
| 5.86 | OFFLINE: A RECORD DELETED ON THE SERVER IS ITS OWN KIND OF ISSUE. A queued write (or action) on a record that no longer exists answers CONFLICT with details.deleted; the device lists it as reason: "deleted" (with⦠|
| 5.85 | A vApp CAN TAKE ITS WINDOW FULL SCREEN. Vizum.ui.fullscreen() / exitFullscreen() (grant "fullscreen" in permissions.ui; exitFullscreen is always allowed) on the desktop and the native Odoo host: the window is painted⦠|
| 5.84 | NATIVE STRINGS FROM THE CATALOGUE. The i18n/<lang>.json an app already ships (5.73, 5.77) now also translates what the app puts in Odoo's own backend: field labels and help, selection options, model labels, groups, me⦠|
| 5.83 | the shared vector scope of a user-scoped app. |
| 5.82 | two asks of a paired-device page. |
| 5.81 | two server-computed facts a guard could not learn, and two limits lifted. |
| 5.80 | the offline data layer. |
| 5.79 | Vizum.identity.confirm: step-up authentication. |
| 5.78 | group_ops. |
| 5.77 | V.tr. |
| 5.76 | Vizum.app.key. |
| 5.75 | V.sha256. |
| 5.74 | NETWORK REACH AND IMAGE FETCH. permissions.net may hold "*": any PUBLIC website (never a private, loopback, link-local, metadata or tunnel address; every hop of a redirect is checked again, the connection is pinned to⦠|
| 5.73 | SERVER-SIDE TRANSLATIONS. The i18n/<lang>.json files the browser SDK reads also answer tr() in the app's model methods: the install stores them on vizum.app.i18n_catalog (models/vapp_i18n.py), tr() reads the language⦠|
| 5.72 | RESUMABLE CHUNKED UPLOADS. A dropped connection no longer restarts a big Vizum.files.putBlob from zero: POST /vizum/apps/files/<key>/upload/status?token answers {received, next_part, size, sha256, expires_at} behind t⦠|
| 5.71 | LARGE APP FILES. Vizum.files.putBlob above 32 MB uploads in parts (begin / part / commit / abort: models/vizum_app_upload.py, the platform reserves the declared size against every quota and verifies size + sha256 befo⦠|
| 5.70 | PAIRED DEVICES AND LARGE FILES. A route may be auth: "device": a screen, kiosk or time clock with no Odoo login pairs with an 8-digit PIN the platform shows and an administrator claims on the desktop; it then holds⦠|
| 5.69 | UPDATES THAT RE-ASK, VERSIONED CONTRACTS, VERIFIED CALLERS, INSTANCE TELEMETRY. An update that changes server code (script hooks and library, model methods, routes, jobs, webhooks, fields with server logic, constraint⦠|
| 5.68 | A MODULE THE SIZE OF ELEV8DM AS A vAPP (its 1766 tests run on the vApp; plan 2026-10-02-plataforma-5.68-elev8dm). |
| 5.67 | THE PLATFORM CONTRACT AND SDK RELIABILITY (SDK plan P0βP4). The platform contract reaches apps (Vizum.platform, Vizum.sdk, Vizum.features(), Vizum.supports()); every failed call rejects with a VizumError {code, categoβ¦ |
| 5.66 | A WHOLE ODOO MODULE AS A vAPP (psi_dsd β PSI DSD Core, its 818 tests green on the vApp). |
| 5.65 | Vizum.team COVERS WHAT A USER-PROVISIONING MODULE DID (united_insurance_team). |
| 5.64 | A vAPP CAN TAKE OVER THE DATA OF THE MODULE IT REPLACES. native.adopt: [{model, from, fields: {own field: module field}}] copies every row of an installed module's model into one of the app's own models, once (vizum.a⦠|
| 5.63 | THE LAST FIELD SHAPES A MODULE USES. translate: true on a char/text/html field (one value per language, as translate=True in a module); type "json" (fields.Json); a one2many from an own model to a SHARED model whose⦠|
| 5.62 | A SANDBOX FOR REAL LOGIC, WITH A CLOCK. Script hooks may define top-level functions (def, no decorators, no nesting β so no closures) and use while; the app may share code between its hooks in native.script.lib ({β¦ |
| 5.61 | NATIVE DATA ACROSS VERSIONS. native.migrations: [{to, ops}] run by reconcile on the update that crosses to (vizum.app.native_schema_version remembers the version last reconciled; a first install migrates nothing): r⦠|
| 5.60 | Vizum.team: A MANAGER OF THE APP CREATES AND EDITS THE USERS WHO PLAY ITS ROLES. The manifest declares native.team {managers: [group refs], roles: [{name, label, help, groups}]} under the privileged grant permissions.β¦ |
| 5.59 | A vAPP CAN DEFEND A FIELD OF ITS OWN MODELS (closes the 2026-09-01 field-ceiling concern). |
| 5.58 | WHAT A MODULE'S PYTHON GIVES ITS MODELS, DECLARED. On the app's own models: default (a literal of the field's type, or {"dynamic": today|now|user|company}) and sequence ({prefix, padding}: numbered on create from⦠|
| 5.57 | A vAPP CAN MODEL A DOCUMENT WITH LINES. On the app's own native models: a one2many names its inverse (a many2one declared on another of the app's own models, pointing back) and compiles to Odoo's relation_field ββ¦ |
| 5.56 | A HOOK CAN GUARD A DELETION. native.script.hooks[].trigger.on accepts "unlink" (with enforce): a base.automation on_unlink whose action calls the same dispatcher, run BEFORE the deletion β a hook that raises (V.β¦ |
| 5.55 | PUBLIC ROUTE PAGES. A route may declare auth: "public" β a PAGE only (a public json route does not exist), with no groups β at /app/<slug>/<route>, open to anyone, signed in or not, of the company it is served in (tβ¦ |
| 5.54 | A TRIGGER KNOWS WHO WROTE, WHO OWNS, AND WHAT THE WRITE LEFT. The hook payload gains uid (env.uid of the write β 1 when server code writes as the superuser), owner (the row's create_uid) and new (post-image of tβ¦ |
| 5.53 | ROUTES A vAPP DECLARES (native.routes), portal and internal only. |
| 5.52 | Vizum.assistant.kbSearch(query, {catalogs, topK}): passages from the Knowledge (RAG) catalogs for a vApp holding permissions.assistant β the Assistant's own retrieval (hybrid, rerank, CRAG), run as the CALLING user (tβ¦ |
| 5.51 | AN EXPLICIT COMPANY ON A company_scoped NATIVE MODEL IS HONOURED, AND VETTED ON WRITE. A create used to replace an explicit x_company_id with env.company, and a write was not checked at all (Odoo re-checks record rule⦠|
| 5.50 | MENUS HIDDEN PER USER OR PER GROUP. vizum.menu.hide (menu, users, groups, hidden until, why, app_key) is subtracted from ir.ui.menu._visible_menu_ids β the result Odoo's menu service, the command palette and the Vizβ¦ |
| 5.49 | AN APP'S OWN NATIVE MODELS NEED NO ORM GRANT IN THE BROWSER EITHER. The server (_orm_rule) always granted an app its own native models, and the native fields it added to shared models, without permissions.orm; the⦠|
| 5.48 | MAP TILES THROUGH THE PLATFORM. OpenStreetMap answers a tile request with no Referer with its "Access blocked" PNG, and a vApp iframe (opaque origin) never sends one. |
| 5.47 | WORKFLOW CONTEXT KEYS. api_orm (and V.orm in hooks) accepts, besides lang, the two context keys Odoo's web client itself sends to finish a PARTIAL transfer: skip_backorder and picking_ids_not_to_backorder on stock.p⦠|
| 5.46 | RETIRING A COMPUTED FIELD FREEZES IT INSTEAD OF BREAKING RECOMPUTES. A vApp version that stops declaring a formula field used to archive it by rename (<name>__archived_<id>) with its compute still writing the old na⦠|
| 5.45 | THE PGVECTOR GUIDE CAN FINISH THE JOB. Before it, nothing a vApp could call set the RAG storage mode to pgvector: the "RAG pgvector Setup" guide sent the admin to Managed AI, and Managed AI answered "enable it from th⦠|
| 5.44 | RAG THAT OBEYS ODOO, AND A DOCUMENT REPLACED IN ONE PIECE. (1) vector.search / dump / indexed_docs and rag.list_chunks withhold every passage derived from an Odoo record the CALLER cannot read under record rules, or w⦠|
| 5.43 | COMPARE-AND-SET ON A NATIVE MODEL: orm.write(model, ids, vals, {expect}) in the JS SDK and V.orm.write(model, ids, vals, expect=β¦) in the script runtime. |
| 5.42 | vizum.migration.run.action_discover(): the migration plan and the sourceβtarget field map are built SERVER-SIDE from both live schemas (models/migration_engine/plan.py), and the run exposes discovery_json / `incluβ¦ |
| 5.41 | db.share(table, id, {users: ["job_user", β¦]}): the literal "job_user" resolves server-side to the identity the app's jobs/hooks run as (job_user_id, default installer; never superuser/archived/portal). |
| 5.40 | V.vector.search(namespace, query, opts) / V.vector.namespaces() in server scripts (hooks and jobs): READ-ONLY, through the same api_vector the iframe uses, gated by permissions.vector, metered on its own per-r⦠|
| 5.39 | Vizum.db compare-and-set: db.update(table, id, values, {expect: {field: value}}) writes ONLY if the row still holds every expected value, under a row lock, and the result says _updated: true|false. |
| 5.38 | Vizum Instances pass: Vizum.instances.pass() -> {token, expiresAt} behind the new boolean grant permissions.instances_api (privileged; gaining it widens). |
| 5.37 | Report Builder 1.0 (native visual editing of Odoo reports, Β§3.3 of its spec): four small platform changes, all four lines. |
| 5.36 | native.bindings: a manifest may bind the app to the Action menu of a NATIVE model (native.bindings, one entry per {name, model, label, groups}, gated by the native_bindings grant). |
| 5.35 | assistant.send accepts the CALLING APP's model / reasoning effort / knowledge base ({model, effort, namespace}). |
| 5.34 | an AI call may carry ref, an opaque attribution tag naming which of the app's OWN internal consumers spent the tokens (Agent Center names the agent). |
| 5.33 | V.db β the app's own Vizum.db TABLES reached from a server-side script. |
| 5.32 | enforcing, delta-aware script triggers. |
| 5.29 | native.groups: the schema compiler creates real res.groups for a manifest's declared roles (stable xmlid), so access/rules can reference "@name" instead of every vApp with roles needing a hand-written Odoo addon just⦠|
| 5.28 | V.commit() in the JOB runtime + one savepoint/commit PER APP in the job cron. |
| 5.27 | Vizum.files.publicUrl β an unguessable but PUBLIC https link to one of the app's own files (permissions.files_public). |
| 5.26 | the SOCIAL OAuth rail: shared (brand) connections usable by every user AND by the unattended jobs (permissions.oauth_shared + V.oauth in the script runtime), many accounts per provider (account_ref), PKCE + provider c⦠|
| 5.25 | Vizum.ai.image β image GENERATION for vapps (managed mode only). |
| 5.24 | Vizum.devices.location β HOST-mediated one-shot GPS fix (devices:["location"]). |
| 5.21 | Vizum.ui.openUrl β the host opens an external http(s) URL in a new tab. |
| 5.20 | server-side vApp code: a manifest jobs[].script runs a native.script.hooks entry as job_user_id, with V.net / V.ai / V.data / V.json (and, from 5.33, V.db) and the time helpers (V.now_odoo, V.add_minutes, V.minu⦠|
| 5.5 | Vizum.audio (transcription β local Whisper default / managed metered-per-minute gateway). |
| 5.4 | Vizum.ocr (OCR β local Tesseract default / managed metered-per-page gateway) |
| 5.3 | AI tools β manifest ai_tools + Vizum.ai.tools.handle (client-handled) + Vizum.ui.openApp(key, payload) |
| 5.2 | Vizum.docs (raw-document store for RAG re-indexing) + Vizum.vector.pgv* (Mode-3 local-pgvector setup) |
| 5.1 | Vizum.domain (Visual Domain Builder) |
| 5.0 | Vizum.vector / Vizum.embed / Vizum.sessions |
Details
5.86
OFFLINE: A RECORD DELETED ON THE SERVER IS ITS OWN KIND OF ISSUE. A queued write (or action) on a record that no longer exists answers CONFLICT with details.deleted; the device lists it as reason: "deleted" (with canRecreate) instead of a generic rejection, removes the local row (no ghost), and the user either discards the change or recreates the record (keep_mine: ONE create from what they saw, offered when the app may create the model offline). Vizum.supports("offline-deleted"). Also hardened in the same release, no contract change: a replay runs in the company it was queued under (a company the user lost is a clear refusal), an app waiting for approval holds the queue instead of rejecting it, a write naming a field that left the model is refused before anything is written, relation commands compare against the base correctly, and every replay outcome leaves a platform audit row. An app that handles reason: "deleted" sets min_platform 5.86. sdk-next/docs/offline-data.md.
5.85
A vApp CAN TAKE ITS WINDOW FULL SCREEN. Vizum.ui.fullscreen() / exitFullscreen() (grant "fullscreen" in permissions.ui; exitFullscreen is always allowed) on the desktop and the native Odoo host: the window is painted over the viewport with the dock, the Odoo navbar and the titlebar hidden (a render mode: the window's saved geometry is never touched), and the browser is asked for real fullscreen too, best effort - the click happened inside the sandboxed iframe and only some browsers hand that activation to the host document, so the kiosk layer never needs a gesture. A host Exit button, Escape and the browser's own exit end it (Vizum.ui.onFullscreenChange). Vizum.supports("ui-fullscreen"); Solo and device pages answer {active:false, supported:false} (device pages keep Vizum.device.fullscreen). An app that calls it sets min_platform 5.85 (vzm check). sdk-next/docs/ui-fullscreen.md.
5.84
NATIVE STRINGS FROM THE CATALOGUE. The i18n/<lang>.json an app already ships (5.73, 5.77) now also translates what the app puts in Odoo's own backend: field labels and help, selection options, model labels, groups, menus, window/server actions, view and report arch terms, the translatable values of native.records, a binding's label and a public page's website-menu entry. A key is the plain English text (an existing catalogue works at once) or an optional context key "@native:<ref>" (model, model.field, model.field#help, model.field=value, @group, <record id>.<field>, app, route:<name>; models/vapp_native_i18n.py). The strings are written at install, update, approval and language install/reactivation (and by vizum.app.action_native_i18n_reload) into the jsonb columns Odoo reads, computed from the stored en_US every time; en_US is never written and a missing string stays English. An administrator's own translation of one of these strings is kept (a hash of what the platform last wrote lives on native_i18n_state). The schema compiler now compares and writes the source columns in en_US, so a Spanish installer no longer rewrites them. Not a permission change: a translations-only update still asks for no approval and is audited (i18n:update, i18n:native). Keys do nothing on an older platform, so no min_platform is required. vzm check warns about untranslated native strings and dangling @native: keys (--strict-i18n makes them errors). sdk-next/docs/server-side.md.
5.83
the shared vector scope of a user-scoped app. Vizum.vector.sharedNamespaces() lists the namespaces (name, count, model, dim) the app owns in the SHARED (global) scope, and Vizum.vector.moveScope(ns, {from, to, user_id?, doc_id?, batch?}) moves a namespace's rows between the shared scope and a user's scope WITHOUT re-embedding: the owner of the existing rows changes, so embedding, text, metadata and chunk order are the stored bytes, nothing is embedded and nothing is metered. Administrator or the app's service user only (the store does not record who wrote a shared row, so a plain user cannot be given "the shared rows that are theirs"); user to global is administrator only; batched, re-runnable, never overwrites a row the destination already holds. V.vector.shared_namespaces() (server code) reads the list. An app that calls V.vector.shared_namespaces sets min_platform 5.83. Managed RAG storage lists but cannot move.
5.82
two asks of a paired-device page. (1) Player control: the app cannot reach the YouTube / Vimeo player the PAGE framed for it, so it names the handle embed() returned and the page posts the player's own postMessage command (setVolume 0-100, mute, unmute, play, pause) to that iframe, targetOrigin the provider's exact origin (never "*"), only for players this page created. No new host, no CSP change. Vizum.device.embed(...) handles gain setVolume/mute/unmute/play/pause; feature device-embed-control. (2) Per-device storage, Vizum.device.store get/set/delete/all: a frame with an opaque origin has no localStorage, so a screen's settings (volume) live server side in vizum.app.device.kv, keyed by the paired device (so by app + device), reached only with that device's own token through POST /vizum/device/<slug>/_store: at most 64 keys of at most 4 KB of JSON each (256 KB a device), dropped when the device is revoked. Feature device-store. Device pages only.
5.81
two server-computed facts a guard could not learn, and two limits lifted. (1) The trigger payload carries su: True when the write that fired the trigger ran in a superuser environment (env.su: a vApp method's sudo(), platform-internal code, uid 1), False for the user's own write. sudo() keeps the uid, so uid alone could not tell them apart; it is read from the write's records in base.automation._process, never from the app, and cannot be forged. (2) V.is_service_user(): True when the code runs as the app's own service user (the passwordless job user that jobs, triggers and device routes run as), False for any human. A vApp that reads rec["su"] or names V.is_service_user sets min_platform 5.81 (the install gate refuses it below). (3) A unique of a company_scoped model may list x_company_id, the synthesized company column (a prefix unique per company, one settings row per company); nulls_not_distinct works with it as for any column. A unique that names it sets min_platform 5.81. (4) A job may run with permissions.data "user": the platform writes no shared tick for it, and the job's own V.data is the scope of the user it runs as (the app's service user), never another user's private data; before 5.81 jobs required "global". An app with jobs and data "user" sets min_platform 5.81.
5.80
the offline data layer. A manifest may declare offline {datasets, writes, fallback, max_mb}: named, approved slices of the user's own Odoo data (model, fields, domain with {uid}/{company_ids}/{todayΒ±N}, order, limit) that the HOST keeps on the device, and the writes (create / write, with the fields each may touch) that may be queued with no network. The device sends a dataset NAME; the server (api_offline: datasets, config, sync) reads model, fields and domain from the approved snapshot and runs the read as the user, so grants, field ceilings and record rules apply as to a live call. A queued write carries the values the user saw (base) and a queued create or action the computed fields the user saw (expect); a difference answers CONFLICT, and a replay for another user answers OFFLINE_USER_MISMATCH and is kept. The host owns the outbox in IndexedDB (per database and user), applies a queued change to the local copy at once (rollback when the server refuses it), swaps a temporary id (vzm-tmp:...) for the real one when its create replays, syncs on start, on reconnect, on app open and every 15 minutes, hides a copy older than 7 days, and removes the rows (never the queue) on logout or a change of user. Vizum.offline gains datasets, sync, query, readGroup, create, write, conflicts, resolve. A vApp that declares offline sets min_platform 5.80; every actions[].model must be granted or owned. sdk-next/docs/offline-data.md.
5.79
Vizum.identity.confirm: step-up authentication. A vApp asks the HOST to re-confirm the user; the host shows its own dialog (dimming the whole desktop), the user types the account password there and it goes over a dedicated route to the closed model vizum.app.stepup, never through the bridge or to the iframe. The app gets a single-use token (120 s, bound to the user, app, action and session) and spends it in server code with V.identity.consume(token, action), which returns a receipt (id, user, action, meaning, reason, confirmed_at). Not on Solo (UNSUPPORTED_CALL); a device page gets PERMISSION_DENIED. A vApp that names V.identity sets min_platform 5.79 (the install gate refuses it below). sdk-next/docs/identity-step-up.md.
5.78
group_ops. An orm grant may carry group_ops: [{groups, ops}], extra ops (create, write, unlink, call:<method>) for the CALLING user who is in at least one of the groups: "@name" (the app's own native.groups), "<app-key>/@name" (an app in requires) or a real xmlid, never an administrative group. They are computed in _orm_rule, so api_orm, call:, actions and read_group all honour them, and Solo's access group is minted from ops only: a UI app can keep ops read-only for every Solo user and give the owner app's administrators the writes. An op is in ops or in group_ops, not both; moving an op from ops into group_ops narrows (no re-approval), adding one widens. A vApp that uses it sets min_platform 5.78 (the validator and vzm check refuse it below).
5.77
V.tr. The script-hook (trigger guards included), job and native-method sandboxes get V.tr(source, *args, lang=None, **kwargs): the method-side tr() on the app's own i18n catalogue (5.73), interpolated after the lookup, never raising for a missing or malformed translation. Language: lang= when installed, else the call's (a hook: the user whose write fired it), else the language of the user the code runs as; a job runs in its job user's language. A vApp that names V.tr sets min_platform 5.77 (the install gate refuses it below).
5.76
Vizum.app.key. The host already sent the app's key in vizum:ready (app) and the SDK dropped it, so vApps parsed it out of the run URL, which on a multi-database host reads "@<db>" (/vizum/apps/run/@<db>/<key>/...). The SDK now exposes it; vzm check and the store gate warn on code that parses the key from the URL. No server behaviour changes.
5.75
V.sha256. The script-hook, job and native-method sandboxes get V.sha256(data, encoding=None): the SHA-256 hex of a str (UTF-8), bytes or base64 str, at most 32 MiB a call and 256 MiB / 2000 calls a run, charged before the input is decoded. It is an integrity digest, not a signature (package signing is the authenticity check). hashlib stays in the method sandbox for now. A vApp that names V.sha256 sets min_platform 5.75 (the install gate refuses it below).
5.74
NETWORK REACH AND IMAGE FETCH. permissions.net may hold "*": any PUBLIC website (never a private, loopback, link-local, metadata or tunnel address; every hop of a redirect is checked again, the connection is pinned to the address that was verified, and only a short header allowlist is sent), download-only; V.net.fetch_image / Vizum.net.fetchImage fetch ONE image server-side, with a size cap and a deadline for the whole fetch. A host the manifest NAMES reaches ports 80 and 443 only unless the entry says host:port (an app below min_platform 5.74 keeps the old behaviour and is warned). A vApp that uses any of it sets min_platform 5.74.
5.73
SERVER-SIDE TRANSLATIONS. The i18n/<lang>.json files the browser SDK reads also answer tr() in the app's model methods: the install stores them on vizum.app.i18n_catalog (models/vapp_i18n.py), tr() reads the language of the call (context.lang through the bridge, an installed language is just lang; one the instance lacks is kept as vizum_req_lang next to the lang the ORM kept, so the ORM keeps working and a nested with_context(lang=X) still wins), tries es_419 -> es -> msgid and keeps the % formatting. The instance, the Store upload and vzm check refuse a catalogue whose placeholders differ from the msgid's, with markup, or over 512 KB (2 MB together); an update that changes only translations asks no approval and is audited (i18n:update). A vApp that uses it sets min_platform 5.73.
5.72
RESUMABLE CHUNKED UPLOADS. A dropped connection no longer restarts a big Vizum.files.putBlob from zero: POST /vizum/apps/files/<key>/upload/status?token answers {received, next_part, size, sha256, expires_at} behind the same user / app / expiry gate as the other upload routes; the host keeps the token on a network error, sends the same part again with backoff, asks status and goes on from next_part, and when it gives up it aborts explicitly (an abort that cannot reach the server is retried when the network returns) so the quota reservation and the upload slot are freed. Nothing changes for an app: the same Vizum.files.putBlob call. A vApp that wants the guarantee sets min_platform 5.72.
5.71
LARGE APP FILES. Vizum.files.putBlob above 32 MB uploads in parts (begin / part / commit / abort: models/vizum_app_upload.py, the platform reserves the declared size against every quota and verifies size + sha256 before the file reaches the filestore); Vizum.files.getBlob and getRange stream a file out with Range support. permissions.file_limits.max_mb may go up to 512 MB, but a file above 200 MB needs its own administrator approval (permissions.large_files, synthesized) and an app storage cap; the single-request routes (Vizum.files.put, putBlob up to 32 MB, orm.writeBlob) stay at 200 MB. Duplicate app files that an old race left behind are renamed (never deleted) by a one-time migration. A vApp that uses any of it sets min_platform 5.71.
5.70
PAIRED DEVICES AND LARGE FILES. A route may be auth: "device": a screen, kiosk or time clock with no Odoo login pairs with an 8-digit PIN the platform shows and an administrator claims on the desktop; it then holds a hashed, revocable token and reaches only the app's device pages, json and file routes (controllers/device.py, device-routes.md); a device page plays YouTube / Vimeo over the app (Vizum.device.embed, framed by the page, only the providers of device.embeds) and keeps an offline cache (device_sw.js, device_host.js); Vizum.pairedDevices manages the devices from the desktop. permissions.file_limits raises Vizum.files per app (up to 200 MB a file) with a streamed upload β Vizum.files.putBlob, Vizum.orm.writeBlob. A vApp that uses any of it sets min_platform 5.70.
5.69
UPDATES THAT RE-ASK, VERSIONED CONTRACTS, VERIFIED CALLERS, INSTANCE TELEMETRY. An update that changes server code (script hooks and library, model methods, routes, jobs, webhooks, fields with server logic, constraints, automations, native migrations, the evaluated parts of native.ui and manifest actions) sends the app back to needs_approval like a wider grant, and api_update_preflight names each permission and code change (lifecycle.md); Vizum.backend task rows carry status β retrying, expired, dead_letter (reliability.md); provides may name a contract version (name@x.y.z) and requires a contract in a version range (composition.md); a method another app calls through its call: grant reads that app's key with caller_app(), verified by the platform (server-side.md); the instance reports a daily technical profile (edition, active-user band, installed vApps) from 2026-11-04 per Terms 15.4 v1.2, and an administrator can switch the upload off. A vApp that uses versioned contracts, caller_app() or task status sets min_platform 5.69.
5.68
A MODULE THE SIZE OF ELEV8DM AS A vAPP (its 1766 tests run on the vApp; plan 2026-10-02-plataforma-5.68-elev8dm). In the sandbox: the module's own classes as objects (new_class: public attributes, the program's own methods, a closed list of dunders), its pure modules as the app's library (up to 1 MB / 32 parts with methods; a program of 3 MB / 300k nodes), fields_of(model), with savepoint(): (the only with), base_url(), sign / consteq / verify_legacy (the module's signed links, only for an app that replaces it), format_text, a governed PIL (Image, ImageOps), hashlib, uuid, binascii, pg_errors, clock (time/monotonic), lock_read / fetched_row, with_user / with_env, and fields_get / check_access / has_access on the app's own models; self.env.cr / execute / registry / pool stay unreachable (refused at upload by the platform and the store). An app's methods share one program per process, as a module does. Models: unique entries {fields, nulls_not_distinct, name} (empty values collide; the index named <table>_<name>, as Odoo names the module's constraint), check entries {name, field, op, value} (a PostgreSQL CHECK <table>_<name>), a many2many pair (one relation from two own models), default_get and invalidation by an alias name, order, readonly on any field; native.replaces_min_version (adopt only from a module at its last migration); native.routes kind "file" (a binary behind a link the platform signs: route_file_url / route_file_token / route_file_verify); native.records match (a seeded row whose xmlid is gone is anchored again by its unique fields); the app's groups under a privilege/category named after it; native.adopt on real data: the rows are copied as data, not as events (the app's create/write overrides stay out, its constraints run once over the adopted rows), attachment binaries are shared by reference (no byte read), refs ({id_field, model_field | model_by}) and links to ir.model / ir.model.fields follow the adopted rows; an UPDATE whose requires names an app that is missing or out of range is refused, the installed version stays. A vApp that uses any of it sets min_platform 5.68.
5.67
THE PLATFORM CONTRACT AND SDK RELIABILITY (SDK plan P0βP4). The platform contract reaches apps (Vizum.platform, Vizum.sdk, Vizum.features(), Vizum.supports()); every failed call rejects with a VizumError {code, category, retryable, correlationId}, and Vizum.errors.is / normalize / catalog read them; every call carries a request id; orm.create, actions.run, backend.enqueue and offline.run take an idempotency key, so a retry or an offline replay runs once; the vApp bus no longer fans out across users and apps; packages can be signed (vzm keygen / pack --sign, package-signing.md) and every install verifies them, refusing a modified package; the store can revoke a version and every instance disables it; requires takes version ranges and contracts, Vizum.apps.status() reports them and a required app is not uninstalled by accident (composition.md); an app declares event_contracts and the desktop refuses an emit that breaks them, and Vizum.events.catalog() lists them (events.md); Vizum.apps.health() and api_update_preflight say how an app runs and what an update would change before it is installed (lifecycle.md); the CLI gains a platform emulator (vzm test, vzm dev), vzm diff and agent-ready scaffolds (emulator.md, cli.md); inbound webhooks get a delivery ledger with replay protection, ack/fail (from the browser or a job: V.webhooks) and a dead letter, the bridge a per-app rate limit and metrics, and Vizum.errors.retry (reliability.md); an AI tool declares its risk and side effects, and every host confirms an update or destructive one with the user before the app is reached (ai-tools.md); files carry a sha256, put refuses damaged or disguised bytes and public links expire (files.md); an administrator can give each app an AI-token, network and storage budget (QUOTA_EXCEEDED, reliability.md); a manifest action declares retry and timeout_minutes for its backend tasks (reliability.md). See platform-contract.md. A vApp that uses any of it sets min_platform 5.67.
5.66
A WHOLE ODOO MODULE AS A vAPP (psi_dsd β PSI DSD Core, its 818 tests green on the vApp). native.methods: Python model methods run in the sandbox and installed as real methods of the Odoo class β self/env[...] are governed recordsets (the app's orm grants, ACLs and rules; sudo() lifts ACLs, never the grants), overrides of Odoo methods call super_() (privileged permissions.methods), private helpers are p_x, another app calls them through its own call: grant; computes, defaults, constraints and jobs by method; permissions.private_calls / params / peer_data. native.aliases: the module's field names are real (related) fields of the ORM. native.records / rules / ui (views, actions, menus, reports, the vzm_geo_map widget) / groups.implies. native.replaces: while the named module is installed the app waits (no alias, override, constraint, backend or job on Odoo's models) and adopts β its rows, its columns on Odoo's models, its groups' members (groups[].adopt), and the chatter of each adopted row when the module is uninstalled; then the app goes live by itself. A vApp that uses any of it sets min_platform 5.66.
5.65
Vizum.team COVERS WHAT A USER-PROVISIONING MODULE DID (united_insurance_team). team.whoami() -> {uid, manager, role}; member rows carry phone, last_login, self, role_label and admin (an administrative account holding a role is listed, still untouchable); save takes a phone, reactivates a member the app manages and refuses to change the caller's own role. A role may let its own groups into other apps of the same vendor (role apps: [{key, solo?}], each listed in the privileged permissions.team_apps; restricted desktop access and, with solo, the phone launcher; never removed). native.team.adopt_existing "dedicated" (privileged permissions.team_adopt) takes over, on install/update, the accounts that already play the app's roles and hold nothing else, so its managers can reset their password; an account with any other group keeps only "give or take the role". A vApp that uses any of it sets min_platform 5.65.
5.64
A vAPP CAN TAKE OVER THE DATA OF THE MODULE IT REPLACES. native.adopt: [{model, from, fields: {own field: module field}}] copies every row of an installed module's model into one of the app's own models, once (vizum.app.adopt.map), on each install/update while that model exists: rows first, then the links between adopted models translated through the map (links to shared models keep their ids). Each from must be listed in the privileged grant permissions.adopt (a new one widens); platform, identity and security models and secret fields are refused. The customer uninstalls the module afterwards. A vApp that adopts sets min_platform 5.64.
5.63
THE LAST FIELD SHAPES A MODULE USES. translate: true on a char/text/html field (one value per language, as translate=True in a module); type "json" (fields.Json); a one2many from an own model to a SHARED model whose inverse is a many2one column the app adds to that model in native.fields (created after it, in _apply_shared_one2many); up to 80 own models per app (was 50). Also fixed: an app that added to res.partner a many2one pointing at its own model could not be uninstalled (Odoo reflects the column on res.users as a 'base' field, and dropping the model refused it): such a column is now dropped before the model. A vApp that uses translate, json, a one2many to a shared model or more than 50 models sets min_platform 5.63.
5.62
A SANDBOX FOR REAL LOGIC, WITH A CLOCK. Script hooks may define top-level functions (def, no decorators, no nesting β so no closures) and use while; the app may share code between its hooks in native.script.lib ({name: code}, vetted the same way, run before each hook). In exchange every run has a TIME BUDGET β 30 s, 120 s for a scheduled job β enforced by a check the platform injects into every loop and function body (no signals or threads); a run that exhausts it stops with a clear error. On a 5.61 build a def or a while is refused at install, so a vApp that uses them (or lib) sets min_platform 5.62.
5.61
NATIVE DATA ACROSS VERSIONS. native.migrations: [{to, ops}] run by reconcile on the update that crosses to (vizum.app.native_schema_version remembers the version last reconciled; a first install migrates nothing): rename_field (the column and its data keep living under the new name; the app's views are dropped first and rebuilt), copy (old column into an empty new one, both existing), hook (a script hook of the app, run as its job user with installing=True). Order: renames, new schema, data ops, then the stale columns go. native.seed: [{model, key, rows | file (a .json in the bundle), update?}] upserted by the natural key on every install/update, references {"$ref": "<model>:<key>"} to rows seeded earlier. Uninstall deletes the data, as a native module's uninstall does. A vApp that declares either sets min_platform 5.61.
5.60
Vizum.team: A MANAGER OF THE APP CREATES AND EDITS THE USERS WHO PLAY ITS ROLES. The manifest declares native.team {managers: [group refs], roles: [{name, label, help, groups}]} under the privileged grant permissions.team; api_team (vizum_app_team.py) offers roles / members / save / set_active / remove / set_password / invite. Only a manager (or a settings admin) calls it; a role gives exactly its approved groups and never one that is or implies an administrative group (checked on the implied closure at run time); administrative accounts are never touched; a user the app's team did not create (vizum.app.team.member) can only gain or lose the app's roles. It replaces the per-customer Odoo module apps needed for this (united_insurance_team, psi_dsd's manager_provision_user). A vApp that calls Vizum.team sets min_platform 5.60.
5.59
A vAPP CAN DEFEND A FIELD OF ITS OWN MODELS (closes the 2026-09-01 field-ceiling concern). An own-model field may declare writers (["@server", "@<declared group>", "<group xmlid>"]), server_only: true (= ["@server"]) or computed_by: "<hook>" (a trigger hook of the app on that model computes it; = ["@server"]). The ORM refuses a create/write that sets it unless the caller is a writer β one gate for the iframe, the invoked hooks, the backend and RPC (vizum.app.native.runtime on base). "@server" is the app's SERVICE USER β an internal user with no password, created when the app declares a server writer (or native.service_user), member of base.group_user and of the groups its models grant, made its job user (triggers and jobs run as it), archived at uninstall β or Python running as the superuser/sudo, as for a native module's field only its code writes. Administrators are writers only when listed. Apps that declare none of this keep their job user unchanged. min_platform 5.59.
5.58
WHAT A MODULE'S PYTHON GIVES ITS MODELS, DECLARED. On the app's own models: default (a literal of the field's type, or {"dynamic": today|now|user|company}) and sequence ({prefix, padding}: numbered on create from the app's own ir.sequence), both applied by the platform runtime (vizum.app.native.runtime extends default_get and create on base, acting only on a READY app's x_vzm_ models, from a cached map of the manifests); unique (real PostgreSQL unique indexes, vizum.app.native.unique, refused at install when rows collide); chatter (mail thread + activities, only ever turned on) with tracking on fields. Computed fields on own models: formula (manifest names, dependencies derived), related (from one of the model's many2one) and aggregate ({of: <one2many>, op: sum|count|min|max|avg, field}) β the totals of a document β compiled into platform-owned compute bodies, created in dependency order (Odoo checks a compute's path against the registry at create time). They hold for the vApp, the backend and RPC. A 5.57 build refuses or ignores every one of these keys, so a vApp that uses any of them sets min_platform 5.58.
5.57
A vAPP CAN MODEL A DOCUMENT WITH LINES. On the app's own native models: a one2many names its inverse (a many2one declared on another of the app's own models, pointing back) and compiles to Odoo's relation_field β before, the validator accepted a one2many the compiler could not install. A many2one takes ondelete ("set null", the default; "cascade"; "restrict"), enforced by PostgreSQL's foreign key, and any field but an x2many or a boolean takes required (a required many2one cannot "set null"). A one2many may also sit on a SHARED model to list the app's rows (res.partner -> its orders); there required and cascade are refused β they would act on records other modules own. A version may add, change or drop them: the compiler drops a one2many before its inverse (Odoo refuses the reverse outside a module uninstall), a dropped ondelete returns the field to "set null", and a one2many is never archived (it has no column). The bridge's relational guard already governs the nested commands. On a 5.56 build a one2many dies at install on Odoo's own error and ondelete/required are silently ignored β so a vApp that uses any of them sets min_platform 5.57.
5.56
A HOOK CAN GUARD A DELETION. native.script.hooks[].trigger.on accepts "unlink" (with enforce): a base.automation on_unlink whose action calls the same dispatcher, run BEFORE the deletion β a hook that raises (V.fail) aborts it, for every ORM unlink: the backend, sudo, crons, the app's own Vizum.orm.unlink. Payload: event "unlink", changed [], old = every stored content field as the row is before the deletion (same ceilings), new {}. Every trigger payload gains installing (true while the app's own install/update runs its migrations; the guard still runs and decides). Uninstall is unaffected: the automation is torn down before the model, whose table is dropped, not unlinked. NOT seen by any trigger: deletions PostgreSQL performs itself β a set-null or cascade many2one (every vApp many2one is set null), many2many link rows β so guard the record actually deleted. A vApp that declares on: "unlink" sets min_platform 5.56.
5.55
PUBLIC ROUTE PAGES. A route may declare auth: "public" β a PAGE only (a public json route does not exist), with no groups β at /app/<slug>/<route>, open to anyone, signed in or not, of the company it is served in (the website's company when website is installed, else the public user's; an app of another company answers the identical 404). The page carries the bundle credential and nothing else: NO caps and NO route run-token, so the bridge refuses every call from its iframe; an anonymous visitor's page is Cache-Control: public, max-age=300. Public page names surface as permissions.public_routes, a separate privileged grant (widening β needs_approval) shown on its own approval card with each full URL. Optional website_menu ({text <= 40, sequence}) becomes a website.menu entry per website of the app's company (all websites without one), a recorded website_menu artifact removed when the app leaves ready, drops the route, or is uninstalled; skipped without website. Optional sitemap: true lists the page in the website sitemap (part of the approved descriptor). A vApp that declares a public route sets min_platform 5.55.
5.54
A TRIGGER KNOWS WHO WROTE, WHO OWNS, AND WHAT THE WRITE LEFT. The hook payload gains uid (env.uid of the write β 1 when server code writes as the superuser), owner (the row's create_uid) and new (post-image of the written fields; every stored content field on a create), read with the platform's rights like old and under old's ceilings: the app's read grant, never a field the app hides from the hook identity (VZ-SEC-23), binaries as their size, text over TRIGGER_TEXT_CAP as its length (also in old now). A guard runs as the job user, whose record rules may hide the row it guards; before 5.54 "only the owner may share this" could not be enforced server-side (Vizum Sheets). A hook that reads uid/owner/new sets min_platform 5.54.
5.53
ROUTES A vAPP DECLARES (native.routes), portal and internal only. The platform serves GET /app/<slug>/<route>[/<sub>] (a platform page embedding the app's sandboxed iframe, with the route as its intent β Vizum.route) and POST /app/<slug>/<route> (a json route: CSRF, body <= 64 KB, rate-limited per user/app/route, audited, the declared hook run AS THE VISITOR), plus the GET alias /my/<slug>/β¦ for a portal page and an optional card on the portal home. A route is served only while its exact descriptor is in the approved snapshot (permissions.routes; declaring or editing one needs approval, and the app needs install_scope admin). A portal (share) user reaches the bridge ONLY through the route run-token the dispatcher mints for that app, route and user β and only api_session (minimal) / api_orm / api_run_hook (the route's json hooks, with that route's rate limit, size cap and audit) / api_watch_hash / api_eval_domain / api_route_token_refresh, within the app's grant bounded by the visitor's own rules. auth: public and website_menu are refused by the validator. A vApp that declares routes sets min_platform 5.53.
5.52
Vizum.assistant.kbSearch(query, {catalogs, topK}): passages from the Knowledge (RAG) catalogs for a vApp holding permissions.assistant β the Assistant's own retrieval (hybrid, rerank, CRAG), run as the CALLING user (their scope and per-reader record filter, never the job user), catalogs the user cannot read refused by name, bounded (query 2000 chars, 20 catalogs, top-k 8, 1500 chars per passage) and traced in Knowledge β Trazas as vapp:<key>. A vApp that reads Knowledge this way (Vapp Creator) sets min_platform 5.52.
5.51
AN EXPLICIT COMPANY ON A company_scoped NATIVE MODEL IS HONOURED, AND VETTED ON WRITE. A create used to replace an explicit x_company_id with env.company, and a write was not checked at all (Odoo re-checks record rules after a create, only before a write), so an app could move a row into a company its user is not in, or make it global. Now an explicit x_company_id β on create, write, a nested line update or a copy default β must be one of env.companies (the browser's active companies; the job user's own companies for a hook), else ValidationError; False on a write is refused; a create naming none is still stamped. A vApp that files rows under another of its user's companies without re-writing x_company_id after the create sets min_platform 5.51.
5.50
MENUS HIDDEN PER USER OR PER GROUP. vizum.menu.hide (menu, users, groups, hidden until, why, app_key) is subtracted from ir.ui.menu._visible_menu_ids β the result Odoo's menu service, the command palette and the Vizum desktop all read β with its submenus. Not an ir.rule: Odoo caches visible menus by GROUP SET, so a per-user rule leaks one user's hiding to everyone with the same groups. Administrators are never subject to it, Settings and Apps cannot be hidden, an empty table costs no query, and uninstalling the vApp that created rows (app_key) deletes them. Admin-only ACL. A vApp that manages hidden menus (Hide Menu) sets min_platform 5.50.
5.49
AN APP'S OWN NATIVE MODELS NEED NO ORM GRANT IN THE BROWSER EITHER. The server (_orm_rule) always granted an app its own native models, and the native fields it added to shared models, without permissions.orm; the browser gate of orm/mail/activity/meta still demanded a non-empty permissions.orm, so a vApp whose only data was its own model got PermissionDenied. The host caps now carry a server-computed orm_native (_client_caps). A vApp that calls its own models without declaring an orm grant sets min_platform 5.49.
5.48
MAP TILES THROUGH THE PLATFORM. OpenStreetMap answers a tile request with no Referer with its "Access blocked" PNG, and a vApp iframe (opaque origin) never sends one. The platform now serves /vizum/tiles/<key>/<token>/<exp>/<z>/<x>/<y>.png (the iframe's run capability is the credential β never an open proxy) and /vizum/tiles/<z>/<x>/<y>.png for a signed-in user: it fetches from OSM identified as the tile usage policy asks, caches on disk for at least 7 days, never caches a refusal, and serves with CORS. The SDK exposes Vizum.map.tileUrl(). A vApp that uses it sets min_platform 5.48.
5.47
WORKFLOW CONTEXT KEYS. api_orm (and V.orm in hooks) accepts, besides lang, the two context keys Odoo's web client itself sends to finish a PARTIAL transfer: skip_backorder and picking_ids_not_to_backorder on stock.picking.button_validate β typed, ids restricted to the call's own (ORM_WORKFLOW_CONTEXT). A 5.46 build refuses them, so a vApp that validates partial transfers in-app (with or without a backorder) gates on 5.47 or hands the transfer to the Odoo form.
5.46
RETIRING A COMPUTED FIELD FREEZES IT INSTEAD OF BREAKING RECOMPUTES. A vApp version that stops declaring a formula field used to archive it by rename (<name>__archived_<id>) with its compute still writing the old name: every recompute of its dependencies raised KeyError(<old name>) β on res.partner no contact could be renamed and no user created (measured on dev with CRM Booster 2.1.0). From 5.46 the archive clears compute/depends first and keeps the stored data, and the 5.46 upgrade repairs fields archived before it. A vApp whose new version retires a computed field sets min_platform 5.46, so the installer refuses the update on an instance without the fix.
5.45
THE PGVECTOR GUIDE CAN FINISH THE JOB. Before it, nothing a vApp could call set the RAG storage mode to pgvector: the "RAG pgvector Setup" guide sent the admin to Managed AI, and Managed AI answered "enable it from the guide". vector.pgvActivate({active, force?}) (admin) switches Mode 3 on β refusing when the infrastructure is missing, when stored vectors are not yet in pgvector (they would drop out of search), or out of managed storage β and back to Local. vector.pgvIndex(opts) exposes the existing per-dimension hnsw op, and pgvStatus adds per-dimension counts and the table size for administrators.
5.44
RAG THAT OBEYS ODOO, AND A DOCUMENT REPLACED IN ONE PIECE. (1) vector.search / dump / indexed_docs and rag.list_chunks withhold every passage derived from an Odoo record the CALLER cannot read under record rules, or whose id now names another record (measured: a global sale.order job served order text to a user with no sales access). (2) vector.delete takes {doc_id, keep_gen} (commit a rewrite: retire every chunk of the doc not of that meta.gen) and {doc_id, gen} (roll a failed rewrite back) β a 5.43 build ignores both keys and deletes the WHOLE document, so a vApp that writes generations gates on 5.44. Also: rag jobs prune records deleted at the source, rag.update changes only the keys it is sent (and keeps the owner), and Ollama chat sends num_ctx + think:false.
5.43
COMPARE-AND-SET ON A NATIVE MODEL: orm.write(model, ids, vals, {expect}) in the JS SDK and V.orm.write(model, ids, vals, expect=β¦) in the script runtime. The rows are locked (SELECT β¦ FOR UPDATE), re-read, and written only if every expected field still holds its value; the call then resolves to {updated, current} instead of Odoo's bare True. Vizum.db has had this since 5.39 β but ONLY for an app's own tables (api_update), so a native Odoo model had nothing and the only way to CLAIM a record was read-then-write. Two clients that read the same value both wrote it: two approvers of one Agent Center proposal both read pending, both wrote executing, both executed. The same shape reaches money β two registers claiming one IoT print job, two terminals redeeming one gift card. A 5.42 build refuses expect as an unsupported keyword (_orm_bind_args fails closed), so a vApp that relies on it gates on 5.43 rather than silently losing the guarantee.
5.42
vizum.migration.run.action_discover(): the migration plan and the sourceβtarget field map are built SERVER-SIDE from both live schemas (models/migration_engine/plan.py), and the run exposes discovery_json / include_master / include_vapps / extra_models. Until 5.42 the plan could only be built by the Migration Assistant's own browser code, from the destination's schema alone β a 5.41 build has no action_discover and the call raises, so a vApp that plans this way gates on 5.42.
5.41
db.share(table, id, {users: ["job_user", β¦]}): the literal "job_user" resolves server-side to the identity the app's jobs/hooks run as (job_user_id, default installer; never superuser/archived/portal). Lets a row reach the app's automations without being public (Agent Center memory, audit P4). A 5.40 build does int("job_user") and raises, so a vApp that shares this way gates on 5.41.
5.40
V.vector.search(namespace, query, opts) / V.vector.namespaces() in server scripts (hooks and jobs): READ-ONLY, through the same api_vector the iframe uses, gated by permissions.vector, metered on its own per-run budget (20). Lets a scheduled agent consult the knowledge base (Agent Center audit P2). A 5.39 script has no V.vector and fails with AttributeError, so a vApp whose hook uses it gates on 5.40.
5.39
Vizum.db compare-and-set: db.update(table, id, values, {expect: {field: value}}) writes ONLY if the row still holds every expected value, under a row lock, and the result says _updated: true|false. It is what lets two approvers race for the same proposal and have exactly one win (Agent Center audit P1, docs/audits/2026-09-25-agentcenter-rule-reach.md). A 5.38 build drops the fourth argument and writes unconditionally, so a vApp that relies on it gates on 5.39.
5.38
Vizum Instances pass: Vizum.instances.pass() -> {token, expiresAt} behind the new boolean grant permissions.instances_api (privileged; gaining it widens). The server asks the Vizum Store (/store/api/v1/instance/assertion) with this database's instance token β which never reaches the iframe β for a 5-minute pass signed with the Store's Ed25519 key: database, Vizum account, acting user by Odoo email. The Vizum Instances API verifies it with the Store's public key, so no key is pasted by hand into each Odoo (decision 2026-09-24). A 5.37 build has no instances namespace: the call is refused, so the vApp gates on 5.38.
5.37
Report Builder 1.0 (native visual editing of Odoo reports, Β§3.3 of its spec): four small platform changes, all four lines. reports.html(name, ids) -> {html} β the host fetches /report/html/... and inlines same-origin CSS, because the app iframe is an opaque origin that cannot fetch either itself. permissions.reports accepts "<prefix>.*" and "*" (Report Builder's templates are dynamic: vzm_reportbuilder.template_<id>; an exact-name-only allowlist would force re-approving the manifest per saved template) β "*" is privileged. api_orm kwargs.context now accepts ONLY {"lang": "xx"} (reading an arch without translating it); any other key is refused. _apply_bindings skips (log, don't fail) a binding whose model this base does not have installed, and reconcile() retries it on the next version change.
5.36
native.bindings: a manifest may bind the app to the Action menu of a NATIVE model (native.bindings, one entry per {name, model, label, groups}, gated by the native_bindings grant). The platform compiles one ir.actions.server per binding whose only code calls the trusted dispatcher vizum.app._open_binding, which opens the app with the selected records as Vizum.intent {binding, model, ids}. min_platform 5.36: an older build has no whitelist under native, so bindings is silently ignored β the app installs and the Action-menu entry never appears, which is the silent no-op that makes it a capability. 2026-09-12, still 5.36: a binding may add target: "window" | "modal" (modal = ActionDialog over the record). NOT a bump on purpose: _valid_native_bindings ignores unknown keys, so a 5.36 build installs a manifest that declares target and opens it in a window β a graceful degradation the author can see, not a silent no-op.
5.35
assistant.send accepts the CALLING APP's model / reasoning effort / knowledge base ({model, effort, namespace}). vizum.assistant.pref is per-USER, so before this a vertical vapp's administrator could not fix the AI behaviour for their crew: two technicians in the same app got different models. All three are REQUESTS β the server still caps effort at the user's vizum.ai.policy ceiling and filters the model through it, so an app can never exceed the policy. The forced namespace IS a hard filter over what the model asks for. min_platform 5.35: on an older build the three fields are silently dropped and the app's setting reaches nobody β a silent no-op, which is why it is a capability and not a soft add.
5.34
an AI call may carry ref, an opaque attribution tag naming which of the app's OWN internal consumers spent the tokens (Agent Center names the agent). The host pops it before the provider ever sees it and records it on the same audit row as the charge, so Managed AI can break an app's spend down one level with nothing to reconcile. An app that sends ref MUST declare min_platform 5.34: on an older build the field is not recognised as the host's and is forwarded into the provider request, which rejects the unknown field β so the app installs happily and every AI call fails.
5.33
V.db β the app's own Vizum.db TABLES reached from a server-side script. The store was browser-only: V wired orm/net/oauth/files/ai/data/json, so a SCHEDULED job could not read the very tables its app's UI writes. An app whose cron acts on app-owned state (an automation registry, a retry queue, a schedule) therefore had to mirror that state into a V.data collection and hand-sync two stores for one fact. V.db routes to api_db, the same permission-gated dispatcher the iframe uses, so the manifest schema, the declared rules and indexes, the admin-authored write gate and the acting-user row scoping all still apply. An app whose job reads its own tables MUST declare min_platform 5.33 β on an older build V.db is simply absent and the hook dies with a NameError on its first tick, having installed happily.
5.32
enforcing, delta-aware script triggers. A trigger hook may declare trigger.enforce, which makes the platform REFUSE the triggering write when the hook cannot run instead of proceeding unguarded; and every trigger hook's rec now carries the write's own change set (event, changed, old). Both are load-bearing for a guard: without enforce an archived job user silently disarms it, and without changed it can only assert properties of the post-image, every one of which the caller can satisfy by writing more fields in the same call. An app that relies on either MUST declare min_platform 5.32 β on an older build enforce is ignored and changed is absent, so the guard would install and then quietly not guard.
5.29
native.groups: the schema compiler creates real res.groups for a manifest's declared roles (stable xmlid), so access/rules can reference "@name" instead of every vApp with roles needing a hand-written Odoo addon just to get a group to bind to.
5.28
V.commit() in the JOB runtime + one savepoint/commit PER APP in the job cron. A job POSTs to Instagram (irreversible) and then writes remote_id (undoable). They used to share ONE transaction spanning every job of every app, so any later failure rolled the bookkeeping back while the post stayed published β and the next tick posted it AGAIN.
5.27
Vizum.files.publicUrl β an unguessable but PUBLIC https link to one of the app's own files (permissions.files_public). Instagram has NO byte upload: you hand Meta a URL and Meta downloads it. Without this a social vApp can generate an image and then has no way to put it on the post. revokePublic kills the link once the network has taken it.
5.26
the SOCIAL OAuth rail: shared (brand) connections usable by every user AND by the unattended jobs (permissions.oauth_shared + V.oauth in the script runtime), many accounts per provider (account_ref), PKCE + provider client-auth styles (X, TikTok), Meta's long-lived page-token dance, and a refresh cron so a token cannot lapse overnight and kill the 09:00 post.
5.25
Vizum.ai.image β image GENERATION for vapps (managed mode only). Metered PER IMAGE, not per token, so it needs its own permissions.ai_image on top of permissions.ai.
5.24
Vizum.devices.location β HOST-mediated one-shot GPS fix (devices:["location"]). navigator.geolocation is auto-denied inside the opaque-origin iframe (same reason the mic is host-mediated), so the host asks on its real origin and only {lat,lng,accuracy,ts} crosses.
5.21
Vizum.ui.openUrl β the host opens an external http(s) URL in a new tab. A sandboxed, opaque-origin iframe CANNOT window.open (it returns null and nothing happens), so any vapp that sends the user to Stripe, an OAuth consent screen or a doc link needs this. All shipped.
5.20
server-side vApp code: a manifest jobs[].script runs a native.script.hooks entry as job_user_id, with V.net / V.ai / V.data / V.json (and, from 5.33, V.db) and the time helpers (V.now_odoo, V.add_minutes, V.minutes_between, V.clock). An app that must answer a webhook with nobody's browser open declares min_platform 5.20 β on an older platform its job would install happily and then silently never run.
5.5
Vizum.audio (transcription β local Whisper default / managed metered-per-minute gateway).
5.4
Vizum.ocr (OCR β local Tesseract default / managed metered-per-page gateway)
5.3
AI tools β manifest ai_tools + Vizum.ai.tools.handle (client-handled) + Vizum.ui.openApp(key, payload)
5.2
Vizum.docs (raw-document store for RAG re-indexing) + Vizum.vector.pgv* (Mode-3 local-pgvector setup)
5.1
Vizum.domain (Visual Domain Builder)
5.0
Vizum.vector / Vizum.embed / Vizum.sessions
Concepts
The platform contract β versions, feature detection, stability and errors GENERATED
Everything a vApp can rely on β which API exists, what it needs, since which capability, how stable it is and how it fails β comes from ONE machine-readable file, vizum-platform-contract.json (served at /vizum_apps_platform/static/sdk/vizum-platform-contract.json and shipped inside @vizum/vapp-cli), which the platform, the SDK, the vzm CLI and this page are all built from. A coding agent should read that file (or run vzm context) rather than this page.
Contract 1 Β· capability 5.86 Β· SDK 5.86.0 Β· api version 1 Β· manifest schema 1.
Using it
await Vizum.connect();
// Which platform, which host β informational; never branch on `build`.
console.log(Vizum.platform.capability, Vizum.platform.host, Vizum.sdk.version);
// Feature-detect before using something newer than your min_platform, or that only
// some hosts serve. On a platform older than 5.67 supports() itself is absent.
const can = (n) => typeof Vizum.supports === "function" && Vizum.supports(n);
if (can("realtime")) { Vizum.realtime.channel("board").on("changed", reload); }
else { Vizum.refresh.on(reload); } // Solo has no realtime receive
// Branch on the error's code or category, never on its text.
try {
await Vizum.orm.write("x_vzm_board", [id], vals, { expect: { x_rev: rev } });
} catch (e) {
if (Vizum.errors.is(e, "CONFLICT")) { await reloadAndMerge(); }
else if (e.retryable) { scheduleRetry(); } // network, timeout, rate limit
else { Vizum.ui.notify(e.message, "danger"); Vizum.errors.capture(e); }
}
Six versions, six meanings
| Version | What it is | Read it from |
|---|---|---|
| capability | The feature level of the platform (PLATFORM_CAPABILITY). Identical on every maintained Odoo line. A manifest's min_platform is compared with it at install; the installer refuses an app that needs more. | Vizum.platform.capability (also Vizum.platform.version and Vizum.capabilities().platform) |
| platform_build | The build that carries the capability on one Odoo line: the installed module version (e.g. 19.0.10.150.30) and the Odoo major. Informational: never gate on it. | Vizum.platform.build {module, odoo} |
| sdk | The version of the in-iframe runtime SDK (static/sdk/vizum-sdk.js) served by the platform: <capability>.<patch>. | Vizum.sdk.version |
| api | The major version of the app-facing contract: the bridge envelope, the error model, the semantics of existing members. Raised ONLY by a breaking change, which the stability policy forbids inside one api version; a host lists the api versions it serves. | Vizum.sdk.apiVersion, Vizum.platform.apiVersions |
| manifest_schema | The manifest format ("schema": 1 in manifest.json; static/sdk/manifest.schema.json). New optional keys do not change it. | manifest.schema |
| app | The app's own semver (version in manifest.json). Updates compare it; migrations key on it. | manifest.version |
Gate an app on capability (min_platform) or on a feature (Vizum.supports(...)), never on the platform build or the Odoo version.
Stability policy
| Level | Meaning | What may change |
|---|---|---|
stable | Will not change incompatibly inside this api version. Safe for every app. | additive only (new optional arguments, new result keys) |
beta | Feature-complete and supported; its shape may still change in a minor capability, announced in the capability notes. | shape, with notice |
experimental | May change or disappear in any capability. Do not ship a store app that depends on it without a feature check (Vizum.supports). | anything |
deprecated | Still works; has a named replacement and a capability after which it may be removed (removed_after). vzm check warns where it is used. | removal after removed_after, only with an api version bump |
removed | No longer served. Calling it answers UNSUPPORTED_CALL. Kept in the contract so vzm check can name the replacement. | n/a |
Error catalogue
Every failed call rejects with an Error carrying code, category, retryable, correlationId and details (see Vizum.errors). message keeps the text it always had, so code that reads e.message keeps working; new code should test e.code.
| Code | Category | Retryable | Meaning | Was (legacy message) |
|---|---|---|---|---|
PERMISSION_DENIED | permission | no | The app's approved manifest permissions do not cover this call (refused by the host gate before reaching the server). | PermissionDenied |
UNSUPPORTED_CALL | unsupported | no | This platform does not serve that namespace or method (a newer SDK, a removed member, a typo). Check Vizum.supports() first. | UnsupportedCall, Unknown call |
ACCESS_DENIED | authorization | no | The server refused: the user (or the app's grant) may not do this on this record or model. | β |
AUTHENTICATION_FAILED | authentication | no | Credentials were refused. | β |
SESSION_EXPIRED | authentication | no | The Odoo session ended; the user must sign in again. | β |
OFFLINE_USER_MISMATCH | authentication | no | A queued offline operation was recorded for another user than the one now signed in (another tab or another person signed in on the device). The operation is kept, not run: it waits for its own user to sign in again. | β |
VALIDATION_FAILED | validation | no | The arguments or the data are invalid. | β |
BUSINESS_RULE | validation | no | A business rule refused the operation (an Odoo UserError, or V.fail() in a server-side hook). The message is meant for the user. | β |
NOT_FOUND | not_found | no | The record, app or object does not exist (or no longer does). | β |
CONFLICT | conflict | no | The data changed since it was read (compare-and-set expect, collection rev). Re-read, then decide. | rev_conflict |
CONCURRENT_UPDATE | conflict | yes | Two transactions touched the same rows at once; retrying the same call is safe for idempotent operations. | β |
IDEMPOTENCY_IN_PROGRESS | conflict | yes | An operation with the same idempotency key is still running. Retry later with the same key. | β |
IDEMPOTENCY_MISMATCH | validation | no | The idempotency key was already used for a DIFFERENT operation or arguments. | β |
USER_CANCELLED | user_cancelled | no | The user declined a confirmation or closed a picker. | UserDenied, NoFile |
NETWORK_ERROR | network | yes | The request did not reach the server or the answer was lost. Retry only idempotent operations (or pass an idempotency key). | β |
OFFLINE | network | yes | The device is offline. | β |
CONNECTIVITY_CHANGED | network | yes | Connectivity changed while the user was confirming; nothing was sent. | ConnectivityChanged |
TIMEOUT | timeout | yes | No answer within the time budget. | β |
RATE_LIMITED | rate_limit | yes | Too many calls; wait and retry. | β |
QUOTA_EXCEEDED | rate_limit | no | A metered quota is exhausted (AI balance, storage); retrying will not help until it is raised. | β |
APP_NOT_INSTALLED | dependency | no | The app this call targets is not installed or not ready. | AppNotInstalled |
LICENSE_REQUIRED | authorization | no | The app's licence does not cover this. | LicenseRequired |
SERVER_ERROR | server | no | The server failed unexpectedly. Not retryable by default: the operation may have run. | β |
INTERNAL_ERROR | server | no | The host failed while serving the call (a bug in the platform's browser side). Not retryable by default. | β |
UNKNOWN_ERROR | server | no | An error an older host reported without a code, and that matches no known message. Treat like SERVER_ERROR. | β |
Request ids, retries and idempotency
Every call carries a request id (the SDK mints it). It reaches the server, which records it on the audit rows the call produces, and it comes back on a failure as VizumError.correlationId β one id names the call from the iframe to the server's trail (Vizum.audit.search({request_id})). An AI call stores it on its charge too (vizum.ai.usage.request_id, 5.69): a billed charge names the call that caused it. It stays on the instance β it is not sent to the Store with the charge.
Whether a call may be repeated depends on what it does. The rule is explicit per operation:
| Operation | Retry it blindly? | With {idempotencyKey} |
|---|---|---|
Reads (orm.search/read/readGroup, data.get, meta.*, β¦) | yes β nothing changes | not needed |
orm.write | applies the same values again (last writer wins); use {expect} (5.43) to refuse a stale write | not needed |
orm.create | no β a second record | created at most once; the retry gets the first ids |
actions.run | no β the action runs twice | runs at most once; the retry gets the first result |
backend.enqueue | no β a second task | one task per key |
offline.run | replays are keyed automatically (the queued op's id), so a reconnect, a second tab or a lost answer never runs it twice | the app's key becomes the op's id |
net.fetch, ai.*, oauth.fetch | no β external side effects and cost | not offered: make the remote call idempotent on the remote side |
A key is 8-128 characters of A-Z a-z 0-9 _ . : - (crypto.randomUUID() works), scoped to the app and the user, and lives 8 days (the whole offline window). Only a success is remembered: a call that failed did not happen, and a retry with the same key runs it. A key reused for different arguments rejects with IDEMPOTENCY_MISMATCH instead of returning another operation's result. VizumError.retryable says when repeating the same call can help (lost connection, timeout, rate limit); repeat a write that is not idempotent only with its key.
const key = crypto.randomUUID(); // one key per business operation
async function save() {
try { return await Vizum.orm.create("x_vzm_order", vals, { idempotencyKey: key }); }
catch (e) { if (e.retryable) { return save(); } throw e; } // safe: same key
}
Permissions
Every grant a manifest can hold, from the same registry the installer's risk tier is checked against (tools/store_risk_tier_matches_contract_test.py). Privileged grants make the app wait for an administrator at install, and the store for a human reviewer; an update that ADDS one is re-approved. *Derived* grants are not written by the author: the validator builds them from another manifest section.
| Permission | Shape | Privileged | What it grants |
|---|---|---|---|
data | "user" | "global" | when global | Where Vizum.data / collections live: per user, or shared by every user of the app. |
schema | {models, create_models} | yes | Legacy native schema declaration. |
logic | "declarative" | "script" | when script | Server-side code: native.script hooks, jobs that run scripts, V.* bridge. |
orm | [{model, ops, fields?, write_fields?, domain?, chatter_only?, group_ops?}] | yes | Odoo models the app reads and writes, per model: operations, field ceilings, a domain. A create also has to satisfy the domain. group_ops: [{groups, ops}] (5.78) adds ops for the members of those groups only (@name of the app's own native.groups, <app-key>/@name of an app in requires, or a real xmlid; never an administrative group); an op sits in ops or in group_ops, not both, and min_platform is 5.78 or later. Closed models: vizum.* takes only an allowlist (the store's own vizum.licence* / vizum.store.* belong to com.vizumapps.hq); ir.actions.*, base.automation, ir.server.object.lines, ir.default and ir.filters are read-only; ir.cron takes scheduling writes only. The models whose rows are code are scoped to the app: ir.ui.view keys and ir.actions.report names must start with vzm_<the app id as a slug>. (every character of the id outside a-z0-9 becomes _: com.thelounge.lounge is vzm_com_thelounge_lounge.) and the platform records each one the app creates as the app's own, so only those can be changed; their QWeb expressions and static t-call targets are vetted on every write; an inherit_id must be the app's own view or an Odoo report template; a report accepts a short field list (no attachment, no groups); mail.template writes touch only the app's own templates with vetted expressions, a model the app can read, and no attachments, reports or layout. None of them is created, edited or linked through another record's x2many. Only call:get_combined_arch (view), call:send_mail (template) and call:report_action (report) run on them. ir.model.data, website.page and website.controller.page are read-only. A plain <style> (no url(), @import or other fetching construct) is allowed only in the app's own QWeb report templates. A copy of an Odoo report keeps that report's own expressions only when they are byte-identical to ones in the standard view or report the app read through the bridge in the last hour (call:get_combined_arch, or read of an arch field): read the standard document, then write the copy; a copy that redefines, with t-set or t-foreach/t-as, a name such an expression uses is refused |
ui | [op] | no | Host UI operations beyond the always-allowed confirm/openHtml/openDocument/exitFullscreen (fullscreen, 5.85, needs its entry). |
events | {emit: [name], listen: [name]} | no | Browser-local app-to-app events (see Event contracts). |
net | [host | *.wildcard | $setting:key | "*"] | yes | Hosts the server-side proxy may reach (SSRF-pinned). "*" (5.74) is any PUBLIC website and is its own approval line ("send requests to, and download from, any public website"; data the app can read may be included): GET/HEAD only, ports 80/443, a short header allowlist, public addresses only whatever net_allow_private says, never a $secret, a default daily cap, and the manifest needs min_platform 5.74. A $setting:key host is set only by an administrator typing it in the host settings dialog (settings.open): its manifest default is shown as a placeholder, never prefilled or stored, and the dialog warns that the app can send credentials to it. The app's own settings.set cannot change it, and api_settings / api_settings_admin_set are not valid orm call: targets. Approving a permissions.net that newly names $setting:key clears any value stored under that key while it was an ordinary setting, so the administrator must enter it. |
apps | [app id] | no | Apps this app may open with an intent. |
reports | [report name | "<prefix>.*" | "*"] | when * | Odoo reports the app may render. |
media | ["camera" | "microphone"] | no | Host-mediated camera / microphone (the browser still prompts the user). |
devices | ["print" | "scan" | "location" | "serial"] | yes | Host-mediated devices. |
secrets | [name] | no | Vault secret NAMES the app may ask for (values never reach the iframe; configure is admin-only). |
oauth | [provider] | yes | OAuth providers; tokens stay server-side. |
oauth_shared | boolean | no | Shared (company) OAuth connections usable by every user and by jobs. Needs oauth. |
oauth_paths | {provider: [path prefix]} | no | Narrows each provider to the API paths the app calls. Needs oauth. |
ai | boolean | [model id] | yes | The managed / BYOK AI gateway (metered). |
ai_image | boolean | no | Image generation, metered per image. Needs ai. |
ai_admin | boolean | yes | Instance-mutating AI store ops (BYOK key, billing, instance settings, RAG migration). Needs ai. |
store_account | boolean | yes | Links/unlinks the instance-wide Vizum store account. |
vector | "user" | "global" | when global | The server-side vector store, per user or shared. |
rag | boolean | yes | Server-side RAG indexing jobs (Knowledge). |
assistant | boolean | yes | Runs the AI Assistant agent as the real user. |
ocr | boolean | yes | Managed OCR (metered). |
audio | boolean | yes | Managed transcription (metered). |
editable | boolean | yes | AI live-document control between editor apps and the assistant. |
audit | boolean | yes | The platform's oldβnew change audit on the app's declared models. |
cutover | boolean | yes | Licence cutover (adopt / release), admin-only. |
sessions | [op] | yes | Native session revoke / login-alert policy, admin-only. |
db_cross_read | boolean | yes | Read other apps' shared Vizum.db rows the user can already see. |
files_public | boolean | yes | Mint public https links to the app's own files. |
instances_api | boolean | yes | Vizum Instances pass (store-signed identity for an external service). |
team | boolean | yes | Vizum.team: managers create and edit the users who play the app's roles. |
team_apps | [app id] | yes | Same-vendor apps a team role may be let into. Needs team. |
team_adopt | "dedicated" | yes | Take over the dedicated accounts that already play the app's roles. Needs team. |
adopt | [model] | yes | Copy the rows of a replaced module's models into the app's own (native.adopt). |
private_calls | ["<model>._<method>"] | no | Private Odoo methods the app's SERVER code calls. Needs logic. Only reachable from server scripts, which need logic: "script" (privileged). |
params | [ir.config_parameter key] | no | System parameters the app's SERVER code reads and writes. Needs logic. Only reachable from server scripts, which need logic: "script" (privileged). |
peer_data | ["<app id>:<key prefix>"] | no | Another app's GLOBAL data rows, read-only, from SERVER code. Needs logic. Only reachable from server scripts, which need logic: "script" (privileged). |
actions | [name] | yes | The approved action catalogue. *Derived from actions.* |
tables | [name] | yes | The app's Vizum.db tables. *Derived from tables.* |
workflows | [name] | yes | The app's workflows. *Derived from workflows.* |
webhooks | [name] | no | The app's inbound webhooks (they need data: "global", which is privileged). *Derived from webhooks.* |
native_bindings | [name] | yes | Action-menu bindings on native models. *Derived from native.bindings.* |
routes | [descriptor] | yes | Portal / internal / public pages. *Derived from native.routes.* |
public_routes | [descriptor] | yes | The public (anonymous) pages among them. *Derived from native.routes.* |
public_route_menus | [descriptor] | no | Website menu entries of public pages. *Derived from native.routes.* |
methods | ["<model>.<method>"] | yes | Native methods and overrides the app declares. *Derived from native.methods.* |
public_methods | ["<model>.<method>"] | no | Methods opened to every signed-in user. *Derived from native.methods.* |
native_acl | sha256 | no | Fingerprint of the native ACLs / rules / groups: any change is re-approved. *Derived from native.* |
device_routes | [route name] | yes | The app's auth "device" routes: paired screens and kiosks reach these, with no user session. *Derived from native.routes.* |
file_limits | {max_mb, count, total_mb} | yes | Raise Vizum.files above 64 MB a file / 50 files / 128 MB (ceilings 200 MB, 5000 files, 16 GB). A file over 32 MB goes through Vizum.files.putBlob (in parts of 32 MB). max_mb above 200 (up to 512, from 5.71) is its own administrator approval (permissions.large_files, derived) and needs an explicit storage cap set for the app. |
offline | {datasets: [{name, model, fields, domain?, order?, limit?}], writes?: [{model, ops, fields, expect?}], fallback?: boolean, max_mb?: number} | yes | The offline data layer (5.80): up to 12 named datasets (an orm grant that reads the model; fields inside its read ceiling; domain placeholders {uid}, {company_ids}, {today}, {today-N}, {today+N}; limit at most 20000; names ^[a-z][a-z0-9_]{1,40}$), the creates and writes that may be queued offline (ops inside the grant's ops, fields inside its write ceiling, expect names the computed fields the server compares on replay), fallback (Vizum.orm reads answer from the local copy when offline) and max_mb (at most 100). Part of the approved snapshot: any edit asks for approval again. min_platform is 5.80 or later. *Derived from offline.* |
large_files | {max_mb, total_mb} | yes | Derived from permissions.file_limits.max_mb above 200: the approval card reads "files up to X MB, Y GB per user". Without that approval the effective limit stays 200 MB. *Derived from permissions.file_limits.* |
Host features
Vizum.capabilities().features lists what the host the app runs in serves. Detect a feature before relying on it; not every host serves every feature.
| Feature | What | Since | Desktop | Odoo action | Solo |
|---|---|---|---|---|---|
3d-kit | vizum-3d-kit runtime | 4.2 | β | β | β |
actions | Vizum.actions | β | β | β | β |
activity | Vizum.activity | β | β | β | β |
ai | Vizum.ai | β | β | β | β |
ai-tools | manifest ai_tools + Vizum.ai.tools | 5.3 | β | β | β |
ai-tool-contracts | manifest ai_tools risk / side_effects / confirm / idempotent: validated at install, told to the assistant, and update/destructive tools confirmed with the user by every host before the app receives them | 5.67 | β | β | β |
app-budgets | per-app budgets set by an administrator: AI tokens over 30 days (hard, reserved), Vizum.net.fetch calls per day, Vizum.files storage across users; past one a call rejects QUOTA_EXCEEDED | 5.67 | β | β | β |
audio | Vizum.audio | 5.5 | β | β | β |
audit | Vizum.audit | β | β | β | β |
backend | Vizum.backend | β | β | β | β |
badge | ui.setBadge | β | β | β | β |
callOn | orm.callOn | β | β | β | β |
charts | vizum-charts runtime | 4.2 | β | β | β |
collections | Vizum.data.collection (named JSON collections with rev) | β | β | β | β |
composition | requires with version ranges and contracts, optional dependencies, Vizum.apps.status(), uninstall protection for required apps | 5.67 | β | β | β |
confirm | ui.confirm | β | β | β | β |
contract | Vizum.platform / Vizum.sdk / Vizum.features() / Vizum.supports() | 5.67 | β | β | β |
db | Vizum.db | β | β | β | β |
devices | Vizum.devices | β | β | β | β |
docs | Vizum.docs raw-document store | 5.2 | β | β | β |
editable | Vizum.editable (AI live-document control) | 5.19 | β | β | β |
errors | Vizum.errors | β | β | β | β |
event-contracts | manifest event_contracts (versioned payload schemas) enforced on emit, meta {producer, version} to listeners, Vizum.events.catalog() | 5.67 | β | β | β |
file-integrity | Vizum.files: sha256 recorded and returned, put({sha256}) refuses damaged bytes, the content must match the name's type, publicUrl links expire (expires_in, default one day) | 5.67 | β | β | β |
files | Vizum.files (app-scoped binary storage) | β | β | β | β |
format | Vizum.format helpers | β | β | β | β |
health | Vizum.apps.health(): healthy/degraded/failing from approval state, dependencies, runtime errors and failed tasks; administrators get api_health and api_update_preflight | 5.67 | β | β | β |
idempotency | an idempotency key on orm.create, actions.run, backend.enqueue and offline.run (opts.idempotencyKey, or the SDK makes one): a retry or an offline replay runs once β IDEMPOTENCY_IN_PROGRESS / IDEMPOTENCY_MISMATCH | 5.67 | β | β | β |
i18n | i18n/<lang>.json + Vizum._t | β | β | β | β |
import | Vizum.import | β | β | β | β |
instances | Vizum.instances.pass | 5.38 | β | β | β |
intents | launch intents (Vizum.intent, apps.open payloads) | β | β | β | β |
jobs | Vizum.jobs | β | β | β | β |
license | Vizum.license | β | β | β | β |
lifecycle | onVisibility/onPause/onResume/onClose | 5.7 | β | β | β |
mail | Vizum.mail | β | β | β | β |
media | host-mediated camera/microphone | β | β | β | β |
meta | Vizum.meta | β | β | β | β |
net | Vizum.net.fetch (server proxy) | β | β | β | β |
net-any | permissions.net "*": any public website, download-only (privileged) | 5.74 | β | β | β |
net-image | Vizum.net.fetchImage / V.net.fetch_image: one image, redirects re-checked, byte cap, PNG/JPEG/GIF/WEBP by magic number | 5.74 | β | β | β |
notifications | Vizum.notifications | β | β | β | β |
oauth | Vizum.oauth | β | β | β | β |
ocr | Vizum.ocr | 5.4 | β | β | β |
offline | Vizum.offline outbox | β | β | β | β |
openList | ui.openList | β | β | β | β |
openRecord | ui.openRecord | β | β | β | β |
palette | ui.addPaletteCommand | β | β | β | β |
panels | window.openPanel | β | β | β | β |
permissions | Vizum.permissions | β | β | β | β |
pgvector | Mode-3 pgvector setup (vector.pgv*) | 5.2 | β | β | β |
read_group | orm.readGroup | β | β | β | β |
realtime | Vizum.realtime (live receive needs the desktop bus) | β | β | β | β |
reports | Vizum.reports | β | β | β | β |
roles | Vizum.roles | β | β | β | β |
runtime | self-hosted no-build runtime (/vizum/runtime/*) | 4.2 | β | β | β |
scope | Vizum.scope() | 4.1 | β | β | β |
secrets | Vizum.secrets | β | β | β | β |
session | Vizum.session (version-safe identity, company, currency) | β | β | β | β |
settings | Vizum.settings | β | β | β | β |
task-retry | manifest actions[].retry {max, backoff_seconds} and timeout_minutes: a backend task retries a retryable failure with doubling backoff, and has a deadline (progress() answers cancel_requested; the reaper fails a runaway) | 5.67 | β | β | β |
telemetry | Vizum.telemetry | β | β | β | β |
uikit | vizum-ui.js kit | β | β | β | β |
user | Vizum.user (version-safe identity/groups) | 4.1 | β | β | β |
vector | Vizum.vector | β | β | β | β |
version | Vizum.session.odoo_version | 4.1 | β | β | β |
watch | orm.watch / orm.live / Vizum.refresh (live re-read) | β | β | β | β |
webhook-deliveries | inbound webhook ledger: delivery-id dedupe, timestamp window, statuses, ack/fail, dead letter and retry | 5.67 | β | β | β |
webhooks | Vizum.webhooks | β | β | β | β |
workflow | Vizum.workflow | β | β | β | β |
update-code-approval | an update that changes server code returns the app to approval; api_update_preflight names permission and code changes item by item | 5.69 | β | β | β |
task-status | Vizum.backend task rows carry status: retrying / expired / dead_letter | 5.69 | β | β | β |
contract-versions | provides name@x.y.z and requires {contract, version} | 5.69 | β | β | β |
caller-app | caller_app() in a method called through a call: grant: the calling app's key, verified by the platform | 5.69 | β | β | β |
device-routes | Vizum.device β a paired-device page | 5.70 | β | β | β |
paired-devices | Vizum.pairedDevices β pair, list and revoke an app's devices | 5.70 | β | β | β |
large-files | Vizum.files above 200 MB: chunked putBlob, streamed getBlob/getRange, permissions.file_limits.max_mb up to 512 | 5.71 | β | β | β |
upload-resume | A chunked putBlob survives a dropped connection: same token, the part sent again with backoff, status then next_part, an explicit abort when it gives up | 5.72 | β | β | β |
native-i18n | Native strings from the catalogue: the same i18n/<lang>.json also translates what the app puts in Odoo's backend β field labels and help, selection options, model labels, groups, menus, window/server actions, view arch terms and the translatable values of native.records. A key is the English text or an optional "@native:<ref>" context key; written at install, update, approval and language install, from the stored en_US; an administrator's own translation is kept. Keys do nothing on an older platform, so no min_platform is needed | 5.84 | β | β | β |
server-i18n | Server-side translations: the bundle's i18n/<lang>.json also answers tr() in the app's model methods (language from context.lang, es_419 -> es -> msgid) | 5.73 | β | β | β |
script-sha256 | V.sha256(data, encoding=None) in script hooks, jobs and native methods: the SHA-256 hex of a str (UTF-8), bytes or base64 str; at most 32 MiB a call and 256 MiB / 2000 calls a run. An integrity digest, not a signature | 5.75 | β | β | β |
script-tr | V.tr(source, *args, lang=None, **kwargs) in script hooks (trigger guards included), jobs and native methods: the method-side tr() on the app's own i18n catalogue, in the language of the call (a hook: the user whose write fired it; a job: its job user) or of an installed lang=; a missing or malformed translation gives the source | 5.77 | β | β | β |
orm-group-ops | group_ops on a permissions.orm grant: [{groups: [refs], ops: [...]}] gives extra ops (create, write, unlink, call:<method>) only to the calling user who is in at least one of the groups, so a UI app can leave ops read-only for everyone and give the owner app's administrators the writes. Solo mints its access group from ops only, never from group_ops | 5.78 | β | β | β |
offline-data | permissions.offline {datasets, writes, fallback, max_mb}: named, approved slices of the user's own Odoo data kept on the device and read with Vizum.offline.query / readGroup with no network, and the creates and writes that may be queued meanwhile. Sync runs as the user on the server (grant, field ceiling and record rules apply); a queued write carries the values the user saw and a differing server value answers CONFLICT; a replay for another user answers OFFLINE_USER_MISMATCH. The rows are removed on logout or a change of user, the queue is not. Hosts: the desktop, the Odoo client action host and Solo, each on the one per-user layer its shell owns. | 5.80 | β | β | β |
offline-deleted | A queued offline write or action on a record the server deleted meanwhile is listed by Vizum.offline.conflicts() with reason "deleted" (and canRecreate), not as a generic rejection: the server answers CONFLICT with details.deleted, the device drops the row it held, and the user discards the change or recreates the record (resolve keep_mine makes one create from what the user saw, offered when the app may create the model offline; keep_server and discard leave it deleted). | 5.86 | β | β | β |
step-up | Vizum.identity.confirm({action, reason, meaning}) in a host dialog that dims the whole desktop: the user types the account password THERE (never in the app), the host checks it on a dedicated route and hands the app a single-use token (120 s, bound to the user, app, action and session). Server code spends it with V.identity.consume(token, action), which returns a receipt (id, user, action, meaning, reason, confirmed_at). Every attempt is a row in a ledger only the platform writes. Not on Solo (UNSUPPORTED_CALL) or on a device page (PERMISSION_DENIED). | 5.79 | β | β | β |
trigger-su | The trigger payload's su (rec["su"] in a script hook): True when the write that fired the trigger ran in a superuser environment (env.su: a vApp method's sudo(), platform-internal code, uid 1), False for the user's own write. sudo() keeps the uid, so uid could not tell them apart. Computed by the platform from the write's own records, never from the app. A hook that reads it sets min_platform 5.81 | 5.81 | β | β | β |
script-service-user | V.is_service_user() in script hooks, jobs and native methods: True when the code runs as the app's own service user (the passwordless job user the platform minted for it; jobs, triggers and device routes run as it), False for any human, an administrator included. Read-only and computed by the platform from the user the code runs as. An app that names it sets min_platform 5.81 | 5.81 | β | β | β |
company-unique | A unique of a company_scoped native model may list x_company_id, the synthesized company column (a prefix unique per company, one settings row per company); nulls_not_distinct works with it as for any column. A unique that names it sets min_platform 5.81 | 5.81 | β | β | β |
jobs-user-data | A job may run with permissions.data "user" (before 5.81 jobs required "global"). The platform writes no shared tick for it, and the job's own V.data is the private scope of the user it runs as (the app's service user): it cannot read a human user's keys. An app with jobs and data "user" sets min_platform 5.81 | 5.81 | β | β | β |
device-embed-control | Vizum.device.embed handles control the player the page framed: setVolume(0-100), mute(), unmute(), play(), pause(). The page posts the YouTube IFrame API / Vimeo player.js command to that iframe only, to the provider's exact origin, for players this page created. Device pages only. An app that uses them sets min_platform 5.82 | 5.82 | β | β | β |
device-store | Vizum.device.store get/set/delete/all: small per-device settings (the frame has no localStorage), kept server side for the paired device and reachable only with that device's token. At most 64 keys of at most 4 KB of JSON each; dropped when the device is revoked. An app that uses it sets min_platform 5.82 | 5.82 | β | β | β |
vector-scope | Vizum.vector.sharedNamespaces() lists the namespaces (name, count, model, dim) a user-scoped app owns in the shared (global) scope; Vizum.vector.moveScope(ns, {from, to, user_id?, doc_id?, batch?}) moves a namespace's rows between the shared scope and a user's scope without re-embedding (the owner of the stored rows changes: nothing is embedded, nothing is metered). Administrator or the app's service user only; user to global is administrator only; batched ({moved, remaining, conflicts, done}), re-runnable, never overwrites a row the destination holds. V.vector.shared_namespaces() reads the list from server code. An app that calls V.vector.shared_namespaces sets min_platform 5.83 | 5.83 | β | β | β |
ui-fullscreen | Vizum.ui.fullscreen() takes the whole screen for the app: the host paints the app's window over the viewport with the desktop chrome (dock, Odoo navbar, titlebar) hidden - always, with no user gesture - and also asks the browser for real fullscreen (browser in the result; best effort, because the click that led there happened inside the app's iframe and only some browsers carry that activation to the host). Vizum.ui.exitFullscreen() hands it back (always allowed, ends only the app's own full screen); a host button, Escape and the browser's own exit also end it, announced to Vizum.ui.onFullscreenChange(cb). The window's geometry is never touched. fullscreen is declared in permissions.ui; an app that calls it sets min_platform 5.85. Desktop and native hosts; Solo and device pages answer {active:false, supported:false} (device pages keep Vizum.device.fullscreen) | 5.85 | β | β | β |
API reference
since is set where a capability introduced the member; a member without one predates the capability notes and is available on every supported platform. Permission is what the host gate and the server enforce for the bridge namespace behind the member.
Top level
Vizum._t, Vizum.app (since 5.76), Vizum.capabilities, Vizum.connect, Vizum.env, Vizum.features (since 5.67), Vizum.intent, Vizum.isMobile, Vizum.model, Vizum.onClose (since 5.7), Vizum.onPause (since 5.7), Vizum.onResume (since 5.7), Vizum.onVisibility (since 5.7), Vizum.platform (since 5.67), Vizum.query, Vizum.route, Vizum.scope, Vizum.sdk (since 5.67), Vizum.session, Vizum.supports (since 5.67), Vizum.theme
Signatures (from vizum-sdk.d.ts):
_t(key: string, ...params: any[]): stringβ i18n/<lang>.json lookup in the iframe,%sfilled in order.readonly app: { readonly key: string | null }β SDK 5.76 β this app's own key, sent by the host in the handshake:keyis set onceconnect()resolves (null before, and on a host older than 5.76).capabilities(): Vizum.Capabilitiesconnect(): Promise<{ caps: Vizum.Caps; theme: any; session: Vizum.Session | null; intent: any; mobile: boolean }>env: { mobile: boolean }β SDK v3 runtime environment.features(): string[]β SDK 5.67 β the feature ids this host serves (a copy ofcapabilities().features).readonly intent: anyreadonly isMobile: booleanβ True on a phone-sized desktop (mirrors env.mobile).model<T = Record<string, any>>(name: string): { readonly name: string; search(domain?: Vizum.Domain, opts?: object): Promise<number[]>; searchRead(domain?: Vizum.Domain, fields?: (keyof T & string)[], opts?: object): Promise<Array<Partial<T> & { id: number }>β¦β A per-model accessor β the no-build equivalent of @vizum/vapp-types' model<T>().onClose(cb: (event: Vizum.CloseEvent) => void): Vizum.Subscriptionβ One-shot dispose signal posted just before teardown.onPause(cb: (event: Vizum.VisibilityEvent) => void): Vizum.Subscriptionβ Sugar over onVisibility, fired only on the hidden edge (visible === false).onResume(cb: (event: Vizum.VisibilityEvent) => void): Vizum.Subscriptionβ Sugar over onVisibility, fired only on the visible edge (visible === true).onVisibility(cb: (event: Vizum.VisibilityEvent) => void): Vizum.Subscriptionβ SDK v5.7 lifecycle events (opt-in, advisory; no manifest grant).readonly platform: Vizum.PlatformInfoβ SDK 5.67 β the platform, its versions and its host (sdk-next/docs/platform-contract.md).query(model: string): Vizum.Queryβ SDK v4 fluent query builder (also Vizum.orm.query).readonly route: Vizum.Route | nullβ SDK 5.53 β the declared route (native.routes) this app was opened on, from a route page (/app/<slug>/<route>[/<sub>] or its /my/<slug>/β¦ alias); null anywhere else.scope(): Promise<Vizum.Scope>β SDK 4.1 β what storage scope this app runs in.readonly sdk: Vizum.SdkInfoβ SDK 5.67 β the runtime SDK's own version and api version.readonly session: Vizum.Session | nullsupports(name: string): booleanβ SDK 5.67 β true when this platform AND this host servename: a feature id ("realtime") or an SDK member ("files.publicUrl","phone").readonly theme: { accent: string; dark: boolean }
actions
stability stable Β· permission: permissions.actions (non-empty)
Members: Vizum.actions.list, Vizum.actions.run
Bridge ops: list, run
list(): Promise<Array<{ name: string; label: string; model: string; groups: string[] }>>run(name: string, params: { ids: number[]; [k: string]: any }, opts?: Vizum.Idempotent): Promise<{ ok: boolean; result: any }>β SDK 5.67idempotencyKey: a retried run never runs the action twice.
activity
stability stable Β· permission: permissions.orm (or own native models)
Members: Vizum.activity.done, Vizum.activity.list, Vizum.activity.schedule
Bridge ops: done, list, schedule
done(model: string, id: number, activityId: number, feedback?: string): Promise<boolean>list(model: string, id: number): Promise<Array<{ id: number; summary: string; note: string; date_deadline: string | null; user: [number, string]; type: string; overdue: boolean }>>schedule(model: string, id: number, opts: { summary?: string; note?: string; date_deadline?: string; user_id?: number; type?: string }): Promise<{ id: number }>
ai
stability stable Β· permission: permissions.ai; storeAccount also store_account; billing/byok/instance settings also ai_admin; image also ai_image
Members: Vizum.ai.account, Vizum.ai.appPermissions, Vizum.ai.billing, Vizum.ai.byokFallback, Vizum.ai.byokSet, Vizum.ai.byokStatus, Vizum.ai.chat, Vizum.ai.complete, Vizum.ai.embed, Vizum.ai.getInstanceSettings, Vizum.ai.image (since 5.25), Vizum.ai.managedModels, Vizum.ai.models, Vizum.ai.ragMigrate, Vizum.ai.rerank, Vizum.ai.setAllowedModels, Vizum.ai.setDefaultModel, Vizum.ai.setInstanceSettings, Vizum.ai.storeAccount, Vizum.ai.tools, Vizum.ai.usageByApp, Vizum.ai.usageByUser, Vizum.ai.usageHasRefs
Bridge ops: account, appPermissions, billing, byokFallback, byokSet, byokStatus, chat, complete, embed, getInstanceSettings, image, managedModels, models, ragMigrate, rerank, setAllowedModels, setDefaultModel, setInstanceSettings, storeAccount, usageByApp, usageByUser, usageHasRefs
account(): Promise<{ mode: string; account_type: string; balance: number; currency: string; trial: { active: boolean; remaining: number; expiry: string | null }; subscription_state: string; subscription_cancel_at?: string | null; data_purge_at?: string | nullβ¦β SDK v5.8 admin surface β base.group_system only; others get AccessError.appPermissions(payload: { keys: string[] }): Promise<{ rows: Array<{ key: string; name: string; data: string; logic: string | null; ai: boolean; ai_image: boolean; vector: string | null; orm: Array<{ model: string; ops: string[]; write_fields: string[] | nullβ¦β Admin only.billing(payload: { op: "topup" | "subscribe" | "portal" | "plans"; amount?: number; plan_id?: number }): Promise<{ url?: string; id?: string; plans?: any[] }>β SDK v5.8 admin surface β create or fetch a Stripe Checkout / portal / plans session.byokFallback(payload: { value: boolean }): Promise<{ ok: boolean }>byokSet(payload: { provider: string; key: string }): Promise<{ ok: boolean }>byokStatus(): Promise<{ providers: Array<{ provider: string; has_key: boolean }>; ai_byok_fallback: boolean }>β SDK v5.15 admin β read whether this instance BYOKs (bring your own key) to the Vizum store, per provider (booleans only β the key is never returned).chat(payload: { messages?: AiMessage[]; prompt?: string; system?: string; model?: string; max_tokens?: number; temperature?: number; tools?: any[]; /** Extended reasoning budget. Today only the anthropic branch * ('_apply_effort') applies it: raises 'max_tokeβ¦complete(prompt: string, opts?: { model?: string; system?: string; max_tokens?: number; temperature?: number }): Promise<string>embed(input: string | string[], opts?: { model?: string }): Promise<{ vectors: number[][]; model: string }>getInstanceSettings(): Promise<VizumAiInstanceSettings>β SDK v5.14 admin surface β instance-wide AI parameters.image(payload: { prompt: string; n?: number; size?: string } ): Promise<{ images: string[]; model: string | null; mime: string }>β SDK 5.25 β image GENERATION.managedModels(): Promise<{ models: Array<{ id: string; provider: string; model: string; label: string; enabled: boolean }>; all_enabled: boolean; default_model_id: string | null; mode: string; editable: boolean }>β SDK v5.9 admin surface β managed-MODEL selection: the models the owner offers this instance + which are enabled.models(): Promise<Array<{ id: string; label: string; provider: string }>>ragMigrate(opts?: { dryRun?: boolean }): Promise<{ migrated: number; skipped: Record<string, number>; namespaces: string[] }>β SDK v5.15 admin β local β managed RAG migration.rerank(query: string, documents: string[], opts?: { topN?: number; model?: string } ): Promise<{ results: Array<{ index: number; score: number }>; model: string | null }>β SDK v5 listwise reranker (provider-agnostic β runs on the chat model, no Cohere/Jina key).setAllowedModels(payload: { model_ids: string[] }): Promise<{ models: Array<{ id: string; provider: string; model: string; label: string; enabled: boolean }>; all_enabled: boolean; default_model_id: string | null; mode: string; editable: boolean }>β Admin: enable a subset of the owner's offered models (clamped to the owner ceiling).setDefaultModel(payload: { model_id: string | number }): Promise<{ models: Array<{ id: string; provider: string; model: string; label: string; enabled: boolean }>; all_enabled: boolean; default_model_id: string | null; mode: string; editable: boolean }>β SDK v5.13 admin β the default CHAT model for no-model managed calls (e.g.setInstanceSettings(payload: Partial<Pick<VizumAiInstanceSettings, "assistantMaxTokens" | "assistantTimeBudget" | "generalMaxTokens">>): Promise<VizumAiInstanceSettings>β SDK v5.14 β persist any subset of the instance AI parameters (clamped server-side) and return the fresh snapshot.storeAccount(payload: { op: "status" } | { op: "logout" } | { op: "login"; email: string; password: string } | { op: "signup"; name: string; email: string; password: string } ): Promise<{ linked: boolean; email?: string; name?: string; can_buy?: boolean; offlβ¦β SDK v5.22 β link / unlink / inspect the Vizum store account from inside the app.tools: { handle(name: string, handler: (values: Record<string, any>) => any): void; }β SDK v5.3 β AI TOOLS: let the AI Assistant DRIVE your app.usageByApp(payload?: { days?: number }): Promise<{ rows: Array<{ app_id: number | null; key: string | null; name: string; calls: number; tokens_in: number; tokens_out: number; charge: number; /** Per-consumer model usage, judged against the users who ran thatβ¦βrefsis the app's ownVizum.ai.chat({..., ref})breakdown β empty when the app never tags a call.usageByUser(): Promise<{ rows: Array<{ user_id: number | null; name: string; login: string | null; spend: number; calls: number; tool_calls: number; by_kind: Array<{ kind: string; charge: number; calls: number }> }> }>β SDK v5.8 admin surface β per-user spend + call rollup.usageHasRefs(payload?: { days?: number }): Promise<{ available: boolean }>β Admin: cheap yes/no β does any app tag its spend withref(no 90-day fold)?
apps
stability stable Β· permission: none (open targets checked against permissions.apps)
Members: Vizum.apps.find, Vizum.apps.health (since 5.67), Vizum.apps.installed, Vizum.apps.open, Vizum.apps.provides, Vizum.apps.requires, Vizum.apps.status (since 5.67)
Bridge ops: find, health, installed, open, provides, requires, status
find(contract: string): Promise<Array<{ key: string; name: string; version: string; ready: boolean; provides: string[] }>>health(): Promise<{ status: "healthy" | "degraded" | "failing"; lifecycle: "running" | "blocked" | "pending_approval" | "disabled" | "withdrawn"; checks: Array<{ name: "state" | "dependencies" | "runtime_errors" | "background_tasks"; status: "healthy" | "degrβ¦β 5.67 β how this app is doing: approval state, dependencies, its own runtime errors and failed background tasks over the last 24 h.installed(): Promise<Array<{ key: string; name: string; version: string; ready: boolean; provides: string[] }>>open(key: string, payload?: any): Promise<boolean>provides(): Promise<string[]>requires(): Promise<Array<Vizum.DependencyRow & { key: string; installed: boolean; ready: boolean }>>status(): Promise<{ state: "ok" | "degraded" | "blocked"; dependencies: Vizum.DependencyRow[] }>β 5.67 β whether everything this app requires is ready.
assistant
stability stable Β· permission: permissions.assistant
Members: Vizum.assistant.cancel, Vizum.assistant.confirm, Vizum.assistant.contexts, Vizum.assistant.delete, Vizum.assistant.deleteProject, Vizum.assistant.history, Vizum.assistant.kbInfo, Vizum.assistant.kbSearch (since 5.52), Vizum.assistant.models, Vizum.assistant.newSession, Vizum.assistant.projects, Vizum.assistant.rate, Vizum.assistant.reject, Vizum.assistant.rename, Vizum.assistant.saveProject, Vizum.assistant.send, Vizum.assistant.sessions, Vizum.assistant.setContext, Vizum.assistant.setEffort, Vizum.assistant.setModel, Vizum.assistant.setSessionProject, Vizum.assistant.sourceDoc, Vizum.assistant.thumb, Vizum.assistant.upload
Bridge ops: cancel, confirm, contexts, delete, deleteProject, history, kbInfo, kbSearch, models, newSession, projects, rate, reject, rename, saveProject, send, sessions, setContext, setEffort, setModel, setSessionProject, sourceDoc, thumb, upload
cancel(sessionId: number): Promise<{ ok: boolean }>β Ask the in-flight turn on this session to abort at its next step.confirm(sessionId: number, actionId: number): Promise<Record<string, any>>contexts(): Promise<{ available: Array<{ id: number; name: string; industry?: string; section?: string; summary?: string }>; current: number; current_name?: string; locals: Array<{ name: string }> }>β The user's available pre-contextualizations (personas) + their current pick + the applicable LOCAL contexts.delete(sessionId: number): Promise<{ ok: boolean }>deleteProject(id: number): Promise<{ ok: boolean }>history(sessionId: number): Promise<Array<Record<string, any>>>kbInfo(): Promise<{ docs: number; namespaces: string[]; names: string[]; tags: string[]; categories: string[] }>β SDK 5.19 β a CHEAP snapshot of the user's Knowledge Base (no LLM): how many documents are actually indexed, the namespaces, and the tag/category vocab.kbSearch(query: string, opts?: { catalogs?: string[]; topK?: number }): Promise<{ ok: boolean; catalogs?: string[]; note?: string; error?: { code: string; message?: string }; passages?: Array<{ source: string; text: string; score: number; kind?: string; categβ¦β SDK 5.52 β passages from the Knowledge (RAG) catalogs THIS user can read, with their sources: the Assistant's own retrieval (hybrid search, rerank), run as the calling user.models(): Promise<{ configured: boolean; models: Array<{ id: string; provider: string; model: string; label: string; vision: boolean }>; chosen: string | false }>newSession(title?: string, projectId?: number): Promise<{ id: number }>projects(): Promise<Array<{ id: number; name: string; persona_id: number; persona_name: string; instructions: string; session_count: number }>>β Per-user chat folders, each with an attached persona.rate(sessionId: number, messageId: number | string, rating: "up" | "down"): Promise<{ ok: boolean; message_id?: number; rating?: "up" | "down" }>β SDK 5.19 (active learning, still evolving) β thumbs up/down on ONE assistant message (its id comes from history()/send()'s message_id β a numeric Odoo id, a numeric string also works, the server coerces with int()).reject(sessionId: number, actionId: number): Promise<Record<string, any>>rename(sessionId: number, title: string): Promise<{ ok: boolean }>saveProject(vals: { id?: number; name: string; persona_id?: number; instructions?: string; sequence?: number; active?: boolean }): Promise<Record<string, any>>β Create/update a project.send(sessionId: number, text: string, opts?: { /** Multimodal input (SDK v5.11) β attachments from upload(). */ attachments?: Array<{ id: string; route?: string }>; /** SDK v5.19 β the editor document the user is viewing (live-edit awareness). */ active_doc?:β¦sessions(): Promise<Array<Record<string, any>>>setContext(id?: number): Promise<{ ok: boolean; context_id: number }>β Pick one of YOUR allowed global contexts (or 0/null to clear).setEffort(effort?: "auto" | AiEffort): Promise<{ ok: boolean; effort: string }>β Pick your reasoning EFFORT (extended thinking for Claude & thinking-capable models).setModel(model?: string): Promise<{ ok: boolean; model: string }>β Pick the model for YOUR chats (one of your allowed models; ""/null = automatic).setSessionProject(sessionId: number, projectId?: number): Promise<{ ok: boolean; project_id: number | false }>β Move a chat into a project (0/null = out of any project).sourceDoc(namespace: string, docId: string): Promise<{ ok: boolean; doc?: { doc_id: string; name: string; content_type: string; size: number; data: string } }>β SDK v5.2 β fetch a cited Knowledge SOURCE document for in-chat preview.thumb(attachmentId: string): Promise<{ ok: boolean; mime?: string; name?: string; data?: string | null }>β A small image preview for an uploaded attachment (history re-render).upload(sessionId: number, data: string, name?: string, mime?: string): Promise<{ attachment_id: string; mime: string; name: string; size: number; session_id: number }>β SDK v5.11 β store an uploaded file (base64) as a session-scoped attachment; keeps bytes OUT of the chat text.
audio
stability stable Β· since 5.5 Β· permission: recordClip/recordStop/micLevel: permissions.media ['microphone']; transcribe/status: permissions.audio
Members: Vizum.audio.micLevel, Vizum.audio.recordClip (since 5.10), Vizum.audio.recordStop, Vizum.audio.status, Vizum.audio.transcribe, Vizum.audio.transcribeBatch
Bridge ops: micLevel, recordClip, recordStop, status, transcribe
micLevel(): Promise<{ level: number; ready: boolean }>β Instantaneous RMS mic level (0..~1) β for a live meter / dead-mic detection.recordClip(ms: number | { ms?: number }): Promise<{ b64: string; mime: string; size: number }>β Record ~msof audio from the mic in-browser -> a clip.recordStop(): Promise<{ ok: boolean }>β Release the microphone acquired by recordClip().status(): Promise<{ mode: "local" | "managed"; provider: string; available: boolean; credits_per_min?: number | null }>β Which engine is live + a cost hint.transcribe(input: File | Blob | string | { data: string; content_type?: string } | { namespace: string; doc_id: string }, opts?: { mode?: "local" | "managed"; language?: string; translate?: boolean; model?: string; onProgress?: (p: { stage?: string; duration?β¦β input: a File | Blob | base64 string | {data,content_type} | {namespace,doc_id}.transcribeBatch(inputs: Array<File | Blob | string>, opts?: { mode?: "local" | "managed"; language?: string; translate?: boolean; model?: string; onProgress?: (p: { item: number; total: number }) => void } ): Promise<{ results: Array<Vizum.AudioResult | { errβ¦β Batch transcribe; per-item progress via opts.onProgress.
audit
stability stable Β· permission: none (app-scoped); activity: permissions.audit server-side
Members: Vizum.audit.activity, Vizum.audit.log, Vizum.audit.search
Bridge ops: activity, log, search
activity(query?: { model?: string; res_id?: number; action?: "create" | "write" | "unlink"; user_id?: number; source?: "vapp" | "native"; date_from?: string; date_to?: string; limit?: number; offset?: number }): Promise<Array<{ id: number; ts: string; user: [β¦β SDK v5: the old->new CHANGE trail the platform captures for the app's declared native.audit models.log(action: string, payload?: Record<string, any>): Promise<{ id: number; ts: string }>search(query?: { action?: string; user_id?: number; ok?: boolean; date_from?: string; date_to?: string; limit?: number; offset?: number }): Promise<Array<{ id: number; ts: string; user: [number, string]; action: string; payload: Record<string, any>; ok: booleβ¦
backend
stability stable Β· permission: permissions.actions (non-empty)
Members: Vizum.backend.approve, Vizum.backend.cancel, Vizum.backend.enqueue, Vizum.backend.list, Vizum.backend.progress, Vizum.backend.status
Bridge ops: approve, cancel, enqueue, get, list, progress
approve(id: number | string): Promise<any>cancel(id: number | string): Promise<any>enqueue(action: string, params?: Record<string, any>, opts?: Vizum.Idempotent): Promise<{ id: number | string; [k: string]: any }>β SDK 5.67idempotencyKey: one task per key, however often it is retried.list(q?: Record<string, any>): Promise<Vizum.BackendTask[]>progress(id: number | string, vals?: { done?: number; total?: number; cursor?: any; /** Report a percentage directly when 'total' isn't known/tracked this call β * used ONLY when 'total' is absent; 'done'/'total' take priority when both are * given. */ progreβ¦β A RUNNING action reports {done,total,cursor?} between batches for a live progress bar + resumable cursor, and gets back {cancel_requested} so it can stop cleanly on a cooperative cancel().status(id: number | string): Promise<Vizum.BackendTask>
cutover
stability stable Β· permission: permissions.cutover (+ administrator)
Members: Vizum.cutover.adopt, Vizum.cutover.release
Bridge ops: adopt, release
adopt(p?: { app_key?: string; source_db?: string; version?: string; [k: string]: any }): Promise<any>release(p?: { app_key?: string; source_db?: string; version?: string; [k: string]: any }): Promise<any>
data
stability stable Β· permission: none (scope from permissions.data)
Members: Vizum.data.collection, Vizum.data.del, Vizum.data.get, Vizum.data.list, Vizum.data.set, Vizum.data.subscribe
Bridge ops: collection, del, get, list, set, subscribe
collection<T = any>(name: string): Vizum.Collection<T>del(k: string): Promise<boolean>get(k: string): Promise<any>list(): Promise<string[]>set(k: string, v: any): Promise<boolean>subscribe(k: string, cb: () => void): Promise<boolean>
db
stability stable Β· permission: permissions.tables; meta_tables/meta_fields/query_app: permissions.db_cross_read; owner_query: row ACL
Members: Vizum.db.count, Vizum.db.delete, Vizum.db.get, Vizum.db.insert, Vizum.db.insertMany, Vizum.db.insert_many, Vizum.db.meta, Vizum.db.ownerQuery, Vizum.db.queryApp, Vizum.db.search, Vizum.db.share, Vizum.db.update
Bridge ops: count, delete, get, insert, insert_many, meta_fields, meta_tables, owner_query, query_app, search, share, update
count(table: string, domain?: any[][]): Promise<number>delete(table: string, id: string): Promise<boolean>get(table: string, id: string): Promise<Record<string, any> | null>insert(table: string, values: Record<string, any>): Promise<Record<string, any>>insertMany(table: string, rows: Array<Record<string, any>>): Promise<Array<Record<string, any>>>β Bulk insert in one round-trip; returns the created rows (with ids).insert_many(table: string, rows: Array<Record<string, any>>): Promise<Array<Record<string, any>>>meta: { tables(): Promise<Array<Record<string, any>>>; fields(appKey: string, table: string): Promise<Array<Record<string, any>>>; }β Cross-app Vizum.db read (Phase 4) β PRIVILEGED, READ-ONLY, ACL-bounded.ownerQuery(table: string, rowId: string, widgetId: string): Promise<any[]>β Run-as-owner render of a SHARED row's owner-persisted widget query.queryApp(appKey: string, table: string, spec?: Record<string, any>): Promise<any>search(table: string, domain?: any[][], opts?: { order?: string; limit?: number; offset?: number; scope?: "mine" | "shared" }): Promise<Array<Record<string, any>>>share(table: string, id: string, opts: { users?: Array<number | "job_user">; visibility?: "private" | "shared" | "public" }): Promise<any>β 5.41:usersmay include "job_user" β the identity this app's jobs run as, resolved server-side (never the superuser, an archived or a portal user).update(table: string, id: string, values: Record<string, any>, opts?: { expect?: Record<string, any> }): Promise<Record<string, any> & { _updated?: boolean }>β 5.39:opts.expect= compare-and-set β written only if the row still holds those values (row lock); the result then carries_updated: true | false.
dev
stability stable Β· client-side only (no bridge op)
Members: Vizum.dev.logs
logs(q?: Record<string, any>): Promise<any[]>
device
stability stable Β· hosts: device Β· permission: an auth "device" page (native.routes)
Members: Vizum.device.blobUrl (since 5.70), Vizum.device.cache (since 5.70), Vizum.device.call (since 5.70), Vizum.device.embed (since 5.70), Vizum.device.embeds (since 5.70), Vizum.device.fileUrls (since 5.70), Vizum.device.fullscreen (since 5.70), Vizum.device.info (since 5.70), Vizum.device.onReload (since 5.70), Vizum.device.store (since 5.82)
Bridge ops: blobUrl, cache, call, embed, embedMute, embedPause, embedPlay, embedRemove, embedSetVolume, embedUnmute, embedUpdate, embeds, fileUrls, fullscreen, info, onReload, storeAll, storeDelete, storeGet, storeSet
blobUrl(url: string): Promise<string | null>β An object URL forurl, served from the offline cache when the network is gone.cache(urls: string[]): Promise<{ cached: number; bytes: number }>β Pre-fetch URLs into the offline cache (LRU, the page's device.cache_mb).call(route: string, payload?: Record<string, any>): Promise<any>β Run the app's device json routeroute(its hook sees V.device).embed(opts: Vizum.DeviceEmbedOptions): Promise<Vizum.DeviceEmbedHandle>β A YouTube / Vimeo player laid over this app by the platform page (a player nested in the sandboxed app frame does not play).embeds(): Promise<number>β Remove every player this app opened; resolves with how many.fileUrls(route: string, ids: number[]): Promise<Record<string, string>>β Signed URLs (24 h) for records of the device file routeroute, keyed by id.fullscreen(): Promise<boolean>β Ask the platform page to go fullscreen.info(): Promise<Vizum.DeviceInfo>β This device: id, name, the record it is linked to, the page it was paired on.onReload(cb: () => void): () => voidβ The administrator pressed "Reload screen".store: Vizum.DeviceStoreβ SDK 5.82 β per-device key/value storage (the app frame has no localStorage).
devices
stability stable Β· permission: permissions.devices: print | scan | location
Members: Vizum.devices.location (since 5.24), Vizum.devices.onScan, Vizum.devices.print, Vizum.devices.printReceipt, Vizum.devices.readTag, Vizum.devices.scan, Vizum.devices.serialSupported
Bridge ops: location, onScan, print, printReceipt, readTag, scan
location(opts?: { timeout?: number; maximumAge?: number; highAccuracy?: boolean }): Promise<{ lat: number; lng: number; accuracy: number; ts: number }>β SDK 5.24 (grant devices:["location"]) β one-shot GPS fix, HOST-mediated (geolocation is auto-denied in the opaque-origin iframe).onScan(cb: (scan: { type: "nfc" | "rfid"; value: string }) => void): Promise<boolean>β NFC taps + RFID/barcode reader scans captured by the desktop (grant devices:["scan"]).print(title: string, lines: string[]): Promise<boolean>β Host-rendered text printing (scriptless).printReceipt(lines: Array<string | { text: string; align?: "left" | "center" | "right"; bold?: boolean; size?: "normal" | "double"; underline?: boolean }>, opts?: { cut?: boolean; [k: string]: any }): Promise<boolean>β Send an ESC/POS receipt to the user's default paired printer (grant devices:["print"]).readTag(opts?: { timeout?: number }): Promise<{ ok: boolean; type?: "nfc" | "rfid"; value?: string; cancelled?: boolean; degraded?: boolean }>β One-shot ACTIVE NFC/RFID read β "read a tag now", the active twin of onScan (grant devices:["scan"], no new token).scan(opts?: Record<string, any>): Promise<{ ok: boolean; text?: string; format?: string; cancelled?: boolean }>β One-shot barcode/QR scan on the native host β the phone camera, or the hardware laser on a rugged PDA (grant devices:["scan"], same grant as onScan/readTag).serialSupported(): booleanβ Whether navigator.serial is available (declare permissions.devices ["serial"]).
docs
stability stable Β· client-side only (no bridge op)
Members: Vizum.docs.del, Vizum.docs.get, Vizum.docs.list, Vizum.docs.put
del(namespace: string, sel: { doc_id?: string } | { all: true }): Promise<{ deleted: number }>β Delete by id or all in the namespace.get(namespace: string, docId: string): Promise<{ doc_id: string; name: string; content_type: string; size: number; data: string } | null>β Fetch one document (with its base64data), or null.list(namespace?: string): Promise<Array<{ doc_id: string; name: string; namespace: string; content_type: string; size: number; at: string }>>β List stored documents (metadata only β no bytes).put(namespace: string, doc: { doc_id: string; name?: string; content_type?: string; data: string }): Promise<{ doc_id: string; size: number }>β Store/replace a source document.
domain
stability stable Β· client-side only (no bridge op)
Members: Vizum.domain.compile, Vizum.domain.mount, Vizum.domain.open, Vizum.domain.parse, Vizum.domain.summarize
compile(ast: any): Promise<Vizum.Domain>β Pure helper: AST -> Odoo domain array.mount(elOrId: HTMLElement | string, opts: DomainBuilderOpts): Promise<DomainController>β Mount an inline builder into a container (element or element id).open(opts: DomainBuilderOpts): Promise<Vizum.Domain | null>β Open a modal builder; resolves to a domain array, or null if cancelled.parse(domain: Vizum.Domain | string): Promise<any>β Pure helper: Odoo domain (array or string) -> editable AST.summarize(domain: Vizum.Domain | string, opts?: { model?: string; fields?: DomainField[] }): Promise<string>β Human-readable one-liner for a domain (filter chips / previews).
editable
stability stable Β· since 5.19 Β· permission: permissions.editable
Members: Vizum.editable.active, Vizum.editable.apply, Vizum.editable.clear, Vizum.editable.onApply, Vizum.editable.publish
Bridge ops: active, apply, clear, publish
active(): Promise<{ app_key: string; doc_id: string; title?: string; snapshot: string; tools: Array<{ op: string; args?: any; desc?: string }> } | null>apply(appKey: string, docId: string, ops: any[]): Promise<{ ok: boolean }>clear(): Promise<{ ok: boolean }>onApply(cb: (ops: any[], docId: string) => void): () => voidpublish(reg: { doc_id: string; title?: string; snapshot: string; tools?: Array<{ op: string; args?: any; desc?: string }> }): Promise<{ ok: boolean }>
errors
stability stable Β· client-side only (no bridge op)
Members: Vizum.errors.capture, Vizum.errors.catalog (since 5.67), Vizum.errors.is (since 5.67), Vizum.errors.normalize (since 5.67), Vizum.errors.retry (since 5.67)
readonly _count: numberβ Internal rate-limit counter (capped at 20 per session) β read-only, not something a vApp should set.capture(err: any, ctx?: Record<string, any>): Promise<boolean>catalog(): Record<Vizum.ErrorCode, { category: Vizum.ErrorCategory; retryable: boolean; legacy: string[]; server: string[] }>β SDK 5.67 β the error catalogue this SDK was built with.is(err: unknown, codeOrCategory: Vizum.ErrorCode | Vizum.ErrorCategory): booleanβ SDK 5.67 βis(err, "CONFLICT")oris(err, "network"): a code or a category.normalize(err: unknown): Vizum.VizumErrorβ SDK 5.67 β any failure (a rejected call, an Error, a string, a resolved{ok: false, error}value) as a VizumError.retry<T>(fn: (attempt: number) => Promise<T>, opts?: { retries?: number; baseMs?: number; maxMs?: number }): Promise<T>β SDK 5.67 β retryfnwhile it fails with a retryable error, with exponential backoff (defaults: retries 3, baseMs 250, maxMs 4000); any other error rejects at once.
events
stability stable Β· permission: permissions.events.emit / .listen (built-in host events need none)
Members: Vizum.events.catalog (since 5.67), Vizum.events.emit, Vizum.events.on
Bridge ops: catalog, emit:<name>, listen:<name>
catalog(): Promise<Array<{ name: string; version: number; description: string; schema: Record<string, unknown>; producer: string }>>β 5.67 β the event contracts installed apps declare.emit(name: string, payload?: any): Promise<boolean>β Rejects with VALIDATION_FAILED (5.67) when the payload does not match the event's contract in this app's manifestevent_contracts.on(name: string, cb: (payload: any, meta: Vizum.EventMeta | null) => void): Promise<boolean>βmeta(5.67): who emitted the event and the contract version it follows; null for host events and events without a contract's producer.
files
stability stable Β· permission: none (app-scoped); publicUrl/revokePublic: permissions.files_public
Members: Vizum.files.del, Vizum.files.download, Vizum.files.get, Vizum.files.getBlob (since 5.71), Vizum.files.getRange (since 5.71), Vizum.files.list, Vizum.files.publicUrl (since 5.27), Vizum.files.put, Vizum.files.putBlob (since 5.70), Vizum.files.revokePublic (since 5.27)
Bridge ops: del, download, get, getBlob, getRange, list, publicUrl, put, putBlob, revokePublic
del(name: string): Promise<boolean>download(filename: string, data: Blob | ArrayBuffer | Uint8Array | string, contentType?: string): Promise<boolean>β Save a file to the user's disk.get(name: string): Promise<{ name: string; b64: string; mimetype: string; size: number; sha256?: string }>getBlob(name: string): Promise<Blob>β SDK 5.71 β the stored file as a Blob, streamed (no base64).getRange(name: string, offset: number, length: number): Promise<Uint8Array>β SDK 5.71 βlengthbytes (at most 32 MB) fromoffset.list(): Promise<Array<{ name: string; size: number; mimetype: string; sha256?: string | null }>>β sha256 is null for a file stored before 5.67 until it is next read.publicUrl(name: string, opts?: { expires_in?: number }): Promise<{ name: string; url: string; size: number; mimetype: string; expires_at?: string }>β 5.67: the link expires βopts.expires_inseconds, 60 ..put(name: string, b64: string, opts?: { sha256?: string }): Promise<{ name: string; size: number; mimetype?: string; sha256?: string }>β 5.67: the bytes must be what the name says (a .png that is not an image is refused, VALIDATION_FAILED);opts.sha256β the checksum the app computed β refuses a file damaged on the way.putBlob(name: string, blob: Blob, opts?: { sha256?: string; onProgress?: (p: { phase: "hash" | "upload"; sent: number; total: number }) => void }): Promise<{ name: string; size: number; mimetype: string; sha256: string }>β SDK 5.70 β a Blob or File streamed by the host (no base64): past 16 MB, up to permissions.file_limits.max_mb (ceiling 200).revokePublic(name: string): Promise<boolean>β Invalidate every public link previously minted for this file.
format
stability stable Β· client-side only (no bridge op)
Members: Vizum.format.date, Vizum.format.datetime, Vizum.format.money, Vizum.format.number
date(d: Date | string): stringdatetime(d: Date | string): stringmoney(n: number | string): stringnumber(n: number | string, decimals?: number): string
help
stability stable Β· client-side only (no bridge op)
Members: Vizum.help.button, Vizum.help.show
button(title: string, sections: Array<{ h?: string; p?: string; rows?: [string, string][] }>): HTMLButtonElementshow(title: string, sections: Array<{ h?: string; p?: string; rows?: [string, string][] }>): void
identity
stability stable Β· since 5.79 Β· hosts: desktop, native Β· permission: none (like ui.confirm): the host asks the user for the account password in its own dialog; the password never reaches the app. A vApp that names Vizum.identity sets min_platform 5.79
Members: Vizum.identity.confirm (since 5.79)
Bridge ops: confirm
confirm(opts: Vizum.ConfirmRequest): Promise<Vizum.ConfirmResult>β Ask the HOST to re-confirm the user.
import
stability stable Β· client-side only (no bridge op)
Members: Vizum.import.csv, Vizum.import.open, Vizum.import.pick, Vizum.import.xlsx
csv(text: string): { columns: string[]; rows: Array<Record<string, any>> }β Parse CSV text (auto-detects , ; tab; full quote handling).open(opts?: { accept?: string; requiredColumns?: string[] }): Promise<{ columns: string[]; rows: Array<Record<string, any>>; filename: string }>β Picker + parse in one call; validates opts.requiredColumns.pick(accept?: string): Promise<File>β Native file picker; resolves with the chosen File (rejects Error("UserDenied")/Error("NoFile")).xlsx(arrayBuffer: ArrayBuffer): Promise<{ columns: string[]; rows: Array<Record<string, any>> }>β Parse an .xlsx ArrayBuffer (native ZIP + inflate + sharedStrings reader).
instances
stability stable Β· since 5.38 Β· permission: permissions.instances_api
Members: Vizum.instances.pass (since 5.38)
Bridge ops: pass
pass(): Promise<{ token: string; expiresAt: number }>
jobs
stability stable Β· permission: none (the app's own manifest jobs)
Members: Vizum.jobs.list, Vizum.jobs.logs, Vizum.jobs.runNow, Vizum.jobs.status
Bridge ops: list, logs, run_now, status
list(): Promise<any[]>logs(name: string): Promise<any[]>runNow(name: string): Promise<any>status(name: string): Promise<any>
license
stability stable Β· permission: none
Members: Vizum.license.current, Vizum.license.openUpgrade, Vizum.license.require
Bridge ops: current, openUpgrade
current(): Promise<{ licensed: boolean; [k: string]: any }>openUpgrade(): Promise<boolean>require(): Promise<{ licensed: boolean; [k: string]: any }>β Throws Error("LicenseRequired") AND opens the store when unlicensed.
mail
stability stable Β· permission: permissions.orm (or own native models)
Members: Vizum.mail.log, Vizum.mail.post
Bridge ops: log, post
log(model: string, id: number, body: string): Promise<{ id: number }>post(model: string, id: number, body: string, opts?: { subject?: string }): Promise<{ id: number }>
map
stability stable Β· client-side only (no bridge op)
Members: Vizum.map.tileUrl (since 5.48)
tileUrl(): string
media
stability stable Β· permission: permissions.rag or permissions.audio
Members: Vizum.media.cancel, Vizum.media.ingest, Vizum.media.jobs, Vizum.media.result, Vizum.media.runNow, Vizum.media.status, Vizum.media.upload
Bridge ops: cancel, ingest, list, result, run_now, status, upload_chunk, upload_finish, upload_init
cancel(id: number): Promise<{ ok: boolean }>ingest(cfg: { source_type: string; source_ref: string; web_url?: string; name?: string; namespace: string; language?: string; max_minutes?: number; scope_kind?: "user" | "group" | "global"; [k: string]: any }): Promise<{ id: number; state: string }>jobs(): Promise<Array<Record<string, any>>>result(id: number): Promise<{ transcript?: { text: string; segments: any[] }; [k: string]: any }>β -> the transcript once state === 'done'.runNow(id: number): Promise<Record<string, any>>status(id: number): Promise<{ state: string; progress?: number; duration_s?: number; chunks_indexed?: number; [k: string]: any }>upload(file: File | Blob, cfg?: { name?: string; language?: string; translate?: boolean; max_minutes?: number; doc_id?: string; index_rag?: boolean; namespace?: string; scope_kind?: "user" | "group" | "global"; window_seconds?: number; onProgress?: (p: { phasβ¦β SDK v5.21 β chunked upload of a LOCAL GB file β async server transcription.
meta
stability stable Β· permission: permissions.orm (or own native models)
Members: Vizum.meta.fields, Vizum.meta.models, Vizum.meta.selection
Bridge ops: fields, models, selection
fields(model: string): Promise<Array<{ name: string; label: string; type: string; required: boolean; readonly: boolean; selection: Array<[string, string]> | null; relation: string | null }>>models(): Promise<Array<{ model: string; label: string; ops: string[]; fields: string[] | null }>>selection(modelDotField: string): Promise<Array<[string, string]>>
net
stability stable Β· permission: permissions.net (host allowlist)
Members: Vizum.net.fetch, Vizum.net.fetchImage (since 5.74)
Bridge ops: fetch, fetchImage
fetch(url: string, opts?: { method?: string; headers?: Record<string, string>; body?: any; timeout?: number }): Promise<{ status: number; ok: boolean; headers: Record<string, string>; binary: boolean; body: string; json?: any }>β Headers / URL / string bodies may carry$secret:nameplaceholders β substituted server-side, the value never reaches the browser.fetchImage(url: string, opts?: { max_bytes?: number; timeout?: number }): Promise<{ content_type: "image/png" | "image/jpeg" | "image/gif" | "image/webp"; size: number; sha256: string; base64: string }>β SDK 5.74 β download ONE image server-side.
notifications
stability stable Β· permission: none (app + user scoped)
Members: Vizum.notifications.clear, Vizum.notifications.count, Vizum.notifications.create, Vizum.notifications.list, Vizum.notifications.markRead
Bridge ops: clear, count, create, list, mark_read
clear(): Promise<boolean>count(unread?: boolean): Promise<number>create(n: { title: string; body?: string; priority?: "low" | "normal" | "high"; user_id?: number; action?: Record<string, any> }): Promise<AppNotification>list(query?: { unread?: boolean; limit?: number; offset?: number }): Promise<AppNotification[]>markRead(ids: number[], value?: boolean): Promise<boolean>
oauth
stability stable Β· permission: permissions.oauth
Members: Vizum.oauth.accounts, Vizum.oauth.configure, Vizum.oauth.configured, Vizum.oauth.connect, Vizum.oauth.connected, Vizum.oauth.disconnect, Vizum.oauth.fetch, Vizum.oauth.status
Bridge ops: configure, connect, disconnect, fetch, status
accounts(provider: string): Promise<Array<{ account_ref: string; name: string; shared: boolean; expired: boolean }>>β SDK 5.26 β the accounts of one provider (see status().accounts).configure(provider: string, conf: { client_id: string; client_secret: string; scopes: string; auth_url?: string; token_url?: string; api_base?: string; }): Promise<{ ok: boolean; configured: boolean }>β Admin-only: store the provider's client_id/secret/scopes in the DB registry.configured(provider: string): Promise<boolean>β True once an admin has configured the provider's client_id/secret/scopes.connect(provider: string, opts?: { shared?: boolean } ): Promise<{ provider: string; connected: boolean }>β Host-opened consent popup; resolves when the provider round-trip lands.connected(provider: string): Promise<boolean>disconnect(provider: string, account?: string | null): Promise<boolean>βaccount= "" for the caller's own connection, or a page/business id.fetch(provider: string, path: string, opts?: { method?: string; headers?: Record<string, string>; body?: any }): Promise<{ status: number; ok: boolean; headers: Record<string, string>; binary: boolean; body: string; json?: any }>β Proxied call to the provider's api_base with the user's token injected.status(): Promise<Array<{ provider: string; configured: boolean; connected: boolean; /** SDK 5.26 β the accounts this caller can act as. A brand with five Facebook * pages is FIVE entries, not one boolean. 'expired' = the token lapsed and * cannot be refresheβ¦
ocr
stability stable Β· since 5.4 Β· permission: permissions.ocr
Members: Vizum.ocr.recognize, Vizum.ocr.recognizeBatch, Vizum.ocr.scan, Vizum.ocr.status
Bridge ops: recognize, status
recognize(input: File | Blob | string | { data: string; content_type?: string } | { namespace: string; doc_id: string }, opts?: { want?: "text" | "blocks" | "tables"; languages?: string[]; mode?: "local" | "managed"; pages?: string; dpi?: number; preprocess?:β¦β input: a File | Blob | base64 string | {data,content_type} | {namespace,doc_id}.recognizeBatch(inputs: Array<File | Blob | string>, opts?: { want?: "text" | "blocks" | "tables"; languages?: string[]; onProgress?: (p: { item: number; total: number }) => void } ): Promise<{ results: Array<Vizum.OcrResult | { error: string }>; pages_total: β¦β Batch recognise; per-item progress via opts.onProgress.scan(opts?: { want?: "text" | "blocks" | "tables"; languages?: string[]; camera?: boolean; mode?: "local" | "managed"; pages?: string; dpi?: number; preprocess?: boolean; model?: string; onProgress?: (p: { page: number; total: number }) => void } ): Promise<Vβ¦β Quick capture UI (file, or the device camera withcamera: true) β an OcrResult, or null if cancelled.status(): Promise<{ mode: "local" | "managed"; provider: string; languages?: string[]; available: boolean; credits_per_page?: number | null }>β Which engine is live + a cost hint.
offline
stability stable Β· permission: status/pending: none; run/flush: permissions.actions; datasets/sync/query/readGroup/conflicts/resolve: permissions.offline.datasets; create/write: permissions.offline.writes (5.80)
Members: Vizum.offline.conflicts (since 5.80), Vizum.offline.create (since 5.80), Vizum.offline.datasets (since 5.80), Vizum.offline.flush, Vizum.offline.on, Vizum.offline.onConflict (since 5.80), Vizum.offline.onFlushed, Vizum.offline.onSync (since 5.80), Vizum.offline.pending, Vizum.offline.query (since 5.80), Vizum.offline.readGroup (since 5.80), Vizum.offline.resolve (since 5.80), Vizum.offline.run, Vizum.offline.status, Vizum.offline.sync (since 5.80), Vizum.offline.write (since 5.80)
Bridge ops: conflicts, create, datasets, flush, pending, query, readGroup, resolve, run, status, sync, write
conflicts(): Promise<Vizum.OfflineConflict[]>β SDK 5.80 β the changes the server refused as a conflict and that wait for a decision.create(model: string, vals: Record<string, any>, opts?: Vizum.Idempotent & { expect?: Record<string, any> }): Promise<{ id: string; queued: true; opId: string }>β SDK 5.80 β queue a create (a model inoffline.writeswith opcreate).datasets(): Promise<Vizum.OfflineDataset[]>β SDK 5.80 β the datasets the manifest'soffline.datasetsdeclares, with what the device holds of each.flush(): Promise<{ sent: number; failed: Array<{ id: string; error: string }>; remaining: number }>on(cb: (s: { online: boolean }) => void): voidonConflict(cb: (c: Vizum.OfflineConflict) => void): { stop(): void }β SDK 5.80 β a queued change came back as a conflict.onFlushed(cb: (r: { sent: number; remaining: number }) => void): voidonSync(cb: (r: Vizum.OfflineSyncResult) => void): { stop(): void }β SDK 5.80 β a dataset finished syncing.pending(): Promise<Array<{ id: string; action: string; params: object; ts: number }>>query(name: string, opts?: Vizum.OfflineQuery): Promise<Vizum.OfflineQueryResult>β SDK 5.80 β read a dataset from the device:domain(= != < <= > >= in not in like ilike =like =ilike, & | !),order,limit,offset,fields.readGroup(name: string, opts: Vizum.OfflineReadGroup): Promise<Array<Record<string, any>>>β SDK 5.80 β a read_group over a dataset, on the device: one or twogroupbyfields (date fields with:day|week|month|quarter|year), aggregatesfield:count|sum|avg|min|max.resolve(id: number, choice: "keep_mine" | "keep_server" | "discard"): Promise<{ resolved: boolean; choice: string }>β SDK 5.80 β settle one:keep_minesends it again over the server's values,keep_serverdrops it and shows the server's values,discarddrops it.run(action: string, params?: { ids?: number[]; [k: string]: any }, opts?: Vizum.Idempotent): Promise<{ ok: boolean; result: any } | { id: string; queued: true }>status(): Promise<{ online: boolean }>sync(name?: string): Promise<{ datasets: Vizum.OfflineSyncResult[] }>β SDK 5.80 β bring one dataset (or all) up to date now.write(model: string, ids: Array<number | string>, vals: Record<string, any>, opts?: Vizum.Idempotent): Promise<{ queued: true; opId: string; opIds: string[] }>β SDK 5.80 β queue a write (a model inoffline.writeswith opwrite).
orm
stability stable Β· permission: permissions.orm (or own native models); per-model ops, fields, domain
Members: Vizum.orm.call, Vizum.orm.callOn, Vizum.orm.create, Vizum.orm.live, Vizum.orm.query, Vizum.orm.read, Vizum.orm.readGroup, Vizum.orm.search, Vizum.orm.searchCount, Vizum.orm.searchRead, Vizum.orm.unlink, Vizum.orm.watch, Vizum.orm.write, Vizum.orm.writeBlob (since 5.70)
call(model: string, method: string, args?: any[], opts?: { context?: Vizum.OrmContext }): Promise<any>βopts.context: see {@link Vizum.OrmContext}.callOn(model: string, ids: number[], method: string, args?: any[], opts?: { context?: Vizum.OrmContext }): Promise<any>βopts.context: see {@link Vizum.OrmContext}.create(model: string, vals: object, opts?: { context?: Vizum.OrmContext } & Vizum.Idempotent): Promise<number[]>β SDK 5.67idempotencyKey: a retry with the same key never creates a second record.live(renderFn: () => any, opts?: { interval?: number; debounce?: number; models?: string[] }): { refresh(): void; stop(): void }β SDK v5.4 β LIVE DATA the easy way.query(model: string): Vizum.Queryread(model: string, ids: number[], fields?: string[]): Promise<any[]>readGroup(model: string, domain: Vizum.Domain, groupby: string[], aggregates: string[], opts?: { limit?: number; offset?: number; order?: string }): Promise<any[]>search(model: string, domain?: Vizum.Domain, opts?: object): Promise<number[]>searchCount(model: string, domain?: Vizum.Domain): Promise<number>searchRead(model: string, domain?: Vizum.Domain, fields?: string[], opts?: object): Promise<any[]>unlink(model: string, ids: number[]): Promise<boolean>watch(model: string, domain: Vizum.Domain, cb: (h: { count: number; max_write: string | null }) => void, opts?: { interval?: number }): Vizum.Watcherwrite(model: string, ids: number | number[], vals: object, opts?: { expect?: Record<string, unknown>, context?: Vizum.OrmContext }): Promise<boolean | { updated: boolean; current: Record<string, unknown> }>β Withopts.expect({field: value}, SDK 5.43) this is a COMPARE-AND-SET on a NATIVE model: the rows are locked, re-read, and written only if every expected field still holds its value β and it resolves to `{updated, curβ¦writeBlob(model: string, id: number, field: string, blob: Blob): Promise<{ id: number; field: string; size: number; sha256: string }>β SDK 5.70 β stream a file into a binary field of one record (what a devicefileroute plays); checked likewrite(model, [id], {[field]: base64}).
pairedDevices
stability stable Β· hosts: desktop, native, solo Β· permission: native.routes with auth "device" (permissions.device_routes); changes need base.group_system
Members: Vizum.pairedDevices.claim (since 5.70), Vizum.pairedDevices.link (since 5.70), Vizum.pairedDevices.list (since 5.70), Vizum.pairedDevices.reload (since 5.70), Vizum.pairedDevices.rename (since 5.70), Vizum.pairedDevices.revoke (since 5.70)
Bridge ops: claim, link, list, reload, rename, revoke
claim(pin: string, opts: { name: string; link?: number | null }): Promise<Vizum.PairedDevice>β Claim the PIN a device page shows; the device receives its token on its next poll.link(id: number, link: number | null): Promise<Vizum.PairedDevice>list(): Promise<Vizum.PairedDevice[]>reload(id: number): Promise<Vizum.PairedDevice>β The screen reloads on its next heartbeat.rename(id: number, name: string): Promise<Vizum.PairedDevice>revoke(id: number): Promise<{ ok: true }>β Unpair: the device's token dies now and the page returns to the PIN screen.
perf
stability stable Β· permission: none (sessions: administrator)
Members: Vizum.perf.desktop, Vizum.perf.ping, Vizum.perf.sessions
Bridge ops: desktop, ping, sessions
desktop(): Promise<{ windows: { total: number; open: number; minimized: number; vapps: number; actions: number; byKind: Record<string, number>; activeId: number | null }; memory: { heapMB: number; totalMB: number; limitMB: number; pct: number } | null; fps: nβ¦β This browser tab's live runtime: windows open, JS heap, sampled FPS, desktop state.ping(): Promise<{ latencyMs: number; server: { ts: string; uid: number } }>β Round-trip latency (ms) of a cheap server call β a proxy for how busy the Odoo server is (server CPU/RAM are not exposed to apps).sessions(): Promise<{ supported: boolean; online_users: number | null; online_mine: number; sessions_total: number | null; sessions_mine: number | null; sessions_note?: string }>β ADMIN only: active-session counts across the instance.
permissions
stability stable Β· permission: none
Members: Vizum.permissions.explain, Vizum.permissions.raw
Bridge ops: explain, raw
explain(): Promise<Array<{ capability: string; detail: string; risk: "low" | "medium" | "high" }>>raw(): Promise<Record<string, any>>
phone
stability stable Β· since 5.31 Β· hosts: desktop Β· permission: permissions.media ['microphone']
Members: Vizum.phone.answer, Vizum.phone.dial, Vizum.phone.dtmf, Vizum.phone.hangup, Vizum.phone.mute, Vizum.phone.onState, Vizum.phone.register, Vizum.phone.state, Vizum.phone.unregister
Bridge ops: answer, dial, dtmf, hangup, mute, register, state, unregister
answer(): Promise<any>β Answer what is ringing.dial(opts?: Record<string, any>): Promise<any>dtmf(digits: string): Promise<any>hangup(): Promise<any>β Ends a live call, and REJECTS one that never got answered β a BYE on an unestablished session is ignored and the caller keeps ringing.mute(on?: boolean): Promise<any>onState(cb: (state: string) => void): () => voidβ "registered" | "registration" | "ringing" | "dialing" | "answered" | "ended" | "mute" | "audio_blocked" | "transport_down"register(ticket?: Record<string, any>): Promise<any>state(): Promise<any>unregister(): Promise<any>
rag
stability stable Β· permission: permissions.rag
Members: Vizum.rag.create, Vizum.rag.estimate, Vizum.rag.feedback, Vizum.rag.fields, Vizum.rag.groups, Vizum.rag.indexDoc, Vizum.rag.jobs, Vizum.rag.listChunks, Vizum.rag.models, Vizum.rag.remove, Vizum.rag.runNow, Vizum.rag.update
Bridge ops: create, delete, estimate, feedback, fields, groups, index_doc, list, list_chunks, models, run_now, update
create(cfg: { name: string; model_name: string; domain?: Vizum.Domain; namespace: string; field_names: string[]; scope_kind?: "user" | "group" | "global"; interval_number?: number; interval_type?: "minutes" | "hours" | "days"; max_per_run?: number; [k: stringβ¦estimate(cfgOrId: number | Record<string, any>): Promise<{ ok: boolean; pending: number; this_run: number; est_chunks: number; per_record: number; note?: string }>β Estimate a SAVED job (pass its id) or an ad-hoc config object.feedback(cfg: { ns?: string; namespace?: string; docIds?: string | string[]; doc_ids?: string | string[]; rating: "good" | "ok" | "bad"; q?: string; question?: string; a?: string; answer?: string }): Promise<{ ok: boolean }>β SDK 5.19 (active learning, still evolving) β rate an answer/its sources good | ok | bad, tagged with the doc_id(s) it was grounded in.fields(model: string): Promise<Array<Record<string, any>>>β A model's text+binary fields.groups(): Promise<Array<Record<string, any>>>β Admin β groups eligible for shared-scope jobs.indexDoc(cfg: { namespace: string; doc_id: string; name?: string; content_type?: string; scope?: string }): Promise<{ ok: boolean; doc_id: string; chunks: number; kinds: { text: number; table: number; image: number } }>β SDK v5.19 β MULTIMODAL index of an already-uploaded document (its bytes stored via Vizum.docs).jobs(): Promise<Array<Record<string, any>>>listChunks(cfg: Record<string, any>): Promise<Array<Record<string, any>>>models(): Promise<Array<Record<string, any>>>β Indexable models.remove(id: number): Promise<{ deleted: boolean }>runNow(id: number): Promise<Record<string, any>>update(cfg: { id: number; [k: string]: any }): Promise<Record<string, any>>
realtime
stability stable Β· permission: none (app-scoped)
Members: Vizum.realtime.channel
Bridge ops: publish, subscribe, unsubscribe
channel(name: string): RealtimeChannel
refresh
stability stable Β· client-side only (no bridge op)
Members: Vizum.refresh.on
on(cb: () => void, opts?: { interval?: number; debounce?: number; models?: string[] }): { refresh(): void; stop(): void }
reports
stability stable Β· permission: permissions.reports (per report name)
Members: Vizum.reports.data, Vizum.reports.download, Vizum.reports.html
Bridge ops: data, download, html
data(reportName: string, ids: number[]): Promise<{ b64: string; size: number }>β SDK v3: the PDF as bytes for re-upload (e.g.download(reportName: string, ids: number[], filename?: string): Promise<boolean>html(reportName: string, ids: number[]): Promise<{ html: string }>β Report Builder 1.0: a self-contained HTML string (same-origin CSS already inlined, <script> stripped) β for a live preview inside the app; the app iframe cannot fetch /report/html/...
roles
stability stable Β· permission: none
Members: Vizum.roles.has, Vizum.roles.list, Vizum.roles.mine
Bridge ops: has, list, mine
has(name: string): Promise<boolean>list(): Promise<Array<{ name: string; label: string; active: boolean }>>mine(): Promise<string[]>
scope
stability stable Β· permission: none
Bridge ops: get
script
stability stable Β· permission: permissions.logic = 'script'
Members: Vizum.script.run
Bridge ops: run
run(name: string, payload?: Record<string, any>): Promise<any>
secrets
stability stable Β· permission: permissions.secrets
Members: Vizum.secrets.configure, Vizum.secrets.exists, Vizum.secrets.names
Bridge ops: configure, names
configure(name: string): Promise<{ name: string; configured: boolean }>β Host-rendered dialog where an ADMIN types the value (rejects Error("UserDenied") on cancel).exists(name: string): Promise<boolean>names(): Promise<Array<{ name: string; configured: boolean }>>β Every manifest-declared secret + whether an admin configured it.
sessions
stability stable Β· permission: permissions.sessions (+ administrator)
Members: Vizum.sessions.getPolicy, Vizum.sessions.revoke, Vizum.sessions.revokeUser, Vizum.sessions.setActivityAudit, Vizum.sessions.setActivityAuditGlobal, Vizum.sessions.setLoginAlerts, Vizum.sessions.setSessionTimeout
Bridge ops: getPolicy, revoke, revokeUser, setActivityAudit, setActivityAuditGlobal, setLoginAlerts, setSessionTimeout
getPolicy(): Promise<{ login_alerts: boolean; session_timeout: { enabled: boolean; hours: number; min_hours: number }; activity_audit: { enabled: boolean; global: boolean }; supported: boolean }>β Read the session security policy.revoke(deviceId: number): Promise<{ revoked: number[] }>β Revoke one session by its res.device id (signs that session out).revokeUser(userId: number): Promise<{ revoked: number[] }>β Revoke ALL of a user's other sessions by res.users id.setActivityAudit(on: boolean): Promise<{ login_alerts: boolean; session_timeout: { enabled: boolean; hours: number; min_hours: number }; activity_audit: { enabled: boolean; global: boolean }; supported: boolean }>β Master switch for the activity audit (admin).setActivityAuditGlobal(on: boolean): Promise<{ login_alerts: boolean; session_timeout: { enabled: boolean; hours: number; min_hours: number }; activity_audit: { enabled: boolean; global: boolean }; supported: boolean }>β ADVANCED master switch: also audit native-UI / RPC writes (a global per-write hook on the whole DB).setLoginAlerts(on: boolean): Promise<{ login_alerts: boolean; session_timeout: { enabled: boolean; hours: number; min_hours: number }; activity_audit: { enabled: boolean; global: boolean }; supported: boolean }>β Enable/disable in-app "new sign-in" alerts (admin; needs Odoo 18+).setSessionTimeout(on: boolean, hours?: number): Promise<{ login_alerts: boolean; session_timeout: { enabled: boolean; hours: number; min_hours: number }; activity_audit: { enabled: boolean; global: boolean }; supported: boolean }>β Enable/disable idle-session auto-revoke afterhours(admin; needs Odoo 18+).
settings
stability stable Β· permission: none (set: administrator)
Members: Vizum.settings.get, Vizum.settings.open, Vizum.settings.set (since 5.35)
Bridge ops: get, open, set
get(): Promise<Record<string, any>>β Stored values with manifest defaults merged in.open(): Promise<Record<string, any>>β Opens the host settings dialog (admin saves; rejects Error("UserDenied") on cancel).set(values: Record<string, any>): Promise<Record<string, any>>β SDK 5.35 β write the app's own settings from the app's own form, for choices whose options only exist at runtime (models this gateway offers, knowledge bases that exist).
team
stability stable Β· since 5.60 Β· permission: permissions.team
Members: Vizum.team.invite, Vizum.team.members, Vizum.team.remove, Vizum.team.roles, Vizum.team.save, Vizum.team.setActive, Vizum.team.setPassword, Vizum.team.whoami (since 5.65)
Bridge ops: invite, members, remove, roles, save, set_active, set_password, whoami
invite(id: number): Promise<boolean>β Send the sign-up / reset-password email (needs auth_signup).members(): Promise<Vizum.TeamMember[]>remove(id: number): Promise<boolean>β Take away every role of this app (the user's other groups stay).roles(): Promise<Array<{ name: string; label: string; help: string }>>save(member: { id?: number; name?: string; login?: string; email?: string; phone?: string; role: string; password?: string }): Promise<Vizum.TeamMember>β Create (noid) or update a member;roleis a role name from the manifest.setActive(id: number, active: boolean): Promise<Vizum.TeamMember>setPassword(id: number, password: string): Promise<boolean>whoami(): Promise<{ uid: number; manager: boolean; role: string | false }>β SDK 5.65 β the caller: is a manager of the team, and the role they play.
telemetry
stability stable Β· permission: none
Members: Vizum.telemetry.summary, Vizum.telemetry.track
Bridge ops: summary, track
summary(query?: { event?: string; date_from?: string; date_to?: string }): Promise<{ totals: Record<string, number>; total: number }>track(event: string, n?: number): Promise<boolean>
ui
stability stable Β· permission: permissions.ui lists the op (confirm/openHtml/openDocument/exitFullscreen always allowed)
Members: Vizum.ui.addPaletteCommand, Vizum.ui.confirm, Vizum.ui.editDomain, Vizum.ui.evalDomain, Vizum.ui.exitFullscreen (since 5.85), Vizum.ui.fullscreen (since 5.85), Vizum.ui.notify, Vizum.ui.onFullscreenChange (since 5.85), Vizum.ui.openApp, Vizum.ui.openDocument, Vizum.ui.openHtml, Vizum.ui.openList, Vizum.ui.openRecord, Vizum.ui.openUrl (since 5.21), Vizum.ui.pinWidget, Vizum.ui.setBadge, Vizum.ui.sound
Bridge ops: addPaletteCommand, confirm, editDomain, evalDomain, exitFullscreen, fullscreen, notify, openApp, openDocument, openHtml, openList, openRecord, openUrl, pinWidget, setBadge, sound
addPaletteCommand(label: string): Promise<boolean>confirm(message: string, title?: string): Promise<boolean>β The host's own confirmation dialog (Odoo's, neverwindow.confirm): true on Confirm, false on Cancel, Escape or close.editDomain(model: string, domain?: string, title?: string): Promise<string | null>β Host-rendered REAL Odoo domain builder (DomainSelectorDialog).evalDomain(domainStr: string): Promise<Vizum.Domain>β Evaluate an Odoo domain STRING into a concrete domain ARRAY in the host (py.js), resolving relative-date virtual operators.exitFullscreen(): Promise<{ active: boolean; browser: boolean; supported?: false }>β SDK 5.85: hand the screen back.fullscreen(on?: boolean): Promise<{ active: boolean; browser: boolean; supported?: false }>β SDK 5.85 (grant "fullscreen"): take the whole screen for this app.notify(msg: string, type?: "info" | "success" | "warning" | "danger"): Promise<boolean>onFullscreenChange(cb: (e: { active: boolean }) => void): () => voidβ SDK 5.85:cb({ active: false })when the full screen ends for a reason the app did not ask for (the host's Exit button, Escape, the browser leaving fullscreen).openApp(key: string, payload?: any): Promise<boolean>β Open another INSTALLED vapp by key (grant "openApp").openDocument(doc: { data: string; contentType: string; name?: string }): Promise<{ opened: boolean; mode: "view" | "download"; popupBlocked?: boolean }>β Open the app's OWN stored document (a KB source PDF, an image, a text file).openHtml(html: string, title?: string): Promise<boolean>β Open a self-contained HTML document (e.g.openList(model: string, domain?: Vizum.Domain, label?: string): Promise<boolean>openRecord(model: string, id: number, label?: string): Promise<boolean>openUrl(url: string): Promise<boolean>β Open an EXTERNAL http(s) URL in a new tab.pinWidget(spec: { label: string; model: string; measure?: string; domain?: Vizum.Domain; format?: string; icon?: string; [k: string]: any }): Promise<boolean>β SDK 4.1 (grant "pinWidget"): pin a LIVE KPI to the desktop.setBadge(n: number | string): Promise<boolean>sound(name?: "message" | "notification" | "share" | "snap" | "trash" | "error"): Promise<boolean>β SDK 5.30 β a short synthesized chime for an event the user should HEAR (an arriving chat message above all).
user
stability stable Β· permission: none
Members: Vizum.user.groups, Vizum.user.id, Vizum.user.inGroup, Vizum.user.info, Vizum.user.isAdmin, Vizum.user.isSystem, Vizum.user.login, Vizum.user.name
Bridge ops: groups, in_group, info
groups(): Promise<string[]>id: numberinGroup(xmlid: string): Promise<boolean>info(): Promise<{ id: number; name: string; login: string; isAdmin: boolean; isSystem: boolean; lang: string; tz: string; company: { id: number; name: string } }>isAdmin: booleanisSystem: booleanlogin: stringname: string
vector
stability stable Β· permission: permissions.vector
Members: Vizum.vector.del, Vizum.vector.dump, Vizum.vector.getDocAccess, Vizum.vector.global, Vizum.vector.graph, Vizum.vector.graphClear, Vizum.vector.graphDocs, Vizum.vector.graphRecompute, Vizum.vector.indexedDocs, Vizum.vector.moveScope (since 5.83), Vizum.vector.namespaces, Vizum.vector.pgvActivate, Vizum.vector.pgvBackfill, Vizum.vector.pgvIndex, Vizum.vector.pgvProvision, Vizum.vector.pgvStatus, Vizum.vector.principals, Vizum.vector.search, Vizum.vector.setDocAccess, Vizum.vector.sharedNamespaces (since 5.83), Vizum.vector.stats, Vizum.vector.tags, Vizum.vector.unpack, Vizum.vector.upsert
Bridge ops: delete, docdelete, docget, doclist, docput, dump, get_doc_access, global, graph, graph_clear, graph_docs, graph_recompute, indexed_docs, move_scope, namespaces, pgvactivate, pgvbackfill, pgvindex, pgvprovision, pgvstatus, principals, search, set_doc_access, shared_namespaces, stats, tags, upsert
del(namespace: string, sel: { id?: number | number[] } | { doc_id?: string | string[] } | { doc_id: string | string[]; gen: string | number } | { doc_id: string | string[]; keep_gen: string | number } | { all: true }): Promise<{ deleted: number }>β{doc_id, keep_gen}(platform 5.44) retires every chunk of the doc whosemeta.genis notkeep_gen(commit a rewrite);{doc_id, gen}retires only that generation (roll a failed rewrite back).dump(namespace: string, opts?: { offset?: number; limit?: number }): Promise<{ namespace: string; dim: number; model: string | null; total: number; offset: number; limit: number; items: Array<{ id: number; doc_id: string; seq: number; text: string; meta: Recoβ¦β Small-corpus offline fallback (packed base64 float32; refused above a few thousand vectors).getDocAccess(namespace: string, docId: string): Promise<{ is_public: boolean; users: Array<{ id: number; name: string }>; groups: Array<{ id: number; name: string }>; can_manage: boolean }>β Per-document ACCESS grants (users / groups / public=internal).global(namespace: string, query: string, opts?: Record<string, any>): Promise<Array<{ cid: string; title: string; summary: string; size: number; rank: number; member_names: string[] }>>β GraphRAG G2 global/map-reduce β the community summaries most relevant toquery(broad/thematic "what are the main themes" questions).graph(namespace: string): Promise<{ entities: Array<{ name: string; type: string; rank: number; mentions: number; community: number }>; edges: Array<{ src: string; dst: string; relation: string; weight: number }> }>β GraphRAG G1 β the knowledge graph for a namespace: entities (with PageRankrank+community) + relation edges (scoped like the chunks).graphClear(namespace: string): Promise<{ ok: boolean; cleared: number }>β WIPE the namespace's knowledge graph for the caller's writable scope(s) so it can be rebuilt clean.graphDocs(namespace: string): Promise<{ doc_ids: string[]; counts: Record<string, number> }>β Which documents in a namespace are IN the knowledge graph (the per-doc "in the graph" indicator's authoritative data source) + how many entities mention each.graphRecompute(namespace: string): Promise<{ communities: number; summarized: number; entities_described: number; entities: number; edges: number } | { busy: true }>β GraphRAG on-demand recompute: rebuild the namespace's graph NOW (PageRank + community detection/summaries + entity descriptions) for the CALLER's writable scope, so a "Temas" panel fills right after ingest instead of waβ¦indexedDocs(namespace: string): Promise<{ doc_ids: string[]; counts: Record<string, number>; degraded?: boolean; degraded_reason?: string }>β Which documents are ACTUALLY indexed on the server fornamespace, in the caller's readable scope (global βͺ own βͺ my-groups + per-doc grants), with per-doc chunk counts β server truth for a Library, not a local registrβ¦moveScope(namespace: string, opts: { from: "global" | "user"; to: "user" | "global"; user_id?: number; doc_id?: string | string[]; batch?: number }): Promise<{ moved: number; remaining: number; conflicts: number; done: boolean; from: string; to: string; user_β¦β SDK 5.83 β move one namespace's rows between the shared scope and a user's scope WITHOUT re-embedding: the owner of the stored rows changes (embedding, text, metadata and chunk order stay as stored; nothing is embedded β¦namespaces(): Promise<Array<{ name: string; count: number; model: string | null; dim: number }>>pgvActivate(opts: { active: boolean; force?: boolean }): Promise<{ available: boolean; installed: boolean; column_ready: boolean; active: boolean; mode: string; pending_backfill: number; error?: string; [k: string]: unknown }>β Switch RAG storage to Mode 3 or back to Local (admin, platform 5.45).pgvBackfill(): Promise<{ backfilled: number; skipped?: number; remaining: number; error?: string }>β Migrate existing base64 vectors into pgvector (admin, batched).pgvIndex(opts?: { blocking?: boolean }): Promise<{ extversion: string | null; dims: number[]; present: number[]; built: number[]; invalid: number[]; unindexable?: number[]; max_ann_dim?: number; sql: string[]; blocking: boolean; error?: string; failed?: { dimβ¦β Per-dimension hnsw index state (admin, platform 5.45).pgvProvision(): Promise<{ available: boolean; installed: boolean; column_ready: boolean; active: boolean; mode: string; pending_backfill: number }>β Install the pgvector extension + column in this database (admin).pgvStatus(): Promise<{ available: boolean; installed: boolean; column_ready: boolean; active: boolean; mode: string; pending_backfill: number }>β Mode-3 (local pgvector) setup, for the "RAG pgvector Setup" guide.principals(query: string): Promise<{ users: Array<{ id: number; name: string }>; groups: Array<{ id: number; name: string }> }>β For the share picker β people/groups matchingquery.search(namespace: string, query: string | number[], opts?: { topK?: number; minScore?: number; mode?: "vector" | "keyword" | "hybrid"; filter?: { doc_id?: string | string[] } } ): Promise<{ matches: Array<{ id: number; score: number; text: string; doc_id: strβ¦β Top-k retrieval.setDocAccess(namespace: string, docId: string, cfg: { is_public?: boolean; user_ids?: number[]; group_ids?: number[] } ): Promise<{ ok: boolean }>sharedNamespaces(): Promise<Array<{ name: string; count: number; model: string | null; dim: number }>>β SDK 5.83 (Vizum.supports("vector-scope")) β the namespaces this user-scoped app owns in the SHARED (global) scope, with row counts: what an app that narrowed from vector "global" to "user" must migrate.stats(namespace: string): Promise<{ namespace: string; count: number; dim: number; model: string | null; bytes: number; doc_count: number }>tags(namespace: string): Promise<{ tags: string[]; categories: string[] }>β The distinct tag vocabulary + auto-classified categories in a namespace.unpack(b64: string): Float32Arrayβ Decode one packed base64 float32 blob to a Float32Array (offline path).upsert(namespace: string, items: Array<{ id?: number; text?: string; embedding?: number[]; doc_id?: string; seq?: number; meta?: Record<string, any> }>, opts?: { embed_model?: string }): Promise<{ upserted: number; dim: number; model: string }>β Upsert chunks.
vision
stability stable Β· since 5.11 Β· permission: permissions.media ['camera']
Members: Vizum.vision.onResults, Vizum.vision.start, Vizum.vision.stop
Bridge ops: start, stop
onResults(cb: (r: { hands: Array<{ landmarks: Array<{ x: number; y: number; z?: number }>; handedness: any }>; preview?: string; }) => void): Vizum.Subscriptionstart(opts?: { numHands?: number; preview?: boolean }): Promise<{ ok: boolean; already?: boolean }>stop(): Promise<{ ok: boolean }>
webhooks
stability stable Β· permission: permissions.webhooks
Members: Vizum.webhooks.ack (since 5.67), Vizum.webhooks.deliveries (since 5.67), Vizum.webhooks.fail (since 5.67), Vizum.webhooks.list, Vizum.webhooks.retry (since 5.67), Vizum.webhooks.url
Bridge ops: ack, deliveries, fail, list, retry, url
ack(deliveryId: number): Promise<Vizum.WebhookDelivery>β 5.67 β mark a queued delivery (its row'sv.delivery) processed.deliveries(hook?: string | null, opts?: { status?: Vizum.WebhookDeliveryStatus; limit?: number }): Promise<Vizum.WebhookDelivery[]>β 5.67 β the delivery ledger (metadata only, newest first, at most 200).fail(deliveryId: number, error?: string): Promise<Vizum.WebhookDelivery>β 5.67 β could not process it: re-queued, or dead-lettered at the hook's max_attempts.list(): Promise<Array<{ name: string; url: string; signed: boolean }>>retry(deliveryId: number): Promise<Vizum.WebhookDelivery>β 5.67 β installer or administrator: send a failed / dead-lettered delivery back.url(name: string): Promise<{ url: string }>
window
stability stable Β· permission: none
Members: Vizum.window.close, Vizum.window.openPanel, Vizum.window.resize, Vizum.window.setTitle
Bridge ops: close, openPanel, resize, setTitle
close(): Promise<boolean>openPanel(path: string, opts?: { title?: string; w?: number; h?: number; payload?: any }): Promise<boolean>resize(w: number, h: number): Promise<boolean>setTitle(t: string): Promise<boolean>
workflow
stability stable Β· permission: permissions.workflows
Members: Vizum.workflow.approve, Vizum.workflow.cancel, Vizum.workflow.get, Vizum.workflow.list, Vizum.workflow.reject, Vizum.workflow.start
Bridge ops: approve, cancel, get, list, reject, start
approve(id: number, opts?: { note?: string }): Promise<WorkflowInstance>cancel(id: number): Promise<WorkflowInstance>get(id: number): Promise<WorkflowInstance>list(query?: { workflow?: string; state?: string; mine?: boolean; limit?: number }): Promise<WorkflowInstance[]>reject(id: number, opts?: { note?: string }): Promise<WorkflowInstance>start(name: string, payload?: Record<string, any>): Promise<WorkflowInstance>
Compatibility matrix
Generated from the since values above: the oldest capability that serves each member introduced by a capability note. Members without a since are served everywhere.
| Member | Since | At 5.86 |
|---|---|---|
Vizum.ocr.* | 5.4 | β |
Vizum.audio.* | 5.5 | β |
Vizum.onClose | 5.7 | β |
Vizum.onPause | 5.7 | β |
Vizum.onResume | 5.7 | β |
Vizum.onVisibility | 5.7 | β |
Vizum.audio.recordClip | 5.10 | β |
Vizum.vision.* | 5.11 | β |
Vizum.editable.* | 5.19 | β |
Vizum.ui.openUrl | 5.21 | β |
Vizum.devices.location | 5.24 | β |
Vizum.ai.image | 5.25 | β |
Vizum.files.publicUrl | 5.27 | β |
Vizum.files.revokePublic | 5.27 | β |
Vizum.phone.* | 5.31 | β |
Vizum.settings.set | 5.35 | β |
Vizum.instances.* | 5.38 | β |
Vizum.instances.pass | 5.38 | β |
Vizum.map.tileUrl | 5.48 | β |
Vizum.assistant.kbSearch | 5.52 | β |
Vizum.team.* | 5.60 | β |
Vizum.team.whoami | 5.65 | β |
Vizum.apps.health | 5.67 | β |
Vizum.apps.status | 5.67 | β |
Vizum.errors.catalog | 5.67 | β |
Vizum.errors.is | 5.67 | β |
Vizum.errors.normalize | 5.67 | β |
Vizum.errors.retry | 5.67 | β |
Vizum.events.catalog | 5.67 | β |
Vizum.features | 5.67 | β |
Vizum.platform | 5.67 | β |
Vizum.sdk | 5.67 | β |
Vizum.supports | 5.67 | β |
Vizum.webhooks.ack | 5.67 | β |
Vizum.webhooks.deliveries | 5.67 | β |
Vizum.webhooks.fail | 5.67 | β |
Vizum.webhooks.retry | 5.67 | β |
Vizum.device.blobUrl | 5.70 | β |
Vizum.device.cache | 5.70 | β |
Vizum.device.call | 5.70 | β |
Vizum.device.embed | 5.70 | β |
Vizum.device.embeds | 5.70 | β |
Vizum.device.fileUrls | 5.70 | β |
Vizum.device.fullscreen | 5.70 | β |
Vizum.device.info | 5.70 | β |
Vizum.device.onReload | 5.70 | β |
Vizum.files.putBlob | 5.70 | β |
Vizum.orm.writeBlob | 5.70 | β |
Vizum.pairedDevices.claim | 5.70 | β |
Vizum.pairedDevices.link | 5.70 | β |
Vizum.pairedDevices.list | 5.70 | β |
Vizum.pairedDevices.reload | 5.70 | β |
Vizum.pairedDevices.rename | 5.70 | β |
Vizum.pairedDevices.revoke | 5.70 | β |
Vizum.files.getBlob | 5.71 | β |
Vizum.files.getRange | 5.71 | β |
Vizum.net.fetchImage | 5.74 | β |
Vizum.app | 5.76 | β |
Vizum.identity.* | 5.79 | β |
Vizum.identity.confirm | 5.79 | β |
Vizum.offline.conflicts | 5.80 | β |
Vizum.offline.create | 5.80 | β |
Vizum.offline.datasets | 5.80 | β |
Vizum.offline.onConflict | 5.80 | β |
Vizum.offline.onSync | 5.80 | β |
Vizum.offline.query | 5.80 | β |
Vizum.offline.readGroup | 5.80 | β |
Vizum.offline.resolve | 5.80 | β |
Vizum.offline.sync | 5.80 | β |
Vizum.offline.write | 5.80 | β |
Vizum.device.store | 5.82 | β |
Vizum.vector.moveScope | 5.83 | β |
Vizum.vector.sharedNamespaces | 5.83 | β |
Vizum.ui.exitFullscreen | 5.85 | β |
Vizum.ui.fullscreen | 5.85 | β |
Vizum.ui.onFullscreenChange | 5.85 | β |
Concepts
Composition β apps that need other apps
*Since capability 5.67* (the plain app-id form below works since SDK v4).
An app declares what it needs in requires and what it offers in provides. The platform resolves every dependency against what this database has installed, reports it to the app and to administrators, and stops an administrator from removing an app other apps cannot run without β by accident.
{
"min_platform": "5.67",
"provides": ["crm:leads:v1"],
"requires": [
"com.acme.contacts",
{ "app": "com.acme.inventory", "version": "^1.4" },
{ "app": "com.acme.labels", "version": ">=2.1.0 <3.0.0", "optional": true },
{ "contract": "payments:v1", "optional": true }
]
}
| Entry | Means |
|---|---|
"com.acme.contacts" | that app, any version, required (SDK v4 β every platform accepts it) |
{ "app": β¦, "version": "^1.4", "optional": false } | that app, in a version range |
{ "contract": "payments:v1" } | any ready app whose manifest provides that contract name, exactly |
{ "contract": "payments", "version": "^1.2" } | any ready app that provides payments in a version inside the range (5.69) |
The object forms need min_platform 5.67: an older platform refuses the manifest at install, and vzm check says so.
Versioned contracts (5.69)
Before 5.69 a contract carried its version in its name (payments:v1, payments:v2), so a provider could only say "I am v1". Since 5.69 a provides entry may name the version of the contract the app implements, and a requirement may take a range:
{
"min_platform": "5.69",
"provides": ["payments@1.3.0"],
"requires": [{ "contract": "ledger", "version": "^2.1" }]
}
- A versioned requirement is met only by a provider that names a version inside the range; a provider in another version reports the dependency incompatible (the row's
provided_versionssays which version each provider names). A provider that names no version cannot meet a range. - An unversioned requirement (
{ "contract": "payments" }) accepts a provider in any version. - The 5.67 names keep their meaning:
payments:v1is a name, matched exactly, by every platform. Vizum.apps.find(name)takes the contract name (payments, orpayments:v1).- Removal protection counts only providers in the dependent's range: uninstalling the last provider of
^1.2is refused even while another app providespayments@3.0.0. vzm diffreads a provider's minor bump (@1.3.0β@1.4.0) as compatible, and a new major, or a provider that stops naming its version (@1.3.0β no version), as breaking. A dependent that pins an exact version (1.3.0) instead of a range opts out of minor bumps: semver ranges (^1.3) are what a provider's compatible release promises.
Both forms need min_platform 5.69: an older platform refuses them at install (its provides grammar has no @, and a contract requirement there takes no version), and both the install gate and vzm check (MIN_PLATFORM_TOO_LOW) refuse a manifest that uses them with a lower min_platform. A versioned contract may appear once in provides.
Version ranges
Space-separated comparators that must all hold:
| Range | Accepts |
|---|---|
^1.4 | >=1.4.0 <2.0.0 (for 0.x: the same minor β ^0.2.1 is <0.3.0) |
~1.2.3 | >=1.2.3 <1.3.0 |
1.4.x, 1.x, 1 | that prefix |
1.2.3 / =1.2.3 | exactly that version |
>=1.2.0 <2.0.0 | both |
Status
Each dependency is ready (installed, approved, in range), missing, disabled (installed but waiting for approval or disabled) or incompatible (installed, outside the range). The app as a whole is ok (every required dependency ready), degraded (an optional one is not) or blocked (a required one is not).
const { state, dependencies } = await Vizum.apps.status();
if (state === "blocked") {
const what = dependencies.filter((d) => !d.optional && d.status !== "ready");
showSetupScreen(what); // tell the user what to install or update
}
Installing and updating
A first install never fails for an unmet requirement: the order apps arrive in is not the installer's to choose, so the app installs and reports blocked until what it needs arrives.
Approving a first install whose non-optional requirement is missing or out of range does not make it ready: the app stays in needs_approval with the reason waiting for <app> <range> (Apps Manager health, api_installed_detail().waiting_for). Approving several apps at once still approves the others. This holds for a zero-risk first install too (nothing to approve, a plain Install click): it does not land ready and wait to be refused at open, it waits in needs_approval with the same reason. Once the required app is installed, a normal approval puts it in ready. An app that is already ready while a non-optional requirement is missing (installed before this rule) is refused when opened: "<App> needs <X>, which is not installed". Optional requirements and installed-but-disabled dependencies never hold an app back.
The name of a requirement is not shown to people who cannot open the app. When an app is restricted (Specific people) and a user outside the list opens it, the platform checks the access first and answers the ordinary access error; the "needs X, which is not installed" message, which names the app's requirement, is only for users who may open the app (administrators included).
An update never replaces a working app with a version that cannot run. When the new version requires an app (or contract) that is missing or incompatible, the install is refused with an error naming it, before anything is written: the installed version stays as it was and keeps working. The same refusal covers the store's automatic update, which keeps the app on its current version. An app that is installed and in range but disabled (or waiting for approval) does not refuse the update β an administrator brings it back and nothing is lost β and an optional requirement never does. The store's automatic update waits (no failed attempt counted, the version is not blacklisted) and installs once the dependency arrives. Restoring the rollback snapshot of a failing update is not stopped by this gate. A required app that is ready but still waiting for the Odoo module it replaces counts as ready, so its dependents can be installed in the middle of a module-to-vApp cutover. Approving an update installed before this gate also refuses a missing requirement.
Vizum.apps.requires() returns the same rows with the SDK v4 fields (key, installed, ready) still on them.
A first install never fails because a dependency is missing β apps arrive in whatever order they arrive (an update does, see above). api_install returns the report (dependencies) for a first install or a version change (re-installing the same version answers only {id, key, state}), and the installed-app detail shows it next to required_by.
Removal protection
Uninstalling an app that another ready installed app requires (not optionally) β by id, or as the last ready provider of a contract it requires β is refused, naming the apps that need it. A disabled or unapproved dependent does not hold it in place. An administrator can force it: the desktop's uninstall dialog names the apps that depend on it, and confirming there forces the uninstall (api_uninstall(key, force=true) over RPC); the dependents then report blocked instead of failing without a word. Whether you may uninstall is checked first, so a user who may not learns nothing about the dependents.
Checked before you ship
vzm check reports INVALID_REQUIRES for a range that does not parse, an app requiring itself or a contract it provides (in any version), a duplicate entry or a versioned contract provided twice, and MIN_PLATFORM_TOO_LOW for the object form below 5.67 and for versioned contracts below 5.69.
Group-scoped ops on the models of an app you require (5.78)
A UI app that sits on an owner app (an Engine) often needs the owner's administrators to write where its technicians only read. An orm grant says so with group_ops:
{"model": "x_vzm_elev8dm_brand",
"ops": ["search", "read"],
"group_ops": [{"groups": ["com.elev8.elev8dm/@admin"],
"ops": ["create", "write", "unlink", "call:rebuild_index"]}]}
opsis what everyone who runs the app gets. Eachgroup_opsentry adds itsopsonly for the calling user (never the installer) who is in at least one of itsgroups.fields,write_fields,domainandchatter_onlystay the grant's own and bound both lists.- A group is
"@name"(one of this app'snative.groups),"<app-key>/@name"(a group of an app inrequires, resolved the way that app's own rules resolve it) or a real xmlid such assales_team.group_sale_manager.base.group_system,base.group_erp_managerand any group that implies them are refused. A group that does not exist on the instance grants nothing. - The platform computes it in one place, so
Vizum.orm,call:, actions andread_groupall honour it. In Solo mode the access group every Solo user holds is minted fromopsonly, never fromgroup_ops: a technician who is not in the owner's admin group keeps the owner's own read-only ACL. The group named ingroup_opshas to carry the Odoo ACL for the model itself (the owner'snative.models[].access, or a module). - An op is in
opsor ingroup_ops, not both. For the approval: moving an op fromopsintogroup_opsnarrows the grant (the update applies with no new approval and replaces the approved snapshot); adding agroup_opsop nobody had, or naming one more group for it, widens it.Vizum.app.info-style introspection (api_metamodels) lists the ops the current user holds. min_platform5.78 or later. An older platform ignores the key, so the app would lose the group's ops silently; the validator, the Store gate andvzm check(MIN_PLATFORM_TOO_LOW) refuse the manifest below it.Vizum.supports("orm-group-ops")tells a screen whether the host serves it.
Concepts
Events β between open apps, with a contract
Vizum.events lets the apps open on a desktop talk to each other: one emits, the others listen. Both sides need the grant (permissions.events.emit / .listen). Events are browser-local and immediate β nothing is stored or delivered to someone who is not looking. For data other users or a later session must see, use Vizum.realtime (server fan-out to the users of an app), Vizum.db or webhooks.
await Vizum.events.emit("order.created", { id: 42, total: 99.5 });
Vizum.events.on("order.created", (payload, meta) => {
// meta (5.67): { producer: "com.acme.orders", version: 2 }
});
Event contracts (capability 5.67)
The app that emits an event can declare its shape, once, in its manifest:
{
"min_platform": "5.67",
"permissions": { "events": { "emit": ["order.created"] } },
"event_contracts": {
"order.created": {
"version": 2,
"description": "A sales order was confirmed",
"schema": {
"type": "object",
"required": ["id", "total"],
"additionalProperties": false,
"properties": {
"id": { "type": "integer", "minimum": 1 },
"total": { "type": "number", "minimum": 0 },
"status": { "enum": ["draft", "confirmed"] }
}
}
}
}
}
What the platform does with it:
- At install it checks the contract: only events the app may emit, an integer
version1β999, and a schema written with the supported keywords only βtype,enum,const,properties,required,additionalProperties,items,minItems,maxItems,minLength,maxLength,pattern,minimum,maximum,description. Any other keyword is refused instead of being silently ignored. Apatternthat repeats a group made only of repeated parts β(a+)+,(\w+\s?)+β is refused too: it can take exponential time on the desktop of whoever emits. - A contract reserves the event's name. Once a ready app declares a contract for an event, no other app may emit it: the desktop rejects the emit with
VALIDATION_FAILEDnaming the producer. Adding an event topermissions.events.emitin an update is a permission widening, like adding one tolistenβ an administrator approves it. - On every emit the desktop host checks the payload against the contract. A payload that does not match is not delivered to anyone, and
emitrejects with aVizumErrorVALIDATION_FAILEDnaming each problem (payload.total must be >= 0). - Every listener receives
meta = { producer, version }as the second argument β the app that emitted it and the contract version, so a listener can handle two versions while the producer migrates.versionisnullwhen the producer declares no contract for that event;metaisnullfor the host's own events. Vizum.events.catalog()lists every contract the ready (approved, enabled) installed apps you can see declare:[{ name, version, description, schema, producer }]β what you can listen for, and in which shape.
Versioning. Raise version whenever a listener could break: a removed or renamed field, a changed type, a new required field. Adding an optional field to an open object is not a new version.
vzm check reports INVALID_EVENT_CONTRACT (an event the app does not emit, an unsupported keyword), MIN_PLATFORM_TOO_LOW below 5.67, and EVENT_UNDOCUMENTED (info) for each event the app emits without a contract.
Events run on the desktop. In Odoo's own menus and in Vizum Solo an app is alone in its page, so emit, on and catalog degrade there (Vizum.supports("event-contracts") is false).
Concepts
Lifecycle, health and update preflight
*Since capability 5.67.*
Where an app is
| Lifecycle | Means |
|---|---|
running | approved, and every required dependency is ready |
blocked | approved, but a required dependency is missing, disabled or the wrong version (composition) |
pending_approval | installed; an administrator has not approved it yet (a first install of a privileged app, an update that widened its permissions, or β SDK 5.69 β an update that changed its server code) |
disabled | an administrator switched it off |
withdrawn | the Vizum Store revoked this version (package signing) |
Which companies see it
An app installed from a zip (a developer install) is available to the company the installer was working in. Another company's user who tries to install the same key gets "already installed for the company X ...". A Settings administrator changes that in Apps Manager ("Available to": all companies, or one of the administrator's own companies); the call is vizum.app.api_set_app_company(key, company_id) (false = every company) and each change is audited as platform:company_scope. Apps from the Vizum Store and the first-party defaults stay available to every company: their licence belongs to the whole database, so restricting one is refused and a Store update clears the restriction. This is visibility only; the rows of a company_scoped model stay isolated by company either way. No SDK call and no capability.
How it is doing β Vizum.apps.health()
const h = await Vizum.apps.health();
// { status: "healthy" | "degraded" | "failing", lifecycle: "running", since: "β¦",
// checks: [{ name: "runtime_errors", status: "degraded", detail: "1 error(s) β¦" }, β¦] }
Five checks, from facts the platform already records β nothing to configure:
| Check | healthy | degraded | failing |
|---|---|---|---|
state | approved and running | β | waiting for approval, disabled, or withdrawn |
dependencies | all ready | an optional one is not | a required one is not |
runtime_errors | none | 1β2 | 3 or more |
background_tasks | none failed | 1β2 failed | 3 or more failed |
schema | the database matches the manifest | a declared field's type or comodel in the database differs from the manifest | β |
A sixth check appears only when it has something to say: jobs_time_limit (degraded) names each job whose last run in the window went past the platform's 300 second limit for method jobs (see reliability); a healthy app reports nothing.
Errors and tasks count over the last 24 hours. A runtime error counts only when the app's own code threw it, and not a benign notice β the same evidence the automatic update uses to roll a bad release back. The app's status is its worst check.
The schema check names each drifted field β job.hours: database char, manifest datetime β left by an update that changed a field's type before the platform refused it (see below). It never blocks an update; the cure is a migration in the app.
The desktop's Apps Manager shows this instead of the bare state: one call, api_apps_status(), when it opens, gives every visible app its lifecycle, whether a pending_approval is an *update* waiting (not a first install), the missing apps of a blocked one and β for the installer or an administrator β its health and the checks that are not healthy. The card reads *Ready*, *Needs attention*, *Broken*, *Needs approval*, *Update awaits approval*, *Missing dependency*, *Disabled* or *Withdrawn*, with the reason in its tooltip, and the filter chips count those same words.
An app reads only its own health. An administrator or the app's installer reads any app's with api_health(key), and the installed-app detail includes it.
What an update will do β api_update_preflight
In the Store window, the Update button shows this report before it installs anything. It opens a review sheet that downloads the new version, runs the preflight (nothing is installed or written) and lists data loss, irreversible changes, server code, permissions and migrations. A version with data loss or an irreversible change needs the administrator to tick a box; a version with a blocking finding cannot be confirmed; if the preflight itself fails the sheet blocks and offers *Retry*. The preview download is not counted as a download of the app in the store's statistics.
Before installing a package over an installed version, ask what it would change. Nothing is installed or written.
env["vizum.app"].api_update_preflight(vapp_base64)
{
"ok": true, "blocking": [],
"version": {"from": "1.4.2", "to": "2.0.0", "change": "major"},
"trust": {"state": "store_verified", "publisher": "acme", "publisher_key": "ed25519:β¦"},
"permissions": {"added": ["ai"], "removed": [], "changed": ["net", "orm"], "tier": "privileged",
"needs_approval": true,
"detail": {"ai": {"from": null, "to": true},
"net": {"added": ["api.stripe.com"]},
"orm": {"widened": [{"model": "res.partner", "ops": ["write"],
"fields": ["email"]}]}}},
"code": {"hooks": {"changed": ["sync_orders"], "added": ["refund"]},
"routes": {"changed": ["portal_status"]}},
"schema": {"create": 3, "remove": ["model:x_vzm_acme_old"],
"data_loss": ["model:x_vzm_acme_old"], "refused": []},
"migrations": {"db": [{"version": "2.0.0", "table": "orders"}], "native": []},
"dependencies": {"state": "ok", "dependencies": []},
"reversible": {"reversible": false, "why": ["removes native schema that can hold data"]},
"approval": {"needed": true, "reasons": ["server code changed", "permissions widened"]}
}
blockingβ every install gate that would refuse the package:min_platform, Odoo version and modules, routes, games, licence binding, a revoked version, a signature that does not hold, this instance'spackage_trustpolicy, an admin-only install, and a field whose type or comodel changes (schema.refused, below).permissions.needs_approvalβ the update widens what the app may do, so the app goes back topending_approvaluntil an administrator approves it.permissions.detailβ the change, item by item, against what this database APPROVED: hosts, secrets, devices and every other list grant asadded/removed; scalars asfrom/to; nested grants (OAuth providers and scopes) one level down;ormper model β new models, and for existing ones the operations, fields andwrite_fieldsgained (dropping a field list reads asfields: ["*"], every readable field) and a changeddomain.codeβ server code and entry points that change: script hooks (code or trigger), the script library, model methods, routes, jobs, webhooks, fields with server behaviour (compute,default,formula,aggregate,related,depends), constraints, automations, native migrations, manifestactions, and what the server evaluates innative.uiβ view expressions (QWebt-*,domain,context,attrs/invisible/readonly/required,groups,decoration-*), action domains and context, server-action code, report expressions β asadded/changed/removed. Layout is not code: reordering, relabelling or a plain menu does not count. They run with the grants the app already holds, so since SDK 5.69 a change here sends the app back topending_approvallike a wider grant (below).approvalβ whether installing the package leaves the app waiting for an administrator, and why:first install,server code changed,permissions widened. A zero-tier app (no privileged permission) never waits.schema.data_lossβ native schema the new version stops declaring that can hold data β every app-owned model and every stored field, whether or not it has rows today (an app-owned model is dropped; a field on a shared model is archived, not dropped).reversibleβ whether the update can be undone. The automatic pre-update snapshot restores the app's data (KV and collections,Vizum.dbrows, its files) β not its code; the previous code comes back by reinstalling the earlier package (automatic for an auto-update, which keeps it). Neither brings back native-model data an update drops or a native migration rewrites, and a downgrade may not read newer data.
A server-code change is approved like a wider grant (SDK 5.69)
An update β a new version, the automatic update, or a same-version repackage β whose code is not empty puts the app in pending_approval. Until an administrator approves it, none of its server code runs: hooks, methods, routes, webhooks and jobs run only for an approved app. An app an administrator disabled stays disabled.
- The reference is the installed manifest, compared item by item (the full SHA-256 of each hook's code and trigger, each library part and method, each route, job and webhook, each field's server behaviour, each constraint, automation and migration step). A migration that newly runs an approved hook, or a compute that wires an approved method to a new field, counts as a change: the code that runs changed even though no body did. No fingerprint is stored, so the first update after an instance moves to 5.69 re-asks only if the code really changed.
- An update with the same code (new UI, new assets, a new name) and an app with no server code keep their state. An asset-only refresh of the same version changes nothing.
- The installed-app detail carries
code_changeβ the names that changed since the last approval, never the code β and a second update while the app waits adds to that list instead of replacing it. Approving clears it.
In the desktop, the detail of an app whose update waits for approval starts with a card This update asks for β every line of the permission cards the new version produces that the approved one did not, in the same words β and, when its code changed, a card Server code changed: "Script hooks changed: sync_orders", "Routes added: portal_status"β¦
Only the app's installer or an administrator can plan an update of an installed app; any internal user can plan a first install.
What an update may not change β a field's type or comodel
An update that changes the type of a field that already exists, or the comodel of a relational one, is refused on every instance before anything is written, with the list of fields; api_update_preflight reports it in blocking and schema.refused; the Store refuses to publish it; vzm diff and vzm schema diff exit with SCHEMA_CHANGE_REFUSED. This holds on the app's own models and on the columns it adds to shared ones, whatever the version number.
Why: the platform reconciles the schema by adding and removing fields, never by changing one in place. Measured on Odoo 19 before the refusal existed β a changed type was not applied (the column kept the old type, silently, and the app read and wrote the wrong one), and a changed comodel was rewritten under the stored ids, which then pointed at another model's records.
The way to do it, in one update:
- declare a new field with the new type or comodel;
- fill it from the old one β
{"op": "copy", "model": "job", "from": "hours", "to": "duration"}in this version'snative.migrations(a plain column copy), or a migration hook for a conversion a copy cannot do (a char to a datetime, a partner id to a user id); - stop declaring the old field. It is dropped (or archived, on a shared model) in the same update, after the copy has run.
A rename_field keeps the field's type and comodel, so a rename alone passes; a rename that also changes the type is refused like any other type change. A one2many may change its comodel (it stores no ids; the platform re-points it), never its type. A verified rollback β the automatic one, reinstalling a snapshot whose bytes still match the digest recorded when it was taken β is exempt: it returns the app to a manifest it already ran. The Store compares an upload with every lower version still installable (passed or pending, not revoked), because an instance may jump to it from any of them.
Concepts
Where your data lives β JSON vs native Odoo tables
Vizum gives you three places to keep data, from a zero-ceremony JSON keyβvalue bag up to real Odoo tables that the rest of Odoo β reports, pivots, other modules, Studio β can see. Choose by one question: does anything outside your app need to read this?
The three storage tiers
| Mechanism | What it is | Best for | Visible to native Odoo? (reports / pivots / other modules) |
|---|---|---|---|
Vizum.data |
Per-user or global JSON keyβvalue blobs. No schema. | UI state, preferences, tiny app-private scratch data. | NO β opaque JSON |
Vizum.db |
App-private JSON rows in an internal relational store (SDK v4). Equality indexes, ~1M rows/table. | Structured data only your app needs to query. | NO β app-only JSON |
native.* + Vizum.orm |
Real Odoo tables & columns declared in your manifest (or provided by a companion addon). | Anything accountants, dashboards, automations or sibling apps must read, aggregate or report on. | YES β first-class Odoo |
Vizum.data for tiny UI state, Vizum.db
for app-private structured data nobody else needs, and go native the moment anything else
must see it β Odoo's report engine, a pivot/graph view, an automation, a sibling app, or a real
many2one into res.partner / sale.order.
JSON rows can never be pivoted, filtered or joined by Odoo; native rows always can.
native.* rows cross the ORM boundary into Odoo's reporting and views; JSON storage stays inside your app.Declaring fields on an existing model
Add columns to a shared Odoo model β e.g. surface an app-owned attribute on
res.partner. The platform creates a real manual field named
x_vzm_<app>_<name> and archives it (never drops it)
on uninstall, so data survives.
"permissions": {
"schema": { "models": ["res.partner"], "create_models": false }
},
"native": {
"fields": [
{ "model": "res.partner", "name": "health", "type": "integer", "label": "Health Score" },
{ "model": "res.partner", "name": "tier", "type": "selection", "label": "Tier",
"selection": [["bronze","Bronze"], ["silver","Silver"], ["gold","Gold"]] }
]
}
Declaring a brand-new model
Own a whole table. The platform creates a real model
x_vzm_<app>_<model> with x_-prefixed
columns. Owned models are hard-dropped on uninstall (their rows go with them), so use
them for app-owned data, not shared business records.
"permissions": {
"schema": { "models": [], "create_models": true }
},
"native": {
"models": [
{ "name": "vehicle", "label": "Vehicle",
"access": [{ "group": "base.group_user", "ops": ["read","write","create","unlink"] }],
"fields": [
{ "name": "make", "type": "char", "label": "Make" },
{ "name": "year", "type": "integer", "label": "Year" },
{ "name": "owner", "type": "many2one", "relation": "res.partner", "label": "Owner" },
{ "name": "status", "type": "selection", "label": "Status",
"selection": [["active","Active"], ["sold","Sold"]] }
]
}
]
}
Field types: char, text,
integer, float, monetary, boolean,
date, datetime, selection,
many2one/many2many/one2many (with
relation; a one2many also names its
inverse, from SDK 5.57). From 5.57 a many2one
takes ondelete (set null, cascade,
restrict) and a field takes required β a
document and its lines. From 5.58 your own models also take
default, sequence,
unique, chatter/tracking
and computed fields (formula, related,
aggregate) β see the server-side guide.
Reading & writing native data from app.js
app.js
await Vizum.connect();
const MODEL = "x_vzm_vehicles_vehicle"; // x_vzm_<app>_<model>; fields are x_-prefixed
const rows = await Vizum.orm.searchRead(MODEL, [["x_status","=","active"]],
["x_make","x_year","x_owner"], { order: "id desc", limit: 50 });
const id = await Vizum.orm.create(MODEL,
{ x_make: "Ford", x_year: 2024, x_owner: partnerId }); // bare int id for a many2one
await Vizum.orm.write(MODEL, [id], { x_status: "sold" });
const byYear = await Vizum.orm.readGroup(MODEL, [], ["__count"], ["x_year"]); // real SQL aggregation
Vizum.data blobs and Vizum.db rows
cannot be pivoted, grouped or joined by Odoo's report engine, list/pivot/graph views, or other
modules β only native.* tables can. Migrating JSON β native later means a one-time
backfill; choosing native up front avoids it.
native block raises the app to the
PRIVILEGED tier β an administrator approves it at install β and a
brand-new native model needs the Odoo server restarted once before its rows are queryable
(a registry hot-reload gap; see Troubleshooting). Adding fields
to an existing model needs no restart.
Already using a companion addon?
If your app calls Vizum.orm against models defined by a packaged
Odoo addon you ship alongside (the way the PSI field-service apps use dsd.*),
you are already fully native β those are real tables the rest of Odoo sees, no
native.* needed. The manifest native.*
mechanism is the alternative for apps that want to declare their schema without shipping
a separate module. Don't re-declare an addon's models as native.models
β that creates empty parallel tables and orphans the real data.
Concepts
Theming β look native for free
The host pushes the desktop theme into your app, live. The SDK keeps two things in sync:
| Hook | What it does |
|---|---|
--vizum-accent | CSS variable on :root β the user's accent color. |
.vizum-dark | Class toggled on <html> when the desktop is in dark mode. |
With vizum-glass.css you also get --vz-bg Β· --vz-ink Β·
--vz-card Β· --vz-muted Β· --vz-border plus ready-made
.vz-card Β· .vz-btn Β· .vz-input classes:
.my-panel { background: var(--vz-card); color: var(--vz-ink); }
.my-cta { background: var(--vizum-accent); }
/* nothing else to do β light/dark/accent switches reach you instantly */
SDK & server
The browser SDK β window.Vizum
Load two files, call connect(), and everything below is yours
(within your approved permissions).
Vizum.* and VUI.* calls in your browser
against a built-in mock β no Odoo, no install, nothing leaves your machine. Edit any of the
examples (KV storage, ORM reads, an internal table, a form, a chart, dialogs) and hit Run.
<link rel="stylesheet" href="/vizum_apps_platform/static/sdk/vizum-glass.css">
<script src="/vizum_apps_platform/static/sdk/vizum-sdk.js"></script>
Vizum.connect()
const { caps, theme } = await Vizum.connect();
// caps = the permissions the admin actually approved
// theme = { accent: "#6c5bd8", dark: false } β kept live afterwards
Vizum.data β persistence
await Vizum.data.set("settings", { sound: true, limit: 20 }); // any JSON
const s = await Vizum.data.get("settings"); // null if absent
const keys = await Vizum.data.list();
await Vizum.data.del("settings");
// global-scope apps can react to writes made by OTHER users:
Vizum.data.subscribe("counter", () => refresh());
Vizum.orm β Odoo data
const rows = await Vizum.orm.searchRead(
"sale.order", [["state", "=", "sale"]],
["name", "amount_total"], { limit: 10, order: "amount_total desc" });
const ids = await Vizum.orm.search("sale.order", [["state", "=", "sale"]]);
const recs = await Vizum.orm.read("sale.order", ids, ["name", "state"]);
const count = await Vizum.orm.searchCount("sale.order", []);
const created = await Vizum.orm.create("crm.lead", { name: "From my vapp" });
const newId = created[0]; // β create returns number[] (an array of ids) β take [0] for a single record
await Vizum.orm.write("crm.lead", newId, { priority: "2" }); // passing the array as the id raises "unhashable type: list"
await Vizum.orm.call("res.currency", "name_search", ["USD"]); // needs "call:name_search"
// server-side aggregation (needs the "read_group" op) β dashboards should
// NEVER pull thousands of rows to add them up in JS:
const byMonth = await Vizum.orm.readGroup(
"sale.order", [["state", "=", "sale"]],
["date_order:month"], // groupby (granularity optional)
["amount_total:sum", "__count"], // aggregates
{ limit: 12, order: "date_order:month" });
// β [{ "date_order:month": "2026-05-01", "amount_total:sum": 80214.5, "__count": 31 }, β¦]
// many2one groupby values arrive as [id, display_name]
Denied calls reject with Error("PermissionDenied") β catch them
and degrade gracefully.
Vizum.query β fluent queries (SDK v4)
const hot = await Vizum.query("crm.lead")
.where("stage_id.name", "=", "Proposition")
.where("expected_revenue", ">", 10000)
.orWhere("priority", "=", "3") // (revenue>10k OR priority=3)
.order("expected_revenue desc")
.fields("name", "expected_revenue", "partner_id")
.limit(20)
.all();
const total = await Vizum.query("sale.order").where("state", "=", "sale").count();
const lead = await Vizum.query("crm.lead").where("id", "=", id).first(); // row | null
const pageOf = await Vizum.query("account.move")
.whereIn("move_type", ["out_invoice", "out_refund"])
.group([["state", "=", "posted"], ["state", "=", "draft"]]) // (posted OR draft)
.order("invoice_date desc")
.page(0, 25); // {rows, total, page, pages}
A chainable builder that compiles to a real Odoo domain (inspect it with
.toDomain()) and runs through the same
orm proxy β so the manifest allowlist, your declared
fields and the user's ACLs apply exactly as with
searchRead. where chains
with AND; orWhere ORs with the previous term;
group([...]) adds a parenthesised OR-block.
Terminals: all Β· first Β· count Β· ids Β· page Β·
groupBy. Reads only β use Vizum.orm.create/write
to change data. Available as Vizum.query() or
Vizum.orm.query().
Vizum.net β external APIs (allowlisted)
// manifest: "permissions": { "net": ["api.frankfurter.app"] }
const res = await Vizum.net.fetch("https://api.frankfurter.app/latest?to=EUR");
if (res.ok) {
console.log(res.json.rates.EUR); // .json is pre-parsed for JSON responses
}
// POST with headers β Cookie/Host are stripped, your API keys pass through:
await Vizum.net.fetch("https://api.example.com/items", {
method: "POST",
headers: { "X-Api-Key": "β¦" },
body: { name: "from my vapp" }, // objects are sent as JSON
});
The request happens on the Odoo server, never in the browser: no CORS, no leaked
session. Hosts outside permissions.net reject with
PermissionDenied; binary bodies arrive base64-encoded with
binary: true.
Reach and images (5.74). A host you name is reachable on ports 80 and 443; for another
port write "host:port". "net": ["*"]
is any public website (download-only, never a private or metadata address, privileged,
needs min_platform 5.74).
Vizum.net.fetchImage(url, {max_bytes}) downloads one image
(PNG, JPEG, GIF or WEBP, checked by its bytes) and re-checks every redirect.
Details: Network & images.
Vizum.ui β talk to the desktop
Vizum.ui.notify("Saved!", "success"); // info | success | warning | danger
Vizum.ui.openRecord("res.partner", 42, "ACME"); // opens a REAL Odoo form window
// open a real Odoo LIST window, filtered (grant: "openList"):
Vizum.ui.openList("sale.order", [["partner_id", "=", 42]], "ACME's orders");
// host-rendered confirmation dialog (always available) -> boolean:
const yes = await Vizum.ui.confirm("Post 3 invoices now?", "Approval Center");
if (!yes) { return; }
confirm is Odoo's own dialog on the desktop and in native windows, never the
browser's window.confirm (which a sandboxed frame cannot show). It resolves
true on Confirm and false on Cancel, Escape or close.
Vizum.window β your own window
Vizum.window.setTitle("Pomodoro β 12:25 left");
Vizum.window.resize(480, 600);
Vizum.window.close();
Full screen β Vizum.ui.fullscreen (SDK 5.85)
A kiosk, a signage console or a counter screen wants every pixel. Declare
"ui": ["fullscreen"] and call it from your own button:
if (Vizum.supports("ui-fullscreen")) {
const r = await Vizum.ui.fullscreen(); // -> { active: true, browser: true | false }
Vizum.ui.onFullscreenChange((e) => { if (!e.active) showEnterButton(); });
}
await Vizum.ui.exitFullscreen(); // always allowed, ends only YOUR full screen
Two layers, because of the gesture. The click happens inside your sandboxed iframe, and the
browser's Fullscreen API asks for a fresh user gesture in the page that calls it — the host.
Chrome and Firefox pass the activation up to the host for a few seconds; Safari does not. So the host
always paints your window over the whole viewport with the dock, the Odoo navbar and the
titlebar hidden (a kiosk: no gesture needed, and the window's saved size and position are never
touched), and also asks the browser for real fullscreen. browser
tells you which one you got; a refusal is normal, not an error, and the kiosk still stands.
Leaving. The host always shows a small Exit full screen button, and Escape (or the
browser's own exit) gives the screen back, because a sandboxed app cannot be trusted to. You hear it in
onFullscreenChange({active:false}). Closing, minimising or focusing another
window ends it too. A step-up confirmation (identity.confirm) always leaves full screen first and refuses it while its dialog is open. Desktop and Odoo-menu hosts only; Solo and paired-device pages answer
{active:false, supported:false} (a device page already has
Vizum.device.fullscreen). Set min_platform 5.85;
vzm check flags a missing "fullscreen" entry in
permissions.ui.
Vizum.help β ship documentation with your app
// One call gives you a floating ? button + a styled help overlay. Free.
Vizum.help.button("My App β guide", [
{ h: "Getting started", p: "One-paragraph overview of what the app does." },
{ h: "Shortcuts", rows: [["Ctrl+B", "bold"], ["β³", "refresh the data"]] },
]);
// or open it yourself from any button:
Vizum.help.show("My App β guide", sections);
Every serious app ships in-app help β the Vapp Creator template already includes a
wired-up Vizum.help.button() for you to edit.
Vizum.session & Vizum.format β who is using you (SDK v2)
const { session } = await Vizum.connect();
// { uid, name, login, lang, tz, company: {id, name},
// currency: { name, symbol, position, decimal_places } }
Vizum.format.money(1234.5); // "$ 1,234.50" β REAL Odoo currency & position
Vizum.format.number(0.1234, 2); // locale-aware
Vizum.format.date("2026-06-11");
Vizum.format.datetime("2026-06-11 14:00:00"); // Odoo naive-UTC β user tz
A safe subset only: no tokens, no groups. Stop hardcoding "$" β every serious app
formats through Vizum.format.
Vizum.app.key β your own key (SDK 5.76)
await Vizum.connect();
const key = Vizum.app.key; // "com.acme.crm"
The host sends the key in the connect handshake on every host (desktop, native, Solo,
device). Never parse it out of the URL: on a multi-database host the run path is
/vizum/apps/run/@<db>/<key>/β¦ and a regex over it reads
@<db> as your key. vzm check and the
Store upload gate warn on code that does. Choosing a shell before the first paint? Await
Vizum.connect() first; the key is there when it resolves.
Collections β multi-user data done right (SDK v2)
const tasks = Vizum.data.collection("tasks");
const { id, rev } = await tasks.insert({ title: "Ship v2", done: false });
await tasks.update(id, rev, { title: "Ship v2", done: true }); // rev 1 β 2
// stale writes REJECT instead of clobbering a teammate:
try { await tasks.update(id, 1, { done: false }); }
catch (e) { /* e.message === "rev_conflict" β reload the item */ }
const rows = await tasks.list(); // [{id, rev, value}, β¦] (β€500)
await tasks.remove(id);
tasks.subscribe(() => refresh()); // live, when data:"global"
Each item is its own record with an optimistic revision β the cure for the
classic βtwo users editing one big KV documentβ clobber. Scope follows
permissions.data (user/global) exactly like the KV.
Vizum.files β binary storage (SDK v2)
await Vizum.files.put("export.png", base64Png); // small files; 64 MB/file by default
const f = await Vizum.files.get("export.png"); // {b64, mimetype, size}
const all = await Vizum.files.list(); // 50 files, 128 MB by default
await Vizum.files.del("export.png");
// Big files (5.70+): streamed, no base64 in the browser
await Vizum.files.putBlob("tour.mp4", file, { onProgress: p => bar(p.sent / p.total) });
const part = await Vizum.files.getRange("tour.mp4", 0, 1_000_000); // Range read
Backed by real attachments, scoped like your KV, never reachable through the public
run URL. Stop smuggling base64 blobs through data.set.
Raise the limits with permissions.file_limits (up to 200 MB a file
since 5.70, 512 MB since 5.71, the part above 200 MB behind its own administrator
approval). Above 32 MB the host uploads in parts, re-checks the quota at commit, and since
5.72 resumes after a dropped connection: it asks the server which part is next, sends
only what is missing and, if it gives up, aborts so nothing stays reserved (nothing changes in
your code; Vizum.supports("upload-resume") tells you the host does it).
Details: Files.
Vizum.apps β intents (SDK v2)
// manifest: "permissions": { "apps": ["com.vizumapps.kds"] }
await Vizum.apps.open("com.vizumapps.kds", { table: "T5" });
// the TARGET app receives the payload:
const { intent } = await Vizum.connect(); // when opened by an intent
Vizum.events.on("intent", (p) => { β¦ }); // when already running
Declared targets only, and only if the user has the target installed β
Error("AppNotInstalled") otherwise.
Dock badge & command palette (SDK v2)
Vizum.ui.setBadge(7); // red counter on your dock tile
Vizum.ui.setBadge(""); // clear it
Vizum.ui.addPaletteCommand("POS: new sale"); // shows in the desktop palette
Vizum.events.on("palette", (label) => { β¦ }); // fired when the user runs it
Pin a live KPI to the desktop (SDK 4.1)
Pin any aggregate as a desktop widget that re-queries Odoo on its own timer β
it keeps updating even after your app is closed. Declare
"ui": ["pinWidget"]; the model
must be one your app is granted to read, and the widget runs as the user.
await Vizum.ui.pinWidget({
label: "Open deals",
model: "crm.lead",
measure: "expected_revenue:sum", // "field:sum|avg|min|max"; omit β record count
domain: [["probability", "<", 100]],
format: "cur", // "cur" | "int" | "dec" | "pct" | null
icon: "π―",
});
Clicking the widget opens the matching Odoo list. Sheets Lite ships this:
select a =ODOO(β¦) cell and hit π Pin KPI.
Vizum UI Kit β components for free (SDK v2)
<script src="/vizum_apps_platform/static/sdk/vizum-ui.js"></script>
document.body.appendChild(VUI.table({
columns: [
{ key: "name", label: "Product" },
{ key: "price", label: "Price", align: "right", format: (v) => VUI.money(v) },
],
rows: products,
onRow: (r) => Vizum.ui.openRecord("product.template", r.id, r.name),
}));
document.body.appendChild(VUI.chart.bar(series)); // series = [{label, value}]
// SDK v4.20 β the full chart family, one call each:
VUI.chart.line(series); VUI.chart.area(series); // trend, filled trend
VUI.chart.donut(series); VUI.chart.pie(series); // share-of-total + legend
VUI.chart.hbar(series); // horizontal ranking
VUI.chart.multibar(groups, seriesList, { mode: "stacked" }); // or "grouped"
VUI.chart.multiline(groups, seriesList); // several trends, one plot
// multi-series shape: groups = [{label}], seriesList = [{name, values: [...]}]
const ok = await VUI.confirm("Delete this board?");
VUI.searchPicker({ model: "res.partner", fields: ["name", "email"],
onPick: (r) => setCustomer(r) });
Zero dependencies, XSS-safe by construction (everything renders through
textContent), themed by the same tokens as
vizum-glass.css. Components:
el Β· dialog Β· confirm Β· prompt Β· table Β·
chart.bar/line/area/donut/pie/hbar/multibar/multiline Β·
searchPicker Β· tabs Β· empty Β· form Β· wizard Β· pipeline Β· signature Β· pdf Β· dataGrid Β· xlsx.
VUI.dataGrid β the enterprise grid (SDK v3)
const grid = VUI.dataGrid({
model: "account.move",
columns: [
{ field: "name", label: "Number" },
{ field: "partner_id", label: "Customer" }, // many2one renders its name
{ field: "invoice_date", label: "Date", type: "date" },
{ field: "amount_total", label: "Total", type: "money", total: true, editable: true },
],
domain: [["move_type", "=", "out_invoice"]],
search: ["name", "partner_id"], // top search box
groupBy: "partner_id", // grouped headers w/ counts + Ξ£, click to drill in
orderBy: "invoice_date desc", pageSize: 25, id: "invoices",
actions: [{ label: "β Post", onClick: (ids) => postAll(ids) }],
onOpen: (r) => Vizum.ui.openRecord("account.move", r.id, r.name),
});
container.appendChild(grid);
grid.reload(); // call after external changes
Server-side everything: paging (searchRead +
searchCount), column sort, per-column filters and
grouping (readGroup) all build real Odoo domains β
so the manifest allowlist, your declared fields and the user's ACLs apply to
every byte. Inline editing (double-click) goes through
orm.write and therefore through the host's
confirmation gate. Bulk actions receive the selected ids. The β menu hides
columns per user (persisted). β€ XLSX exports every row matching the
current filters (up to 10k) as a real .xlsx built
in-browser with zero dependencies β also available standalone as
VUI.xlsx("file.xlsx", [{name, rows}]) for any
tabular data (strings, numbers, full unicode).
VUI.wizard β multi-step flows (SDK v4)
const wiz = VUI.wizard({
steps: [
{ title: "Customer", fields: [ // a step is a VUI.form specβ¦
{ name: "name", label: "Company", required: true, width: "full" },
{ name: "email", label: "Email", type: "email" } ] },
{ title: "Items", render: (box, ctx) => { // β¦or draw anything
box.appendChild(buildLineEditor(ctx.values)); } },
{ title: "Confirm", render: (box, ctx) => {
box.appendChild(VUI.el("p", { text: "Create order for " + ctx.values.name + "?" })); } },
],
onCancel: () => win.close(),
onFinish: async (v) => { await Vizum.orm.create("sale.order", toOrder(v)); },
});
container.appendChild(wiz.el);
A guided multi-step flow with a numbered step rail (done / current / pending),
Back / Next / Finish, and accumulated state. A step with
fields builds a VUI.form
and won't advance until it validates; a step with
render(box, ctx) draws anything and reads prior
answers from ctx.values. An optional
validate(values) per step returns an error string
to block. onFinish(allValues) runs busy-stated;
completed steps are clickable to go back. Returns
{el, values(), goTo, next, back}.
VUI.signature + VUI.pdf β capture & document (SDK v4)
const sig = VUI.signature({ width: 420, height: 170, label: "Customer signature" });
container.appendChild(sig.el);
// later β build a proof-of-delivery PDF and stash it on the record:
if (!sig.isEmpty()) {
const doc = VUI.pdf({ size: "A4", margin: 50 });
doc.text("Proof of Delivery", { size: 20, bold: true });
doc.text("Order " + order.name, { y: 120, size: 12, color: [0.3, 0.3, 0.3] });
doc.line(50, 140, 545, 140, { width: 0.8 });
doc.text("Received by:", { y: 160 });
doc.image(sig.toDataURL("image/jpeg"), { x: 50, y: 180, w: 240 }); // JPEG only
doc.save("delivery_" + order.name + ".pdf"); // or doc.toBase64()
await Vizum.files.put("pod_" + order.id + ".pdf", doc.toBase64()); // keep it
}
VUI.signature is a HiDPI-correct canvas pad with smooth strokes, a
placeholder hint and a clear button; isEmpty(),
clear() and
toDataURL()/toBlob() (JPEG-on-white by default).
VUI.pdf is a zero-dependency PDF builder β text (Helvetica /
Helvetica-Bold, color, left/center/right), line,
rect, multi-page
(addPage) and JPEG images (DCTDecode), with
top-left point coordinates. Output as
save(filename) Β· toBase64() Β· toBlob() Β· dataUrl() β
hand the base64 to Vizum.files.put or a net upload.
Images are JPEG-only so embedding stays robust (no PNG predictors) β and
signature.toDataURL() already returns JPEG, so the
two compose directly. For pixel-perfect branded documents use server-side
Vizum.reports (QWeb); reach for
VUI.pdf when an app needs to generate one on the
spot with no template.
VUI.pipeline β a drag-and-drop kanban board (SDK v4)
const board = VUI.pipeline({
model: "crm.lead",
stageField: "stage_id",
stageModel: "crm.stage", // shows empty stages too (else derived from data)
domain: [["type", "=", "opportunity"]],
fields: ["name", "expected_revenue", "partner_id"],
sumField: "expected_revenue", // per-column Ξ£ in the header
search: ["name", "partner_id"],
onOpen: (r) => Vizum.ui.openRecord("crm.lead", r.id, r.name),
// onMove defaults to orm.write({stage_id: newStage}); override for side-effects
});
container.appendChild(board);
board.reload();
One column per stage, cards drag between them, and the move persists through
orm.write β so the manifest allowlist, the host
confirmation gate and the user's ACLs apply to every stage change. Stages come
from stageModel (so empty columns still render),
from an explicit stages list, or are derived from the
data with read_group. Each column shows a live count
and an optional sumField total; supply a
card(rec) renderer for a custom card or let the kit
draw name + amount. Override onMove(id, stageId, rec)
to run a real Odoo method (e.g. callOn to mark won)
instead of a plain write.
A call: result is plain JSON data:
dates and datetimes as ISO strings in UTC ("2026-10-03",
"2026-10-03 12:30:05"), decimals as numbers, UTF-8 bytes
as text, sets and tuples as arrays, a top-level recordset as its ids. The whole result comes back as
true (and the platform logs a warning) when it holds a recordset nested in a
dict or list, bytes that are not UTF-8, NaN or Infinity, any other object, more than 32 levels,
10 000 entries or 4 MiB.
VUI.form β declarative forms with validation (SDK v4)
const f = VUI.form({
columns: 2,
fields: [
{ name: "name", label: "Name", required: true, width: "full" },
{ name: "email", label: "Email", type: "email" },
{ name: "amount", label: "Budget", type: "money", min: 0 },
{ name: "stage", label: "Stage", type: "select",
options: [["new", "New"], ["won", "Won"], ["lost", "Lost"]] },
{ name: "partner_id", label: "Customer", type: "many2one",
model: "res.partner", fields: ["name", "email"] },
{ name: "due", label: "Due", type: "date" },
{ name: "vip", label: "VIP account", type: "checkbox" },
],
values: { stage: "new" },
onSubmit: async (v) => { // busy spinner until it resolves
await Vizum.orm.create("crm.lead", v);
Vizum.ui.notify("Saved", "success");
},
});
container.appendChild(f.el);
// programmatic: f.values(), f.setValues({β¦}), f.validate(), f.reset()
One object describes the whole form; the kit renders inputs, labels, help text
and inline validation, and wires a busy-stated submit button. Field
type: text Β· textarea Β· number Β·
integer Β· money Β· email Β· tel Β· url Β· password Β· select Β· checkbox Β· date Β·
datetime Β· many2one. many2one fields open a
searchPicker over your granted model.
required Β· min Β· max and a custom
validate(value, all) run before
onSubmit; throw {field, message}
from onSubmit to flag a server-side error on a specific
field. Same theme tokens, same XSS-safe rendering as the rest of the kit.
Vizum.orm.watch β live data without hammering (SDK v2.1)
const watcher = Vizum.orm.watch("pos.order",
[["state", "in", ["draft", "paid"]]],
(h) => refresh(), // called ONLY when something changed
{ interval: 6 }); // seconds (4β60, default 8)
watcher.stop();
The desktop polls a cheap server fingerprint (record count + max
write_date, one aggregated query) and wakes your app only on a
change. Needs the search grant on the model; your manifest
domain is injected as always. (The poll pauses while the tab is hidden.)
Vizum.orm.live / Vizum.refresh β auto live data (SDK v5.4)
Don't list the models by hand. Vizum.orm.live(render) runs your
render, notes every model it reads, watches them, and re-runs it whenever any
change β created/updated/deleted by you OR another user β plus when the tab regains
focus. One line replaces a reload timer.
// vanilla: a single idempotent render (clear-then-build) stays live by itself
const live = Vizum.orm.live(async () => {
const rows = await Vizum.orm.searchRead("sale.order", dom, fields);
render(rows); // auto re-runs on any sale.order change / tab focus
});
// live.refresh() β force a re-run now; live.stop() β tear down
// no single render fn (load-once, or a React store)? point refresh at your reload:
Vizum.refresh.on(() => reload()); // fires on any model you've READ changing + on focus
Both build on watch (same cheap fingerprint, same
search grant) β no extra permission. opts:
{ interval?:4β60s, debounce?:ms, models?:[β¦] }.
Vizum.window.openPanel β multi-window apps (SDK v2.1)
// a SECOND window of your app, showing another file of your package:
Vizum.window.openPanel("inspector.html", { title: "Inspector", w: 380, h: 520,
payload: { recordId: 42 } });
// inspector.html reads the payload via Vizum.intent and talks to the main
// window with Vizum.events (same app = same event bus)
Panels close automatically with their parent window.
Vizum.reports β real QWeb PDFs (SDK v2.1)
// manifest: "permissions": { "reports": ["account.report_invoice"] }
await Vizum.reports.download("account.report_invoice", [invoiceId], "invoice.pdf");
Rendered by Odoo's own report engine as the user β record rules apply. Per-report allowlist in the manifest.
i18n β ship translations in the package (SDK v2.1)
// package: i18n/es.json β {"New order": "Nueva orden", "Hello %s": "Hola %s"}
Vizum._t("New order"); // "Nueva orden" when session.lang = es_*
Vizum._t("Hello %s", name);
The SDK loads i18n/<lang>.json (then the short code) at
connect; missing keys fall back to the key itself.
The same files translate the server (5.73). The package's
i18n/<lang>.json also answers tr()
in your model methods: tr("%s items", n) reads the call's
context.lang, tries es_419 then
es, then your source text. Use Odoo's language codes for the files and
the manifest's i18n list (es_419, not
es-419); each translation keeps the placeholders of its source text;
a translations-only update needs no new approval. The Desktop, native-window and Solo hosts all send the user's language
(Vizum.supports("server-i18n")). A language the instance has not installed no
longer breaks the call. Set min_platform 5.73. See
Server-side code.
Native strings from the same files (SDK 5.84)
The package's i18n/<lang>.json also translates what your app puts in
Odoo's backend: field labels and help, selection options, model labels, groups, menus, actions and
view terms. Nothing to declare and no min_platform: a key is the English
text of the manifest ("Customer": "Cliente") or, to remove an ambiguity,
"@native:audit.state=done": "Hecho"
(@native:<model>, .<field>,
#help, =<value>,
@native:@<group>,
@native:<record id>.<field>,
@native:app). The strings are written at install, update, approval and
whenever a language is installed; the English source is never touched, a missing string stays in
English and a translation an administrator typed by hand is kept.
vzm check warns about untranslated native strings
(--strict-i18n makes it an error). See
Server-side code.
Translated messages in hooks and jobs (SDK 5.77)
Trigger hooks (guards included), jobs and methods have
V.tr(source, *args, lang=None, **kwargs), the same lookup as
tr() on your i18n/<lang>.json files.
A guard's V.fail(V.tr("Phone cannot change")) reaches the user in the
language of the write that fired it; a job speaks its job user's language, and
lang=partner.lang translates one mail per recipient (an unknown code is
ignored). The text is looked up first and interpolated after; a missing or malformed translation
gives your source text and never raises. Set min_platform 5.77; the install
gate and vzm check refuse V.tr below it. See
Server-side code.
Who wrote, and who is running β rec["su"] and V.is_service_user() (SDK 5.81)
A trigger hook's payload has rec["su"]: True when the
write that fired it ran in a superuser environment (a method of your app that wrote with
sudo(), platform code, the superuser), False when it
was the user's own write. sudo() keeps the user's id, so
rec["uid"] alone cannot tell the two apart; with su a
guard can say "a ticket is created only through wl_issue". The platform sets it
from the write itself, and your app cannot. V.is_service_user(), in methods, hooks and
jobs, is True when the code runs as your app's own service user (what its jobs,
triggers and device routes run as) and False for any person, an administrator
included: a method can tell that your paired screen, not a person at the counter, called it. Set
min_platform 5.81; the install gate and vzm check refuse
both below it. See Server-side code.
Signing an action β Vizum.identity.confirm (SDK 5.79)
For an approval, a release or a period close, ask the host to re-confirm the user:
const {token} = await Vizum.identity.confirm({action: "qms.doc:42:approve",
meaning: "approval", reason: "Approve SOP-017"}). The host draws its own dialog over a backdrop
that dims the whole desktop and the user types the account password there; your app never
sees it. Spend the single-use token (120 seconds, bound to the user, your app, that exact
action and the session) in server code with
V.identity.consume(token, action): it returns a receipt
(id, name, login, meaning, reason, time) to store on your signature
record, or raises one fixed error for every failure. Put the record in action.
Rejects USER_CANCELLED, RATE_LIMITED
(details.retryAfter) or PERMISSION_DENIED; Vizum Solo
answers UNSUPPORTED_CALL (check Vizum.supports("step-up")).
Every attempt is a row in a ledger only the platform writes. Never draw a password box in your own
window: vzm check and the Store warn
(PASSWORD_INPUT_IN_BUNDLE). Set min_platform 5.79; the
install gate refuses V.identity below it. See
Server-side code.
Paired screens β kiosks, signage, time clocks (SDK 5.70)
A route with auth: "device" serves a page to a screen that has no
Odoo login. The screen shows an 8-digit PIN, an administrator claims it on the desktop, and it
then holds a revocable token. The device's code runs only as the app's own service user
(native.service_user: true), the page can embed YouTube or Vimeo and
keeps an offline cache, and a screen that has not reached the server for 7 days must be paired
again. Vizum.pairedDevices lists, claims (by PIN), renames, links, reloads and revokes them
from the desktop. Everything, with the limits: Device routes.
Screen volume and settings β player control and Vizum.device.store (SDK 5.82)
The handle Vizum.device.embed returns can now steer its player:
setVolume(0β100), mute(),
unmute(), play() and
pause(). The page sends the player's own command to the player it created,
addressed to its exact origin, never a wildcard; a handle the page did not issue is
NOT_FOUND. The app frame has no localStorage, so
Vizum.device.store (get, set,
delete, all) keeps a screen's own settings on the
platform: JSON values of at most 4096 bytes, at most 64 keys per paired device, readable only by that
device of that app, dropped when it is revoked. Set min_platform 5.82;
vzm check refuses Vizum.device.store below it. See
Device routes.
Hashing on the server (SDK 5.75)
Hooks, jobs and native methods have V.sha256(data), a metered SHA-256
hex digest (a string, bytes or base64). It is a fingerprint, for detecting change or
deduplicating β a hash is not a signature; it never proves who made the data. Set
min_platform 5.75. See Server-side code.
Background jobs (SDK v2.1)
// manifest (requires data:"global"; max 5 jobs):
"jobs": [{ "name": "daily_digest", "every_hours": 24,
"webhook": "https://api.example.com/hook" }] // host must be in net
// in the app β react live or on next open:
Vizum.data.subscribe("job:daily_digest", () => buildDigest());
const last = await Vizum.data.get("job:daily_digest"); // {ts, job}
The platform cron writes a tick into your global store on schedule (and optionally POSTs your webhook). Your JS never runs server-side β the tick is the contract.
Camera & microphone (SDK v2.2)
// manifest: "permissions": { "media": ["camera"] } // or "microphone"
// then use the STANDARD web APIs β no Vizum wrapper needed:
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
const codes = await new BarcodeDetector().detect(videoEl); // barcode scanning
Without the manifest entry the sandboxed iframe gets no device access at
all (Permissions-Policy). With it, the host delegates the capability β and the
browser still shows its own camera/mic prompt to the user on first use, so
access is always double-gated. Feature-detect with
Vizum.capabilities().features.includes("media").
Adding media in an update re-triggers approval
(permission widening). See Vizum Barcode for a worked camera scanner.
Vizum.audit β a business diary you can show an auditor (SDK v3)
await Vizum.audit.log("approved_purchase", { purchase_id: 55, amount: 1200000 });
const logs = await Vizum.audit.search({ action: "approved_purchase",
date_from: "2026-06-01" });
// β [{ts, user: [id, name], action, payload, ok, duration_ms, app_version}]
App-scoped: every user of the app reads its trail (an approver wants to see everyone's approvals), never another app's. Payloads cap at 4 KB, entries prune after 180 days, and action runs land here automatically β including failures, written in an isolated transaction so a rollback can't erase the evidence.
Vizum.actions β auditable business operations (SDK v3)
// manifest β the admin approves a CATALOGUE, not loose method calls:
"actions": [
{ "name": "invoice.post", "model": "account.move", "method": "action_post",
"label": "Post invoices", "groups": ["account.group_account_invoice"] }
]
// in the app:
await Vizum.actions.run("invoice.post", { ids: [12, 13, 14] });
Each run: host confirmation dialog β required Odoo groups checked on
top of normal ACLs β record-bound method executes as the user β automatic
audit entry with who/ids/params/result/duration. Declaring actions makes the
app privileged-tier, and adding one in an update requires re-approval. This is
what turns βthe app calls action_postβ into
βfinance approved posting invoices from this app, and every run is on fileβ.
Vizum.webhooks β receive events, stop polling (SDK v3)
// manifest (requires data:"global"):
"webhooks": [{ "name": "whatsapp_inbound", "secret_name": "wa_app_secret" }]
// in the app β give the URL to the provider, react live:
const { url } = await Vizum.webhooks.url("whatsapp_inbound");
Vizum.data.subscribe("c:hook_whatsapp_inbound", refreshChat);
const deliveries = await Vizum.data.collection("hook_whatsapp_inbound").list();
The URL is a capability (an HMAC token bound to app+hook β unguessable,
no session needed). Hooks with a secret_name also
verify Meta-style X-Hub-Signature-256 against the
vaulted secret. GET handles the Meta/WhatsApp subscribe handshake (use the
URL's last segment as the verify token). Deliveries are 64 KB max, kept
FIFO 500 per hook in the app's global collection, with a bus ping so open
desktops update instantly. Shopify orders, Stripe payments, WhatsApp
messages β no polling.
Heads-up β public URL. The webhook URL is built from your Odoo's
web.base.url. If that is an internal address (a LAN
IP like https://192.168.x.x:8090, localhost, a
docker host), external providers like Meta/WhatsApp, Shopify or Stripe can't
reach it. Set the system parameter
vizum_apps_platform.public_base_url (Settings βΈ
Technical βΈ System Parameters) to your public HTTPS origin and the
webhook URL uses that instead β without moving
web.base.url, which also drives email/report links.
The server must still be reachable from the internet (reverse proxy / tunnel)
with a valid TLS certificate.
Vizum.import + VUI.importMapper β Excel in, Odoo records out (SDK v3)
// one call: file picker -> parse -> map columns -> batch create
const res = await VUI.importMapper({ model: "res.partner" });
// res = {created: [idsβ¦], errors: [{row, error}], total}
// or the pieces, when you want your own flow:
const { columns, rows } = await Vizum.import.open({ requiredColumns: ["name"] });
const fields = await Vizum.meta.fields("res.partner"); // labels, types, requiredβ¦
CSV parsing auto-detects , ; tab and handles quoted
delimiters/newlines; .xlsx is read natively (ZIP central directory +
DecompressionStream + sharedStrings β zero
libraries). The mapper auto-suggests by column/label similarity, casts types,
imports in chunks of 50 and falls back to row-by-row on a failed chunk so one
bad line never sinks fifty good ones β every failure reported with its row
number. Vizum.meta.fields() returns metadata only,
and only for models the manifest already grants.
Vizum.jobs β a scheduler you can operate (SDK v3)
// manifest β cron expressions and retries (or keep every_hours):
"jobs": [{ "name": "sync_orders", "cron": "*/15 * * * *",
"webhook": "https://api.acme.com/hook",
"retry": { "count": 3, "backoff": "exponential" } }]
// in the app:
await Vizum.jobs.runNow("sync_orders"); // manual trigger
const st = await Vizum.jobs.status("sync_orders"); // {last_run, fails, retry_atβ¦}
const history = await Vizum.jobs.logs("sync_orders"); // audit-backed run log
Full 5-field cron (*/n, ranges, lists, dow 0-7) at
10-minute platform resolution. Failed webhook deliveries retry with
exponential or linear backoff up to count times β
state is visible in status(), and every run
(scheduled, manual or retry) lands in the audit trail with its outcome.
A job that calls a native method has a 300 s limit. A method cannot be
interrupted from outside, so an overrun is measured, not cut off: it is recorded on the job,
in the audit trail and in the app's health as jobs_time_limit.
Keep a batch under the limit. (A script job has its own 120 s budget, which does stop it.)
Vizum.sessions β see & revoke who is logged in (SDK 5)
// manifest: "permissions": { "sessions": ["revoke", "revokeUser"] } β admin-gated.
// Read the device list through Vizum.orm on res.device (Odoo 18+):
const devices = await Vizum.orm.searchRead("res.device", [],
["user_id", "browser", "platform", "ip_address", "last_activity", "is_current"]);
await Vizum.sessions.revoke(deviceId); // kill ONE session (a res.device id)
await Vizum.sessions.revokeUser(userId); // kill ALL of a user's OTHER sessions
A sandboxed app cannot end an Odoo session itself β the real kill
(res.device._revoke()) is private and identity-checked.
The platform performs it under a base.group_system gate plus a host
confirmation, so you can build a "my devices" panel, an IT force-logout, or a
kill-switch for a lost laptop without shipping a native module. Needs Odoo 18+
for res.device.
Session security policy β alerts, idle timeout, audit (SDK 5)
// manifest: "permissions": { "sessions": ["getPolicy", "setLoginAlerts",
// "setSessionTimeout", "setActivityAudit", "setActivityAuditGlobal"] }
const p = await Vizum.sessions.getPolicy();
// β { login_alerts, session_timeout:{enabled, hours, min_hours},
// activity_audit:{enabled, global}, supported }
await Vizum.sessions.setLoginAlerts(true); // in-app notify on every NEW sign-in
await Vizum.sessions.setSessionTimeout(true, 24); // auto-revoke sessions idle > 24h (min 2h)
await Vizum.sessions.setActivityAudit(true); // turn ON the change-log (see Vizum.audit.activity)
await Vizum.sessions.setActivityAuditGlobal(true); // ADVANCED: also log native-UI / RPC writes
One platform capability replaces a whole "advanced session management" add-on β
no native install. getPolicy() reads on every Odoo
version; the setters are admin-only. Login alerts poll new
res.device sign-ins on a 2-minute cron and notify the
user in-app. Session timeout auto-revokes idle sessions on a cron β with a
2-hour floor that protects active users, because Odoo only refreshes
last_activity about once an hour. Both degrade
gracefully where res.device is absent (Odoo 17);
supported in the policy tells you which side you are on.
Vizum.audit.activity β oldβnew field history (SDK 5)
// manifest β declare WHICH models/fields to change-log, and opt in:
"permissions": { "audit": true },
"native": { "audit": { "models": [
{ "name": "res.users", "fields": ["login", "active", "name"] },
{ "name": "x_vzm_myapp_contract", "fields": ["x_state", "x_amount"] }
] } }
// in the app β read the trail (needs the admin's master switch ON):
const rows = await Vizum.audit.activity({ model: "res.users", limit: 50 });
// β [{ ts, user:[id,name], model, res_id, record_name, action, source,
// fields_changed:["login"], changes:{ login:{old, new} }, summary }]
A field-level "who changed what, from what, to what" diary. Where
Vizum.audit logs business operations you record
explicitly, this captures real oldβnew diffs on the models you declare in
native.audit. Two layers: writes through your app
are captured at the ORM choke point; flip
setActivityAuditGlobal(true) and a global
base write/create/unlink hook also captures native-UI and
external-RPC writes to the same models. Off by default, entries prune after 180 days,
and the query filters by model, res_id, user_id, action, source,
date_from, date_to, limit.
Vizum.db β internal relational tables (SDK v4)
// manifest β typed tables with indexes, internal m2o and Odoo refs:
"tables": {
"commission_run": { "fields": { "period": "string", "state": "selection",
"total": "float" }, "indexes": ["period"] },
"commission_line": { "fields": { "run_id": "many2one:commission_run",
"invoice_id": "odoo:account.move",
"amount": "float" } }
}
// in the app β no 500-row cap, domain search, typed coercion:
const run = await Vizum.db.insert("commission_run", { period: "2026-06", total: 18.5e6 });
const lines = await Vizum.db.search("commission_line",
[["run_id", "=", run.id], ["amount", ">", 0]], { order: "amount desc", limit: 100 });
await Vizum.db.update("commission_run", run.id, { state: "posted" });
A lightweight relational store per app for the data Collections can't
hold well: commission engines, bank reconcilers, import staging, approval
boards. Field types: string Β· text Β· integer Β· float Β·
monetary Β· boolean Β· date Β· datetime Β· selection Β· many2one:<table> Β·
odoo:<model>. Searches take a domain (= != <
<= > >= in like ilike) applied server-side; values are type-coerced
and odoo: references are existence-checked with the
user's ACLs. Rows are scoped user/global like the KV store. Declaring tables
makes the app privileged-tier; adding a table in an update re-triggers
approval.
// manifest "migrations" β applied to stored rows when the version climbs:
"version": "1.2.0",
"migrations": [
{ "version": "1.1.0", "table": "commission_line", "ops": [
{ "rename": ["amt", "amount"] }, // amt β amount on every row
{ "default": ["approved", false] } ] }, // fill missing field
{ "version": "1.2.0", "table": "commission_line", "ops": [
{ "drop": "legacy_note" } ] }
]
Because rows are schema-less JSON, adding a field needs no migration β
new rows carry it, old rows read the default. For renames, drops, copies and
back-fills, declare a migrations entry per
version: when an updated package installs, the platform runs every migration
whose version falls in
(installed, new], in order, transforming the app's
existing rows. Ops: {rename:[a,b]} Β· {drop:f} Β·
{default:[f,v]} Β· {copy:[a,b]}. They run server-side at install time β
there is no runtime API and nothing for the app to call.
// a table can declare validation rules β enforced on every insert/update:
"commission_line": {
"fields": { "email": "string", "amount": "float", "status": "string", "period": "string" },
"rules": [
{ "field": "amount", "required": true, "min": 0 },
{ "field": "email", "format": "email" },
{ "field": "status", "choices": ["draft", "posted"] },
{ "unique": ["period"] }
]
}
Beyond field types, a table's rules are enforced by
the server on every Vizum.db.insert/update β
required, numeric
min/max,
format (email /
url), choices (an
allowed set) and composite unique (no two of the
app's rows share that field combination). A violation rejects the write with a
clear error, so the invariant holds no matter which client wrote it β the same
guarantee an Odoo model constraint gives, declared in the manifest.
Vizum.workflow β a process engine (SDK v4)
// manifest β ordered steps, group assignees, per-step SLA:
"workflows": [{
"name": "purchase_approval",
"steps": [
{ "name": "manager_review", "assignee": "group:purchase.group_purchase_manager", "sla_hours": 24 },
{ "name": "finance_review", "assignee": "group:account.group_account_manager" }
]
}]
// in the app β start, then assignees approve/reject down the chain:
const wf = await Vizum.workflow.start("purchase_approval", { purchase_id: 55 });
// running | approved | rejected | cancelled β wf.step is the live stage
await Vizum.workflow.approve(wf.id, { note: "ok by manager" });
const mine = await Vizum.workflow.list({ state: "running" });
Where Vizum.actions run one approved
operation, a workflow manages a whole process: ordered steps, each with
a group (group:<xmlid>) or the starter
(user:starter) as assignee, optional SLA deadlines,
and approve/reject with notes. Only an assignee of the current step can
act; advancing notifies the next step's assignees via
Vizum.notifications, and a breached SLA escalates
automatically (a 15-minute cron). Every transition lands in the instance history
and the audit trail. Declaring workflows makes the app
privileged-tier.
Vizum.notifications β a persistent per-user inbox (SDK v4)
await Vizum.notifications.create({
user_id: 12, title: "PO00045 pending", body: "Needs your approval",
priority: "high", action: { payload: { purchase_id: 45 } } });
const unread = await Vizum.notifications.count(); // badge it
const rows = await Vizum.notifications.list({ unread: true });
await Vizum.notifications.markRead(rows.map((r) => r.id));
An inbox owned by your app and scoped per user on the server β it survives
reloads, carries read/unread state, a lowΒ·normalΒ·high
priority and a click action, and pings the bus so open
desktops update live. No permission needed: the server scopes every row by
app + user. The inbox is FIFO-bounded and auto-pruned after 90 days. This is the
channel workflows, jobs and webhooks use to reach a human.
Vizum.realtime β ephemeral multi-user pub/sub (SDK v4)
// every open copy of THIS app shares a named room β nothing is stored:
const ch = Vizum.realtime.channel("kds");
ch.on("ticket", (t, { from }) => renderTicket(t));
ch.on("bump", (id) => removeTicket(id));
// publishing reaches every other screen instantly (via the Odoo bus):
await ch.publish("ticket", { id: 7, table: 12, items: [...] });
// later: ch.close();
A live room for screens that must agree right now: kitchen displays,
picking stations, live approval queues, presence cursors. A channel is just a
name every open copy of the same app shares; publish
fans a small JSON event (β€16 KB) out over the Odoo bus to every other
instance, stamped with the sender's user id. Nothing is persisted β when a
screen reloads it has no history, so pair realtime with
Vizum.db or Collections for the durable copy.
Channels are app-scoped by construction (the host only forwards your own app's
messages) and need no permission.
Vizum.offline β act now, sync later (SDK v4)
// run an approved action β or queue it if there's no connection:
const r = await Vizum.offline.run("delivery.confirm", { ids: [orderId] });
if (r.queued) { VUI.toast && VUI.toast("Saved β will sync when back online"); }
Vizum.offline.on((s) => { offlineBanner.hidden = s.online; }); // connectivity flip
Vizum.offline.onFlushed((f) => refresh()); // outbox drained
const queued = await Vizum.offline.pending(); // [{id, action, params, ts}]
const { online } = await Vizum.offline.status();
A vapp runs in an opaque-origin iframe and cannot persist anything
locally β so the host (a normal origin) owns the outbox. While the
browser is offline, offline.run(action, params)
queues the intent in localStorage and returns
{queued: true}; the moment connectivity returns the
host replays each queued action through the same
api_action path, so it is re-validated
server-side (allowlist, groups, ACLs, audit) exactly as if run live. A
permanent rejection (bad action / no access) is dropped and reported in
flush()'s failed list;
a transport error keeps the item queued. The unit of deferral is an
approved action, never arbitrary code β so offline never weakens the
security model. Confirmation is asked once, when the user acts; the automatic
replay does not re-prompt. Needs permissions.actions.
Offline data β read and write with no coverage (SDK 5.80)
// manifest: "offline": {datasets: [...], writes: [...]}, "min_platform": "5.80"
await Vizum.offline.sync(); // bring the device copy up to date
const { records, as_of } = await Vizum.offline.query("stops", { domain: [["x_state", "=", "todo"]], order: "x_seq" });
const made = await Vizum.offline.create("sale.order", { partner_id: 7 }, { expect: { amount_total: 120 } });
await Vizum.offline.write("x_vzm_dsd_visit", [4, made.id], { x_note: "gate code" }); // made.id is a temp id
const issues = await Vizum.offline.conflicts(); // what the server refused
await Vizum.offline.resolve(issues[0].id, "keep_mine"); // or "keep_server", "discard"
The host keeps the datasets your manifest declares on the device and answers
query and readGroup from them with no
network. create and write are queued, applied to the
local rows at once and replayed in order, exactly once, as the user, when the network is back; a queued
write carries the values the device saw and the server answers CONFLICT if
someone changed them. A refused change is listed for the user to keep, replace or discard. The rows
are removed on logout; the queue stays, and signing out with changes or sync issues still pending
warns first and never discards them. Everything is in Offline data;
vzm new offline-field-app scaffolds a working app with tests.
App composition β requires & provides (SDK v4)
// manifest β declare what you need and what you offer:
"requires": ["com.acme.crm_core"],
"provides": ["contract:invoice_printer", "contract:lead_scorer"]
// at runtime β check your deps, discover peers, route to a contract:
const deps = await Vizum.apps.requires(); // [{key, installed, ready}]
if (deps.some((d) => !d.ready)) { showSetupBanner(deps); }
const printers = await Vizum.apps.find("contract:invoice_printer");
if (printers[0]?.ready) { await Vizum.apps.open(printers[0].key, { invoice_id: id }); }
const all = await Vizum.apps.installed(); // [{key, name, version, ready, provides}]
Apps stop being islands. requires names peer vapps
this one needs β Vizum.apps.requires() reports
whether each is installed and ready so you can prompt the user to add a missing
one (advisory, not a hard install block, since apps can arrive in any order).
provides publishes capability contracts
(free-form strings) that any app can discover with
Vizum.apps.find(contract) and then reach through the
existing intent (Vizum.apps.open, gated by
permissions.apps). The catalogue read
(installed()) is low-risk β the user already sees
every app in the launcher β so discovery needs no permission; only
opening a peer does.
Vizum.telemetry β product analytics, privately (SDK v4)
Vizum.telemetry.track("export_clicked");
Vizum.telemetry.track("rows_imported", batch.length);
const s = await Vizum.telemetry.summary({ date_from: "2026-06-01" });
// { totals: { export_clicked: 42, rows_imported: 1880 }, total: 1922 }
Where Vizum.audit records business operations
(who posted which invoice), Vizum.telemetry is the
other axis β product analytics: which features get used, how often.
To stay bounded and privacy-safe it stores only daily aggregated counters
per (app, event, day) β never per-user rows, never
free-form payloads. track(event, n?) bumps today's
counter; summary({event?, date_from?, date_to?})
returns the app's own roll-up. Admins see it under Vizum Apps Admin β
Telemetry. App-scoped and zero-risk.
Vizum.permissions + Vizum.meta β self-aware apps (SDK v4)
// show the user exactly what this app can do (risk-tagged):
const caps = await Vizum.permissions.explain();
// [{capability: "Read & change Contacts", detail: "ops: read, write", risk: "high"}, β¦]
// build dynamic UIs from the granted models' metadata:
const models = await Vizum.meta.models(); // [{model, label, ops, fields}]
const cols = await Vizum.meta.fields("crm.lead"); // [{name, label, type, β¦}]
const stages = await Vizum.meta.selection("crm.lead.priority"); // [["0","Low"], β¦]
Vizum.permissions.explain() turns the app's
approved grants into a human-readable, risk-tagged list β drop it into
a "what can this app do?" panel so users (and you) can audit a vapp from the
inside; raw() returns the snapshot itself.
Vizum.meta exposes metadata for the models the
manifest grants β fields(model),
models() (every granted model + label + ops) and
selection("model.field") β so generic tools (import
mappers, report builders, dynamic forms) can adapt to the schema. Metadata
only: reading actual data still goes through the
orm proxy with every gate intact. Both are
read-only and zero-risk.
Vizum.mail + Vizum.activity β live in Odoo (SDK v4)
// post to the record's chatter (the model needs a "write" orm grant):
await Vizum.mail.post("crm.lead", id, "Called the customer β very interested");
await Vizum.mail.log("crm.lead", id, "Internal note: budget confirmed");
// schedule / list / complete real Odoo activities on the record:
await Vizum.activity.schedule("crm.lead", id,
{ summary: "Send proposal", date_deadline: "2026-06-20" });
const todos = await Vizum.activity.list("crm.lead", id); // [{id, summary, overdue, β¦}]
await Vizum.activity.done("crm.lead", id, todos[0].id, "Sent");
The point of a Vizum app is to live inside Odoo, not beside it.
Vizum.mail posts to a record's chatter
(message_post) β a real message or an internal note;
Vizum.activity schedules, lists and completes the
same activities the rest of Odoo shows in the systray and on the form.
Both ride the model's existing orm grant β a
mutation needs the write op, listing needs
read β and run as the user, so Odoo's ACLs
and record rules decide. No new permission: if your app can already write the
record, it can talk about it.
Vizum.roles β declarative app roles (SDK v4)
// manifest β name your roles and map each to Odoo groups:
"roles": [
{ "name": "manager", "label": "Approver", "groups": ["base.group_system"] },
{ "name": "agent", "groups": ["base.group_user"] }
]
// in the app β gate your OWN UI (real authority stays in Odoo ACLs):
if (await Vizum.roles.has("manager")) { showApproveButton(); }
const mine = await Vizum.roles.mine(); // ["agent", β¦]
const all = await Vizum.roles.list(); // [{name, label, active}]
A clean way to express "who can do what" inside your app without scattering
group xmlids through the UI. Each role maps to one or more Odoo groups;
has()/mine() are
evaluated server-side as the real user (has_group),
so the answer can't be spoofed from the iframe β but it's for
presentation: the server still enforces real ACLs on every
orm/action call. A role
with no groups matches everyone. Read-only and zero-risk, so declaring roles
never changes the install tier.
Vizum.ai β a managed LLM gateway (SDK v4)
// manifest β declare access (true = any gateway model, or an allowlist):
"permissions": { "ai": true }
// in the app β the platform admin owns the provider key:
const out = await Vizum.ai.chat({
system: "You are a terse sales assistant.",
messages: [{ role: "user", content: "Summarise lead #" + id + ": " + notes }],
max_tokens: 400,
});
console.log(out.content, out.usage); // {input, output} tokens
const text = await Vizum.ai.complete("Write a follow-up email subject line.");
const models = await Vizum.ai.models(); // [{id, label, provider}]
const { vectors } = await Vizum.ai.embed(["alpha", "beta"]); // OpenAI gateway
A platform-managed bridge to an LLM: the administrator configures one
gateway once (provider + key + the models to expose, under
Vizum Apps Admin β AI), and any app that declares
permissions.ai can call it. The provider key is
encrypted at rest and never crosses the iframe boundary β exactly like
Vizum.secrets, but owned by the platform. Five
providers ship β anthropic Β·
openai Β· gemini Β·
ollama (Cloud) Β· grok β
all normalised to one shape β {content, tool_calls, usage,
model, finish_reason} β so swapping providers doesn't touch app code.
Every call is metered (token usage per app/user) and written to the audit
trail, the output-token ceiling and an optional monthly budget are enforced
server-side, and permissions.ai can pin the app to
a specific model allowlist. Declaring it makes the app privileged-tier (it can
incur provider cost).
Vizum.assistant β an AI bridge to Odoo & your apps (SDK 5)
// manifest: "permissions": { "assistant": true } β admin-approved, high-risk.
// A server-side agent runs a tool-loop as the REAL user (no sudo): it reads any
// model the user can read and PROPOSES writes you confirm. Per-user history.
const turn = await Vizum.assistant.send(0, "how many open opportunities do I have?");
// β { session_id, text, tool_steps:[β¦], pending_actions:[β¦] }
// the model proposed a write? it never auto-runs β confirm (or reject) it:
const t2 = await Vizum.assistant.send(turn.session_id, "create a lead 'Acme' for partner 14");
if (t2.pending_actions.length)
await Vizum.assistant.confirm(t2.session_id, t2.pending_actions[0].id);
// per-user chat history (each user sees ONLY their own):
const chats = await Vizum.assistant.sessions();
const msgs = await Vizum.assistant.history(chats[0].id);
await Vizum.assistant.newSession("Q3 review");
await Vizum.assistant.rename(id, "Renamed"); await Vizum.assistant["delete"](id);
The agent runs server-side as the logged-in user, so Odoo's ACLs and
record rules are the real limit β it only sees and changes what that user can.
Reads execute immediately; create/update PROPOSE a pending action
the user must confirm() (a hallucination can't
silently write). Model tiers: technical/infra models
(ir.*, res.users,
mail.mail, payment internals) are blocked;
accounting/stock (account.move,
stock.*) are read-only; everything else is
read + confirm-write. The admin configures the provider once (Anthropic /
OpenAI / Gemini / Ollama Cloud / Grok) β keys never leave the server.
Ships as the AI Assistant app (chat UI + an "Ask AI" desktop entry).
Vizum.perf β desktop health metrics (SDK 5)
// this browser tab's live runtime β windows, JS heap, sampled FPS, desktop state
const d = await Vizum.perf.desktop();
// d.windows {total, open, minimized, vapps, actions, byKind, activeId}
// d.memory {heapMB, totalMB, limitMB, pct} | null d.fps d.desktop {active, mobile, effects, spaceβ¦}
// server responsiveness β round-trip latency of a cheap call (server CPU/RAM aren't exposed)
const { latencyMs } = await Vizum.perf.ping();
// ADMIN only β active-session counts across the instance (sessions_* need Odoo 18+; null on 17)
const s = await Vizum.perf.sessions(); // {supported, online_users, online_mine, sessions_total, sessions_mine}
Read-only health metrics for monitoring the Vizum desktop's impact on
speed, memory and the server. The sandboxed iframe can't read the parent
tab's performance APIs, so desktop() is computed by
the host. Ships as the admin-only Vizum Monitor app. Degrades gracefully
on Odoo 17 (no res.device session counts).
Vizum.vector & Vizum.docs β Knowledge / RAG (SDK 5β5.2)
// manifest: "permissions": { "vector": "user", "ai": true }
// Vizum.vector is the durable, server-side embedding store (an opaque-origin vapp
// iframe cannot persist anything). Rows are scoped user/global like Vizum.db; embedding
// rides permissions.ai so the provider key never reaches the iframe.
await Vizum.vector.upsert("kb", [
{ text: "chunk one", doc_id: "f1", seq: 0, meta: { src: "policy.pdf" } },
{ text: "chunk two", doc_id: "f1", seq: 1 }]); // host embeds the text
const { matches } = await Vizum.vector.search("kb", "how do I request leave?",
{ topK: 5, mode: "hybrid" }); // vector | keyword | hybrid (RRF) β [{id,score,text,doc_id,seq,meta}]
// Vizum.docs β keep the ORIGINAL document bytes so the index can be RE-BUILT later
// (new embedding model, a storage-mode switch, recovering lost vectors). Stored in the
// FILESTORE (disk), never DB rows, so the database never bloats. Rides permissions.vector.
await Vizum.docs.put("kb", { doc_id: "f1", name: "policy.pdf",
content_type: "application/pdf", data: base64Bytes });
const doc = await Vizum.docs.get("kb", "f1"); // {β¦, data: base64} β re-chunk & re-embed
Three storage modes, chosen by the instance admin (Vizum Apps Admin β AI β Managed Mode β RAG vector storage) β your code is identical in all three; only where the vectors live changes:
| Mode | Where vectors + documents live | Cost / setup |
|---|---|---|
| Local default | this database (embeddings in-row, documents in the filestore), searched in memory | none β zero setup |
| Managed | vizumapps.com pgvector + filestore, per instance | metered $/1,000 vectorsΒ·month (billed like managed AI); plug-and-play |
| Local pgvector | your Postgres via the pgvector extension β searched in-database (<=> cosine) |
high scale, on-prem, no recurring fee; needs the extension |
// Mode-3 (local pgvector) setup β what the "RAG pgvector Setup" app automates.
// pgvStatus is read-only; pgvProvision / pgvBackfill require an admin user.
const s = await Vizum.vector.pgvStatus(); // {available, installed, column_ready, active, mode, pending_backfill}
if (s.available && !s.column_ready) await Vizum.vector.pgvProvision(); // CREATE EXTENSION + column
if (s.pending_backfill) await Vizum.vector.pgvBackfill(); // migrate existing vectors (no re-embed)
Vector residency is the client's choice because a knowledge base can be sensitive.
stats().backend reports where a namespace currently lives
(local / pgvector / managed).
Switching mode does not migrate existing vectors β re-index from
Vizum.docs, or use Backfill for the localβpgvector
move. This powers the Knowledge (RAG) flagship app.
The shared vector scope β sharedNamespaces and moveScope (SDK 5.83)
An app whose approved vector grant is "user" reads and
writes its own rows. An app that narrowed from "global" to
"user" (an update that asks the administrator again) leaves its old rows in the
shared scope, where namespaces() does not list them. Two calls let the
administrator, or the app's own service user in a migration job, find them and move them without paying
for the embeddings again:
// -> [{ name, count, model, dim }]: the namespaces THIS app owns in the shared scope
const shared = await Vizum.vector.sharedNamespaces();
for (const ns of shared) {
let r;
do { // each call moves one batch of whole documents; loop until done
r = await Vizum.vector.moveScope(ns.name, { from: "global", to: "user", batch: 2000 });
} while (!r.done); // r = { moved, remaining, conflicts, done, from, to, user_id }
}
What a move does. The owner of the stored rows changes; the embedding, text, metadata, chunk order and
doc_id stay exactly as stored, no embedding is computed and nothing is metered.
A call is one transaction, bounded by batch (default 2000, at most 10000; whole
documents move together), and re-running it after done moves nothing. A row whose
(doc_id, chunk_seq) the destination already holds is never overwritten or
duplicated: it stays in the source and is counted in conflicts. A document that has
fully left the shared scope loses its knowledge graph there and its per-document access grants, so nothing
public keeps naming it; the graph of the destination is rebuilt by re-indexing or
graphRecompute().
Who may call. An administrator, or the app's service user (V.is_service_user());
never a plain user. The store does not record who wrote a shared row, so "the shared rows that belong to this
user" cannot be told; the caller names the target (user_id; an administrator may omit
it for their own scope, the service user must name it). { from: "user", to: "global" }
publishes private knowledge to every user of the app and is administrator-only. A "global"
app has no user scope to move into and is refused; managed RAG storage lists but cannot move. Server code reads
the list with V.vector.shared_namespaces(). Set min_platform
5.83 (the install gate refuses V.vector.shared_namespaces below it, and
vzm check the browser members); Vizum.supports("vector-scope")
tells a screen whether the host serves it.
AI tools β let the Assistant use your app (SDK 5.3)
// manifest.json β tools the AI Assistant can DISCOVER + invoke. With a `model` the
// Assistant CREATEs a record (confirmed); WITHOUT a model your app builds the content.
"ai_tools": [
{ "name": "create_document",
"description": "Build a document. title + content_md (markdown).",
"input": ["title", "content_md"] }
]
// CLIENT-HANDLED β the Assistant opens your app with the request; you build it:
Vizum.ai.tools.handle("create_document", async ({ title, content_md }) => {
const id = await Vizum.orm.create(MODEL, { x_name: title, x_data: mdToHtml(content_md) });
Vizum.ui.notify("Created by AI", "success");
});
The Assistant calls use_vapp_tool(app_key, tool, values) β so a
user can say "draft a 5-slide deck about Q3" and the right app builds it. Vizum Doc,
Vizum Point, Sheets Lite, Dashboard Builder and Report Builder ship example tools.
Vizum.backend β async business operations (SDK v3)
// the unit of execution is an APPROVED manifest action β never code:
"actions": [{ "name": "invoice.post", "model": "account.move",
"method": "action_post", "approval": true }]
const t = await Vizum.backend.enqueue("invoice.post", { ids: bigList });
// queued | waiting_approval | running | done | failed | cancelled
const st = await Vizum.backend.status(t.id); // + per-task log lines
await Vizum.backend.approve(t.id); // admins release gated tasks
Tasks run on the server (2-minute queue tick) as the user who enqueued
them β their ACLs and record rules decide, and a failed action rolls back
its own writes only (savepoint) while the task records the failure honestly.
Actions with "approval": true wait for an
administrator. The window stays free; runs land in the audit trail like any
action. The app states an intent β the bridge validates β the queue
executes: the same contract heavier external workers will honour.
The queue is bounded: at most 500 pending (waiting_approval
+ queued) tasks per app and 100 per user, and
enqueue() beyond that rejects with
QUOTA_EXCEEDED. A task nobody approves within 7 days is
cancelled (last_error: "approval expired"), never deleted.
Vizum.license + Vizum.errors + Vizum.devices (SDK v3)
// licensing-aware features (paid store apps):
const lic = await Vizum.license.current(); // {pricing, licensed, status, expires}
await Vizum.license.require(); // throws + opens the store when unlicensed
// observability β uncaught errors are captured automatically into the audit
// trail (rate-limited); add your own context:
Vizum.errors.capture(err, { screen: "approval_board" });
const errors = await Vizum.dev.logs();
// devices β manifest: "permissions": { "devices": ["print", "serial"] }
await Vizum.devices.print("Picking 00042", ["PICK 00042", "2 x Desk", "1 x Lamp"]);
const port = await navigator.serial.requestPort(); // scales, scanners, printers
print renders your text into a scriptless
host-side frame and opens the system print dialog β labels and tickets without
a single privileged byte. serial delegates the
standard Web Serial API to your sandboxed iframe (Chromium; the browser shows
its own port chooser). Both are manifest grants and count as permission
widening on update.
Receipt printing & scan capture (SDK 4.1)
// ESC/POS receipt to the user's default paired printer (USB / Bluetooth-BLE /
// Serial β paired once in Settings β Devices). grant: "devices": ["print"]
await Vizum.devices.printReceipt([
{ text: "ACME Foods", align: "center", bold: true, size: "double" },
{ text: "Order S00042", align: "center" },
"--------------------------------",
"2 x Desk $ 90.00",
{ text: "TOTAL $120.00", bold: true },
], { cut: true }); // each line = a string OR {text, align, bold, size, underline}
// NFC taps + RFID/barcode reader scans the desktop captures. grant: "devices": ["scan"]
Vizum.devices.onScan(({ type, value }) => { // type: "nfc" | "rfid"
lookupByCode(value);
});
printReceipt sends a real ESC/POS byte stream to the
printer the user paired in Settings β Devices β no port chooser
per print. onScan surfaces hardware the desktop owns
(an NFC reader via Web NFC, or a keyboard-wedge RFID/barcode scanner), so several
apps can share one device. Both live under the
"devices" grant and count as permission widening on update.
Vizum.settings β admin-ready configuration for free (SDK v3)
// manifest β a typed schema, no UI code needed:
"settings": [
{ "key": "default_journal_id", "type": "many2one", "model": "account.journal",
"label": "Journal" },
{ "key": "alert_days", "type": "integer", "default": 7, "label": "Alert after (days)" },
{ "key": "mode", "type": "selection", "options": ["fast", "thorough"], "default": "fast" },
{ "key": "notify_managers", "type": "boolean", "default": true }
]
// in the app:
const cfg = await Vizum.settings.get(); // {alert_days: 7, β¦} with defaults merged
await Vizum.settings.open(); // HOST renders the whole form
Vizum.events⦠// the "settings" event fires with the new values after save
Types: string, integer,
float, boolean,
selection and many2one
(type-ahead picker that searches as the admin β Odoo ACLs apply). Values are
stored per database, validated/coerced server-side, only administrators can
save, and clearing a field falls back to the declared default. This is what
makes apps installable by non-technical admins.
Network host settings. A setting that permissions.net
names with $setting:<key> is a network host the app may
call and send $secret: credentials to, so it is the administrator's
decision, never the app's. Only the host dialog (Vizum.settings.open())
can write it, and only what the administrator typed: the manifest default
of such a key shows as a placeholder and is not stored until the admin enters it, and the dialog
adds a fixed warning beside the field. Vizum.settings.set refuses the
key with an AccessError. When an update newly names a
setting as a network host, the value stored under that key before is cleared on approval:
the administrator enters it in the dialog.
Vizum.secrets β keys that never live in your zip (SDK v3)
// manifest: "permissions": { "secrets": ["openai_api_key"], "net": ["api.openai.com"] }
if (!await Vizum.secrets.exists("openai_api_key")) {
await Vizum.secrets.configure("openai_api_key"); // HOST dialog β admin types it
}
// use it WITHOUT ever seeing it: the server substitutes $secret:β¦ after
// the allowlist checks, so the value never reaches the browser at all:
const r = await Vizum.net.fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { Authorization: "Bearer $secret:openai_api_key" },
body: { model: "gpt-4o-mini", messages: [...] },
});
The vault is write-only from the app's side: names()
/exists() only reveal what is configured, and
configure() opens a host-rendered password input the
iframe can neither draw nor read (cancelling rejects with
Error("UserDenied")). Values are encrypted at rest
(Fernet keyed off database.secret) and adding a
secret in an update is permission widening β re-approval required.
This is what makes serious integrations shippable: HubSpot sync, WhatsApp
connectors, AI assistants β with zero tokens inside the .vapp package.
Vizum.oauth β Google/HubSpot/anything OAuth2, per user (SDK v3)
// manifest: "permissions": { "oauth": ["google"] }
// 1) once per database, an admin registers the provider (System Parameters β
// vizum_apps_platform.oauth_providers): auth_url, token_url, api_base,
// client_id, client_secret, scopes.
if (!await Vizum.oauth.connected("google")) {
await Vizum.oauth.connect("google"); // host popup β provider consent β done
}
const r = await Vizum.oauth.fetch("google", "/calendar/v3/users/me/calendarList");
console.log(r.json.items);
Connections are per user (your calendar, not your colleague's) and the
whole token lifecycle lives server-side: the code exchange uses the client
secret inside Odoo, tokens are stored encrypted, refreshed automatically a
minute before expiry, and fetch() joins your path
to the provider's registered api_base β there is no
way to point it anywhere else. The callback round-trip is HMAC-signed and
expires in 10 minutes. Combined with Vizum.secrets
this unlocks real connectors: Google Calendar β Odoo scheduler, HubSpot sync,
WhatsApp dashboards β with zero credentials in the package.
Tooling β typings, mock, schema (SDK v2.1)
| File or call | What it gives you |
|---|---|
/vizum_apps_platform/static/sdk/vizum-sdk.d.ts |
TypeScript definitions for the whole SDK surface. |
β¦/sdk/vizum-sdk-mock.js |
Drop-in mock for unit tests (node/jsdom) β override any method, seed
VizumMock.kv, capture VizumMock.log. |
β¦/sdk/manifest.schema.json |
JSON Schema for CI validation of your manifest. |
Vizum.capabilities() |
{platform, features[]} β feature-detect instead of version-sniffing. |
Vizum.events β app β app
// manifest: "events": { "emit": ["pomodoro.done"], "listen": ["pomodoro.done"] }
Vizum.events.emit("pomodoro.done", { task: "Write docs" });
Vizum.events.on("pomodoro.done", (p) => Vizum.ui.notify("Done: " + p.task));
SDK & server
Server-side code β hooks, triggers, guards and the platform's contracts PRIVILEGED
A vApp runs in a sandboxed iframe, and anything enforced only there can be bypassed by opening the same record in Odoo's own form. A rule that must hold, such as "only the owner may share this" or "an approved request cannot change", has to live on the server. This page covers the server side of a vApp: where its code runs, as whom, what it receives, and the platform contracts it can rely on.
For the capability each feature needs, see Platform capabilities (generated from the platform's own notes). The TypeScript side is in @vizum/vapp-types (vizum.d.ts).
1. Where server code lives
Server code is Python, declared in the manifest under native.script.hooks:
"permissions": { "logic": "script" },
"native": {
"script": {
"hooks": [
{ "name": "approve", "code": "..." },
{ "name": "guard_share", "code": "...",
"trigger": { "model": "x_vzm_sheets_doc", "on": "write", "enforce": true } }
]
}
}
permissions.logic: "script"is privileged: an administrator approves it at install time, and the code is AST-vetted at install and again on every run. The code is statements and, from 5.62, top-level functions (see "Functions, a shared library, and the time budget"). There are no imports,lambda,class,withormatch, and no underscore attributes (V._envis unreachable). Loops are allowed, which is why every run has a call budget and, from 5.62, a time budget.- The only rich global is
V:V.orm,V.db(5.33),V.data,V.files(5.27),V.net,V.oauth(5.26),V.ai,V.vector(5.40, read-only; 5.83:shared_namespaces()),V.webhooks(5.67:ack,fail,deliveriesβ settle what a job drained from itshook_*queue, reliability.md),V.json,V.sha256(5.75),V.tr(5.77),V.identity(5.79:consume(token, action)spends a step-up confirmation, identity-step-up.md),V.log,V.fail,V.uid(),V.is_service_user()(5.81), the time helpers (V.now_odoo,V.add_minutes,V.minutes_between,V.clock), andV.commit()in jobs only (5.28). Each run has a call budget: 50net, 10aiand 20vectorcalls. Since 5.67 a script'sV.netandV.files.putalso count against the per-app budgets an administrator sets (QUOTA_EXCEEDED, reliability.md), and a script'sV.filescannot passsha256orexpires_in: its public links get the one-day default (files.md). Since 5.74V.net.fetch_image(url, {max_bytes})downloads one image (redirects re-checked, byte cap, PNG/JPEG/GIF/WEBP only; net.md) and counts as onenetcall. V.orm.readandV.orm.search_readreturn a binary or image field (image_1920,datas) as a base64str, orFalsewhen it is empty, on every Odoo line β the same valueVizum.orm.readgives the iframe. Odoo itself reads it asbyteson 17/18/19 and as{content, size}on 20; the platform normalises it, so a script never branches on the line. Abin_sizeread keeps Odoo's size label. Every value is loaded in full, so a script that only needs to know whether a file exists, or how big it is, reads withcontext={"bin_size": True}instead of pulling the bytes.- A hook ends by setting
result, which is whatVizum.script.runresolves to.
A hook runs in one of three ways:
| How it runs | Declared by | Runs as |
|---|---|---|
App-invoked: Vizum.script.run(name, payload) from the iframe | the hook alone | the calling user, floored by that user's record rules |
| Trigger: on a create, write or (5.56) delete of a model | trigger: {model, on, enforce?} | the app's job user |
| Job: on a schedule | jobs[]: {name, every_hours | cron, script: "<hook>"} (max 5 jobs) | the app's job user |
The job user is job_user_id, or the installer by default. The platform refuses to run a trigger or a job as the superuser, an archived user or a portal user. It logs the refusal and records it in the app's own audit trail. A job is not an ir.cron of its own: one platform cron dispatches every app's jobs, so finding no cron named after your app is expected.
Jobs and permissions.data (5.81)
Before 5.81 a job required permissions.data: "global", because each tick the platform wrote (job:<name>) lives in the app's shared store. Since 5.81 an app with jobs may declare "data": "user". The platform then writes no tick and sends no ping for its jobs (the schedule, Vizum.jobs and the audit trail do not need one), and the job's own V.data is the private scope of the user the job runs as, the app's service user. A job cannot read a human user's private keys; the keys it writes are its own, and no user reads them back through Vizum.data (they are the service user's, not theirs). To hand data to people a job must write it to a model or to a global store. A job of an app without a service user runs as the installer, and its V.data is then that installer's scope. An app that declares jobs with data: "user" must declare min_platform 5.81; the install gate and vzm check enforce it (an older platform refuses the combination), and Vizum.supports("jobs-user-data") tells a screen whether the host serves it.
2. Triggers: the payload
A trigger hook receives rec once per written (or, with on: "unlink", deleted) record. The TypeScript mirror is Vizum.TriggerRec.
| Key | Since | Content |
|---|---|---|
id | β | the record id |
event | 5.32 | "create", "write" or (5.56) "unlink" |
changed | 5.32 | sorted names of the written fields; [] on a create or a delete |
old | 5.32 | pre-image of the written fields on a write; {} on a create; on a delete (5.56), every stored content field as the row is before the deletion |
new | 5.54 | post-image: the written fields on a write, every stored content field on a create; {} on a delete |
uid | 5.54 | env.uid of the write. It is 1 when server code writes as the superuser (a cron, a migration): a guard that decides by uid must handle 1 on purpose |
owner | 5.54 | the row's create_uid, or False |
installing | 5.56 | True while the app's own install or update runs (its migrations). The guard is not skipped then: it is told, and decides |
su | 5.81 | True when the write that fired the trigger ran in a superuser environment (env.su), False when it ran in the user's own environment. See "Who wrote: uid and su" below |
The platform reads old and new with its own rights and applies the same ceilings to both:
- Only fields the app's read grant on that model covers are included.
- Fields the app hides from the hook identity (VZ-SEC-23, see Β§4) are never included.
- A relational value is ids: an id (or
False) for many2one, a list for x2many. - A date or datetime is an ISO string.
- A binary is
{"__binary__": size}. - Text over 64 KB is
{"__text__": length}.
Decide from the payload, do not re-read the row. The hook runs as the job user, whose record rules may hide the very row it guards (a private document is visible only to its owner). This is why 5.54 added uid, owner and new. A hook that reads them sets min_platform: "5.54".
# guard_share β only the owner may change who a document is shared with
if "x_shared_with" in rec["changed"] and rec["uid"] not in (rec["owner"], 1):
V.fail("Only the owner can change who this document is shared with.")
Who wrote: uid and su (5.81)
sudo() keeps the caller's uid, so rec["uid"] is the same for a person's own write and for a write that one of your methods made with sudo() on that person's behalf. rec["su"] tells them apart:
| The write that fired the trigger | rec["uid"] | rec["su"] |
|---|---|---|
a user's own write (the form, V.orm, an RPC) | that user | False |
a method of your app that wrote with self.sudo() / .sudo().create(...) | the user who called the method | True |
platform or Odoo code that wrote with sudo() | the user in that environment | True |
the superuser (a cron, a migration, uid 1) | 1 | True |
Exactly: su is env.su of the records that were written, read by the platform when the write fires the trigger. It is not "the user is an administrator" and not "the app's service user wrote it" (use V.is_service_user() for who the *code* runs as; the service user's own write has su False unless it also called sudo()). The app cannot set it: the platform assigns it on every trigger run, after any context the caller built.
# guard_issue β a ticket is created only by wl_issue, which writes with sudo()
if rec["event"] == "create" and not rec["su"]:
V.fail(V.tr("Tickets are issued from the counter or a screen."))
A hook that reads rec["su"] must declare min_platform 5.81 (the install gate and vzm check refuse it below: on an older platform the key is missing and the guard raises KeyError). Vizum.supports("trigger-su") tells a screen whether the host serves it.
3. A trigger hook that raises aborts the write
A trigger runs inside the transaction of the write that fired it. Any exception it raises, including V.fail(msg), propagates out of Odoo's automation and rolls the triggering write back. The user sees msg as the error. This makes a trigger a guard, and it has three consequences:
- Nothing the hook writes on the failing path survives. An audit row, a counter or a notification written before
V.failis rolled back with the write. To record a refusal, do it from somewhere that does not fail, such as the iframe after it catches the error, or a job. - An ordinary hook that crashes also blocks the write. A
KeyErrorin a numbering hook stops every save of that model. Write non-guard hooks defensively. enforce: truemakes the guard fail closed. Without it, a hook that *cannot run* (the app is not ready, or its job user is missing, archived, portal or the superuser) is skipped, and the write proceeds unguarded. With it, the write is refused instead. Declare it on every hook that exists to refuse writes, and never on one that grants, indexes or numbers.enforceneeds 5.32.
Guarding a deletion (5.56)
on: "unlink" runs the hook before the record is deleted, for every ORM deletion: the Odoo backend (an administrator included), sudo(), crons, odoo shell, and the app's own Vizum.orm.unlink. A hook that raises aborts the deletion, and enforce: true fails closed exactly as for a write. One hook has one event, so a model guarded on write and on delete declares two hooks.
{ "name": "pay_group_delete_guard", "code": "β¦",
"trigger": { "model": "x_vzm_contador365_pay_group", "on": "unlink", "enforce": true } }
# pay_group_delete_guard β a group with members is not deleted
if V.orm.search_count("x_vzm_contador365_employee_link", [["x_pay_group", "=", rec["id"]]]):
V.fail("Move this group's employees to another group first.")
What a delete guard never sees. Deletions that PostgreSQL performs itself do not go through the ORM, so no trigger runs for them:
- the link of a many2one whose target is deleted. Every vApp many2one is
set nullat the database, so the field is simply cleared; - rows removed by an Odoo
ondelete="cascade"field; - the link rows of a many2many.
Guard the record that is actually deleted (the parent) and decide there from what still refers to it. A guard on the child does not run when the parent goes.
Uninstalling the app is never blocked by its own guard. The platform removes the app's automations before its models, and a model's table is dropped, not unlinked row by row.
A vApp that declares on: "unlink" sets min_platform: "5.56"; an older platform refuses the manifest at install.
4. Security contracts the platform enforces
These rules hold whatever the vApp does, so do not build around them.
- Portal users have no bridge (VZ-SEC-21). A portal (share) session cannot call the vApp bridge: no
orm,data,db,run_hookor install. The single exception is a declared route (5.53, below). A route page receives a run-token minted for that app, route and user, and reaches only a minimal session,ormwithin the app's grant (bounded by the visitor's own record rules), the route's own json hooks (with its rate limit, size cap and audit), watch hashes, domain evaluation and token refresh. - Hidden fields stay hidden along every path (VZ-SEC-23). A field the manifest hides with
groups(on anative.models[].fields[]entry) is protected only by the bridge, because Odoo does not applygroupsto a manualx_field. The bridge resolves every hop of a dotted path (x_rel.x_secret) in domains,order,fields,groupby, aggregates,any/not anysub-domains andwatchhashes, and refuses the first hidden field on any model along the path. Do not expect a dotted path to reach a field the direct name cannot. x_company_idis the platform's (5.51). On acompany_scopednative model the platform synthesizesx_company_id; never declare acompany_id.- A create that names none is stamped with the user's current company.
- An explicit value on create, write, a nested line update or a copy default must be one of the user's active companies (for a hook, the job user's companies). Anything else raises a ValidationError.
- Writing
False(making the row global) is refused. - Before 5.51 an explicit company on create was replaced silently, and a write was not checked at all.
- An app's own models need no orm grant (5.49). The server always granted an app its own native models, and the fields it added to shared models, with no
permissions.orm. From 5.49 the browser agrees:connect()returnscaps.orm_native: true. The platform computes it; declaring it in the manifest does nothing.
5. Naming: what your fields are called in Odoo
| You declare | Odoo name |
|---|---|
A model in native.models, "name": "doc" | x_vzm_<app>_doc |
A field of your own model, "name": "status" | x_status, not x_vzm_<app>_status |
A field added to a shared model in native.fields, {"model": "res.partner", "name": "tier"} | x_vzm_<app>_tier |
company_scoped: true on your model | x_company_id (synthesized) |
The x_<name> rule for your own model's fields is the one people get wrong. A domain, a fields list or a V.orm call that uses x_vzm_<app>_status on your own model finds nothing. The platform also mirrors your model's rec_name into x_name.
A document with lines (5.57)
From 5.57 your own models can hold a document and its lines the way an Odoo module does:
"models": [
{ "name": "order", "fields": [
{ "name": "name", "type": "char", "required": true },
{ "name": "line_ids", "type": "one2many", "relation": "x_vzm_<app>_line", "inverse": "order" } ] },
{ "name": "line", "fields": [
{ "name": "order", "type": "many2one", "relation": "x_vzm_<app>_order",
"required": true, "ondelete": "cascade" },
{ "name": "qty", "type": "float" } ] }
]
one2manyneedsinverse: the manifest name (nox_) of a many2one declared on the model inrelation, which must be one of your own models, pointing back at this one.ondeleteon a many2one:set null(the default, as before 5.57),cascade(the line goes with its order) orrestrict(a master still referenced cannot be deleted). PostgreSQL enforces it, so it holds for the backend,sudoand crons too. A cascade is one of the deletions no trigger sees (Β§3): guard the order, not its lines.required: the column is NOT NULL. Not for an x2many or a boolean. A required many2one needscascadeorrestrict.- On a shared model a
one2manymay list your rows ({"model": "res.partner", "name": "orders", "type": "one2many", "relation": "x_vzm_<app>_order", "inverse": "partner"}), butrequiredandcascadeare refused there: they would act on records other modules own. - Write lines through the document with Odoo's commands β
[0, 0, {β¦}]creates,[1, id, {β¦}]updates,[2, id, 0]deletes β fromVizum.ormorV.orm. A version may add, change or drop any of this; droppingondeletereturns the field toset null. - A vApp that uses
inverse,ondeleteorrequiredsetsmin_platform: "5.57".
Defaults, numbering, unique keys, chatter and computed fields (5.58)
What a module writes in Python, your own models declare. The platform applies it in the ORM, so it holds for your vApp, the Odoo backend and RPC alike:
{ "name": "order", "chatter": true, "unique": [["code"]], "fields": [
{ "name": "number", "type": "char", "sequence": { "prefix": "OT-", "padding": 5 } },
{ "name": "code", "type": "char" },
{ "name": "state", "type": "selection", "default": "draft", "tracking": true,
"selection": [["draft", "Draft"], ["done", "Done"]] },
{ "name": "date", "type": "date", "default": { "dynamic": "today" } },
{ "name": "owner", "type": "many2one", "relation": "res.users", "default": { "dynamic": "user" } },
{ "name": "partner", "type": "many2one", "relation": "res.partner" },
{ "name": "partner_email", "type": "char", "related": "partner.email" },
{ "name": "line_ids", "type": "one2many", "relation": "x_vzm_<app>_line", "inverse": "order" },
{ "name": "subtotal", "type": "float", "aggregate": { "of": "line_ids", "op": "sum", "field": "amount" } },
{ "name": "discount", "type": "float" },
{ "name": "total", "type": "float", "formula": "round(subtotal * (1 - discount / 100), 2)" } ] }
default: a literal of the field's type, or{"dynamic": "today" | "now" | "user" | "company"}. An explicit value always wins.sequence: the field is numbered on create when it is left empty, from anir.sequencethe app owns.unique: real PostgreSQL unique indexes. An install or update refuses while existing rows already collide, and names the value. Empty values never collide. Since 5.68 an entry can also be an object:{"fields": [...], "nulls_not_distinct": true}makes empty values collide too (PostgreSQL 15+), and"name": "code_uniq"names the index<table>_code_uniq, as Odoo names a module's constraint β code that recognises its own duplicate byexc.diag.constraint_namefinds it. Since 5.81 auniqueof acompany_scopedmodel may listx_company_id, the synthesized company column, beside declared fields:{"company_scoped": true, "unique": [["x_company_id", "prefix"]]}is a prefix unique per company (two companies reuse one, one company cannot), and[["x_company_id"]]is one settings row per company. It is the real column, sonulls_not_distinctworks as for any other. Auniquethat names it on a model that is notcompany_scopedis refused (there is no such column), and so is a declared field calledx_company_idnamed in the same list (it would be ambiguous). An app that uses it must declaremin_platform5.81; the install gate andvzm checkenforce it, andVizum.supports("company-unique")tells a screen whether the host serves it.check(5.68, on a model): real PostgreSQL CHECK constraints, each{"name": "quantity_positive", "field": "quantity", "op": ">=", "value": 1}β a declared integer, float or monetary field compared (>,>=,<,<=,!=) with a number. The constraint is<table>_<name>, as Odoo names a module'sCHECK (quantity >= 1), and a write that breaks it fails in the database (anIntegrityError), whoever writes. An empty value passes, as in SQL. An install or update refuses while rows already break it, and says how many; removing the entry or uninstalling drops the constraint. A condition over two fields stays aconstraintsmethod.order(5.68, on a model): its default order, as a module's_orderβ"order": "code_id, sequence, id", in your field names, each optionallyasc/descandnulls first/nulls last; besides your declared fields it may nameid,create_date,write_date,create_uidandwrite_uid.pair(5.68, many2many between two of your own models):"pair": "manual.model_ids"onmodel.manual_ids, and"pair": "model.manual_ids"back onmanual.model_ids, make the two fields ONE relation seen from each side β linking a manual on the model shows the model on the manual, as two module fields sharing onerelationtable. Each must name the other and point at the other's model. It is set when the fields are created; an update cannot pair two existing fields (their rows live in different tables).chattergives the model messages, followers and activities.trackingposts a field's changes there.formulareads the record's own fields by their manifest names; its dependencies are worked out for you.relatedreads through one of the model's many2one, and can go on from there:order.customer.emailon a line. Steps through your own models use their manifest names; from the first Odoo model on (res.partner, β¦) use Odoo's names. A related field may itself read through another related field. Add"store": trueto store it.aggregatetotals a one2many (sum,count,min,max,avg). All three compile into compute code the platform writes; your manifest supplies names and operators only.- A vApp that uses any of these keys sets
min_platform: "5.58".
Fields only some writers may set (5.59)
A field of your own model can be reserved, the way a native module keeps a verdict or an approval out of its users' hands:
"fields": [
{ "name": "verdict", "type": "char", "server_only": true },
{ "name": "approved", "type": "boolean", "writers": ["@manager"] },
{ "name": "score", "type": "integer", "computed_by": "score_hook" }
]
writerslists who may set the field on create or write:"@server","@<a group you declare>"or a group xmlid.server_only: trueis["@server"].computed_bynames a trigger hook on this model that computes the field, and is also["@server"].- The ORM refuses everyone else, on every path: your iframe, your invoked hooks (
Vizum.script.runruns as the user), the Odoo backend and RPC. An administrator is a writer only when listed. "@server"is your app's service user. The platform creates it when you declare a server writer, or"native": {"service_user": true}. It is an internal user with no password, so it cannot sign in. It belongs tobase.group_userand the groups your models grant access to. It becomes the app's job user, so your triggers and jobs run as it and may write these fields. Python running as the superuser (migrations,odoo shell) also counts as the server. The service user is archived when the app is uninstalled. It always reaches its own app, even an app restricted to *Specific people* and even when it is not in that list: the app's hooks, guards, jobs and device routes would otherwise be locked out of the app they belong to. The exception is exactly that one user for exactly that app; it opens no other restricted app. It works in the companies of the user who installed the app and mirrors them at all times: a company the installer gains is added to the service user, one it loses is removed, the moment the installer's companies change (not on the next update). A company created later reaches the service user only when the installer has it: a company an administrator creates is NOT one the service user works in until that administrator (or anyone) adds the *installer* to it, from the user form or the company's users; the service user follows that change whoever makes it. When the installer is not a regular active internal user (OdooBot, an archived account), the service user works in the main company only; it never widens. Give the installer the companies first. A hook or job working for another company passesx_company_idexplicitly. The service user is one identity, so a create that names no company is stamped with its *main* company, whatever company the triggering record belongs to. (A later improvement may default a trigger hook to the company of the triggering record; today it does not.)- These keys belong to the app's own models. On a column you add to a shared model (
native.fields, e.g.res.partner)writers,server_onlyandcomputed_byare refused at install (and byvzm check): the runtime enforces them only on your own models, so accepting them would leave the field open to everyone. - The pattern for a protected computed value: the user writes the inputs, a trigger hook (
on: "create_or_write") computes the result withV.orm.write, and the result field sayscomputed_bythat hook. - A vApp that uses any of these keys sets
min_platform: "5.59".
Your app's team: users and roles (5.60)
An app whose managers add their own agents, drivers or technicians used to need an Odoo module for it. From 5.60 the manifest declares the roles and the platform does the rest:
{ "permissions": { "team": true },
"native": {
"groups": [{ "name": "manager" }, { "name": "agent" }],
"team": {
"managers": ["@manager"],
"roles": [
{ "name": "agent", "label": "Agent", "groups": ["base.group_user", "@agent"] },
{ "name": "manager", "label": "Manager", "groups": ["base.group_user", "@manager"] }
] } } }
Vizum.team.roles(),members(),save({id?, name, login, email, role, password?}),setActive(id, active),remove(id),setPassword(id, password)andinvite(id).- Only a member of a
managersgroup, or a settings administrator, may call them. The app sends a role name, never groups: the server gives exactly that role's groups, as an administrator approved them withpermissions.team. - Each role names exactly one of
base.group_user/base.group_portaland at least one of your own groups. A group that is, or implies, an administrative group is refused, in the manifest and again at run time. - Administrative accounts are never touched. Someone your team did not create (an existing employee) can be given or relieved of your roles. Their password and active flag are not yours to change.
removetakes away your app's groups only. The person keeps whatever else they do in the company.- A vApp that calls
Vizum.teamsetsmin_platform: "5.60".
Everything a user-provisioning module did (5.65)
5.65 covers the rest of what a module like united_insurance_team did:
{ "permissions": { "team": true, "team_apps": ["com.acme.field"], "team_adopt": "dedicated" },
"native": { "team": {
"managers": ["@manager"],
"adopt_existing": "dedicated",
"roles": [
{ "name": "agent", "label": "Agent", "groups": ["base.group_user", "@agent"],
"apps": [{ "key": "com.acme.field", "solo": true }] },
{ "name": "manager", "label": "Manager", "groups": ["base.group_user", "@manager"] }
] } } }
Vizum.team.whoami()returns{uid, manager, role}, so the app knows whether to show the team screen.roleis the member's HIGHEST role, the same on Odoo 17, 18, 19 and 20: membership counts the groups a user holds including implied ones, and role A outranks role B when A's own groups imply B's own groups (a manager group that implies the user group makes the manager outrank the user). Roles that do not imply each other tie, and the role declared later innative.team.roleswins. Member rows reportrolethe same way.- Member rows also carry:
phone;last_login;self(the caller);role_label;admin: an administrative account that holds one of your roles is listed, but every write still refuses it.
savechanges for an existing member:- it takes a
phone; - it reactivates a member your app manages;
- it refuses to change the caller's own role.
- it takes a
- A role can let its members into another app of the same vendor (the first two segments of the id), so an agent can open the field app, and with
solo: trueit also appears on the phone launcher. Each app must be listed inpermissions.team_apps, which an administrator approves. Nothing is ever taken away. adopt_existing: "dedicated"(approved aspermissions.team_adopt) runs on install and on every update. It takes over the accounts that already play your roles and hold nothing else: onlybase.group_user, your role and manager groups, and what those imply. These are typically the agents the module you replace created. Your managers can then reset their password or deactivate them. Anyone with another group (an accountant who is also an agent) still only gains or loses your role.- A vApp that uses any of these sets
min_platform: "5.65".
Translations, JSON and lines in another app's model (5.63)
"translate": trueon a char, text or html field keeps one value per language, astranslate=Truedoes in a module."type": "json"stores a JSON value (a diagnosis' raw readings, a rule's parameters).- A one2many may list rows of a shared model: declare in
native.fieldsa many2one that the app adds to that model, pointing at your model, and name it as theinverse. For example,res.partner.visitβx_vzm_<app>_visit, listed by the visit'spartner_ids. - An app may have up to 80 own models.
- A vApp that uses any of this sets
min_platform: "5.63".
Server-side translations: one catalogue for the screen and the server (5.73)
The i18n/<lang>.json files the browser SDK already reads (Vizum._t) now answer tr() in your model methods. There is one catalogue, no new manifest key and nothing to declare beyond what you already do:
manifest.json "i18n": ["es", "es_419", "fr"] (the locales you ship)
i18n/es.json {"Close": "Cerrar", "%s items": "%s artΓculos", "%(n)d of %(t)d": "%(n)d de %(t)d"}
i18n/es_419.json {"Close": "Cerrar (LatAm)"}
- Format: a flat JSON object
{"msgid": "msgstr"}. The msgid is your source text (English); the msgstr is the translation. An empty msgstr counts as untranslated. - Lookup:
tr("%s items", n)in a method reads the language the call asked for: thecontext.langthe bridge received (Vizum.orm.*(..., {context: {lang: "es_419"}})), else the language of the call itself. It tries the exact code, then its base language (es_419thenes), then your msgid. The%formatting is applied after. A missing key, or a translation whose format fails, gives your msgid formatted βtr()never raises because of a catalogue. - Any language code works: the ORM uses
langonly when that language is installed on the instance (otherwise it keeps its own language, and the call works);tr()uses the requested code, installed or not. Awith_context(lang=X)inside your method changes the languagetr()answers in. - Locale names: the files, the manifest's
i18nlist andcontext.langall use Odoo's own language codes:es,es_419,ar_001,pt_BR,sr@latin,sr@Cyrl.es-419,ESor a filei18n/es-419.jsonis refused. - Install-time rules (the instance, the Store's upload and
vzm checkapply the same ones): every value is a string; each msgstr has the same placeholders as its msgid (same count of%s/%d/%(name)s, any order); no executable markup in a value (<script>,on*=handlers,javascript:); no width or precision above 999 or*in a placeholder (%1000d,%*d; a literal50% doneis text, not a placeholder); at most 512 KB per catalogue, 2 MB together, 8192 characters per string. - Updating only the translations asks no new approval and is written to the app's audit trail (
i18n:update, with the changed locales). - Native strings (the labels of native fields, selection values, menus, views) are translated from the same files since 5.84: see "Native strings from the catalogue" below.
- A vApp that uses it sets
min_platform: "5.73"andVizum.supports("server-i18n")tells the screen whether the host serves it (the Desktop, native and Solo hosts all do: the language of the call iscontext.lang, sent on the sameorm.*bridge call in each). The module translator turns a module'si18n/*.pointo these files (fuzzy andmsgctxtentries are skipped).
Native strings from the catalogue (5.84)
The same i18n/<lang>.json also translates what the app puts in Odoo's own backend: field labels and help, selection options, model labels, groups, the app's top menu, menus, window and server actions, view and report terms, a binding's label, the translatable values of native.records (a mail template's subject, a product's name) and a public page's website-menu entry. Nothing to declare: an app that ships a catalogue gets it. No new manifest key and no min_platform (the keys do nothing on a platform before 5.84); Vizum.supports("native-i18n") tells a screen the host serves it.
A key is the English text the manifest declares, so a catalogue that already says "Customer": "Cliente" starts translating the backend with no change, or a context key @native:<ref> that names the string and removes any ambiguity (it wins over the English text):
| Key | What it translates |
|---|---|
@native:<model> | a model's label (audit, the manifest's short name) |
@native:<model>.<field> | a field's label (<model> is the short name of the app's own model, or the real name of a shared one: res.partner.vat_extra) |
@native:<model>.<field>#help | its help text |
@native:<model>.<field>=<value> | one option of a selection |
@native:@<group> | a group's name |
@native:<record id>.<field> | a translatable value of a native.ui / native.records record (menu_audits.name) |
@native:binding:<name> | a binding's label (the app name is put in front, as in English) |
@native:app | the app's name in the top menu and its client action |
@native:route:<name> | the entry of a public page in a website's menu |
A view's, a report's and a portal card's arch is translated term by term: the key is the exact text node or translatable attribute (string, help, placeholder, title, confirm, sum, avg, alt, aria-label, add-label), inline markup included ("Review <b>all</b> items"). A value used as a view term must be a well-formed XML fragment; a bad one is skipped (that term stays in English) and vzm check warns about it.
- When: at install, at every update and approval, when a language is installed or reactivated later (the "Add a language" wizard, "Update terms",
-u vizum_apps_platform), and on demand: select the app's rows in Settings > Vizum > Native Schema by App and use Action > "Reload backend translations" (administrators;vizum.app.action_native_i18n_reload). - Languages: only the installed languages the catalogue covers (
es_419thenes).en_USis the manifest's text and is never written; a string with no msgstr stays in English. - Updates replace: the value is computed from the stored English every time, so a changed msgstr replaces the old one and a removed msgid, or a renamed English source, goes back to English (nothing stale). A translations-only update asks no approval, writes no schema and is audited (
i18n:update,i18n:nativewith the counts per language). While an update waits for approval the strings already live are translated at once; new artefacts are, once approved. - An administrator's own translation is kept: the platform replaces a language value only when it is missing, equals the English, or is exactly what it wrote last time (a hash on
vizum.app.native_i18n_state); anything else is the administrator's and stays. - Not a permission: the keys live in
i18n/*.json, not in the manifest, and the write is made by the platform from the vetted catalogue. An app cannot callupdate_field_translationson a metadata model. vzm checkwarns, per declared language, about native strings with no translation (NATIVE_I18N_UNTRANSLATED), an@native:key that names nothing (NATIVE_I18N_DANGLING) and a view term that is not XML (NATIVE_I18N_BAD_XML);--strict-i18nmakes them errors. Neither the instance nor the Store refuses a package for them. An@native:value carries no%placeholder (that one is an error everywhere).- The browser caches menus and views for the session: reload the page after a language change.
V.tr in hooks and jobs (5.77)
A trigger hook (a guard included), a job and a method can call V.tr(source, *args, lang=None, **kwargs): the same catalogue and the same rules as tr() above, so a guard's message reaches the user in their language instead of a raw code.
# trigger hook (enforce): the message the writer sees
if "phone" in rec["changed"]:
V.fail(V.tr("Phone cannot change"))
# job: one mail per recipient, in the recipient's language
subject = V.tr("%(n)s open nonconformities", n=n, lang=partner["lang"])
- Language:
lang=wins when it is an installed Odoo language code (partner.lang); an unknown, malformed or uninstalled code is ignored without an error and the next rule applies. Otherwise the language of the call: in a hook that is the language of the user whose write fired it (the hook itself runs as the job user); with no language in the call, the language of the user the code runs as. A job (script hook or method) runs in its job user's language, not the scheduler's. - Order of work: look the text up first, then interpolate
*args(%s) or**kwargs(%(name)s). A missing translation, or one whose placeholders do not fit the arguments, gives the source text, never an exception: a guard cannot be broken by a catalogue.sourceis positional-only, soV.tr("%(source)s", source=x)works. - A plain
tr()stays method-side only.V.tralso works inside a method, where it equalstr(pluslang=). - A vApp that names
V.tr(or a name bound toV,W = V; W.tr(...)) in a method, a hook ornative.script.libmust declaremin_platform5.77 or later; the install gate andvzm check(MIN_PLATFORM_TOO_LOW) enforce it.Vizum.supports("script-tr")tells a screen whether the host serves it.
V.is_service_user() in methods, hooks and jobs (5.81)
True when the code runs as the app's own service user: the passwordless, platform-created job user (native.service_user: true, or the identity "@server" writers imply) that the app's jobs, triggers and device routes run as. False for every human (an administrator included), for a job user an administrator picked by hand, and for the superuser. The platform reads it from the user the code runs as, so a script cannot change it, and sudo() inside a method does not hide it (it keeps the uid).
# wl_issue β a kiosk must name its screen; a person at the counter need not
if V.is_service_user() and not screen_id:
raise UserError(tr("Choose the screen this ticket is issued from."))
It replaces the technical-group workaround (a @device group the service user joins): no extra group shows in Odoo's user form. A vApp that names V.is_service_user (or a name bound to V) must declare min_platform 5.81; the install gate and vzm check (MIN_PLATFORM_TOO_LOW) enforce it. Vizum.supports("script-service-user") tells a screen whether the host serves it.
V.vector.shared_namespaces() in jobs and migration hooks (5.83)
Lists the namespaces the app owns in the shared (global) vector scope, [{name, count, model, dim}]. An app whose approved vector grant is "user" cannot see them through namespaces(), and they are what a migration from vector: "global" to "user" has to move. It answers only for an administrator or the app's own service user (V.is_service_user(): a job, trigger or migration hook runs as it); a plain user gets an AccessError, because the store does not record who wrote a shared row. The move itself is Vizum.vector.moveScope (rows change owner, nothing is re-embedded or metered). An app that names V.vector.shared_namespaces (or a name bound to V) must declare min_platform 5.83; the install gate and vzm check (MIN_PLATFORM_TOO_LOW) enforce it. Vizum.supports("vector-scope") tells a screen whether the host serves it.
V.identity.consume in methods and hooks (5.79)
Vizum.identity.confirm (identity-step-up.md) collects a re-confirmation of the user in a host dialog and gives the page a single-use token. Spend it in server code:
receipt = V.identity.consume(token, "qms.doc:%d:approve" % self.id)
self.write({"x_signed_by": receipt["user_name"], "x_signature_id": receipt["id"]})
It returns the receipt (id, user_id, user_name, login, action, meaning, reason, confirmed_at) or raises a UserError with one fixed text for every failure. Only a call that comes from the user's own browser session can spend a token: a job, a cron, a webhook and a device route cannot. A token is bound to this app, this user, this session and this exact action. If your method fails after consume, the transaction rolls back and the token is unspent again. A vApp that names V.identity (or a name bound to V) must declare min_platform 5.79; the install gate, the Store upload gate and vzm check (MIN_PLATFORM_TOO_LOW) enforce it.
Taking over the data of the module you replace (5.64)
When a customer already runs the Odoo module your vApp replaces, its records live in that module's tables. native.adopt brings them over:
{ "permissions": { "adopt": ["dsd.route", "dsd.visit"] },
"native": { "adopt": [
{ "model": "route", "from": "dsd.route", "fields": { "name": "name", "driver": "driver_id" } },
{ "model": "visit", "from": "dsd.visit", "fields": { "name": "name", "route": "route_id",
"partner": "partner_id" } } ] } }
- On every install and update, while
fromexists, each of its rows is copied once into your model.vizum.app.adopt.maprecords the copy, so a row is never adopted twice and rows the module creates later are picked up on the next update. - Links between adopted models are translated (a visit's
routepoints at the adopted route). Links to shared models such asres.partnerkeep their ids. - Every
frommust be listed inpermissions.adopt; the administrator approves it. Platform, identity and security models, and secret fields, are refused. - Uninstalling the module afterwards is the customer's step. Your vApp then owns the data.
tools/module2vapp.py <module> --manifest out.jsonwrites theadoptmap for you.- A vApp that adopts sets
min_platform: "5.64".
Adopting a real database (5.68). The copy is data, not business events:
- Your own
create/writeoverrides and yourconstraintsstay out of the copy: no audit trail, no "cannot publish yet" guard over a link the second pass has not filled. Once every link is in place the constraints run once over the adopted rows; a row that breaks one stops the adoption with the constraint's own message. Seedrecordsand normal writes keep running your overrides. - The mark is per app: only the adopting app's overrides, constraints and trigger hooks stand aside. Another app's guard on the same Odoo model (
res.partner) still runs while the columns are copied, and the adopting app's own constraints over its columns on Odoo's models run once at the end, like the ones over its own models. - A binary stored as an attachment is shared by reference: the adopted row gets its own
ir.attachmenton the same stored file, and no byte is read, so hundreds of megabytes of manuals cost no memory. A binary that cannot be shared is read one row at a time and created in batches of a few MB. refstranslates a (model name, id) pair kept in plain columns: the module's model becomes your technical model and the id the adopted row's. A pair naming a model you did not adopt is left as it is.
{ "model": "audit_event", "from": "my.audit.event",
"fields": { "res_model": "res_model", "res_id": "res_id" },
"refs": [ { "model_field": "res_model", "id_field": "res_id" } ] }
When the model follows from another field (an annotation anchored by its kind), name it instead: { "id_field": "res_id", "model_by": { "field": "kind", "models": { "model": "my.model" } } }. model_field is an adopted char field (a selection lists the module's names, not yours), field any adopted field, id_field an adopted integer. A model_by pair whose record was deleted loses its link (the id becomes 0) rather than land on a stranger's row; the platform logs how many pairs it left as they were and how many it unlinked.
- A compute method that assigns several fields (
imp.count_a = ...; imp.count_b = ...) no longer turns the assignment of a sibling into a write: the fields it computes are protected while it runs, as Odoo does for fields that share one compute. A write override or anappliedguard on the model is not reached by a read. - A link to
ir.modelorir.model.fieldswhose target is a model (or a field) you adopted becomes a link to your own model (fieldx_<name>); the module's rows go with its uninstall.
A whole Odoo module as a vApp (5.66)
5.66 runs a module's own Python as your vApp's code. PSI DSD Engine is the module psi_dsd ported this way, and the module's 818 tests pass against it.
{ "permissions": { "logic": "script", "methods": ["route.close_all", "res.partner.create"],
"orm": [{ "model": "res.partner", "ops": ["read", "search", "write", "create"] }] },
"native": {
"aliases": true,
"methods": [
{ "model": "route", "name": "close_all", "api": "model",
"code": "def close_all(self):\n self.p_assert_manager()\n return len(self.sudo().search([('state', '=', 'open')]))\n" },
{ "model": "res.partner", "name": "create", "override": true,
"code": "def create(self, vals_list):\n recs = super_(vals_list)\n return recs\n" } ] } }
native.methodsare installed as real methods of the Odoo class, so a button, a cron, another app'scall:or plain Odoo code calls them like any method. Inside one,selfandenv[...]are *governed* records: yourormgrants, Odoo's access rights and record rules.sudo()lifts the access rights and rules, never your grants.- An override of an Odoo method (
create,write,action_confirmβ¦) calls the original withsuper_(...). Overrides are listed inpermissions.methods, which the administrator approves. - A private method
_assert_manageris called asself.p_assert_manager(): names that start with_stay forbidden in the sandbox. - Computes, defaults (
{"method": name}), constraints and jobs can name a method. permissions.private_callslists the private Odoo methods your code calls (sale.order._create_invoices) β never the platform's ownvizum.*models, which the validator, the store and the runtime all refuse (vizum.app._store_checkoutwould buy with the saved card).permissions.paramslists their.config_parameterkeys it reads and writes.permissions.peer_datalists"<app id>:<key prefix>"entries: another app's global data rows, read-only.peer_data(app_id, prefix)returns them.- What a
call:returns (orm.call/orm.callOn/V.orm.call): plain JSON data, with this contract (it is the platform's own, not a copy of Odoo's JSON-RPC): dates and datetimes become ISO strings in UTC ("2026-10-03","2026-10-03 12:30:05"; a timezone-aware datetime is converted to UTC first, a naive one is taken as UTC);Decimalbecomes a number (a float);bytesbecome UTF-8 text when decodable; sets and tuples become arrays; a top-level recordset becomes its list of ids. The whole result falls back totrue, and the platform logs a WARNING naming the app, model and method (never the data), when it holds: a recordset nested inside a dict or list (an app never receives ids it did not ask for through a gated read),bytesthat are not UTF-8 text (the platform does not guess base64), NaN or Infinity, a lazy value or any other object, more than 32 levels of nesting, more than 10 000 list or dict entries, or more than 4 MiB serialized. V.sha256(data, encoding=None)(SDK 5.75) β the SHA-256 ofdataas 64 lowercase hex characters, in script hooks, jobs and native methods alike.datais astr(hashed as UTF-8),bytes, or, withencoding="base64", a standard base64strof the bytes (whatV.orm.readandV.files.gethand back for a binary field). It refuses with aUserErroranything over 32 MiB, invalid base64, any other type or encoding, and a run that hashes over 256 MiB in all or makes over 2000 calls. The size is read from the length and charged to the run before any decode, so a refused input still uses budget and a spent budget fails the call without touching the data. It is the metered digest: a hook or job has nohashlib, md5/sha1 or streaming object, and there is no HMAC (a key would have to come from a secret, and scripts never hold one). The one exception is a native method that still carries the pre-5.75hashlibextra of a ported module (not metered; it goes once the apps that call it move toV.sha256).V.sha256or a name bound toV(W = V) that reads.sha256must declaremin_platform5.75 or later;hashlib.sha256and any other base do not count. The install gate andvzm check(MIN_PLATFORM_TOO_LOW) enforce it. A hash is not a signature.V.sha256is an integrity digest: use it as a fingerprint, to detect a change or to deduplicate. Anyone can compute it from the same bytes, so it proves nothing about who made the data and never authenticates a publisher. Who made a package, and whether the store or an instance trusts that publisher, is the job of Ed25519 package signing, publisher keys andstore_verified(seepackage-signing.md).caller_app()(SDK 5.69) β in a method another app invoked through itscall:grant from the bridge, the key of THAT app, verified by the platform (a marker JSON-RPC cannot forge);Nonefor any other entry (a direct RPC call, Odoo, a job, a trigger, the app's own code). The name is a 5.69 feature: a method ornative.script.libpart that namescaller_appmust declaremin_platform5.69 or later, and both the install gate andvzm check(MIN_PLATFORM_TOO_LOW) enforce it. Inside one run the answer is the innermost entry's verified caller: if another app's code re-enters your app through its owncall:grant, your method sees that app, never the outer caller.native.aliases: truemakes each declared field name a real field of the ORM. Your code, the other apps, views, reports and tests keep sayingdriver_id, notx_driver_id.native.records,native.rules,native.ui(views, actions, menus, QWeb reports with vetted expressions, thevzm_geo_mapwidget) andgroups[].impliescarry the rest of a module: its data, its record rules, its backend and its group tree. A seeded record may say"match": ["code"](5.68): if its xmlid is gone (an importer created the row, an uninstall left it), the one row with those values is anchored again instead of being created twice β never a row another seeded record already holds. A view that extends an Odoo view names it as"inherit_id": {"$xmlid": "<module>.<name>"}(or{"$ref": "<an earlier ui id>"}). A malformed xmlid is refused by the validator, and an xmlid that names no view on the database refuses the install: the view is never created standalone.
Replacing a module that is installed. Declare it with "replaces": "<module>".
- While the module is installed, your app *waits*. It creates its models and columns and adopts the data, but adds nothing to Odoo's models: no alias, override, constraint, view, menu or job there. Those names and that behaviour are still the module's.
native.adoptalso copies the module's columns on Odoo's models into yours:{"model": "res.partner", "from": "res.partner", "fields": {"dsd_driver_id": "dsd_driver_id"}}. A link to a model you adopted is translated.groups[].adopt: "<module>.<group xmlid>"puts the people in the module's group into yours. The administrator approves each group by its xmlid inpermissions.adopt.- When the customer uninstalls the module, the chatter and attachments of every adopted record move to its copy before Odoo deletes them. On the next start your app goes live by itself: aliases, overrides and backend.
tools/module2vapp_port.pytranslates a module into these keys, andtools/module2vapp_port_tests.pyports its tests so you can prove nothing changed.- A vApp that uses any of these sets
min_platform: "5.66".
Shipping data, and changing it across versions (5.61)
{ "native": {
"seed": [
{ "model": "brand", "key": "code", "rows": [{ "code": "dk", "name": "Daikin" }] },
{ "model": "item", "key": "name", "update": false, "file": "seed/items.json" }
],
"migrations": [
{ "to": "2.0.0", "ops": [
{ "op": "rename_field", "model": "item", "from": "qty", "to": "quantity" },
{ "op": "copy", "model": "item", "from": "name", "to": "title" },
{ "op": "hook", "name": "migrate_notes" } ] }
] } }
seedupserts rows by a naturalkeyon every install and update, in list order. A value{"$ref": "<model>:<key>"}points at a row seeded earlier."update": falseonly creates what is missing, so a user's later edits survive your updates. Rows go inline, or in a.jsonfile inside your bundle.migrationsrun on the update that crossesto. A first install migrates nothing. The platform remembers the version it last reconciled, even when an update waited for approval.rename_fieldkeeps the column and its data under the new name.copyfills a new, empty column from an old one.hookruns one of your script hooks as the app's job user, withrec = {from, to, installing: true}.- Order: renames first, then the new schema, then copies and hooks while old and new columns both exist, and only then are undeclared columns dropped.
- Uninstalling deletes your models and their data, as uninstalling an Odoo module does.
- A vApp that declares either key sets
min_platform: "5.61".
Functions, a shared library, and the time budget (5.62)
A hook can hold real logic:
- Top-level
defandwhileare allowed. A function may not be nested in another (so there are no closures) and may not carry decorators. Recursion works. native.script.libis{name: code}, code that every hook of the app runs first. Use it for the functions and constants your hooks share: a rules evaluator, the limits of a diagnosis.- Every run has a time budget: 30 seconds, or 120 for a scheduled job. The platform injects a check into every loop and function body. A run that uses its budget stops with Β«Script β¦ stopped: it used its N-second time budgetΒ» and its transaction rolls back.
- Still refused:
import,lambda,class,with,match,try/except*, underscore names and attributes,format, and code nested more than 100 levels deep (the deepest real script measured is 27) β each refused at install with the reason, never a server error. - A vApp that uses
def,whileorlibsetsmin_platform: "5.62".
Tooling for your own models
vzm check manifest.jsonreports, field by field, what the platform will refuse innative(a one2many without its inverse, a default of the wrong type,trackingwithoutchatterβ¦) and themin_platformyour declarations need.vzm packruns it first.vzm introspect --manifest manifest.json --out src/own-models.tswrites typed interfaces of your own models with the names the platform creates (x_vzm_<app>_<model>,x_<field>), computed fields marked readonly.@vizum/vapp-typesexportscmd(cmd.create(vals),cmd.update(id, vals),cmd.delete(id),cmd.link(id),cmd.unlink(id),cmd.set(ids),cmd.clear()) for writing lines through a one2many, andownModel(appId, name)/ownField(name)for the technical names.
6. Routes (5.53) and what is pending (5.55)
native.routes declares pages and json endpoints the platform serves for the app. Its type is NativeRoute in @vizum/vapp-types.
- Paths:
GET /app/<slug>/<route>[/<sub>], and/my/<slug>/β¦for a portal page. The page embeds the app with the route as its intent, available asVizum.route. - Json endpoints:
POST /app/<slug>/<route>. It is CSRF-checked, the body is at most 64 KB, calls are rate-limited per user, app and route and audited, and the declared hook runs as the visitor. authis"portal"(signed-in users ofgroups) or"user"(internal users only).- Approval: declaring or editing a route needs approval, and an app with routes needs
install_scope: "admin"andmin_platform: "5.53".
Pending, 5.55: auth: "public" and website_menu are in progress. Until they merge, the validator refuses both.
6-bis. permissions.orm on vizum.app: an allowlist of call: methods
call:<method> on the platform's own model vizum.app runs as whoever opens the app, often an administrator, and most of its methods act on other apps (action_approve, api_secret, action_disable, api_uninstall). Only these are callable, and the platform, vzm check and the store refuse any other at install, at upload and at run time:
api_list, andapi_vapp_sourcefor the app's own key.- First-party only (they act on other apps):
api_installandapi_build(com.vizumapps.vappcreator);api_admin_app_list,api_set_app_access,api_set_app_native_menu,api_set_app_soloandapi_set_app_users(com.vizumapps.hide_menu). The key is only a string a package can carry, so the instance also demands that the installed package is verified as published by Vizum (signed by the Vizum Store,trust_statestore_verified): a sideloaded or locally built package with that id is refused at install and its grant is dropped at run time. - An
actions[]entry never targetsvizum.app(an action passes the caller's own keyword arguments to the method).
7. Before you publish
- Set
min_platformto the highest capability you use (Platform capabilities). - A guard is proven only when it refuses. Write the refusing write in a test, see it fail, then check the hook catches it through Odoo's own form, not just the iframe.
SDK & server
Routes β portal, internal and public pages (native.routes) PRIVILEGED
A vApp can ask the platform to serve it at a URL of its own, outside the desktop: /app/<slug>/<route>, where <slug> is the last segment of the app id (com.acme.site_pages β site_pages; an app id takes lowercase letters, digits and _, never -). The page is a platform page that embeds the app's sandboxed iframe; the app contributes no server markup. Types: NativeRoute in @vizum/vapp-types. Who can open what, and with which capabilities: docs/vapp-routes-auth-matrix.md in the platform repository (this page is also published as a section of the developer guide, where a relative link would not resolve).
auth | since | kinds | who opens it | caps on the page |
|---|---|---|---|---|
public | 5.55 | page | anyone on the internet, no sign-in | none |
portal | 5.53 | page, json | signed-in users of the route's groups | the app's data grant, bounded by the visitor's rules |
user | 5.53 | page, json | internal users (of groups, if given) | the same |
public | 5.68 | file | whoever holds a link the platform signed for one user | none: a file, read as that user |
Every app with routes needs "install_scope": "admin", and an administrator approves the routes: they are privileged. Public pages are approved on their own ("Public web pages", with each full URL).
A minimal public page with a website menu entry
manifest.json:
{
"schema": 1,
"id": "com.acme.site_pages",
"name": "Site Pages",
"version": "1.0.0",
"min_platform": "5.55",
"entry": "index.html",
"icon": "icon.png",
"install_scope": "admin",
"permissions": { "data": "user" },
"native": {
"routes": [
{ "name": "pricing", "kind": "page", "auth": "public", "title": "Pricing",
"website_menu": { "text": "Pricing", "sequence": 50 },
"sitemap": true }
]
}
}
index.html:
<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pricing</title>
<script src="/vizum_apps_platform/static/sdk/vizum-sdk.js"></script>
</head>
<body>
<main id="app"></main>
<script>
Vizum.connect().then(() => {
const r = Vizum.route; // {name, params, sub, path} or null
const plan = r && r.params.plan; // /app/site_pages/pricing?plan=pro
document.getElementById("app").textContent =
plan ? "Plan " + plan : "Our plans";
});
</script>
</body></html>
What happens once an administrator approves it:
https://<your site>/app/site_pages/pricingopens for anyone, signed in or not. The page sub-path and query string reach the app asVizum.route.sub/Vizum.route.params.- With the
websitemodule installed, "Pricing" appears in the top menu of every website of the app's company (every website when the app has no company). Withoutwebsite, no menu is created and the page still serves (Odoo's plain frontend layout). Installingwebsiteafter the app was approved adds the menu entry only at the app's next reconcile (approving it again, or an update). sitemap: truelists the page in/sitemap.xml(default: not listed). Switching it on later, or adding or rewording the menu entry, is a change the administrator approves again (thesequenceis not).- Disabling the app, an update waiting for approval, or uninstalling it removes the menu entry and answers the site's ordinary 404 at once.
What a public page cannot do
A public page has no capabilities: Vizum.connect() resolves with session: null and empty caps, and every SDK call β Vizum.orm, Vizum.script.run, Vizum.data, Vizum.files, Vizum.ai, Vizum.net, Vizum.vector, Vizum.db, and the host UI calls too β answers PermissionDenied. Map tiles through the platform proxy are not available either. The bundle is served, but never manifest.json or a *.py file. Declaring permissions.orm does not change that: the grant applies to the desktop and to portal/user routes, never to the public page. Build it from what ships in the bundle.
The validator also refuses, by name:
- a public
jsonroute ("public json routes do not exist") β server data for anonymous callers is not part of this version; groupson a public page;website_menuorsitemapon aportaloruserroute;website_menu.parent(entries go in the top menu), unknown keys, atextover 40 characters, asequenceoutside 0β10000;- a hand-written
permissions.public_routesis replaced by the list the validator computes.
Caching
A visitor with no session cookie and no utm_* parameter or cookie gets Cache-Control: public, max-age=300 with Vary: Cookie, Accept-Language, and no cookie is set, so a shared cache can keep it. Everyone else (signed in, an existing session, a campaign link) gets private, max-age=60 with Vary: Cookie. Anything that must change within five minutes for anonymous visitors does not belong in a public page's server-rendered part β and a public page has none beyond the platform frame.
A file behind a signed link (kind: "file", 5.68)
A module that serves a binary to a viewer without a session β a PDF in the app's iframe (an opaque origin carries no cookie), a video that seeks by Range β does it with a signed, dated link made for one user. As a vApp the platform signs and checks that link: the app only asks for it.
{"name": "document", "kind": "file", "auth": "public",
"token": {"max_age": 300},
"files": {"manual": {"model": "manual", "field": "file", "filename": "file_name"}}}
In the app's methods:
url = base_url() + route_file_url("document", "manual", record.id) # for the user asking
token = route_file_token("document", "manual", record.id) # the token alone
route_file_verify("document", "manual", record.id, token) # {"uid", "expiry"}
/app/<slug>/document/manual/<id>/<token> then serves the field as the user the link was made for:
- the platform checks the signature (a key per app: another app's link is worthless here), the expiry, the route, the kind and the record; a link never lives longer than
max_age(the platform's ceiling is 7 days); - the user must be active and internal β a portal user only when the route says
"allow_portal": trueβ never the superuser; a browser with a session of ANOTHER user is refused (a link pasted in a chat does not open from a colleague's account); - that user needs read access to the record and the field;
- a refusal is a
403that says "expired" or "not valid", never which part was wrong; - an authorized answer carries
Access-Control-Allow-Origin: null(the app's iframe has an opaque origin; never*, never the request's origin echoed back) withAccess-Control-Allow-Credentials: trueandVary: Origin: the viewer sends credentials, and the session they carry can only REFUSE a link (another user's session), never grant one β without a valid link it is a 403 whatever the session. A refusal carries no CORS at all, so a sandboxed iframe of another site does not even read the error; - answers honour
Range(Accept-Ranges: bytes), carryX-Content-Type-Options: nosniffand no cookie; a type the browser runs (HTML, SVG, scripts, XML) is a download withContent-Security-Policy: sandbox; PDF, text, JSON, raster images, audio and video are shown inline.
What the route serves (its kinds, models and fields), how long a link lives and whether portal users get links are part of what the administrator approves: changing them asks again.
Run URLs name their database, and are always anonymous
A vApp's own files (index.html, app-*.js, style-*.css, chunks of a multi-file build) are served at /vizum/apps/run/<key>/<token>/<exp>/<entry>. On an instance with several databases behind a dbfilter, the host puts the database in the path, /vizum/apps/run/@<db>/<key>/β¦ (and /vizum/tiles/@<db>/β¦ for map tiles), so every relative URL your bundle requests keeps it. Write relative URLs (./app.js); never build an absolute /vizum/apps/run/... path yourself.
Both URL kinds are capability URLs: a URL that names a database (@<db>) is always served on an anonymous session that is never saved, even when the named database is the one the browser is signed in to, and even when the name is hidden, not loaded or does not exist (the answer is then 404 and no cookie is set or rotated). The page authenticates by its run token, verified inside that database; a token minted for another database opens nothing (403). Do not expect the signed-in user's cookie in a request your iframe makes to these paths: there is none, whatever it sends.
SDK & server
Paired devices β screens and kiosks with no login (auth "device") PRIVILEGED
A screen on a wall, a kitchen display, a kiosk or a time clock is not a person: it has no Odoo login, it must keep running unattended, and it must be revocable from the desktop in one click. SDK 5.70 gives a vApp a paired device identity for that.
- The device opens the app's device page. The platform shows an 8-digit PIN.
- An administrator claims the PIN from the desktop (a platform dialog, or the app through
Vizum.pairedDevices.claim). The device receives a long random device token β once β and keeps it in its own storage. - From then on the device calls the app's
jsonandfileroutes with that token. Each call runs the route's hook withV.deviceset; never a user session, never a cookie. - Unpairing (revoke) kills the token at once: the next call answers 401 and the page goes back to the PIN screen.
The token is stored hashed on the instance; nobody can read it back. The platform owns every security decision: PIN expiry and attempts, the token check, revocation, rate limits, the declared model and fields of a file route, the response headers. The hook only decides *what* this device may see.
Manifest
{
"min_platform": "5.70",
"install_scope": "admin",
"permissions": {
"logic": "script",
"orm": [ { "model": "x_vzm_signage_media", "ops": ["read", "search"], "fields": ["x_file"] } ]
},
"native": {
"script": { "hooks": [
{ "name": "device_snapshot", "code": "β¦" },
{ "name": "device_media", "code": "β¦" }
] },
"routes": [
{ "name": "player", "kind": "page", "auth": "device", "title": "Player",
"device": { "embeds": ["youtube", "vimeo"], "cache_mb": 2048 } },
{ "name": "snapshot", "kind": "json", "auth": "device", "hook": "device_snapshot" },
{ "name": "media", "kind": "file", "auth": "device", "hook": "device_media",
"model": "x_vzm_signage_media", "fields": ["x_file"] }
]
}
}
| key | on | meaning |
|---|---|---|
auth: "device" | page, json, file | a paired device of THIS app opens it; nobody else |
device.embeds | device page | third-party players the page may frame for the app (Vizum.device.embed): youtube (served from www.youtube-nocookie.com), vimeo (player.vimeo.com). Nothing else |
device.cache_mb | device page | offline media cache ceiling, 64β4096, default 1024 |
model, fields | file | the ONE model and the binary fields the route may stream |
hook | json, file | the native.script.hooks entry that answers it |
kind: "file"exists only withauth: "device"in 5.70.- A device
fileroute reads as the job user, so it needs what an orm grant needs: the manifest must carrypermissions.ormwith an explicit rule on thatmodel(the"*"read wildcard does not count: it is scoped to the viewing user, and this route runs as the job user) whoseopsincludereadand whosefields(empty means all) cover the route'sfields, and the platform's orm policy must letreadthrough on the model. Platform models (vizum.*), code-bearing models and the like stay closed β the route does not reopen them. The rule is resolved the way the runtime resolves it: the first rule on the model decides, so an explicit rule withoutreadrefuses even beside a"*"rule. The rule'sdomainis a ceiling on ids as well: a record outside it answers 403 before the hook runs, whatever the hook would say. The platform checks all this at validation, at the store gate and on every file request against the approved permissions: remove or narrow the grant and the route answers 403 at once. The model is the real model name (an app's own model isx_vzm_β¦, granted like any other). Native fields the app owns on a SHARED model are readable throughV.ormwithout a grant, but a device file route stays fail-closed: it still demands the explicit grant. - Declare
native.service_user: true(5.59) in any app with device routes. The platform then creates the app's own service uservzm-service.<slug>at install and makes it the job user the device hooks run as. Without a job user every device call is a 503 (never the installer's rights); the job user always reaches its own app, even one restricted to *Specific people* (server-side);vzm checkwarns (DEVICE_NEEDS_SERVICE_USER) and the Apps Manager flags the app:"native": { "service_user": true, "script": { ... }, "routes": [ ... ] }. - A device route never takes
groups,path,website_menu,sitemaporportal_entry. - The validator synthesizes
permissions.device_routes(the names) β a privileged grant shown on its own line of the approval card ("Paired devices: player, snapshot, media"). Adding a device route, or changingembeds,modelorfields, is a widening the administrator approves again.
Pairing (the platform does it, the app does not)
- The device opens
https://<instance>/app/<slug>/<page>(a device page). With no token in its storage, the platform page shows the app's name, an 8-digit PIN and its expiry (5 minutes), and polls. - An administrator claims it:
- from the desktop: the app's detail in the Apps Manager β Pair a device β PIN, a name, optionally the record the device is linked to;
- or from the app (
Vizum.pairedDevices.claim(pin, {name, link})) β base.group_system only. The app'sclaim,linkandrevokefirst ask the person on the host: a dialog shows the PIN, the device name and the link (the same way an ORM write is confirmed), and nothing reaches the server if it is denied.linkmust be none (nullor"") or a non-negative integer record id: any other value (42.9,true,"12",-1) is refused before the dialog, so the value shown is the value stored.list,renameandreloaddo not ask.
- The device's next poll receives
{token, device}exactly once; the PIN is consumed. A claim must be collected within 60 s of the claim and before the PIN's expiry; a late poll getsexpiredand no token, and the device starts again.
Limits (platform constants): a PIN lives 300 s and has 8 digits (10^8 PINs: with at most 1000 waiting, a typo hits another waiting PIN with probability 1e-5); at most 5 wrong claims per user per app per 10 minutes, then claims are refused for that window; at most 20 PIN requests per source and 100 per app per 10 minutes (an IPv6 source is counted by its /64 prefix) and 1000 waiting PINs per app β the slot is reserved under a per-app database lock, so parallel requests cannot exceed it. _pair/poll is throttled too, before any lookup: 60 per minute per pairing and 300 per minute per source (the page polls every 3 s); past either, 429 with Retry-After.
Behind a reverse proxy, run Odoo with proxy_mode = True. The per-source limit keys on the client address Odoo sees. Without proxy_mode every visitor shares the proxy's address: 20 requests in 10 minutes would then block pairing for the whole instance. With it Odoo reads X-Forwarded-For, so each source is counted on its own (make the proxy overwrite that header, never append to a client's).
The device page (what the app's iframe gets)
The page is a platform shell with no cookies and no user session. Once paired, it loads the app's bundle in the usual sandboxed iframe with allow="fullscreen; autoplay; encrypted-media", and the iframe's SDK exposes:
await Vizum.connect();
Vizum.device.info(); // {id, name, link, page} β never the token
await Vizum.device.call("snapshot", {}); // the json route's hook result
await Vizum.device.fileUrls("media", [12, 13]); // {12: url, 13: url} β signed, 24 h
await Vizum.device.blobUrl(url); // an object URL served from the offline cache
await Vizum.device.cache([url, β¦]); // pre-fetch into the offline cache (LRU, cache_mb)
Vizum.device.onReload(() => location.reload()); // the admin pressed "Reload screen"
Vizum.device.fullscreen(); // request fullscreen on the platform page
Playing YouTube and Vimeo: Vizum.device.embed
A player (YouTube, Vimeo) nested in the app's frame does not play: the app runs in a sandboxed frame with an opaque origin, and the embedders refuse an ancestor chain that contains the origin null (YouTube answers error 153, Vimeo shows no video, whatever the referrer policy). So the device page β the platform's own page, on the instance's origin β frames the player and lays it over the app at the rectangle the app asks for:
const player = await Vizum.device.embed({
provider: "youtube", // "youtube" | "vimeo", and declared in this route's device.embeds
id: "dQw4w9WgXcQ", // YouTube: 11 chars [A-Za-z0-9_-]; Vimeo: 1-12 digits
rect: { x: 40, y: 60, w: 960, h: 540 }, // in the APP frame's coordinates
muted: true, autoplay: true, loop: false, start: 0, // all optional; defaults muted + autoplay
});
await player.update({ rect: { x: 0, y: 0, w: 1280, h: 720 } }); // move/resize, or change muted/loop/...
await player.remove();
await Vizum.device.embeds(); // remove every player this app opened; resolves with how many
- The platform builds the URL; the app never supplies one. Only providers the route's *approved*
device.embedslists are accepted (PERMISSION_DENIEDotherwise), ids are validated (VALIDATION_FAILED), and the player iswww.youtube-nocookie.com/embed/<id>with fixed safe parameters (playsinline,rel=0,enablejsapi=1withorigin=the page's own origin,autoplay,mute,loop,start) orplayer.vimeo.com/video/<id>?dnt=1β¦&api=1. The players' postMessage APIs answer only the framing page; no host or script source is added. - The player's iframe carries
referrerpolicy="strict-origin-when-cross-origin",allow="autoplay; encrypted-media; picture-in-picture; fullscreen"and asandboxthat keeps its own scripts and storage but allows no top-level navigation, popup or form. It takes no taps from the app. - The rectangle is clamped to the app frame's box: a player can never cover platform UI outside the frame, and one wholly outside is hidden. It follows window resizes. At most 4 players at once (
QUOTA_EXCEEDED). - Players are removed on unpair, on offline-lease expiry, on "Reload screen", on page close and when the app is mounted again; the SDK also calls
Vizum.device.embeds()onpagehide. - The device page's CSP gets
frame-src 'self'plus exactly the approved providers; the app bundle's CSP no longer names any third party (a frame there never played, and a hostile bundle gets none for free). - On the desktop editor, the native menu and Solo,
Vizum.device.embedrejects withPERMISSION_DENIEDand one clear message ("plays a video on a paired-device page only"): show your own thumbnail there. The emulator refuses it the same way.
Controlling the player: volume, mute, play, pause (SDK 5.82)
The app cannot reach a player (it lives in the page, not in the app's frame), so the handle embed() returned carries the controls:
const player = await Vizum.device.embed({ provider: "youtube", id: "dQw4w9WgXcQ", rect, muted: false });
await player.setVolume(35); // 0 to 100 (VALIDATION_FAILED outside it)
await player.mute(); await player.unmute();
await player.pause(); await player.play();
- The page posts the player's own postMessage command (YouTube IFrame API, Vimeo player.js) to the iframe it created, with
targetOriginthe provider's exact origin (https://www.youtube-nocookie.com,https://player.vimeo.com), never"*". The message is built by the page from a fixed table; the app supplies a handle and a number, nothing else. No new host, no CSP change. - Only a handle this page issued to this app is accepted; anything else (another id, a removed player,
__proto__) rejectsNOT_FOUNDand posts nothing. - A command sent before the player has loaded is kept, and applied when it is ready; what you asked (volume, muted, playing) is re-applied after
embed.updatereloads the player. - Bridge ops:
embedSetVolume,embedMute,embedUnmute,embedPlay,embedPause. Featuredevice-embed-control. Setmin_platform5.82, or detect it withVizum.supports("device-embed-control"). (vzm checkcannot see a method on a handle: it checksVizum.device.storeand the other named members only.)
Per-device storage: Vizum.device.store (SDK 5.82)
The app's frame has an opaque origin, so it has no localStorage. A screen's own settings (a volume, a layout) are kept by the platform instead, for this paired device of this app:
await Vizum.device.store.set("volume", 40); // any JSON value; resolves true
const volume = await Vizum.device.store.get("volume"); // the value, or null
await Vizum.device.store.delete("volume"); // whether the key existed
const everything = await Vizum.device.store.all(); // { key: value }
| Keys | 1 to 64 characters of a-z A-Z 0-9 _ . : - | |||
| Values | JSON, at most 4096 bytes of compact JSON (UTF-8 bytes, not characters) | |||
| Count | at most 64 keys per device (256 KB), QUOTA_EXCEEDED past a limit; overwriting a key or reusing a freed slot always works | |||
| Scope | the paired device, and so its app; another device, or another app's device with the same key, reads nothing | |||
| Reached by | POST /vizum/device/<slug>/_store with the device's own token: `{op: get | set | delete | all, key, value}`. A body cannot name another device |
| Lifetime | until the device is revoked (its keys go with it); an administrator can read the rows, nobody edits them by hand | |||
| Offline | a screen reloaded while offline reads the last values it saw (kept in the page, wiped on unpair and lease expiry); a write offline rejects NETWORK_ERROR |
Feature device-store; Vizum.device.store needs min_platform 5.82 (vzm check enforces it). It counts as one call of the device's 120 per minute. Do not keep secrets or personal data there: it is a settings store.
Vizum.device exists only on a device page; everywhere else every call answers PermissionDenied. A device page has no other capability: Vizum.orm, Vizum.data, Vizum.ai, β¦ answer PermissionDenied (its server data comes from its own json routes).
The platform sends a heartbeat every 30 s (last_seen, the screen size and user agent); the answer carries reload when an administrator asked for one. A heartbeat with a body that is not a JSON object, is malformed or is over the 64 KB limit answers 400 / 413 like every other device route, and changes nothing: the screen's info is not updated and the pending reload is not consumed, so the next valid heartbeat still receives it.
Calling a device route directly (no SDK)
POST /vizum/device/<slug>/<route> Authorization: VizumDevice <token> json body β€ 64 KB
POST /vizum/device/<slug>/_heartbeat Authorization: VizumDevice <token>
POST /vizum/device/<slug>/_sign Authorization: VizumDevice <token> {"route", "ids"}
POST /vizum/device/<slug>/_store Authorization: VizumDevice <token> {"op", "key", "value"} (5.82)
GET /vizum/device/<slug>/<route>/<id>?s=<signature> (a signed file URL)
POST /vizum/device/<slug>/_pair/start {"page", "info"} β {pairing, pin, poll, expires_in}
POST /vizum/device/<slug>/_pair/poll {"pairing", "poll"} β {state: waiting|paired|expired, token?, device?}
GET /vizum/device/<slug>/manifest.webmanifest?page=<page> (install the page full screen)
GET /vizum/device/<slug>/icon (its icon)
Answers: 200; 400 invalid request; 401 no/revoked/unknown token (the page returns to the PIN screen); 404 unknown app, an app with no approved device page, or an unknown route once the token is valid (the same 404 as any URL: a caller with no valid token or signed URL never learns which routes or apps exist); 413 body too large; 429 over the limit (120 calls per device per minute; Retry-After).
A signed file URL carries no token: s is an HMAC of (app, route, device, id, expiry), valid 24 h so an offline cache can keep it. It stops working the moment the device is revoked.
Hooks: V.device
A device route's hook runs as the app's job user (the app needs one: set it in the app's settings; without it the route answers 503, never the installer's rights, and the Apps Manager flags the app: "devices will not work until the service user is set"), inside a savepoint, with V.device:
# json route
d = V.device # {"id": 7, "name": "Lobby TV", "link": 42, "page": "player"}
screen = V.orm.read("x_vzm_signage_screen", [d["link"]], ["x_playlist_id"])
return {"version": 3, "slides": [...]}
# file route: return True to allow streaming `fields` of record `V.payload["id"]`
return V.orm.search_count("x_vzm_signage_media",
[("id", "=", V.payload["id"]), ("x_screen_ids", "in", [V.device["link"]])]) > 0
V.device is None in every other hook. Because the hook runs as the job user, scope every read by the device β the platform guarantees who the device is, not what it may see.
File responses: X-Content-Type-Options: nosniff, Range support, Cache-Control: private, max-age=86400 for images and documents and private, no-store for video/* and audio/*, no Set-Cookie, no CORS. Media is no-store on purpose: the browser's HTTP cache evicts the oldest entries first, and a streamed video (every Range answer is cached as its own entry) pushed the app frame's own files out of it, so an offline reload showed a blank screen. Offline media comes from the page's Cache Storage (the host downloads it itself), never from the HTTP cache β write your player to use the URL the host gives it, not to rely on the browser caching the file. Only an allowlist is served inline, the same as the 5.68 file routes: pdf, text/plain, json, video/*, audio/* and png/jpeg/gif/webp. Any other type is a download (Content-Disposition: attachment) with Content-Security-Policy: sandbox. The field must be one the job user can read (groups on a field apply). The device bundle responses (the credential is in the path) send Referrer-Policy: no-referrer.
The administrator's side (Vizum.pairedDevices, desktop)
await Vizum.pairedDevices.list(); // [{id, name, link, page, last_seen, online, active}]
await Vizum.pairedDevices.claim("482913", {name: "Lobby", link: 42});
await Vizum.pairedDevices.rename(7, "Lobby TV");
await Vizum.pairedDevices.link(7, 43);
await Vizum.pairedDevices.reload(7); // the screen reloads on its next heartbeat
await Vizum.pairedDevices.revoke(7); // unpair: the token dies now
Needs the app to declare at least one device route; claim, revoke, link and reload need base.group_system. The Apps Manager shows the same list and actions for every app with device routes.
Offline
Offline lease. A screen that is offline cannot receive the 401 of a revocation, so the page enforces a lease locally: a device with no successful authenticated call for 7 days (_session, heartbeat or a json route; platform constant DEVICE_OFFLINE_LEASE_S) tears the app frame down, deletes every device cache and its token, and shows the pairing screen again. The page checks it on load, every minute, and before it serves a cached answer or media file after a network failure. Every successful authenticated answer renews it, and a fresh pairing starts it together with the token. A missing or unreadable lease value counts as expired (fail closed): a screen that has a token but no valid lease is torn down at the next check, and it needs a successful authenticated call to serve cached data or media again.
A screen that is off for more than 7 days must be paired again. The 7 days are the platform constant DEVICE_OFFLINE_LEASE_S (one value for every app, not configurable per app). The server enforces the same lease: a device whose last authenticated call (or pairing) is older than that gets 401 on every route, its signed file URLs and its bundle credential stop working, and it must be paired again. Every authenticated call records last_seen, written at most once a minute per device.
The client lease is a display control. It limits what a left-behind screen keeps showing; it does not protect against someone with access to the browser profile or the clock: they can read Cache Storage with the developer tools, or set the clock back. Treat a stolen screen as compromised and revoke it (the next authenticated call gets the 401 and wipes everything).
A device keeps playing when the network drops: the platform page keeps the last good answer of every json route call and of every fileUrls (the signed URLs are valid 24 h, so an offline reload asks for the same URLs the media cache holds), serves media from its offline cache (Vizum.device.cache / blobUrl), and reconnects on its own. The caches belong to one device: their names carry the device id. When the device is unpaired (the 401 after a revoke) or paired again, the page deletes the data and media caches, the cache index and the saved session: a revoked or stolen screen keeps nothing. Limits: device.cache_mb, least-recently-used eviction, and only responses of THIS app's device routes are cached β never a desktop or session response.
A reload while offline (a power cut, the browser restarting) comes back to the last state:
- the platform page and its scripts come from a service worker scoped to that one device page (
/vizum/device/<slug>/sw.js?page=<page>, registered with scope/app/<slug>/<page>and sent with exactly thatService-Worker-Allowed). It answers only the navigation to that page's own path; the app's other pages (user and portal pages, which need the visitor's cookies) pass through untouched. The page asks it to keep its own URL the first time it opens, so even the first reload offline finds it; - the app is mounted with the last session the screen received. Its bundle credential lives 7 days (bound to the device; unpairing kills it on the next request that reaches the server), and the bundle files are served
Cache-Control: private, max-age=<until it expires>, immutable, so the browser's HTTP cache answers them without the network. (The app's frame is sandboxed with an opaque origin: a service worker cannot serve it.) - with no saved session, or one older than 7 days, the screen shows "Waiting for the networkβ¦" and retries. When the network returns, a screen mounted from the last session asks for a fresh one and starts its heartbeat.
Keeping the screen awake
The platform page takes a Screen Wake Lock once the app is mounted, and takes it again whenever the page becomes visible (a browser drops it when the tab is hidden). The app's frame is also allowed to (allow="screen-wake-lock *"; the page sends Permissions-Policy: screen-wake-lock=*), so navigator.wakeLock.request("screen") works inside the app. Both say * because the app's frame is sandboxed with an opaque origin, which neither self nor a bare feature name matches. Where the browser has no wake lock (some TV browsers, battery saver), set the device's own screen-off timeout to "never".
Full screen
A browser enters full screen only on a user's gesture. Three ways to get it on a screen:
- Install the device page as an app (recommended for Android, ChromeOS, Windows): open the device page in Chrome or Edge β menu β *Install app* / *Add to Home screen*. The page carries a web app manifest (
/vizum/device/<slug>/manifest.webmanifest) withdisplay: "fullscreen", so the installed app opens full screen, with no tap, every time. - Kiosk mode: start the browser with the device page in kiosk mode, e.g.
chrome --kiosk --autoplay-policy=no-user-gesture-required https://<instance>/app/<slug>/<page>(Windows/Linux), or the OS's kiosk / single-app mode (Android *App pinning*, ChromeOS kiosk apps). Also allows sound on autoplay. - One tap: otherwise the platform page shows a transparent layer with a small "Tap to show full screen" hint; the first tap anywhere turns full screen on and the layer goes away. Until that tap, the app plays normally in the window. In the app,
Vizum.device.fullscreen()called from a tap handler works too.
When the page already runs full screen (an installed app, kiosk mode, a browser in F11) the layer is never shown. The page sends Permissions-Policy: fullscreen=*, autoplay=*, encrypted-media=*, picture-in-picture=*, screen-wake-lock=* and the app's frame carries allow="fullscreen *; autoplay *; encrypted-media *; picture-in-picture *; screen-wake-lock *" β on device pages only, never on the desktop.
Large media files (permissions.file_limits, Vizum.files.putBlob)
Vizum.files keeps 64 MB a file, 50 files and 128 MB per user (per app with data: "global"). A signage, training or catalogue app asks for more, as its own approved grant:
"permissions": { "data": "global", "file_limits": { "max_mb": 100, "count": 2000, "total_mb": 8192 } }
| key | default | platform ceiling |
|---|---|---|
max_mb | 64 | 200 (512 from 5.71, see Large files) |
count | 50 | 5000 |
total_mb | 128 | 16384 |
- Privileged: the approval card shows the numbers; raising any of them later is approved again. The administrator's storage cap (Apps Manager β Usage & budgets) still applies on top, for all users together.
- Upload large files with
Vizum.files.putBlob(name, blob)(aBloborFile): the host sends the bytes to/vizum/apps/files/<key>/putwith the user's session β no base64 from the browser, no JSON. The server reads the body into memory (up tomax_mb, at most one streamed upload per user at a time: a second one answers 429 until the first finishes), so keepmax_mbas low as the app needs.Vizum.files.put(name, base64)keeps working for small files; above 16 MB useputBlob. Above 32 MB the host uploads in parts (5.71, files.md): each part is its own streamed request under the same rules β the upload slot, a bounded read,Content-Lengthchecked, the open upload checked before the body is read.
Media a device plays: Vizum.orm.writeBlob
A device's file route streams a binary field of a declared model (signed URL, Range supported, so a 100 MB video plays and seeks without being downloaded whole) β not Vizum.files. To put a large file where a device can play it, stream it into that field:
const rec = await Vizum.orm.create("x_vzm_signage_media", { x_name: file.name });
await Vizum.orm.writeBlob("x_vzm_signage_media", rec, "x_file", file); // {id, field, size, sha256}
- The host streams the bytes to
/vizum/apps/records/<key>/write_blob?model=β¦&res_id=β¦&field=β¦with the user's session (same CSRF proof asputBlob), raw β no base64 in the browser, no JSON-RPC body limit. - The write is
api_orm's, as the user: it passes exactly whatVizum.orm.write(model, [id], {field: base64})would β the app's orm grant (model,write,write_fields), record rules, the app's guards and triggers. On the desktop the user confirms it like any write. - Size: up to the app's approved
file_limits.max_mb(default 64 MB, at most 200 here: these routes read the body in one request; above 200 MB useVizum.files.putBlob, which uploads in parts). Both streamed routes (files/put,write_blob) accept bodies up to that 200 MB platform ceiling (Odoo's own 128 MiB default is raised for them). The host checks the file size against the app's approvedmax_mbbefore it uploads (an over-limit file is refused with "file too large" and nothing is sent), because Odoo answers a body *declared* above the 200 MB ceiling with its own plain HTML 413 before the route runs; the host also reads any non-JSON 413 as "file too large". A body above the app'smax_mbbut within the ceiling is the route's JSON 400.write_blobchecks the app's orm grant (model,write,write_fields) before it reads the body and takes the user's single upload slot first. The write itself still passesapi_ormwith base64, so while it runs a worker holds about 2.3 times the body (the raw bytes are dropped once encoded; Odoo decodes the base64 again when it stores the field): about 470 MB for a 200 MB file, for one upload per user. Odoo re-runs an endpoint after a database serialization failure; the platform keeps the (encoded) body on the request for that retry and refuses a body whose length differs fromContent-Length, so a retry never writes an empty field. - The field must be a
binaryorimagefield; the devicefileroute that plays it declares the same model and field ("model": "x_vzm_signage_media", "fields": ["x_file"]).
Residual risks
Known and accepted; read them before you rely on a limit.
- A distributed flood can delay new pairings. Each source address (an IPv4 address, or an IPv6 /64) may start a limited number of pairings, and an app holds at most 1000 waiting PINs. An attacker who controls many sources (about 100 IPv4 addresses or IPv6 /64 prefixes) can keep that pool full and delay new pairings. Pairing is started by an administrator and can be retried; 8-digit PINs keep the chance that a typo hits somebody else's PIN at about 1 in 100 000 (1000 PINs out of 10^8).
- Behind a proxy, set
proxy_mode. The per-address limits use the address Odoo sees. Withoutproxy_mode, every request comes from the proxy and shares one bucket. - Streamed uploads hold memory.
files/putandwrite_blobhold up to about 2.3 times the body (200 MB ceiling: about 470 MB) per concurrent user; one upload per user at a time, but many users uploading at once add up. Size the workers for it. - The offline lease is a display control (see Offline): browser-profile or clock access defeats it.
SDK & server
Porting an Odoo module to a vApp β runs exactly like the module PRIVILEGED
A vApp can do everything an Odoo module does and run exactly as one: the same models, the same columns on Odoo's models, the same Python methods, record rules, groups, crons, data, views, menus and reports. The difference is how it arrives. A vApp comes from the Vizum Store, the administrator approves what it may touch, and nothing is copied into the customer's addons path.
The proof is PSI DSD Engine: the module psi_dsd (25 models, 385 methods) ported this way. The module's own 818 tests pass against the vApp, and they pass again on a database migrated from the module.
1. Translate the module
python3 tools/module2vapp_port.py path/to/my_module --key com.acme.mymodule --out port.json
# a module of yours whose import-time values the port needs:
python3 tools/module2vapp_port.py path/to/my_module --key com.acme.mymodule \
--evaluate inprocess --out port.json
The translator reads the module's Python and XML and writes the manifest's native section. By default it runs nothing of the module (--evaluate static): the module's code is read as text, so it is safe for a module you did not write. A name the module computes when it is imported (_DEPENDS = component_depends(...), a selection built from a table) stays unknown in that mode and becomes a TODO. For a module you own and trust, --evaluate inprocess runs each models file's top level, with Odoo stubbed, to learn those values; it needs the main thread, applies a time limit to each statement, and is no boundary against hostile code, so never use it on an upload. The paths the module's manifest names must stay inside the module, and a symlink, a FIFO or a device is never read.
| In the module | In the vApp |
|---|---|
class X(models.Model) with _name | native.models[] (x_vzm_<app>_<name>) |
fields added to an Odoo model (_inherit) | native.fields[], columns x_vzm_<app>_<field> |
| field names | kept: native.aliases: true makes them real (related) fields |
model methods, overrides with super() | native.methods[], overrides call super_() |
_private() helpers | p_private() (underscore names stay forbidden in the sandbox) |
@api.depends computes, defaults, @api.constrains, SQL CHECK | computes, defaults and constraints by method |
ir.model.access.csv, ir.rule, res.groups | model access, rules, native.groups (with implies) |
| XML data, sequences | native.records (each adopts the module's xmlid) |
ir.cron | jobs with "method": "<model>.<name>" |
| views, actions, menus, QWeb reports | native.ui |
depends: an Odoo module (sale, stockβ¦) | odoo_requires β the install refuses where it is missing (base, web are always there) |
depends: a custom module already ported | requires {app, version} (5.67) β see below |
Translations
A module's i18n/es.po becomes the app's i18n/es.json (5.84: the same file also translates the backend). Take the entries of the module's .po and map them:
In es.po | In i18n/es.json |
|---|---|
model:ir.model.fields,field_description:<module>.field_<model>__<field> | the English label as the key, or @native:<model>.<field> |
model:ir.model.fields,help:... | @native:<model>.<field>#help |
model:ir.model.fields.selection,name:... | @native:<model>.<field>=<value> |
model:ir.ui.menu,name:<module>.menu_x, model:ir.actions.act_window,name:... | @native:<record id>.name (or the English text) |
model_terms:ir.ui.view,arch_db:<module>.view_x | one entry per term: the exact English text as the key |
code:addons/<module>/... | the English text as the key (a method's tr()) |
vzm i18n import-po does that mapping for you:
vzm i18n import-po my_module/i18n/es.po --manifest manifest.json # writes ./i18n/es.json
It reads the .po (multi-line strings, escapes, plural forms: msgstr[0] is taken) and merges it into the app's i18n/<lang>.json:
- Every entry becomes the plain English
msgidβmsgstr: field labels and help, selection labels, model labels, menus, actions, groups, view terms andcode:strings (they servetr(),V.tr()andVizum._ttoo). - When the same English text has two different translations, and the manifest's
native.models/native.fieldscan name the contexts, each one gets its own key (@native:<model>.<field>,@native:<model>.<field>#help,@native:<model>.<field>=<value>,@native:<model>) and the Odoo model name is mapped to the app's short model name. What the manifest cannot name stays one plain msgid (the most frequent translation) and is listed as AMBIGUOUS in the report. - An existing catalogue entry is never overwritten unless
--overwrite. - Skipped and counted:
#, fuzzyentries, emptymsgstr, translations with executable markup (the same rule as the install gate), placeholders that differ from the msgid's, strings above 8192 characters, and translations equal to the source.
The module translator (tools/module2vapp_port.py) skips fuzzy and msgctxt entries too. vzm check lists the native strings of the manifest that the catalogue does not cover.
A module that depends on another custom module (one in the same addons directory) needs that module ported first. Tell the translator which app it became, and the port requires it:
python3 tools/module2vapp_port.py path/to/my_module --key com.acme.mymodule \
--ported acme_core=com.acme.core@^1.2 --out port.json
The platform then refuses to install com.acme.mymodule without com.acme.core 1.2 or a later 1.x, and refuses to uninstall com.acme.core while this app needs it (composition.md). A custom dependency you have not mapped is reported as a TODO.
What the translator cannot carry is reported as a TODO. It covers raw SQL, a row lock (.lock() exists for SELECT β¦ FOR UPDATE) and another app's data. For each one, write a replacement method in an overrides folder: one file per method, with a comment saying why.
2. What the methods may touch
Inside a method, self and env[...] are *governed* records:
- They are limited by the app's
permissions.ormgrants, by Odoo's access rights and by record rules.sudo()lifts the access rights and rules, never the grants. - Grant
writeonly where the code writes. A write grant nobody exercises is attack surface. - Also listed, and approved by the administrator:
- overrides of Odoo methods, in
permissions.methods; - private Odoo methods the code calls, in
permissions.private_calls; ir.config_parameterkeys, inpermissions.params;- another app's data rows, read-only, in
permissions.peer_data.
- overrides of Odoo methods, in
- Another app calls a method only with its own
call:<method>grant.
3. Prove it behaves the same
python3 tools/module2vapp_port_tests.py path/to/my_module port.json OUT_ADDON_DIR
This copies the module's tests into a test-only addon. Only what names the module changes: model names, the module's xmlids, and imports of module-level helpers. Install the vApp in a test database and run that addon. "Works the same" means those tests pass.
4. Migrate a customer that runs the module
Declare "replaces": "my_module" and the adoption maps (the translator writes them):
- While the module is installed, the app waits. It creates its tables and columns and copies the data. Nothing of it touches Odoo's models yet: no alias, no override, no constraint, no view, no menu, no job. Those names and that behaviour are still the module's.
- What is copied:
- every row of the module's models;
- the module's columns on Odoo's models, with links translated to the adopted rows;
- the members of the module's groups (
groups[].adopt).
- When the customer uninstalls the module:
- the chatter and attachments of every adopted record move to its copy;
- on the next start the app goes live by itself: aliases, overrides, backend.
- Uninstalling the module drops its tables. Rehearse it on a copy of the customer's database first.
A vApp that uses any of this sets min_platform: "5.66" β "5.67" if it requires a ported app (the object form of requires). vzm check names the version each declaration needs.
SDK & server
The vzm CLI β check, doctor, context, sign, verify
vzm ships in @vizum/vapp-cli and needs nothing but Node 22.13 or later: no install step runs its checks. It carries the platform contract and the manifest schema it checks against β copies generated from the platform itself β so its answer is the platform's, not a second opinion.
npx vzm check # manifest + code against the platform contract
npx vzm doctor # is this project and this machine ready?
npx vzm context # the context a coding agent needs for THIS app (--write: AGENT_CONTEXT.md)
npx vzm context --feature orm --model sale.order # only the part of the app being worked on
npx vzm test # the app's tests on the platform emulator (emulator.md)
npx vzm dev # the app in a browser, on the platform emulator
npx vzm diff OLD.vapp manifest.json # what a new version changes; breaking without a major = exit 1
npx vzm schema diff OLD.vapp manifest.json # each data change: SAFE / WARNING / BREAKING / DESTRUCTIVE
npx vzm introspect --manifest manifest.json --out src/models.ts
npx vzm pack dist # zip the build into a .vapp (runs the native checks first)
npx vzm keygen --publisher acme && npx vzm pack dist --sign vzm-signing.key
npx vzm verify app.vapp # who vouches for a package β see package-signing.md
npx vzm new react-ts my-app
npx vzm i18n import-po my_module/i18n/es.po # a module's .po into i18n/es.json (5.84 native strings)
npx vzm --help # every command and its flags
vzm check
npx vzm check [manifest.json] [--src dir]β¦ [--dist dir] [--json] [--strict-i18n]
What it checks, and why each one matters:
| Check | Code | Level |
|---|---|---|
| The manifest against the platform's JSON schema (every key, every type) | SCHEMA | error |
The native section: relations, defaults, numbering, computed fields, migrations, team, adopt, a column/view/Action-menu binding on an Odoo model that permissions.schema.models does not list (the installer refuses it) β and the min_platform it needs | NATIVE | error |
A Vizum.* member the platform does not have (a typo, an invented API, a mock left in) | UNKNOWN_API | error (warning when the code detects it first and falls back) |
A named permissions.net host without a port in an app below min_platform 5.74: from 5.74 it is reachable on 80/443 only (see net) | NET_PORT_UNDECLARED | warning |
Code that parses the app key out of the run URL (a regex or split over /vizum/apps/run/, which on a multi-database host reads @<db>): use Vizum.app.key (see app-key) | APP_KEY_FROM_URL | warning |
A password input (type="password", type: "password") in the bundle: Vizum asks for the account password only in its own host dialog, Vizum.identity.confirm (see identity-step-up); never ask for it inside an app window | PASSWORD_INPUT_IN_BUNDLE | warning |
V.identity in server code with min_platform below 5.79 | MIN_PLATFORM_TOO_LOW | error |
An offline declaration the platform refuses: a dataset or write model without an orm grant that carries the op (the "*" grant does not count), fields outside the grant's ceiling, more than 12 datasets, limit over 20 000, max_mb over 100, an unknown placeholder in a domain, a duplicate name, permissions.offline written by hand | OFFLINE_INVALID | error |
offline with min_platform below 5.80 | MIN_PLATFORM_TOO_LOW | error |
An actions[].model that is neither an own native model, a model the app adds fields to, nor in permissions.orm (the platform refuses the action on every run) | ACTION_MODEL_NOT_GRANTED | error |
V.is_service_user in server code, or rec["su"] in a trigger hook, with min_platform below 5.81 | MIN_PLATFORM_TOO_LOW | error |
V.vector.shared_namespaces in server code with min_platform below 5.83 | MIN_PLATFORM_TOO_LOW | error |
A unique that names x_company_id (a company_scoped model), or jobs beside permissions.data: "user", with min_platform below 5.81 | NATIVE | error |
Vizum.device.store (or another member the contract dates 5.82) with min_platform below 5.82 | MIN_PLATFORM_TOO_LOW | error |
Vizum.ui.fullscreen with min_platform below 5.85 (detected through Vizum.supports("ui-fullscreen") or "ui.fullscreen", it is FEATURE_DETECTED) | MIN_PLATFORM_TOO_LOW | error |
Vizum.ui.fullscreen while permissions.ui does not list "fullscreen" (the bridge drops the call) | PERMISSION_MISSING | warning |
An i18n/<name>.json not named after an Odoo language code (es, es_419; not es-419) | I18N_LOCALE | error |
A route the platform would refuse: a file route that is neither public nor device, or a device file route with no explicit permissions.orm read rule | INVALID_ROUTE | error |
A member newer than min_platform | MIN_PLATFORM_TOO_LOW | error β or FEATURE_DETECTED (info) when the code detects it first: Vizum.supports(), typeof Vizum.x, "x" in Vizum or optional ?. access |
A manifest feature newer than min_platform: object-form requires, event_contracts, AI tool contracts (all 5.67) | MIN_PLATFORM_TOO_LOW | error |
The i18n/<lang>.json files next to the manifest, or in public/i18n/ for a Vite app (5.73): strings only, each msgstr's placeholders equal to its msgid's, no executable markup, no width or precision above 999 or * (I18N_WIDE), at most 512 KB each and 2 MB together β the same rule the instance and the Store apply | I18N_JSON / I18N_VALUE / I18N_PLACEHOLDERS / I18N_MARKUP / I18N_SIZE | error |
A language the manifest's i18n declares that has no i18n/<lang>.json (next to the manifest or in public/i18n/; with --dist <dir>, in the build output β the layout that ships). vzm pack refuses the same package even with --no-check, the instance's install and the Store's upload refuse it too: without the file the catalogue is a 404 and server tr() stays in English | I18N_CATALOGUE_MISSING | error |
Native strings the catalogue does not translate (5.84): per declared language, a field or model label, help, selection option, group, binding, menu or action name, or view term of the manifest with no msgstr (the English text or its @native: key); an @native: key that names nothing the manifest declares; a msgstr used as a view term that is not a well-formed XML fragment. --strict-i18n turns all three into errors. An @native: value with a % placeholder is I18N_PLACEHOLDERS (error) | NATIVE_I18N_UNTRANSLATED / NATIVE_I18N_DANGLING / NATIVE_I18N_BAD_XML | warning (error with --strict-i18n) |
min_platform above every platform the SDK knows | MIN_PLATFORM_UNKNOWN | error |
| A deprecated / removed / experimental member | DEPRECATED_API / REMOVED_API / EXPERIMENTAL_API | warning / error / warning |
A namespace only some hosts serve (phone is desktop-only) used without detection (Vizum.supports(), typeof Vizum.x, "x" in Vizum or optional ?. access, as for MIN_PLATFORM_TOO_LOW) | HOST_LIMITED | warning |
| A permission that does not exist | UNKNOWN_PERMISSION | error |
A grant that needs another (ai_image needs ai) | PERMISSION_REQUIRES | error |
A grant the platform builds itself (tables, actionsβ¦) written by hand | DERIVED_PERMISSION | warning |
| A namespace the code calls without the grant it needs | PERMISSION_MISSING | warning |
A permissions.orm grant the platform refuses at install: a vizum.* platform model outside the allowlist (vizum.app is read + call: only), write/create/unlink/call: on ir.actions.* (except ir.actions.report), create/unlink or fields beyond active, nextcall, interval_number, interval_type, priority on ir.cron, anything but reading the identity models, any access to credential models, write/create/unlink on base.automation, ir.server.object.lines, ir.default, ir.filters, ir.model.data, website.page and website.controller.page (read-only), any call: on ir.ui.view, mail.template or ir.actions.report other than get_combined_arch, send_mail and report_action respectively, and any grant on vizum.licence* / vizum.store.* outside the store's own back-office app (com.vizumapps.hq) | ORM_GRANT_REFUSED | error |
| A privileged grant no scanned code uses | PERMISSION_UNUSED | info |
A name in server code (native.methods, native.script.lib, a hook) that no scope and no sandbox global defines: it installs, then raises NameError when the line runs | UNDEFINED_NAME | error (the Store refuses a new upload with the same finding) |
A requires entry the platform cannot read (bad range, unknown keys) | INVALID_REQUIRES | error |
An event_contracts entry outside the platform's rules (version 1β999, its schema keywords) | INVALID_EVENT_CONTRACT | error |
| An event the code emits that has no contract | EVENT_UNDOCUMENTED | info |
An AI tool contract outside the server's rules (risk, side_effects, confirm) | INVALID_AI_TOOL_CONTRACT | error |
The scan reads src/, js/, hooks/, index.html and app.js next to the manifest (or the --src paths you pass) and follows the SDK where it can see it: Vizum.x, the V export of @vizum/vapp-types, an alias such as export const sdk = V, V. in server hooks, and a parameter typed as the SDK β @param {typeof Vizum} V or (V: typeof Vizum), the pattern that lets the same module run on the emulator. A bare parameter named V is not followed (it is often the app's own). A namespace member the bridge does not serve (Vizum.ai.tools.handle runs in the page) needs no grant. It ignores comments, string contents and test files. --json prints the findings for CI or an agent; the exit code is 1 when there is an error.
Detect a member newer than your min_platform like this, and check accepts it:
const can = (n) => typeof Vizum.supports === "function" && Vizum.supports(n);
if (can("files.publicUrl")) { url = await Vizum.files.publicUrl(path); }
The same checks without a filesystem β for a tool that holds the manifest and sources in memory, such as Vapp Creator in the browser β are in @vizum/vapp-cli/lib/check-core.mjs:
import { analyseInMemory } from "@vizum/vapp-cli/lib/check-core.mjs";
const { problems } = analyseInMemory(manifest, [{ file: "index.html", text }], { contract, schema });
contract and schema are the two JSON files the package carries (lib/platform-contract.json, lib/manifest.schema.json). vzm check calls this function too, so the two cannot disagree.
vzm i18n import-po
vzm i18n import-po <module>/i18n/<lang>.po [--manifest manifest.json] [--out dir] [--lang code] [--overwrite] [--dry-run] [--json]
Turns the .po of a native module being ported into the app's i18n/<lang>.json (the catalogue that 5.84 also uses for the backend: fields, selections, menus, actions, groups, views). The language comes from the file name (es.po β es), --lang overrides it; the catalogue is written next to the manifest (--out changes the directory) and keeps what it already holds. It is checked with the catalogue's own rules before anything is written.
In the .po | In the catalogue |
|---|---|
field label, help, selection, model, menu, action, group, view term, code: | the English msgid β msgstr |
the same msgid with two translations, contexts nameable from the manifest | @native:<model>.<field>, β¦#help, β¦=<value>, @native:<model> |
the same msgid with two translations, contexts not nameable | one plain msgid (the majority) and an AMBIGUOUS line in the report |
#, fuzzy, empty msgstr, executable markup, differing placeholders, equal to the source | skipped, counted |
Existing entries are never overwritten unless --overwrite. --dry-run prints the report only; --json prints it as JSON (added, kept, overwritten, ambiguous, skipped, plurals, kinds). Without a manifest.json no @native: key can be produced (the plain msgids are still imported). Exit code 1 means nothing was written.
vzm doctor
Node version, the CLI and the contract it carries (capability, SDK, api version), the manifest (check's verdict and the install tier: zero, or privileged with the grants an administrator must approve), the @vizum/* packages and whether they resolve outside the sdk-next workspace, node_modules and dist. --json for machines; exit 1 on a failure.
vzm context
Prints, for the app in the current directory, the minimum exact context a coding agent needs: the platform it targets, the rules it must follow, the grants the app holds and what they mean, every member it can call (with the capability each needs, flagged when newer than min_platform), what needs a grant it does not hold, the error catalogue, and vzm check's current findings. Without a manifest it prints the whole platform. --json gives the same as data. Point an agent at it instead of at the documentation:
npx vzm context > AGENT_CONTEXT.md
The whole context of a large app is long β Contador 365's is 14 KB, most of it the field lists of its orm grant. Focus it on the part being worked on:
| flag | effect |
|---|---|
--app <dir | manifest.json> | the app to describe (a directory means its manifest.json) |
--feature <namespace> (repeatable, or comma-separated) | only those Vizum.* namespaces in "can call" / "needs a grant"; an unknown name is refused with the list |
--model <model> (repeatable) | a section per model: how the app reaches it (its own model, an orm grant, or none β then what to add), the grant's ops, field ceiling and domain, the columns the app adds there, and its fields |
--fields <fields.json> | Odoo's fields for --model, offline β the Vizum.meta.fields / fields_get output keyed by model, the same file vzm introspect --from reads |
A model can be named by its manifest name (job) or its technical one (x_vzm_<slug>_job). The app's own models list their fields from the manifest, with the x_ names the platform creates. For an Odoo model without --fields the section says how to get them: field names drift between Odoo 17, 18, 19 and 20. Without a focus, the orm grant is summarised one model per line; its field lists and domains are in --model.
vzm keygen, vzm sign, vzm verify, vzm pack --sign
npx vzm keygen --publisher <name> [--out vzm-signing.key]
npx vzm pack <dist-dir> --sign vzm-signing.key
npx vzm sign <x.vapp> --key vzm-signing.key [--out signed.vapp]
npx vzm verify <x.vapp> [--store-key <base64>] [--json]
keygen writes an Ed25519 key (mode 600) and never overwrites one. pack --sign and sign add signature.json; pack never copies a stale signature.json from the build directory. verify prints the state an instance would record β store_verified needs --store-key β and exits 1 when the package is tampered: changed after it was signed, or a signature that is malformed, does not verify, or names another app, version or key. Format, states and the store's rules: package-signing.md.
vzm diff
npx vzm diff <old manifest.json | old.vapp> [new manifest.json] [--json]
What a new version changes, offline β the same question api_update_preflight answers on an instance: the version jump, permissions added / removed / changed (and the privileged ones that make every administrator re-approve), native models and fields, Vizum.db tables and fields, provides, event contracts and requires. A breaking change β removed native or Vizum.db schema (data), a native field whose type or relation changed, a removed selection value, a removed provides, a removed event contract, an event schema changed without a new version β in a release that is not a new major exits 1 (BREAKING_WITHOUT_MAJOR; from 1.0.0 on β a 0.x release is exempt). So do an event schema changed without raising its contract version, even in a major (EVENT_SCHEMA_CHANGED), an unchanged version (VERSION_NOT_RAISED) and two different app ids (DIFFERENT_APP). A downgrade is a warning (DOWNGRADE); a changed min_platform is info (MIN_PLATFORM_CHANGED: instances below the new value cannot install the update); a new privileged grant is NEEDS_APPROVAL (info).
vzm schema diff
npx vzm schema diff <old manifest.json | old.vapp> [new manifest.json] [--json]
What a new version does to the app's data, change by change, each classified:
| level | means | examples |
|---|---|---|
SAFE | nothing stored changes meaning, nobody loses an ability | a model, field, table or column added; a selection value added; a label, help, default or index changed; a declared rename_field |
WARNING | data is kept, behaviour changes | a required field with no default (existing rows stay empty); a field becoming required; a column the app added to a shared model removed (it is archived, not dropped); a field removed after native.migrations copy; new unique indexes, constraints, Vizum.db rules; changed groups, writers, rules, access, translation, formulas |
BREAKING | the installed schema no longer matches, or the update fails | a field's type changed (the platform keeps the old type, silently); a many2one's relation changed (rewritten in place: stored ids then point at the other model's records); a selection value removed (the update refuses while a row holds it); a Vizum.db column's type changed |
DESTRUCTIVE | stored data is deleted on every instance | the app's own model or one of its fields removed (its column is dropped); a field no longer stored; a Vizum.db table or column removed |
The levels are what the platform does on update, measured on Odoo 19 (vizum_app_schema.reconcile), not a guess. native.migrations of the version jump are taken into account: a rename_field keeps the data (SAFE), a copy carries it into the new field before the old one goes (WARNING); a migration declared for another version does not protect this one.
A changed type or comodel of an existing field exits 1 whatever the version (SCHEMA_CHANGE_REFUSED): every instance refuses that update, and the Store refuses to publish it. The way to do it is a new field with the new type or comodel, filled in the same update's native.migrations ({"op": "copy", "model", "from", "to"}) or a migration hook, and the old field no longer declared. Any other BREAKING or DESTRUCTIVE change in a release that is not a new major exits 1 (SCHEMA_BREAKING_WITHOUT_MAJOR, from 1.0.0 on); a DESTRUCTIVE change always warns (SCHEMA_DESTRUCTIVE). vzm diff counts the BREAKING kinds above as breaking too, and no longer calls a declared rename a removal.
vzm new is agent-ready
Every scaffold gets, generated from the CLI's own contract: AGENTS.md (the commands and the platform's rules for a coding agent), CLAUDE.md (pointing to it), AGENT_CONTEXT.md (vzm context for the new app β refresh it with vzm context --write), a first emulator test in tests/app.test.mjs, and check / test / context scripts in package.json. A template without a build step (vanilla-multi) gets a package.json holding only @vizum/vapp-cli, so npm install && npx vzm test runs its test. Templates: react-ts, vanilla-ts, vanilla-multi, and offline-field-app (5.80: a route list and detail that read from the device, a note, a photo and a signature queued while offline, a Sync issues screen, and emulator tests of the offline paths; also a no-build template).
SDK & server
The platform emulator β vzm test and vzm dev
*Since capability 5.67.* vizum-sdk-mock.js answered every call with success. The emulator answers the way the platform does, so a test that passes on it says something about the platform.
Mock (vizum-sdk-mock.js) | Emulator (@vizum/vapp-cli/emulator) | |
|---|---|---|
| SDK | a hand-written stand-in | the real vizum-sdk.js, on the real bridge protocol |
| Permissions | none | the platform's own bridge gate, with the caps the platform derives from your manifest |
| ORM | succeeds | applies the server's per-model, per-operation rule (permissions.orm, the "*" read grant, the app's own native models) |
| Errors | none | the platform's error results: VizumError with code, category, retryable, correlationId |
| Failures | none | inject any error code, go offline, add latency |
| Unknown call | succeeds | fails "not emulated" until your test stubs it |
What it emulates: data, db (the manifest's tables; each value converted to its column's type as the server does β "42" into an integer column is 42, "3.5" or "abc" is VALIDATION_FAILED, an unknown column too β Odoo domains, order, limit), orm (fixture records, Odoo domains), user, ui (confirm needs an answer you give it), events (enforcing your event_contracts), apps (composition with the platform's version-range grammar), permissions and, from 5.80, Vizum.offline (below).
vzm test
npx vzm test # every tests/**/*.test.mjs, with node's test runner
import test from "node:test";
import assert from "node:assert";
import { bootApp } from "@vizum/vapp-cli/emulator";
test("orders survive a dropped connection", async () => {
const { Vizum, emulator, close } = await bootApp({
fixtures: { orm: { "res.partner": [{ id: 7, name: "ACME", is_company: true }] } },
});
try {
emulator.fail("orm.exec", "NETWORK_ERROR", { times: 1 });
await assert.rejects(Vizum.orm.create("res.partner", { name: "x" }),
(e) => e.code === "NETWORK_ERROR" && e.retryable);
const id = await Vizum.orm.create("res.partner", { name: "x" }); // the retry works
assert.ok(id);
assert.strictEqual(emulator.callsTo("orm").length, 2);
} finally { close(); }
});
bootApp() reads the manifest vzm test points at; bootEmulator({ manifest }) takes one directly. The controls:
| Call | Does |
|---|---|
emulator.fail(pattern, code, { times, message }) | calls matching "orm.exec", "db.*" or "*" fail with that code |
emulator.offline(true) | every server call fails OFFLINE until offline(false); Vizum.offline.* keeps answering from the device store, and going back online replays the queue (await emulator.settled()) |
emulator.serverChange(model, id, vals) | somebody else changed a record on the "server" (5.80): a queued write to it comes back as a conflict |
emulator.serverDelete(model, id) | somebody deleted a record on the "server" (5.86): a queued write to it comes back as a deleted issue (canRecreate), and resolve(id, "keep_mine") recreates it |
emulator.offlineState() | the device's queue, issues and rows (5.80) |
emulator.latency(ms) | every call waits |
emulator.appState("disabled" | "needs_approval" | "ready") | the app's state on the instance: out of ready, every call that reaches the server fails ACCESS_DENIED (Β«App β¦ is not availableΒ», what _get_ready answers) β an administrator disabled it, or an update sent it back to approval while its window was open; host-only calls (ui, devices, vision, reports, editable) keep answering |
emulator.health({ errors, tasks }) | the runtime errors and failed tasks Vizum.apps.health() reports (dependencies come from the apps fixture) |
emulator.answerConfirm(true, falseβ¦) | the answers to the next Vizum.ui.confirm() calls |
emulator.stub(ns, method, fn) | supply a namespace the emulator does not model |
emulator.calls, emulator.callsTo(ns, method) | every call, with ok and code |
emulator.data, .tables, .records, .notifications | the emulated state, to assert on |
Offline data (5.80) is emulated. An in-memory device store sits next to the in-memory records: datasets, sync, query, readGroup, create (temp id), write (with the base the device saw), conflicts, resolve, and the offline:sync / offline:conflict events behave as the host's engine does (see offline-data.md). fixtures.computed[model] = (vals) => ({field: value}) plays the server's computed fields for a create's expect. Not modelled: the 7 day limit, the max_mb quota, storage failures, the scheduler (call sync() yourself), record rules.
SDK 5.70 is not emulated. Paired-device routes (auth: "device", Vizum.device, Vizum.pairedDevices) and the streamed upload (Vizum.files.putBlob, Vizum.orm.writeBlob) need a real instance β PIN pairing, the hashed device token and the byte store live on the platform. The emulator refuses them with UNSUPPORTED_CALL and says why; give a call a value with emulator.stub("files", "putBlob", fn) to test the rest of the app. vzm check still validates the manifest side (auth: "device" routes, file_limits, min_platform 5.70).
Fixtures: { data: {key: value}, db: {table: [rows]}, orm: {model: [records]}, collections: {name: [values]}, apps: [{key, version, ready, provides}], user: {name, groups, is_adminβ¦}, appState: "ready" }.
Live data is emulated too. Vizum.orm.watch (and so orm.live, refresh.on and the @vizum/vapp-query live bindings) fires when a create, write or unlink changes the domain's record count or last write β only then, as the desktop host does. Vizum.data.collection has the server's semantics (rev_conflict on a stale update, 500 rows a page). A data.set or collection change fires the app's subscribe callbacks only for a data: global app β the server pings nothing for per-user data, and neither does the emulator. See live-data.md.
A run that finds no tests fails β zero tests is not a pass.
vzm dev
npx vzm dev # a no-build app: serves the app's own folder
npx vzm dev --dir dist # a built app
npx vzm dev --fixtures fixtures.json --port 5178
Opens the app in your browser inside a host page that runs the emulator: the app's iframe is sandboxed exactly like on the desktop (an opaque origin, no allow-same-origin), it loads the real SDK from the path every vApp already uses, and the side panel lists every bridge call with its result, switches the app offline, sets the app's state (disabled, waiting for approval) and makes the next calls fail with any error code. Vizum.ui.confirm() asks you. In the console, vzm is the emulator (vzm.calls, vzm.fail(...)).
Libraries the SDK lazy-loads from an instance (charts, office formats, 3D) are not served by vzm dev; those calls report that they come from a Vizum instance.
SDK & server
Reliability β webhooks, limits, metrics, retries
*Since capability 5.67.*
Inbound webhooks you can trust
Every delivery to one of your app's webhook URLs is recorded in a delivery ledger, and the row the app reads from hook_<name> carries its id. Webhooks need permissions.data: "global", and the hook_* queue holds raw third-party payloads, so only an administrator or the app's job user can read it β drain it in a manifest job, which runs as that user:
# native.script.hooks β "drain", run by a job ("jobs": [{"name": "drain", "script": "drain", "cron": "*/5 * * * *"}])
queue = V.data.collection('hook_orders')
for row in queue.list():
v = row['value'] # {ts, body, delivery, attempt}
if handle(v['body']): # your function, from native.script.lib
V.webhooks.ack(v['delivery']) # processed
else:
V.webhooks.fail(v['delivery'], 'could not apply') # queued again as a NEW row
queue.remove(row['id']) # always: ack/fail settle the ledger, not the row
The same ack / fail / deliveries exist in the browser (Vizum.webhooks.*) for a screen an administrator uses. They are as privileged as reading the hook_<name> queue: an administrator, or the app's job user β any other user of the app is refused (ACCESS_DENIED). Remove each row you settled: ack and fail change the ledger only, a fail queues the delivery again as a new row once the original row is gone (while it is still queued, no second copy is added), and acking an already processed delivery is refused (VALIDATION_FAILED).
| Status | Means |
|---|---|
received | queued for the app |
processed | the app acknowledged it |
failed | the app reported a failure; it is queued again |
dead_letter | it failed max_attempts times; kept 30 days from when it was received, for an administrator to inspect and retry |
rejected | refused at the door β bad or missing signature, stale timestamp, too large; never queued |
Replays. A delivery whose provider id was already seen β X-GitHub-Delivery, X-Shopify-Webhook-Id, Webhook-Id / Svix-Id (Standard Webhooks), Stripe's evt_β¦ event id, Idempotency-Key, X-Vizum-Delivery β is answered 200 duplicate (the provider stops retrying) and is not queued again. When the provider sends a timestamp (Stripe-Signature t=, Webhook-Timestamp, Svix-Timestamp, X-Slack-Request-Timestamp, X-Vizum-Timestamp), a delivery outside the hook's tolerance is rejected. Ids are remembered 14 days. Rejections are recorded up to 100 per hook per hour. Stripe's signed in-body event id wins over any header.
On a hook with a secret_name, the signature covers the body only β the id and timestamp headers are not signed β so the same body received again within those 14 days is also a duplicate, whatever delivery id it carries.
Signatures (secret_name) are verified for GitHub / Meta (X-Hub-Signature-256) and Shopify (X-Shopify-Hmac-Sha256); a signed hook refuses any other sender with 401. For Stripe or a Standard Webhooks / Svix sender, the id and timestamp headers above give replay protection on an unsigned hook.
"webhooks": [{ "name": "orders", "secret_name": "shop_secret",
"tolerance_seconds": 300, "dedupe": true, "max_attempts": 5 }]
Vizum.webhooks.deliveries(hook, {status, limit}) lists the ledger β metadata only, never the payload. Vizum.webhooks.retry(id) (installer or administrator) sends a failed or dead-lettered delivery back to the app.
The bridge rate limit
Each user may make 600 bridge calls a minute to one app (an administrator sets vizum_apps_platform.bridge_rate_per_minute, or a per-app bridge_rate_per_minute). Past it a call rejects with RATE_LIMITED, which is retryable β Vizum.errors.retry waits and tries again. The window is kept per server worker, so it bounds a runaway loop or a flood rather than metering exact usage.
The instance default is enforced for apps whose min_platform is 5.67 or later. An app published before (its SDK did not retry RATE_LIMITED) is logged when it passes the limit, not refused. A limit an administrator sets on the app itself (bridge_rate_per_minute) is always enforced.
Per-app budgets
An administrator can give one app its own ceiling, so a runaway job or a loop cannot spend what the whole instance has. Every cap is 0 β no cap β until it is set:
| Budget | Counts | Window |
|---|---|---|
ai_token_cap | AI tokens, all users of the app together | rolling 30 days |
net_requests_per_day | Vizum.net.fetch calls | UTC day |
storage_mb_cap | megabytes of Vizum.files, all users together | β |
Past a budget the call rejects with QUOTA_EXCEEDED, which is not retryable: waiting a minute does not help, the window has to move or the cap be raised. Catch it and tell the user:
try {
await Vizum.ai.chat({ prompt });
} catch (e) {
if (Vizum.errors.is(e, "QUOTA_EXCEEDED")) showBanner("This app has used its AI budget for now.");
else throw e;
}
The AI cap is as hard as the gateway and per-user caps it sits next to: every call reserves an estimate under a per-app lock before it reaches the provider, and is reconciled to the real usage after, so two concurrent calls cannot both take the last tokens. A call that costs nothing (your own key on a managed plan, a free trial) does not count. Image generation, OCR and transcription are metered per unit, not per token, and stay outside it. The network budget counts every call the app makes once it passes the permission check β one refused afterwards (a host outside the allowlist, a bad method or body) or one that failed after leaving counts too.
Administrators read and set budgets with vizum.app.api_budgets(key, values) β {ai_token_cap, net_requests_per_day, storage_mb_cap, bridge_rate_per_minute}, whole numbers, 0 for no cap (for bridge_rate_per_minute, 0 means the instance default: 600, or the vizum_apps_platform.bridge_rate_per_minute system parameter) β and see the usage against each in api_metrics(key).budgets. In the desktop, the Vizum Apps window's app detail has the same for an administrator: Usage & budgets in its Manage rail shows what the app used against each cap and edits the caps (only the ones changed are sent).
Retrying the right way β Vizum.errors.retry
const key = crypto.randomUUID();
const [id] = await Vizum.errors.retry( // create returns [id]
() => Vizum.orm.create("sale.order", vals, { idempotencyKey: key }), // the SAME key each time
{ retries: 3, baseMs: 250, maxMs: 4000 }); // retries: at most 10
Only errors the platform marks retryable are repeated (network, timeout, rate limit, concurrent update, offline); anything else rejects at once. Waits grow exponentially with jitter. A write repeated without an idempotency key may run twice β pass the same key on every attempt and the server runs it once.
Backend tasks that retry and time out
A Vizum.backend task runs one of your manifest actions in the background. Since 5.67 the action declares how its runs behave when something goes wrong:
"actions": [{
"name": "invoice.post", "model": "account.move", "method": "action_post",
"retry": { "max": 3, "backoff_seconds": 60 },
"timeout_minutes": 30
}]
retryβ a run that fails with a *retryable* error runs again, up tomaxmore times (1β5). It waitsbackoff_seconds(10β3600, default 60), then twice that, then four timesβ¦ at most 6 hours β and never less than the next pass of the task queue, which runs every 2 minutes. Retryable is the platform's own answer, the sameretryablean app sees in aVizumError:RATE_LIMITED,CONCURRENT_UPDATE, a database serialization conflict, a dropped connection. A validation, access or business error β orQUOTA_EXCEEDEDβ fails at once: it would fail the same way again. Declareretryonly for an action that is safe to run twice (see idempotency inplatform-contract.md); only you know.timeout_minutes(1β1440) β the run's deadline. A batch action reportingVizum.backend.progress()getscancel_requested: trueonce it passes and stops cleanly at that batch; a run still going after it is failed (last_error: "timed out").- cancel β a task waiting for its retry is
queued:Vizum.backend.cancel(id)cancels it for good. A cancelled run is never retried.
Limits on the queue and on method jobs
The queue is bounded, so one app or one user cannot bury it:
- Pending tasks are capped.
waiting_approvalandqueuedtasks together may not pass 500 per app nor 100 per user of that app.Vizum.backend.enqueue()beyond either limit rejects withQUOTA_EXCEEDED(not retryable: wait for the queue to drain, or batch the work). Running and finished tasks do not count. - An unapproved task expires. A task still
waiting_approvalafter 7 days becomescancelledwithlast_error: "approval expired". It is cancelled, never deleted, so the audit trail keeps it; enqueue it again if it is still wanted. Aqueuedtask (already approved, or waiting for a retry) is not on this clock. - A method job has a 300 second limit. A scheduled job that calls a native method is arbitrary Python and cannot be interrupted from outside, so the limit is *measured*, not enforced: a run that goes past 300 s is recorded on the job (
slow), in the audit row (over_limit_seconds) and in the app's health as the conditional checkjobs_time_limit(degraded; see lifecycle). Split a long batch into runs under the limit. A script job keeps its own 120 s budget, which does stop it.
The task row says where it is:
const t = await Vizum.backend.status(id);
// { state: "queued", status: "retrying", attempts: 1, max_attempts: 4, next_run_at: "β¦", last_error: "β¦" }
state keeps its six values (waiting_approval, queued, running, done, failed, cancelled), so code that compares it keeps working. Since 5.69 status names the cases it folds together:
status | state | Means |
|---|---|---|
retrying | queued | a run failed with a retryable error and the next attempt waits its backoff |
expired | failed | the run passed its timeout_minutes deadline |
dead_letter | failed | the action declared retry and every attempt failed |
| (the same word) | any other | waiting_approval, queued, running, done, failed, cancelled |
On an older platform status is absent; read state.
Without retry and timeout_minutes an action runs exactly as before: once, with no deadline.
Metrics β api_metrics(key, hours)
For the app's installer or an administrator, over the last 1β720 hours:
{ "bridge": {"calls": 18342, "refused": 12, "limit_per_minute": 600},
"runtime_errors": 3, "job_failures": 1,
"tasks": {"done": 220, "failed": 2, "cancelled": 0},
"webhook_deliveries": {"processed": 410, "dead_letter": 1, "rejected": 7},
"ai_tokens": {"input": 120400, "output": 30210},
"budgets": {},
"app": "com.acme.orders", "hours": 24, "since": "2026-10-02 08:00:00" }
webhook_deliveries counts the deliveries per status, listing only the statuses that occur; budgets is the usage against each budget an administrator set (above).
Calls are counted in memory and written per app per hour (kept 30 days); the rest comes from records the platform already keeps.
Tracing a job run
Every scheduled or retried run of a manifest job gets an operation id β job-<name>-<time>-<random> β used as the request id of the job's audit row and of every bridge call its script makes, exactly like the request id of a browser call (a Vizum.jobs.runNow run carries the calling request's id instead). The job's tick row (job:<name>) carries it as operation_id; Vizum.audit.search({request_id}) finds everything one run did.
SDK & server
Live data β queries that refresh themselves
*@vizum/vapp-query 0.2. Built on Vizum.orm.watch and the data pings, so nothing new on the server: it refreshes on the desktop and in Vizum Solo (feature watch). In Odoo's own menus the watch degrades β queries still load, and refresh only when they are fetched again.*
The platform already tells an app when its data changes: Vizum.orm.watch(model, domain, cb) is the desktop's fingerprint poll (it fires when the domain's record count or last write changes β created, updated or deleted by this user or another), and a data: global collection pings its subscribers on every change. @vizum/vapp-query binds those signals to TanStack Query, so a query refetches when, and only when, its data changed β no reload timers, no manual invalidation.
React
import { useQuery } from "@tanstack/react-query";
import { searchRead } from "@vizum/vapp-query";
import { useLiveModels } from "@vizum/vapp-query/react";
function Orders() {
useLiveModels(["sale.order"]); // every sale.order query on this screen stays fresh
const { data = [] } = useQuery(searchRead("sale.order", [["state", "=", "sale"]], ["name", "amount_total"]));
...
}
useLiveModels(models, { interval?, domains? }) watches while the component is mounted and stops on unmount; an inline array does not re-subscribe on every render. domains: {model: domain} narrows a watch to the records a screen shows, so a write elsewhere does not refetch it. useLiveCollection(name) does the same for a shared collection (collectionList(name)).
Without React
import { QueryClient } from "@tanstack/query-core";
import { liveModels, liveCollection } from "@vizum/vapp-query";
const queryClient = new QueryClient();
const live = liveModels(queryClient, ["res.partner", "sale.order"], { interval: 8 });
const orders = liveCollection(queryClient, "orders"); // a data: global collection
// β¦
live.stop();
orders.stop();
Any object with invalidateQueries({queryKey}) works as the client.
What refreshes, and what does not
| Change | Refetches |
|---|---|
| A record of a watched model is created, written or deleted β by anyone | its queries (keyFor(model)) |
A write outside a watch's domains entry | nothing |
A data: global collection changes | its queries (collectionKey(name)) |
| A per-user collection changes | nothing β only this user writes it, through this app: invalidate after your own write |
| The desktop tab comes back into view | the watched models' queries (the host does not poll a hidden tab) |
The watch polls every 8 seconds by default (4β60, interval), on the platform's cheapest query (count and last write date). It runs as the user and stops silently on a model the app can no longer read.
Testing it
The platform emulator (@vizum/vapp-cli/emulator, emulator.md) emulates the watch and the collection pings, so a live screen is testable in node:
// tests/live.test.mjs β run with `npx vzm test`, which points bootApp at your manifest.json
import test from "node:test";
import assert from "node:assert";
import { QueryClient } from "@tanstack/query-core";
import { bootApp } from "@vizum/vapp-cli/emulator";
test("a new partner refreshes the partner list", async () => {
const { Vizum, close } = await bootApp({ fixtures: { orm: { "res.partner": [{ id: 1, name: "Ada" }] } } });
// in the desktop the SDK script sets the global before your bundle loads; in node, set it
// before importing the package (it reads the global when it is imported)
globalThis.Vizum = Vizum;
const { liveModels, searchRead } = await import("@vizum/vapp-query");
const qc = new QueryClient();
const query = searchRead("res.partner", [], ["name"]);
await qc.fetchQuery(query);
const live = liveModels(qc, ["res.partner"]);
try {
await Vizum.orm.create("res.partner", { name: "Grace" });
await new Promise((r) => setTimeout(r, 50)); // the watch callback is a bridge event
assert.ok(qc.getQueryState(query.queryKey).isInvalidated);
} finally {
live.stop();
close();
}
});
The manifest must grant what the test calls (permissions.orm with read, search and create on res.partner here): the emulator enforces it as the platform does.
SDK & server
Signing an action β step-up authentication
*Since capability 5.79. Needs min_platform 5.79. No manifest permission.*
Some actions are not just clicks: approving a controlled document, releasing a batch, closing a period. A regulation such as FDA 21 CFR Part 11 or ISO 13485 wants the person to prove who they are again, at the moment they sign, and wants a record of who signed what, when, and why.
Vizum.identity.confirm is that step. Your app asks the host (the desktop, or a vApp opened as an Odoo window) to re-confirm the user. The host shows its own dialog and the user types the Odoo account password there. Your app never sees it. What comes back is a single-use token that your server code spends with V.identity.consume, which hands you a receipt to store on your signature record.
// in the page
const { token } = await Vizum.identity.confirm({
action: "qms.doc:42:approve", // what is being signed: put the record in it
meaning: "approval", // Part 11 Β§11.50: approval, review, authorship...
reason: "Approve SOP-017 revision C", // plain text, shown to the user as "The app says"
});
await Vizum.orm.callOn("x_qms_doc", "x_sign", [[42], token]);
# native method x_sign on the model, or a script hook
def x_sign(self, token):
receipt = V.identity.consume(token, "qms.doc:%d:approve" % self.id)
self.write({
"x_signed_by": receipt["user_name"],
"x_signed_at": receipt["confirmed_at"],
"x_meaning": receipt["meaning"],
"x_signature_id": receipt["id"], # links your record to the platform's evidence
"x_state": "approved",
})
What the user sees
A dialog in the host DOM, over a backdrop that dims the whole desktop, dock and top bar included. An app's iframe can never paint over the dock, so a box that does not dim it is not Vizum's. The dialog shows:
- the app's icon, name and publisher from the installed record, never from your call;
- "Signing as Full Name (login)";
- the action in monospace, the meaning, and your reason in quotes ("The app says");
- a password field and the line *"Vizum asks for your password only in a dialog that dims the whole desktop. Never type it inside an app window."*
The dialog is closed by the lock screen and by closing the window: your promise rejects with USER_CANCELLED and no token exists.
confirm({ action, reason, meaning })
| Field | Rule |
|---|---|
action | Required. 1 to 96 characters of a-z 0-9 _ . : / -, starting with a letter or digit. The token is bound to this exact string, so put the record in it: "qms.doc:42:approve". |
reason | Optional plain text, at most 200 characters. Bidirectional and control characters are removed. |
meaning | Optional plain text, at most 64 characters. The "meaning of the signature" of Part 11 Β§11.50. |
Anything else you pass is dropped by the SDK and again by the host. Resolves with { token, expiresIn, action } (expiresIn is 120 seconds). The server checks the three fields again; it is the authority.
Errors (all VizumErrors from the catalogue):
| Code | When |
|---|---|
USER_CANCELLED | The user closed the dialog, or the desktop locked. |
RATE_LIMITED | The user is locked out (details.retryAfter seconds), or the app asked too often (see below). |
PERMISSION_DENIED | A portal or public user, an app the user cannot run, a device page, or the app is not the active window. |
UNSUPPORTED_CALL | Vizum Solo (see below). |
VALIDATION_FAILED | A malformed action, reason or meaning. |
V.identity.consume(token, action)
Server code only: a method, a trigger hook or native.script.lib. Returns the receipt:
{"id": 118, "user_id": 7, "user_name": "Ada Lovelace", "login": "ada",
"action": "qms.doc:42:approve", "meaning": "approval", "reason": "Approve SOP-017 revision C",
"confirmed_at": "2026-10-05T18:12:44.120000+00:00"} # UTC
id is the row in the platform's ledger. Store it with the name, the time and the meaning on your signature record: that is the link Part 11 Β§11.70 asks for between a signature and its record.
It raises a UserError with one fixed text, "This confirmation is invalid, expired or already used. Confirm again.", for every reason it can fail: not a token, used already, expired, minted for another app, user, session or action, or called from a place that cannot sign. The real reason goes to the server log only, so the error teaches an attacker nothing.
- One spend. A token is spent by one atomic statement. Two calls at once: one wins.
- It is the user's session. Only a call that comes from the user's own browser session can spend a token. A job, a cron, a webhook and a device route cannot (there is no session to bind).
- It rolls back with you. If your method fails after
consumeand the transaction rolls back, the token is unspent again (it expires in 120 s anyway). The signature and the burn commit together or not at all. - Any mismatch is a refusal. The app key comes from the server, not from an argument: a token minted for app A is refused in app B's code.
Security model
| Threat | Defence |
|---|---|
| A fake password box inside an app window | The real dialog is in the host DOM and dims the dock. The app identity comes from the installed record. vzm check and the Store warn (PASSWORD_INPUT_IN_BUNDLE) on an input of type password in a bundle. Residual risk: a maximised window that draws a perfect fake; the dimmed dock is the cue, and the guard line is the education. |
| An app calling the password route itself | The bridge op takes no password. The route refuses Origin: null, any other origin and a missing Origin, and the sandboxed iframe has no session cookie. The model is closed to V.orm, Vizum.orm, a method's env and call: grants. |
| Replaying or forging a token | 256 random bits, only the sha256 stored, single use, 120 s, bound to the user, the app, the action and the session. |
| Brute force | The failure counter of the session is shared with the desktop lock screen (5 failures, then a wait that doubles to 10 minutes); on top, 10 failures in 15 minutes lock the user for 15 minutes across sessions and workers. Every attempt is a ledger row, written on its own cursor so a rollback cannot erase it. |
| An API key standing in for the password | The check is interactive, as Odoo's own identity check: an API key is not a password. |
Users who sign in with an identity provider (OAuth) and have no local password get a clear message ("Set a password in Preferences > Security to sign") and the attempt does not count. 2FA users sign with the password alone in 5.79. Passkeys come later.
Full screen
A step-up confirmation always leaves full screen first (Vizum.ui.fullscreen, 5.85): the cue that the dialog is the host's is that it dims the whole desktop, and a window in full screen has none. The host ends any full screen before the dialog renders and refuses Vizum.ui.fullscreen() while it is open.
Prompt bombing
An app that opens dialogs in a loop is rehearsing a phishing page, so the host limits it: one dialog at a time per window; only for the active window while the tab is visible; after a cancel the same app is RATE_LIMITED for 10 seconds; at most 6 dialogs per app per 10 minutes per tab.
Where it works
| Host | Result |
|---|---|
| Desktop (Vizum window manager) | Served. |
| A vApp opened as an Odoo window | Served. |
| Vizum Solo (PWA, Android) | UNSUPPORTED_CALL. On a phone the app's iframe fills the screen, so no host chrome is left that an app could not imitate. Check Vizum.supports("step-up"). |
| A paired-device page | PERMISSION_DENIED: a device has no user. |
The evidence ledger
Every attempt is a row in vizum.app.stepup (the model is closed to every vApp): who, which app and version, the action, meaning and reason, the outcome (issued, failed, locked, refused), the IP and user agent, the time, and which code spent the token. Only an administrator can read it, and nobody can edit it through the ORM. Rows are kept forever by default; the system parameter vizum_apps_platform.stepup_retention_days sets a number of days (0 means forever). An unused token loses its hash a day after it expires. Uninstalling the platform module deletes the ledger: export it first if you owe those records to a regulator.
The app's own audit trail (Vizum.audit) also gets an identity.confirm line as a convenience, but that line is not evidence: an app can write any text to its own trail. The platform refuses an api_log action that starts with platform: or identity., so an app cannot imitate one.
What this is not
It does not make an app Part 11 compliant by itself. It gives you the three pieces only the platform can: a user who has just proved who they are, a tamper-resistant record of it, and a receipt to link to your signature. Retention of *your* records, the signature manifestation on a printed copy, and your SOPs stay with you.
SDK & server
Offline data β keep working with no coverage PRIVILEGED
A field user opens Vizum with no coverage. The shell and the app open. The app reads its route, customers, products and stock from a copy kept on the device, and records deliveries, sales, collections, notes, photos and signatures. When coverage returns, the work goes to the server in order, exactly once, through the same permission and validation checks as a live call. Anything the server refuses, and anything someone else changed in the meantime, appears in a list the user settles.
This page is the reference. The shorter story of what works offline today, on each host, is offline-pwa-mobile.md. The template offline-field-app (vzm new offline-field-app my-app) is a working app that uses all of it, with tests.
Hosts: the desktop and Vizum Solo (the PWA and the Android app). The native client-action host has no offline layer. Gate on min_platform: "5.80".
The three parts
| Part | What it is | Declared in |
|---|---|---|
| Datasets | named, approved slices of the user's own Odoo data that the device keeps and Vizum.offline.query reads with no network | offline.datasets |
| Queued writes | creates and writes the app may record with no network, replayed when the network is back | offline.writes |
| Actions | approved business actions queued with Vizum.offline.run (older than 5.80, unchanged) | actions |
The manifest
"min_platform": "5.80",
"permissions": {
"orm": [
{ "model": "x_vzm_dsd_visit", "ops": ["read", "write"], "fields": ["name", "x_state", "x_note"] },
{ "model": "sale.order", "ops": ["read", "create"], "fields": ["partner_id", "order_line", "amount_total"] }
]
},
"offline": {
"datasets": [
{ "name": "stops", "model": "x_vzm_dsd_visit", "fields": ["name", "x_state", "x_seq"],
"domain": [["x_driver_id.x_user_id", "=", "{uid}"], ["x_scheduled", ">=", "{today-1}"]],
"order": "x_seq", "limit": 500 }
],
"writes": [
{ "model": "x_vzm_dsd_visit", "ops": ["write"], "fields": ["x_state", "x_note"] },
{ "model": "sale.order", "ops": ["create"], "fields": ["partner_id", "order_line"], "expect": ["amount_total"] }
],
"fallback": false,
"max_mb": 20
}
The rules, enforced when the app installs (vapp_validation.py), when you run vzm check, and when the Store accepts an upload:
- Every dataset and write model is one the app has an orm grant for (the grant must carry
readfor a dataset, and the op for a write), or one of its own native models. The"*"wildcard grant does not count: an offline copy is an explicit decision about a model. A grant whose extra ops are scoped to groups (group_ops, 5.78) counts for those ops. - Dataset
fieldsstay inside the grant's field list. Writefieldsstay inside its write ceiling. Achatter_onlygrant has no field to write offline. - At most 12 datasets (names
[a-z][a-z0-9_]{1,40}, unique),limitat most 20 000,max_mbat most 100 (default 20), at most 24 write entries, at most 40 conditions in a domain. - A dataset
domainuses only these placeholders, each as a whole value:{uid},{company_ids},{today},{today-N},{today+N}. The server expands them. Any other{...}is refused. expectbelongs to a write entry withcreate. It names computed fields (a total, a price) the server compares at replay.min_platformis5.80or later.- Every
actions[].modelis an own native model, a model the app adds native fields to, or has an orm grant. (An action on an ungranted model was refused on every run before this check existed.)
offline is part of the approved permission snapshot. The approval screen lists the datasets. Any edit of it sends the app back to needs_approval; removing it only narrows.
The API
const sets = await Vizum.offline.datasets(); // [{name, model, fields, limit, count, lastSync, stale}]
await Vizum.offline.sync(); // every dataset; sync("stops") for one
const { records, count, as_of, stale } = await Vizum.offline.query("stops", {
domain: [["x_state", "=", "todo"]], order: "x_seq", limit: 50, fields: ["name", "x_state"],
});
const groups = await Vizum.offline.readGroup("stops", { groupby: ["x_state"], aggregates: ["x_seq:sum"] });
const made = await Vizum.offline.create("sale.order", { partner_id: 7 }, { expect: { amount_total: 120 } });
// -> { id: "vzm-tmp:...", queued: true, opId } made.id is a TEMP id
await Vizum.offline.write("x_vzm_dsd_visit", [4, made.id], { x_note: "gate code 4471" });
// -> { queued: true, opId, opIds }
const issues = await Vizum.offline.conflicts();
await Vizum.offline.resolve(issues[0].id, "keep_mine"); // or "keep_server", "discard"
Vizum.offline.onSync((r) => refresh()); // a dataset finished syncing
Vizum.offline.onConflict((c) => badge()); // a queued change came back as a conflict
Plus the older status(), on(), run(), pending(), flush(), onFlushed().
Reads. query answers from the device: = != < <= > >= in not in like ilike =like =ilike, & | !, order, limit, offset, fields. A dotted path (partner_id.country_id), child_of and any are refused rather than answered wrongly. A field the dataset does not hold is refused: asking for one would return the wrong rows. readGroup takes one or two groupby terms (date fields with :day, :week, :month, :quarter or :year) and the aggregates field:sum|avg|min|max|count|count_distinct. A row with a queued change carries pending: true.
Writes. create and write need a matching entry in offline.writes (model, op, every field) and, on the desktop, ask the host's write confirmation a live write asks (Solo relies on the server's check at replay). They return as soon as the op is safely on the device's disk. If the device cannot store it (storage full, op over 1 MB) the call rejects and nothing is queued. A write of several ids queues one op per id.
Read-your-writes. A queued create or write is applied to the local rows at once, so the next query shows it. If the server refuses it, the change is rolled back and the refusal is listed.
Temp ids. create returns vzm-tmp:.... Use it in later write calls and in later values (a many2one that points at the new record). The server never sees one. When the create replays, the host replaces the temp id with the real id in every later queued op, in order. A later op that needs the id of a create the server refused is listed as dependency and not sent.
Actions keep the order of the queue. Vizum.offline.run(action, params) while online goes straight to the server only when the app has nothing waiting. If it still has queued changes (creates, writes or earlier actions), or params holds a temp id (vzm-tmp:...), the action joins the queue behind them, so it never overtakes what it follows and the server never sees a temp id (the replay swaps in the real id). The host then drains the queue at once and the call answers like a live run, with the server's { ok, result }, when the drain reached the action in that call. When it could not (no connection, an expired session, another tab or the Android worker holds the queue), it answers { queued: true, id } and the action replays later, in order. A server refusal of the action itself rejects the call, as a live run does. On the desktop the single confirmation names that path ("runs after your N earlier changes waiting to be sent").
flush() means everything queued before the call has been attempted. A flush() made while a drain is already running waits for that drain and then drains what was queued meanwhile, once. A drain that kept its ops (offline, expired session) is not repeated by the wait; the retry timer owns them.
params.expect on offline.run is not part of the API in 5.80. The replay forwards a non-empty object under that name to the server as the action's expect check, and also leaves it inside the action's params; only the queued path does that, a live run sends it as an ordinary param. Do not rely on it. Use expect on Vizum.offline.create, which is declared in the manifest.
Fallback (opt-in). With offline.fallback: true, Vizum.orm.searchRead, read, searchCount and readGroup on a model a dataset covers answer from the device when there is no connection (or the connection drops mid-call), and the result carries a hidden meta property {offline: true, as_of}. Only reads the copy can answer faithfully are served: the fields asked for must be in the dataset, and a stale copy never answers. Everything else fails as it always did.
What syncing does
The host syncs on start, when the network returns, when the app opens (at most once a minute per app), when the Android app resumes, and every 15 minutes while the page is visible. A run first sends the queue, then pulls the datasets.
The device sends a dataset name and a token. The server reads model, fields and domain from the approved snapshot and runs the read as the user (grant, field ceiling, record rules, multi-company). Nothing from the client shapes the query. The first pull is full; later pulls bring only the rows written since the last one (with a ten minute overlap), and the answer always carries the complete list of ids of the domain, so a row that was deleted or left the domain, or that a rule now hides, is removed from the device. The token is signed and bound to the user, the app, the dataset, the approved manifest, the grant and the user's companies and groups. Change any of them and the next pull is full.
Conflicts
A queued write carries base: the values the device had for the fields it changes (the values the server last sent, not an unsent edit). Before writing, the server reads those fields under a row lock and compares them, type-aware (floats with the field's digits, money with the currency's rounding, many2one by id, x2many by sorted ids, dates normalised, text by equality). If any differs it answers CONFLICT and writes nothing. A field the write does not change never conflicts. The comparison never uses write_date.
A queued create with expect is checked after it ran, inside a savepoint: if a computed value differs from what the device showed, the create is undone and answers CONFLICT.
A conflict leaves the queue and goes to the list Vizum.offline.conflicts() returns, and to the shell's panel:
{ id, model, ids, method: "write" | "create" | "action",
reason: "conflict" | "rejected" | "dependency" | "deleted", // "deleted": SDK 5.86
message, ts, fields: { x_state: { base: "todo", server: "cancelled", mine: "done" } },
canRecreate? } // only with reason "deleted"
The row on the device shows the server's value. The user chooses:
| Choice | Effect |
|---|---|
keep_mine | the same change is queued again over what the server holds now (a new base, a new expect) |
keep_server | the change is dropped; the server's value stays |
discard | the change is dropped |
A record deleted on the server (SDK 5.86, Vizum.supports("offline-deleted")). A queued write (or action) on a record that no longer exists answers CONFLICT with details.deleted; it is never recreated silently and never lost silently. The device removes the row it held (no ghost row until the next sync), the op leaves the queue, and the issue is listed with reason: "deleted" and an empty fields. The choices:
| Choice | Effect on a deleted issue |
|---|---|
keep_mine | recreate: one create from the row as the user last saw it, edits included, queued under a new temp id. Only when the app declares create on that model in offline.writes (canRecreate); otherwise it is refused with PERMISSION_DENIED and the issue stays |
keep_server, discard | the record stays deleted and the change is dropped |
Several edits queued for the same deleted record are one decision: resolving one issue settles all of them, and a recreate makes one record, not one per edit. On a platform older than 5.86 the same case is listed as rejected.
Relation fields written with commands (many2many / one2many) travel as written: [[6, 0, ids]], [[4, id]], [[3, id]], [[5, 0, 0]], [[0, 0, vals]]. The device shows the result of 6, 5, 4, 3 and 2 at once (a 0 makes a record whose id it cannot know, so the list shows no new id). The base of a relation is its id list, and the server applies the commands over it to tell a retry of a write that already landed (no conflict) from a relation someone else changed (conflict). A temp id inside a command is replaced like any other, and the rows on the device that held it are rewritten with the real id as soon as the create lands.
Any other permanent refusal (validation, access) is listed with reason: "rejected" and the server's message; keep_mine retries it. A lost connection, a timeout, a rate limit and an expired session are not refusals: the op stays queued and the queue waits.
When the world moved while the device was offline
| What changed | What the replay does |
|---|---|
| the record was deleted | a deleted issue (above) |
| the user lost the model's access, or a record rule now forbids the create | ACCESS_DENIED, a rejected issue; nothing is created |
| the user lost access to the company the op was queued in | ACCESS_DENIED, a rejected issue. The device remembers the company the copy was last read in and sends it with every op it queues; the replay runs in that company, so a create never lands in whichever company is first today. Actions queued with offline.run carry no company (they run in the company active at replay) |
| the app was updated and waits for an administrator's approval | the queue waits: the server answers CONCURRENT_UPDATE (retryable), the ops and their local changes are kept, and they replay against the new approved declaration once it is approved. Nothing is rejected on the way |
| the app was updated and the declaration changed | the replay reads the approved declaration of the moment: an op naming a field, model or op that left it is refused whole (ACCESS_DENIED, a rejected issue), never half-written; a field that left the Odoo model is VALIDATION_FAILED |
| a dataset was removed or its model or fields changed | the device drops the copy of a removed dataset; one whose model or fields changed keeps only the rows a queued change still rules and reads as empty and stale until the next sync, which is full (the token is bound to the declaration) |
| another user signs in on the device | their queue and issues are in their own database and never replay as anyone else; the rows of every other user, in this Odoo database or another, are removed |
Every replay outcome is audited on the server (platform:offline.applied, .conflict, .failed, .mismatch: counts per app, the kind, the model and method, the exception class on a failure; never a value, a record id or a message).
Users, logout and what stays on the device
- Every replay carries the user it was queued under (
context.vizum_offline.uid). If another user's session answers, the server raisesOFFLINE_USER_MISMATCH; the host keeps the op and pauses the queue until the same user is signed in again. Nothing replays as someone else. - Logout and a change of user remove the dataset rows and their metadata. The queue and the unresolved conflicts stay, tied to that user, and finish the next time they sign in on the device. At start, the rows of every other user on the device are removed.
- Logout warns and never discards. On Solo (the PWA and the Android app) and in the Odoo web client's Log out menu, signing out with changes still waiting to be sent or sync issues not yet resolved shows a warning with both counts, says they stay on the device and are sent when the same user signs in again, and signs out only when the user confirms. Choosing to stay keeps the session and the copy. With nothing pending there is no warning. A sign-out that starts elsewhere (the desktop's strict lock screen, a direct visit to
/web/session/logout) shows no warning, but discards nothing either. - A dataset whose last successful sync is older than 7 days reads as empty and
stale: trueuntil a sync succeeds. - The device keeps nothing the user may not read: sync is the user's own read.
- There is no app-level encryption in 5.80. Rely on device encryption, the per-user wipe and the 7 day limit. A shared, unlocked device shows one user's data to the next person who opens the app before logout.
Limits
| Limit | Value |
|---|---|
| datasets per app | 12 |
| rows per dataset | limit, at most 20 000 |
| space per app and user | max_mb, default 20, at most 100 (rows, queue and issues together) |
| one queued op | 1 MB (shrink photos to about 100 KB before queuing) |
| records per queued write | 1 (a write of N ids queues N ops) |
| age before a copy is hidden | 7 days |
| idempotency memory on the server | 8 days |
A pull that does not fit in max_mb is refused (QUOTA_EXCEEDED) and the old copy stays. The queue is never refused to make room for rows.
Errors
| Code | When |
|---|---|
PERMISSION_DENIED | the call is not in the approved offline declaration (model, op, field, expect) |
VALIDATION_FAILED | a field outside the dataset, an operator the device cannot evaluate, an op over 1 MB, a malformed call |
NOT_FOUND | a dataset that is not declared; a conflict that is already resolved |
OFFLINE | sync with no connection |
QUOTA_EXCEEDED | the dataset does not fit, or the device could not store the op |
CONFLICT | (server, at replay) the server's values differ from base or expect, or the record was deleted (details.deleted, SDK 5.86) |
CONCURRENT_UPDATE | (server, at replay) the app waits for approval of an update: the op is kept and sent later |
OFFLINE_USER_MISMATCH | (server, at replay) the op was queued for another user |
Android and Solo
On Solo (the PWA and the Android app) the layer works as described. On Android (app 1.32 and later), offline.run actions and the creates and writes made with Vizum.offline.create and write are also copied to the app's own storage and sent by a background worker when the app is closed, in the order you made them, with temp ids replaced by the real ids of the creates before them. What the server refuses, and a change someone else made meanwhile, reach your app as conflicts the next time it opens. See offline-pwa-mobile.md.
Testing
vzm test runs your app against the emulator, which keeps an in-memory device store:
await Vizum.offline.sync();
emulator.offline(true); // no network
await Vizum.offline.write("res.partner", [1], { phone: "mine" }); // queued, applied locally
emulator.serverChange("res.partner", 1, { phone: "theirs" }); // someone else, meanwhile
emulator.offline(false); await emulator.settled(); // the queue replays
const [issue] = await Vizum.offline.conflicts(); // a conflict, with both values
emulator.offlineState() returns the queue, the issues and the rows. fixtures.computed[model] is a function (vals) => ({field: value}) that plays the server's computed fields for expect. The emulator does not model the 7 day limit, the quota, storage failures, the scheduler (call sync() yourself) or record rules.
vzm check refuses an offline declaration or an action model the platform refuses, and any Vizum.offline.<data method> call below min_platform 5.80.
SDK & server
Files β checksums, checked types, expiring links
*Since capability 5.67. Vizum.files itself: since SDK v2; public links since 5.27.*
Vizum.files keeps an app's binary files (per user, or shared when the app's data scope is global): 64 MB per file, 50 files, 128 MB in total. Since 5.67 the platform also knows what each file is, and a public link no longer lives forever. Since 5.70 an app may ask for more (permissions.file_limits, up to 200 MB a file; since 5.71 up to 512 MB, see *Large files, above 200 MB* below) and upload large files streamed with Vizum.files.putBlob(name, blob) β see Paired-device routes. A file a paired device must play goes into a record's binary field instead, with Vizum.orm.writeBlob(model, id, field, blob) (same streaming; a device file route streams binary fields, not Vizum.files). The server holds an upload's body in memory (up to the app's max_mb) and allows one streamed upload per user at a time; the host refuses a file above the approved max_mb before it sends it.
Checksums
Every stored file has its sha256, recorded when it is stored and returned by put, get and list. Send the checksum you computed, and a file damaged on the way is refused instead of stored:
const bytes = new Uint8Array(await blob.arrayBuffer());
const digest = await crypto.subtle.digest("SHA-256", bytes);
const sha256 = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
const { mimetype } = await Vizum.files.put("scan.pdf", btoa(bin), { sha256 }); // base64 of the same bytes
A file stored before 5.67 lists sha256: null until it is next read; get computes and keeps it.
The bytes must be what the name says
For the types whose bytes the platform can recognise β PNG, JPEG, GIF, WebP, BMP, AVIF, HEIC, ICO, SVG, PDF, ZIP (and DOCX/XLSX/PPTX/ODT/ODS/EPUB, which are ZIP), GLB, gzip, MP4 β put checks the content against the name (the first 16 KB: an SVG after a long XML prolog and a PDF with a preamble are recognised):
| Stored as | Content | Result |
|---|---|---|
photo.png | a PNG | stored as image/png |
photo.png | a WebP or JPEG | stored, typed by its content (image/webp) β it still displays |
photo.jpg | a HEIC (an iPhone photo) | stored, typed image/heic |
photo.png | HTML, SVG, a PDF, or nothing recognisable | refused, VALIDATION_FAILED |
photo.tiff (any name the host's mimetype table calls image / audio / video / font / model) | markup (HTML / SVG) | refused β a media name never holds markup |
notes.txt, data.json, model.stl | anything else | stored with the type the host's mimetype table gives the name, as before |
The content check is one signature table (models/vizum_app_file.py), the same on Odoo 17, 18, 19 and 20; Odoo's own sniffer is not used for it, because it answers differently per line and per host. Only the names outside that table rely on the host's mimetype table (Python's mimetypes, which reads the system's /etc/mime.types): .tiff is a media type everywhere, .stl only where the system lists it.
Public links expire
publicUrl (permission files_public) now mints a link that expires:
const { url, expires_at } = await Vizum.files.publicUrl("post-42.png", { expires_in: 3600 });
expires_inis in seconds, from 60 to 2,592,000 (30 days). The default is 86,400 (one day).- A file has one live link. Minting again returns the same link, and its end moves to the later of the two, so a link you already handed out never dies earlier than promised.
- An expired link is refused when it is opened. A cron then removes its token (every 15 minutes), and minting after expiry gives a new token: the old URL never works again.
revokePublicstill kills the link at once.
Migration. Before 5.67 a link lived until it was revoked. Links minted before the upgrade keep working until revoked. The one-day default applies to apps whose min_platform is 5.67 or later; an app published before that asks for no expires_in keeps getting a link that lives until revoked (expires_at: null), so a link it stored and reuses later keeps working. Any app that passes expires_in gets that expiry. If a link must outlive a day, ask for it β up to 30 days β and mint again before it ends.
Files served through a link carry Odoo's Content-Security-Policy: default-src 'none' and X-Content-Type-Options: nosniff (17, 18, 19 and 20): an HTML or SVG file behind a link renders as a document that cannot run script.
Large files, above 200 MB (since 5.71)
5.70 reads one request body into a worker, so 200 MB is its ceiling. 5.71 adds three things, all driven by the desktop host (the app's iframe has no session, so it keeps calling the same Vizum.files.* methods):
"permissions": { "data": "global",
"file_limits": { "max_mb": 400, "total_mb": 8192 } }
An app that asks for max_mb above 200 sets min_platform to "5.71" and a total_mb of at least max_mb (the default is 128, which could never hold such a file): the platform and the Vizum Store refuse the manifest otherwise.
One file per name (dedupe). From 5.71 a unique index holds one stored file per (app, name). Two puts of the same *new* name at the same moment no longer leave two rows: the second request is replayed, finds the first one's file and replaces it. A 5.70 database could already hold such duplicates, so the 5.71 upgrade keeps the lowest-id row under the original name and renames every other copy <name>.dup-<id> (nothing is deleted; they show in Vizum.files.list() for the user to inspect and delete).
1. A 512 MB ceiling that needs its own approval. file_limits.max_mb may go up to 512, but a value above 200 is a separate grant the platform derives (permissions.large_files). The approval card reads it out as *"This app asks for files up to 400 MB, 8.0 GB per user"*, and gaining it later (or asking for a higher max_mb) sends the app back to the administrator. Without that approval the effective limit stays 200 MB, whatever the manifest says. And a file above 200 MB is refused (QUOTA_EXCEEDED; the grant and the cap are checked again at commit) unless the administrator also set an explicit storage cap for the app (Apps Manager β Usage & budgets, storage_mb_cap). The operator can also set an instance-wide ceiling on every app's files together, the system parameter vizum.apps.storage_total_mb_cap (MB; unset or 0: no ceiling, behaviour unchanged). It is checked on every put, and counts the bytes other uploads have reserved.
2. Chunked upload. Vizum.files.putBlob(name, blob, opts) uploads a blob larger than 32 MB in parts (the host sends 8 MB parts; the server accepts up to 32 MB; a smaller blob is the single request of 5.70). Nothing changes in the app:
const result = await Vizum.files.putBlob("lobby-loop.mp4", file, {
sha256, // optional; hashed in blocks by the host if omitted
onProgress: ({ phase, sent, total }) => bar(phase === "upload" ? sent / total : 0), // phase: "hash" | "upload"
}); // { name, size, mimetype, sha256 }
What the platform does, in order (/vizum/apps/files/<key>/upload/β¦, the same session and same-site proof as putBlob):
begin {name, size, sha256}returns a token bound to this user, this app, that size and that sha256; it expires one hour after its last accepted part (six hours at most since begin, so a slow link keeps going), and a user has at most two uploads open.beginalso refuses when the server's disk cannot hold every open upload plus a margin. The declared size is reserved at begin against every quota (per-file ceiling, count, per-user total, the app's storage cap, the instance ceiling), so parallel uploads cannot together exceed them (the checks serialise on row-writing locks, which hold under PostgreSQL's repeatable read). The reservation ends with the upload: commit, abort or expiry.part?token&ntakes the parts in order (a part out of order, empty, over 32 MB, or taking the total past the declared size is refused). They are appended to a temp file under the database's filestore (<filestore>/vizum_uploads/<token>), not held in memory. The temp file lives on one server: behind a load balancer the upload needs a sticky route, as the session does.- Each part is a streamed request with the guarantees of every 5.70 upload: the route refuses a body above 32 MB before it runs (
max_content_length), the platform checks the app, the open upload and the part order before it reads the body, a user has one streamed body in flight at a time (the host sends the parts one after the other; a second concurrent upload answers 429 until the first part finishes), the read is bounded and kept for Odoo's retry of the endpoint, and a body whose length differs fromContent-Lengthβ or that has none (chunked transfer, which the host never uses) β is refused, never appended short or cut. The host sends a part again, a few times afterRetry-After, when it meets that slot (429), before it aborts the whole upload. status?token(since 5.72) answers{received, next_part, size, sha256, expires_at}for an open upload, behind the same gate as the other calls (this user, this app, not expired; anything else is the same refusal). It does not extend the expiry; it waits for a part still in flight (the row lock a part holds), so it never answers with the state from before that part.- A part the upload is not waiting for is refused with the same message as always, plus
"code": "out_of_order"and"expected": N(the part the server wants next). commit?tokenchecks size and sha256 and stores the file;abort?tokendrops the upload and frees its reservation.
A dropped connection does not lose the upload (since 5.72). When a request fails with a network error (no HTTP answer at all), the host keeps the token and sends the same part again with exponential backoff (up to 5 tries, each wait at most 10 s). If the part still fails it asks status and goes on from next_part: parts the server already holds are not sent again, and a part whose reply was lost is skipped (the resend of such a part is refused as out_of_order, which the host reads as "already received", never as a failure). After three such rounds without progress, or on any HTTP refusal, the host gives up and calls abort explicitly, so the quota reservation and the upload slot are freed at once instead of at expiry. An abort that cannot reach the server is kept and sent again when the browser fires online, or after the next request that gets through, at most five times in all; it never delays the rejection the app receives. An upload does not survive a page reload (the token is held in memory). The app calls the same putBlob; set min_platform to "5.72" to rely on the behaviour.
commit?tokenchecks that the size and the sha256 are the declared ones; on a mismatch the temp file is deleted and the upload refused. Otherwise the temp file becomes the stored file (it is hard-linked into Odoo's filestore under its sha1 and renamed into place, atomically; the file is never read whole into memory). The content check ofput(the bytes are what the name says) runs on the first 16 KB.abort?tokendrops it. A cron removes expired uploads and any temp directory no live upload owns.
If any step fails the host aborts the upload, so nothing stays reserved.
Per Odoo line. Odoo 17, 18, 19 and 20 store a file at <filestore>/<sha1[:2]>/<sha1>. Odoo 17 to 19 write it with a plain open(path, "wb"), so the move above is the same write without the bytes in memory. Odoo 20 writes into an upload/ staging directory and renames the file into the same final path, so the result on disk is identical. Both behaviours were read from the Odoo sources of each line. A database whose attachments are not plain filestore files (storage set to db, or a module that replaces _file_write, such as an S3 store) cannot take that move: there the ceiling stays 200 MB whatever was approved, and a chunked upload of up to 200 MB is stored by Odoo's own writer (database or object store), with the bytes read from the verified temp file, exactly as 5.70 would have stored them. Vizum.supports("large-files") is true on the desktop and native hosts, not on Solo (offline).
3. Streamed read.
const blob = await Vizum.files.getBlob("lobby-loop.mp4"); // Blob typed with the stored mimetype
const head = await Vizum.files.getRange("lobby-loop.mp4", 0, 65536); // Uint8Array, at most 32 MB
The host fetches GET /vizum/apps/files/<key>/get?name=β¦ with the user's session and hands the app a Blob (structured clone, no base64). The gate is the one get uses (the app must be visible to the user, and the file is in the user's own scope, or the app's shared one when data is global): another user's file is *file not found*. The response is for that user only: Cache-Control: private, no-store, X-Content-Type-Options: nosniff, a download disposition and a sandbox CSP, so opened by hand in a tab it never runs as a page of the site. The server answers Range (so getRange moves only the bytes asked for). Nothing is tracked about reads.
Vizum.files.get(name) (base64 inside a JSON answer) keeps working for files up to 200 MB. For a larger file it answers *this file is larger than 200 MB: read it with Vizum.files.getBlob or Vizum.files.getRange*: the JSON path would hold the file about four times over in one worker.
vzm check raises MIN_PLATFORM_TOO_LOW for max_mb above 200 or getBlob / getRange with a min_platform below 5.71, and FILE_LIMITS_TOTAL_BELOW_MAX as an error for a max_mb above 200 whose total_mb is lower. Feature flag: Vizum.supports("large-files").
SDK & server
Network β any public website, and images PRIVILEGED
*Since capability 5.74. Vizum.net.fetch and named hosts in permissions.net: since SDK v2.*
An app reaches the internet only through the platform's server-side proxy, and only the hosts its manifest names in permissions.net. Two things arrived in 5.74: a way to say "any public website", and a mode that downloads an image instead of an API response.
What the browser itself may reach (report-only today, enforced later)
permissions.net is a rule for the server proxy. A bundle's own JavaScript runs in the browser, and until now nothing limited what it could send out directly (fetch, an <img>, a beacon, a form post). Since the platform build of 2026-10-06 every HTML page of a vApp is also served with a Content-Security-Policy-Report-Only header, so the platform can measure what real apps contact before it blocks anything. Nothing is blocked yet, and this needs no change in your app. It will be enforced in a later platform release, behind a new min_platform; an app that keeps an older min_platform is never enforced.
What the policy says, so you can already build to it:
| The app | connect-src, img-src, media-src |
|---|---|
declares no permissions.net | the platform itself ('self'), data: and blob: only |
| declares hosts | those exact hosts on https, plus the platform. host:port names that port; *.x.com names x.com too |
declares "*" | any https: host (the approval card already says so) |
has ocr or audio | also the hosts the SDK downloads its models from (cdn.jsdelivr.net, huggingface.co) |
frame-src is 'self' blob: (no third-party frames inside the app), form-action is 'none', default-src is 'none', script-src and sandbox are unchanged. The policy uses only the hosts of the approved manifest: a host in a pending update opens nothing.
So an app that loads an image, a map tile or a JSON file directly from the browser from a host it does not list in permissions.net will be reported now and blocked later. Two ways to be ready: name the host in permissions.net (the administrator approves it, as for any host), or fetch through the platform (Vizum.net.fetch, Vizum.net.fetchImage, Vizum.map.tileUrl() for map tiles). Violations are counted per app, directive and blocked origin (never a path or a query); administrators read them in the model vizum.app.csp.report. Details and the plan: docs/adr/0004-vapp-csp-network.md.
Ports of a named host (5.74)
A host named in permissions.net is reachable on ports 80 and 443. A host on any other port must say so in the manifest, as host:port:
{ "permissions": { "net": ["api.example.com", "reports.example.com:8443", "*.cdn.example.com:9000"] } }
api.example.com is then reachable on 80/443 only (https://api.example.com:8080/ is refused: *port 8080 is not declared for host api.example.com in permissions.net*); reports.example.com on 8443 only; two entries for one host add up. The approval card shows the entry as written (reports.example.com:8443) with *port 8443 only*, or *ports 80 and 443 only* for a bare host. A host an administrator typed into an app setting ($setting:<key>) keeps whatever port the setting holds. A host reached only through "*" is held to 80/443 whatever the manifest says.
A redirect from a named host to a host the manifest does not name is judged as a host nobody named: refused when the app has no "*" grant, and under the "*" rules (public addresses, ports 80 and 443) when it has one. net.fetch never follows a redirect.
Older apps keep the old port behaviour. The port rule starts at min_platform 5.74. An app with a lower min_platform still reaches a named host on any port, so nothing that worked breaks; vzm check warns it (NET_PORT_UNDECLARED: *permissions.net names X without a port*) so you declare host:port for the host you call on a non-standard port before you raise min_platform. An app at 5.74 or later with a bare host and a call to port 8443 fails at run time with *port 8443 is not declared*.
Cloud metadata is never reachable. The instance-metadata addresses (169.254.169.254, 169.254.170.2, fd00:ec2::254, 100.100.100.200, 168.63.129.16, 192.0.0.192) are refused in every mode, for a named host and for "*", whatever net_allow_private says.
permissions.net: ["*"] β download from any public website
{ "min_platform": "5.74", "permissions": { "net": ["*"] } }
"*" is a privileged permission, like large_files. The approval card reads it out as *"This app can send requests to, and download from, any public website. Data it can read may be included in those requests (never a private network)"*, and an update that gains it is sent back to the administrator. The manifest must declare min_platform 5.74 or later, or the platform refuses it (and vzm check reports MIN_PLATFORM_TOO_LOW).
What it is, plainly. The app can put any data it can read into the URL of a request to any public host. Approve it only for an app whose job is to fetch from the open web (a photo importer, a link preview), not for one that also holds sensitive data and has no need to go out. To make that visible and bounded, an app holding "*" also gets a default cap of 500 network calls per day (an administrator's own net_requests_per_day on the app wins; the instance parameter vizum_apps_platform.net_any_daily_cap changes the default; only 0 switches it off, a negative or unreadable value means the default).
It is download-oriented and always public-only. For a host that only "*" allows (no hostname or *.wildcard in the manifest names it):
| Rule | What happens |
|---|---|
| Addresses | Only public addresses. Private, loopback, link-local, carrier-grade NAT (100.64.0.0/10), cloud metadata, multicast and reserved addresses are refused, whatever net_allow_private says. The host name is resolved once, every answer is checked, and the connection goes to the checked IP (a DNS rebind cannot swap it). |
| URLs | http and https only, no credentials in the URL, no IP literals in any notation (a dotted quad, 134744072, 0x08080808, [::1]). |
| Ports | 80 and 443 only. |
| Methods | GET and HEAD only; no multipart upload. |
| Headers | Only Accept, Accept-Language, Range, If-None-Match, If-Modified-Since and User-Agent. Any other header is refused (not dropped), so X-HTTP-Method-Override cannot turn a GET into a DELETE and no header carries data out. |
| Secrets | A $secret:name in the URL or a header is refused: a credential must never travel to a host nobody named. Name the host in permissions.net to use one. |
| Job webhooks | A job's webhook host must still be named; "*" does not cover it. |
A host named in the list (an exact name or a *.wildcard) keeps the rules it always had, also when "*" is listed next to it.
$setting: hosts next to "*". An app can write its own settings, so a host that comes from a $setting: entry is not treated as named when "*" is granted: it gets the rules above. A setting value is used only when it is a plain host name or address; a value such as * or https://*/ is ignored (it can never become the "*" grant, which needs its own approval).
Time limit. Every fetch has one absolute deadline (timeout, default 15 s, at most 20 s) that covers name resolution, connecting, the response headers and the body. A server that sends a byte at a time cannot extend it: when it passes, the connection is closed under the read. The deadline also covers the TLS handshake.
Release note (5.74): which hosts get the total deadline. The absolute deadline applies to every host reached through "*" and to image mode (Vizum.net.fetchImage). A host that the manifest names, in an app that calls Vizum.net.fetch (every app written before 5.74), keeps the behaviour it always had: timeout limits each socket read, so a slow API (an LLM endpoint, a report export) that keeps sending is not cut at 15 to 20 s. Those calls only have a generous total of 120 s, which ends a call nobody can still be waiting for.
Public routes and portal visitors. A route hook that a portal visitor can call must not be an anonymous proxy to the open web: V.net.fetch_image refuses a portal user's route call. Fetch on behalf of an internal user, or from a job.
Image mode β fetchImage / fetch_image
net.fetch never follows a redirect and returns 2 MB at most. To bring in a product photo from a CDN, use the image mode:
// in the iframe (Vizum.net.fetchImage, since 5.74)
const img = await Vizum.net.fetchImage("https://cdn.example.com/p/123.jpg", { max_bytes: 4_000_000 });
// -> { content_type: "image/jpeg", size: 183204, sha256: "β¦", base64: "β¦" }
# in a server hook (V.net.fetch_image, since 5.74)
img = V.net.fetch_image(url, {"max_bytes": 4000000})
V.files.put("photo.jpg", img["base64"])
What the platform does, in this order, for the first request and for every redirect:
- Checks the URL:
http/https, no credentials, the host is inpermissions.net(or"*"). - Resolves the name and connects to a validated public IP (always public-only; image mode ignores
net_allow_private). - Follows at most 3 redirects; each target goes through steps 1 and 2 again. A redirect from
httpstohttpis refused. - Streams the body and stops as soon as it passes the cap (
max_bytes, default 10 MB), or the one deadline above (timeout). It does not read the whole body first. - Looks at the first bytes, not at
Content-Type: only PNG, JPEG, GIF and WEBP are returned. SVG, HTML and everything else are refused.
max_bytes is clamped to the instance ceiling: the Odoo system parameter vizum_apps_platform.net_image_max_bytes (default 10 MB, never above 16 MB). A script run may bring in at most 64 MB of images in all, results together. Image mode sends no custom headers and accepts no $secret:. One call counts as one network call: against the run's 50-call budget in a script, and against the app's daily network budget an administrator may set (QUOTA_EXCEEDED), like net.fetch.
A refusal rejects with a platform error whose code says what kind it is: VALIDATION_FAILED (blocked address, too many redirects, too large, not an image, time limit), ACCESS_DENIED (a host outside permissions.net, including on a redirect, or a header or method the "*" grant does not allow) or QUOTA_EXCEEDED (the daily network budget). The message says what failed and never includes the address or the body.
vzm check raises MIN_PLATFORM_TOO_LOW for Vizum.net.fetchImage or V.net.fetch_image below min_platform 5.74. On an older platform the call does not exist.
SDK & server
AI tools β what each tool does, declared
*Contracts: capability 5.67. AI tools themselves: since 5.3.*
An app lets the AI Assistant drive it by declaring ai_tools in its manifest. Since 5.67, each tool can also say what it does to the app's content, and the platform acts on that, so the model doesn't have to guess:
"ai_tools": [
{ "name": "add_card", "description": "Adds a card. values: {title, column}",
"risk": "create", "idempotent": false },
{ "name": "clear_board", "description": "Removes every card from the board.",
"risk": "destructive", "side_effects": "deletes every card on the board" }
]
| Field | Values | Means |
|---|---|---|
risk | read Β· create (default) Β· update Β· destructive | What the tool does to existing content |
side_effects | text, β€ 300 characters | One sentence the user reads before approving. Required for update and destructive |
confirm | boolean | Validated only: update and destructive tools are always confirmed, and confirm: false is refused for them. It changes nothing for read / create tools |
idempotent | boolean | Running it twice with the same values does no more than running it once |
What the platform does with it
- Install. The contract is validated (
vapp_validation._valid_ai_tool_contract), and an invalid one refuses the install. A tool withmodel(the platform creates the record for it) is alwayscreate. - The model is told. The Assistant's prompt lists each
update/destructivetool with itsside_effectsand an instruction to use it only when the user clearly asked for that change. Idempotent tools are marked as safe to retry. - The user confirms. When the Assistant calls an
updateordestructivetool, the host asks the user before your app is opened, showingside_effects. If the user declines, the call rejects withUSER_CANCELLEDandVizum.ai.tools.handlenever fires. The check is in the host, on every way one app opens another with a tool call βVizum.apps.openandVizum.ui.openApp(the Assistant's) β on the desktop (VappHost.confirmAiTool, reading the contract from its app catalogue) and in Odoo's own menus and Vizum Solo (core/vapp_ops.js, asking the serverapi_ai_tool_contract). On every host, a tool with no contract to read β a catalogue loaded before an update, a name the app never declared β is asked about as if it changed content. It is not in the Assistant's UI, which a prompt could steer around.
Your handler does not change:
Vizum.ai.tools.handle("clear_board", async () => {
// reached only after the user approved "deletes every card on the board"
await Vizum.data.collection("cards").clear();
});
Compatibility
- A tool the manifest declares without a
riskiscreate, exactly as before. A tool name the manifest does not declare at all is confirmed. - A platform older than 5.67 does not confirm.
vzm checktherefore reportsMIN_PLATFORM_TOO_LOWwhen a manifest declares a contract withmin_platformbelow 5.67: raisemin_platform, and an older platform refuses to install the app instead of running its tools unconfirmed. - Every host confirms (
ai-tool-contracts: desktop, native, solo).
Checking it before you upload
vzm check applies the same rules as the installer (INVALID_AI_TOOL_CONTRACT), with the risk lists pinned to the platform's by a test. The manifest schema (manifest.schema.json) and the typings (Vizum.AiToolContract, Vizum.AiToolRisk) describe the same fields.
Examples
Worked example β a sticky note, per user ZERO-RISK
The minimal real app: one KV key, theme-aware, ~60 lines. Ships with the platform
as demo_apps/sticky_todo/.
"permissions": { "data": "user" }
(async function () {
await Vizum.connect();
let todos = (await Vizum.data.get("todos")) || [];
const save = () => Vizum.data.set("todos", todos);
function render() {
list.innerHTML = "";
todos.forEach((t, i) => {
const row = el("div", "todo" + (t.done ? " done" : ""));
const cb = el("input"); cb.type = "checkbox"; cb.checked = t.done;
cb.onchange = () => { t.done = cb.checked; save(); render(); };
const span = el("span"); span.textContent = t.text; // textContent, always
const del = el("button"); del.textContent = "β";
del.onclick = () => { todos.splice(i, 1); save(); render(); };
row.append(cb, span, del); list.append(row);
});
}
render();
})();
Because Vizum.data is isolated per user, two colleagues running this snippet never see each other's notes β without you writing a single line of access control. That is a property of the KV store, not of the Sticky Todo app in the Store: that one keeps its lists in a native Odoo model and can share them with your team, with record rules deciding who sees what.
Examples
Worked example β Top Customers PRIVILEGED
A narrow, read-only window into res.partner with two UI capabilities β
the smallest useful shape for a privileged read.
This is a teaching example, not the shipped app. Top Customers in the Store ranks by
real invoiced revenue, grouping sale.order by partner server-side.
customer_rank is a counter Odoo bumps when an order is confirmed: fine for
ordering a demo, but it is not money, and in most databases it is 1 for nearly everyone.
"permissions": {
"data": "user",
"orm": [{ "model": "res.partner", "ops": ["search", "read"],
"fields": ["name", "email", "city", "customer_rank"] }],
"ui": ["notify", "openRecord"]
}
const rows = await Vizum.orm.searchRead(
"res.partner", [["customer_rank", ">", 0]],
["name", "email", "city", "customer_rank"],
{ limit: 15, order: "customer_rank desc" });
rows.forEach((r) => {
const row = card(r); // build DOM with createElementβ¦
row.onclick = () => Vizum.ui.openRecord("res.partner", r.id, r.name);
});
createElement + textContent β
never interpolate them into innerHTML.
Examples
Worked example β two apps talking
A βteam scoreboardβ pattern: shared data + instant updates, across apps and across users.
"permissions": {
"data": "global",
"events": { "emit": ["score.changed"], "listen": ["score.changed"] }
}
await Vizum.connect();
async function addPoint() {
const n = ((await Vizum.data.get("score")) || 0) + 1;
await Vizum.data.set("score", n); // global scope β broadcast to all users
Vizum.events.emit("score.changed", { n }); // same-desktop apps react instantly
}
Vizum.data.subscribe("score", refresh); // cross-user updates (server bus)
Vizum.events.on("score.changed", refresh); // same-desktop updates (no roundtrip)
Examples
Worked example β Rent Roll PRIVILEGED
A property-management board β rental units, tenant leases, occupancy KPIs and one-click rent invoicing β that ships as demo_apps/rentroll/.
Everything earlier in this guide stored a vapp's data in the Vizum store. Rent Roll is different: its two business entities are declared as real native Odoo models in the manifest, so units and leases become first-class records that accounting, reporting and other apps can read directly. Each native.models entry named x is materialised as a model whose technical name is x_vzm_rentroll_<x>, and each field f becomes a column x_f. The relations are the point: a lease carries a many2one to a unit and another to a stock res.partner (the tenant).
"native": {
"models": [
{
"name": "unit", "label": "Rental Unit", "rec_name": "name",
"fields": [
{ "name": "name", "type": "char", "label": "Name" },
{ "name": "addr", "type": "char", "label": "Address" },
{ "name": "size", "type": "float", "label": "Size (mΒ²)" },
{ "name": "rent", "type": "float", "label": "Asking rent" }
],
"access": [ { "group": "base.group_user", "ops": ["read","write","create","unlink"] } ]
},
{
"name": "lease", "label": "Lease",
"fields": [
// many2one whose relation is the SIBLING native model β¦
{ "name": "unit_id", "type": "many2one", "relation": "x_vzm_rentroll_unit", "label": "Unit" },
// β¦ and one to a STOCK Odoo model (real contacts)
{ "name": "tenant_id", "type": "many2one", "relation": "res.partner", "label": "Tenant" },
{ "name": "start", "type": "date", "label": "Start" },
{ "name": "end", "type": "date", "label": "End" },
{ "name": "rent", "type": "float", "label": "Monthly rent" },
{ "name": "last_invoiced", "type": "char", "label": "Last invoiced (YYYY-MM)" }
],
"access": [ { "group": "base.group_user", "ops": ["read","write","create","unlink"] } ]
}
]
}
Because the columns are physically named with the x_ prefix, the app reads through Vizum.orm.searchRead against the full technical model name and then normalises each row back to a clean in-memory shape. The crucial detail for relations: a many2one comes back as a [id, display_name] pair (or false when empty), so a tiny helper collapses it to a bare id.
const M_UNIT = "x_vzm_rentroll_unit";
const M_LEASE = "x_vzm_rentroll_lease";
const LEASE_FIELDS = ["x_unit_id","x_tenant_id","x_start","x_end","x_rent","x_last_invoiced"];
// many2one arrives as [id, "Name"] | false -> collapse to a plain id
const m2oId = (v) => (Array.isArray(v) ? v[0] : (v || 0));
const normLease = (r) => ({
id: r.id,
unit_id: m2oId(r.x_unit_id), // -> the sibling unit's id
tenant_id: m2oId(r.x_tenant_id), // -> the res.partner id
start: r.x_start || "", end: r.x_end || "",
rent: r.x_rent != null ? +r.x_rent : 0,
last_invoiced: r.x_last_invoiced || "",
});
async function reload() {
DB.units = (await Vizum.orm.searchRead(M_UNIT, [], UNIT_FIELDS, { order: "x_name" })).map(normUnit);
DB.leases = (await Vizum.orm.searchRead(M_LEASE, [], LEASE_FIELDS)).map(normLease);
// resolve tenant display names from the real contacts in one read
const ids = [...new Set(DB.leases.map((l) => l.tenant_id).filter(Boolean))];
(await Vizum.orm.read("res.partner", ids, ["name"])).forEach((p) => { TENANTS[p.id] = p.name; });
render();
}
Creating a lease writes the relation the other way: the dialog yields a bare unit id and a tenant id (picked with a real res.partner search field), and the app posts them back into the prefixed x_unit_id / x_tenant_id columns. Odoo enforces the foreign keys, so a lease can never point at a unit or contact that doesn't exist.
await Vizum.orm.create(M_LEASE, {
x_unit_id: v.unit_id, // FK -> x_vzm_rentroll_unit
x_tenant_id: v.tenant_id, // FK -> res.partner
x_start: v.start || false,
x_end: v.end || false,
x_rent: +v.rent || 0,
x_last_invoiced: "",
});
The payoff of native records is interoperability with the rest of Odoo. The "Invoice rent" button doesn't fake a document β it creates a genuine draft customer invoice (account.move) addressed to the lease's tenant, with a single rent line, then opens the real record. A short field on the lease records which month was billed so the button locks after one click per unit per month.
async function invoiceRent(unit, lease) {
const id = await Vizum.orm.create("account.move", {
move_type: "out_invoice",
partner_id: lease.tenant_id, // the same res.partner the lease points at
invoice_date: todayStr(),
invoice_line_ids: [[0, 0, {
name: "Rent β " + unit.name + " β " + monthLabel(new Date()),
quantity: 1,
price_unit: lease.rent,
}]],
});
// remember the billed month so we don't double-invoice
await Vizum.orm.write(M_LEASE, [lease.id], { x_last_invoiced: thisMonth() });
Vizum.ui.notify("Draft invoice created for " + tenantName(lease), "success");
Vizum.ui.openRecord("account.move", id, "Rent invoice"); // open the real Odoo form
}
base.group_user CRUD), enforce real foreign keys to res.partner, and are immediately visible to accounting, BI/reporting and any other app. That's exactly why "Invoice rent" can spin up a genuine account.move against the lease's tenant in one click.
Examples
Worked example β Work Orders PRIVILEGED
A field-service status board backed by a real native Odoo table with per-row team sharing and one-click invoicing β ships as demo_apps/work_orders/.
This is the most advanced app in the catalog. Instead of storing rows in Vizum's per-app key/value store, the manifest declares a native.models block: Vizum provisions a genuine Odoo model, x_vzm_workorders_wo, so every work order is a first-class record that Odoo reports, BI, and other native modules can read. The app owns that table outright (full CRUD via Vizum.orm, no extra grant), and it reproduces a three-level sharing model β private / shared with specific people / public β directly on native columns. A drag-and-drop board moves cards between statuses, and finished orders post straight to account.move as a draft invoice.
The model is declared once in the manifest. Vizum prefixes every field with x_ and the model name with x_vzm_<app>_, so the wo model becomes x_vzm_workorders_wo and status becomes x_status:
{
"native": {
"models": [{
"name": "wo", // -> native model x_vzm_workorders_wo
"label": "Work Order",
"rec_name": "number",
"fields": [
{ "name": "number", "type": "char", "label": "Number" },
{ "name": "customer", "type": "char", "label": "Customer" },
{ "name": "customer_id", "type": "many2one", "relation": "res.partner",
"label": "Customer (contact)" },
{ "name": "status", "type": "selection", "label": "Status",
"selection": [["new","New"],["assigned","Assigned"],
["in_progress","In Progress"],["on_hold","On Hold"],
["done","Done"]] },
{ "name": "total", "type": "float", "label": "Total" },
{ "name": "visibility", "type": "selection", "label": "Visibility",
"selection": [["private","Only me"],["shared","Specific people"],
["public","Everyone"]] },
{ "name": "shared_with", "type": "many2many", "relation": "res.users",
"label": "Shared with" }
],
"access": [
{ "group": "base.group_user", "ops": ["read","write","create","unlink"] }
]
}]
}
}
Sharing is modeled with two native columns and Odoo's own ownership metadata. x_visibility is a selection of private / shared / public; x_shared_with is a many2many to res.users listing exactly who may see a shared row. The row owner is Odoo's built-in create_uid β the app never stores an owner field of its own. Because base.group_user can technically read the whole table, the app scopes what it shows in the read domain rather than relying on row-level security alone.
const MODEL = "x_vzm_workorders_wo";
const myUid = () => (window.Vizum && Vizum.user && Vizum.user.id) || 0;
async function loadOrders() {
const uid = myUid();
// my own rows (any visibility) + everyone's public rows + rows shared with me
const domain = ["|", "|",
["create_uid", "=", uid],
["x_visibility", "=", "public"],
["x_shared_with", "in", [uid]]];
const rows = await Vizum.orm.searchRead(MODEL, domain, WO_FIELDS,
{ order: "id desc", limit: 1000 });
rows.forEach((r) => { orders[r.id] = normWo(r); }); // map x_* -> app shape
}
The app owns this model, so no additional permissions.orm grant is needed to read or write it β the grant in the manifest covers other models the app touches (res.users, account.move for invoicing). Saving sharing is a single write to those two columns. The many2many is replaced wholesale with Odoo's (6, 0, ids) command, so the new audience exactly replaces the old one:
async function saveSharing(w, visibility, users) {
// only list explicit people when visibility === "shared"
const grant = visibility === "shared" ? users.slice() : [];
await Vizum.orm.write(MODEL, [w.id], {
x_visibility: visibility,
x_shared_with: [[6, 0, grant]], // (6,0,ids): replace the whole set
});
// reflect the new annotations onto the in-memory row (badge + board filter)
if (orders[w.id]) {
orders[w.id]._visibility = visibility;
orders[w.id]._shared_with = grant;
}
Vizum.ui.notify("Sharing updated", "success");
}
Dragging a card to another board column is just as direct β a one-field write that any Odoo report immediately sees:
async function setStatus(id, status) {
// columns are statuses; dropping a card writes the native selection
await Vizum.orm.write(MODEL, [id], { x_status: status });
}
When a row comes back from searchRead, the app derives its sharing state β not from a bespoke permissions table, but from native data: _mine = (create_uid === myUid()) marks the owner, _visibility mirrors x_visibility, and _shared_with mirrors the x_shared_with id list. The owner always edits and deletes; a recipient on a shared or public row sees it (the read domain let it through) but _can_edit / _can_delete fall out of _mine, so collaborators view without clobbering. The whole policy lives on real columns of x_vzm_workorders_wo, which is why Odoo's BI, list views, and any other module can honor and report on it without ever loading the vapp.
Any app that declares a native.* block is privileged: it asks Odoo to create real schema, so installing it requires admin approval rather than self-service. And because a brand-new native model adds tables/columns to the database, the Odoo server must be restarted once after the first install so the running registry picks the model up β until then, ORM calls against x_vzm_workorders_wo will fail. Subsequent updates need no restart.
Examples
Worked example β real-time collaborative board PRIVILEGED
A shared kanban where every teammate's open board refreshes the instant someone changes it β ships as demo_apps/taskboard/.
Taskboard is the most complete pattern in the SDK: it combines all three pillars at once. Each board is a row in a native Odoo model (x_vzm_taskboard_board, declared in manifest.native.models) so the data is real, queryable, and survives outside the app. Per-row sharing is expressed with two fields β x_visibility (private/shared/public) and a many2many x_shared_with on res.users β and the read domain folds them into one query. On top of that sits Vizum.realtime: a lightweight pub/sub channel that nudges every open board to reload when the model changes. The same channel pattern drives Rent Roll (Vizum.realtime.channel("rentroll")), where a shared units/leases board refreshes live across the team.
Open one channel at boot, subscribe to a "changed" event, and reload from the native model whenever a ping arrives. The channel name ("taskboard") is the shared namespace β every client running this app joins the same room.
await Vizum.connect();
// One shared room for the whole app β same name on every client.
const CH = Vizum.realtime.channel("taskboard");
// A ping is just a nudge: re-read the source of truth (the native model).
CH.on("changed", () => reload());
await reload(); // initial paint from x_vzm_taskboard_board
The read domain is what makes sharing work: a single OR query returns boards you own, boards explicitly shared with you, and public boards. Per row you then derive the capability flags β _can_edit is owner-or-in-sharees, _can_delete is owner-only β so the UI and the write path can both gate on them.
async function reload() {
// owner OR shared-with-me OR public β one query, three visibilities.
const domain = ["|", "|",
["create_uid", "=", UID],
["x_shared_with", "in", [UID]],
["x_visibility", "=", "public"]];
const rows = await Vizum.orm.searchRead(MODEL, domain, BOARD_FIELDS, { order: "id desc" });
boards = {};
rows.forEach((r) => {
const mine = m2oId(r.create_uid) === UID;
const sw = Array.isArray(r.x_shared_with) ? r.x_shared_with : [];
boards[r.id] = {
id: r.id, name: r.x_name || "", data: r.x_data || "",
_can_edit: mine || sw.includes(UID), // owner or in x_shared_with
_can_delete: mine, // owner only
};
});
render();
}
Publish on write, not on read. The save path writes the board JSON to the native model, then fires one CH.publish("changed", {}) so every other open board reloads. The whole thing is gated behind _can_edit, so a sharee who only has read can never reach the write β and never broadcasts a phantom change.
async function flush() {
const r = boards[current];
if (!r || !r._can_edit) { return; } // read-only sharees never write
try {
await Vizum.orm.write("x_vzm_taskboard_board", [r.id], {
x_name: r.name,
x_data: JSON.stringify(board), // columns + cards as JSON
});
CH.publish("changed", {}); // nudge every open board to reload()
} catch (e) {
Vizum.ui.notify("Save failed: " + e.message, "warning");
}
}
The realtime channel is ephemeral β the payload is empty ({}), it carries no board state, and a missed ping costs nothing because the next reload re-reads everything. The source of truth is always the native model: Vizum.orm.write persists, CH.publish merely tells other clients "go look again." That split keeps sharing honest. A sharee with read-only access sees every update the moment it lands (they receive the ping and reload()), but _can_edit is false for them, so flush() short-circuits β they can watch the board move but never write to it or broadcast a change. Deletion is even stricter: _can_delete is owner-only.
Examples
The reference app β 5.67, used the way an app should
A small orders app that uses what the platform added in 5.67, the way an app should, with the tests that prove it β all on the platform emulator, no Odoo needed:
node --test sdk-next/examples/reference-orders/tests/orders.test.mjs
| What | Where | Read |
|---|---|---|
An optional dependency on a contract (customers:v1) | manifest.json requires | composition.md |
| Events with a declared, versioned shape; an emit that breaks it is refused | event_contracts, createOrder | events.md |
A collection every user shares (data: global), refreshed live in every open copy | index.html | live-data.md |
A concurrent change handled: a stale rev is read again and retried once | cancelOrder | platform-contract.md |
| A file stored with the checksum of its bytes | attachReceipt | files.md |
| An AI tool that changes content declares it; the host asks the user first | ai_tools, registerAiTools | ai-tools.md |
Offline and other failures arrive as a VizumError with retryable | tests | reliability.md |
The manifest and src/orders.js pass vzm check β each function types its SDK parameter (@param {typeof Vizum} V), so the check follows every call | tests | cli.md |
This is an example. It is never packaged or published: a vApp reaches an instance through the Store (RULE 0B), and this one only exists to be read and run.
Ship it
Packaging, installing, updating
cd my-app/
zip -r ../com.yourcompany.appname.vapp . -x '.*'
| Action | How |
|---|---|
| Install | Dock π§© β β+ Install .vappβ β pick the file. Zero-risk apps are ready instantly. |
| Update | Same id, higher semver, install again. Widened permissions wait for the admin; otherwise it swaps in immediately (reopen the window to load fresh files). In the Store the Update button first shows a review of what changes. A first install whose requires app is missing waits in βneeds approvalβ (waiting for <app>) until that app is installed β see Composition. |
| Uninstall | Apps manager β Uninstall (installer or admin). App windows close, KV data is removed. |
Developing comfortably
console.log from your app shows in the browser console.
Files are cached for an hour β close and reopen the window after updating.
Test permission failures on purpose: call something you did not declare and make
sure your UI degrades gracefully.
Ship it
Publish on the Vizum Store
Reach every Vizum desktop with one upload β free apps install in one click, paid apps are licensed per database with signed tokens.
Your vizumapps.com account
One account does everything: buying apps for your database and
publishing your own. Create it without leaving the desktop β the π€ button
in the Vizum Store window opens Sign in / Create account. The desktop
stores only an opaque token (never your password); an administrator links the
account once and every purchase from that database attaches to it. Your card is
entered at checkout through Stripe's embedded payment element β the publisher
never sees card numbers. The same login opens the developer portal
(vizumapps.com/my/vizum-apps) where you submit and
manage your published vapps.
| Step | What happens |
|---|---|
| 1 Β· Create the app | On the publisher backend (Vizum Store β Apps): key, summary, category, screenshots, pricing (free / one-time per database / subscription). |
| 2 Β· Upload a version | Drop the .vapp β version, minimum platform and the permission summary are extracted and validated from the manifest automatically. |
| 3 Β· Publish | The app appears in every connected desktop's Store window. Updates: upload the next version, users see an βUpdateβ pill. Pressing it opens a review sheet that shows what the version changes (data loss, irreversible steps, server code, permissions, migrations) before anything is installed. |
| Paid apps | One purchase licenses the whole customer database. Licenses are ed25519-signed tokens verified offline by the platform; subscriptions get a 14-day offline grace. |
Signing your package
Sign what you upload: npx vzm keygen --publisher yourname once, then
npx vzm pack dist --sign vzm-signing.key. A signature is
integrity β it proves the bytes are yours and unchanged, and the store pins your app to your
key so nobody else can ship a version of it. It is not a security review. Keep the key secret and
backed up. The com.vizumapps.* namespace is reserved for Vizum's own
apps (they need the Vizum publisher signature); use your own reverse-DNS prefix. Full rules:
Package signing.
Ship it
Troubleshooting
| Symptom | Cause β fix |
|---|---|
| βmanifest field 'x' is requiredβ | Fill every required field from Β§04. |
| βillegal path in packageβ | Your zip contains ../ or absolute paths β zip from inside the app folder. |
| βVersion x.y.z already installedβ | Bump version. |
| App stuck βneeds approvalβ | It declares orm/net/data:"global" β an administrator must approve it (dock π§© β Approve). |
PermissionDenied at runtime | The call isn't covered by the approved manifest. Widening needs a version bump + re-approval. |
| Reads return fewer fields than asked | Field filtering at work β only declared fields (+id) come back. |
| Unexpectedly empty search results | A manifest domain, or Odoo record rules for that user, narrow the query. |
| Styles ignore dark mode | Use the CSS variables from Β§07 instead of hardcoded colors. |
Vizum.net.fetch rejects | The host is missing from permissions.net (exact hostname or *.wildcard), or the app update widened it and awaits re-approval. |
| App back in Needs approval after an update that added no permission | Since SDK 5.69 a change to server code β a script hook, the script library, a model method, a route, a job or a webhook β is approved like a wider grant. The app's detail lists what changed under Server code changed; an update with the same code keeps the app running. |
| βneeds approval β waiting for <app>β | The app requires another app that is not installed (or out of range). Install it, then approve; even a zero-risk first install waits. See Composition. |
Vizum.net.fetch βport 8443 is not declaredβ | From min_platform 5.74 a named host is reachable on 80/443 only. Write "host:8443" in permissions.net. vzm check warns NET_PORT_UNDECLARED before you raise min_platform. |
| A paired screen shows the PIN again after a week offline | The server-side lease is 7 days without an authenticated call: pair it again. See Device routes. |
| Device calls answer 503 | The app has device routes but no job user: declare "native": {"service_user": true}. |
| βneeds min_platform 5.7x / 5.8x or laterβ | You used a newer API than your min_platform says: V.tr 5.77, group_ops 5.78, V.identity 5.79, offline 5.80, a unique on x_company_id or jobs with data: "user" 5.81, V.vector.shared_namespaces 5.83. vzm check tells you before the installer does. |
| Pack or install refuses βdeclares a language without its catalogueβ | The manifest's i18n lists a language that has no i18n/<lang>.json (I18N_CATALOGUE_MISSING). Add the file or drop the language; for a Vite build, vzm check --dist dist reads the build output. |
| Odoo's own screens (field labels, menus, selection options) stay in English | Since 5.84 they come from the same i18n/<lang>.json, matched by the English text or an @native: key; a missing key stays English and a translation an administrator typed by hand is kept. vzm check lists the strings the catalogue does not cover; vzm i18n import-po converts an existing module's .po. |
| A server message arrives in English on Solo or in a job | The host must send the user's language: Desktop, native windows and Solo all do (Vizum.supports("server-i18n")); a job speaks its job user's language, and V.tr(..., lang=partner.lang) chooses one. |
| A guard cannot tell the user's write from your own method's | Read rec["su"] (5.81): True for a superuser environment such as sudo(). V.is_service_user() says the code runs as the app's own job user. |
Vizum.identity.confirm refused | Not available on Solo (UNSUPPORTED_CALL) and a device page gets PERMISSION_DENIED. The token lives 120 s and is single use: spend it with V.identity.consume(token, action) in server code. See Signing an action. |
An offline write answers CONFLICT or OFFLINE_USER_MISMATCH | The server record changed since the user saw it (base/expect differ), or the queue belongs to another user. Conflicts wait in Vizum.offline.conflicts() for resolve(); a mismatched replay is kept, never applied. See Offline data. |
| A user cannot do a write that an administrator can | Check group_ops (5.78): the extra ops reach only users in the listed groups, and ops stay what everyone gets. |
Vizum.vector.moveScope refused | Administrator or the app's service user only; user-to-global is administrator only. Managed RAG storage lists but cannot move. Nothing is re-embedded or metered. |
| A screen's volume or setting is lost on reload | An opaque-origin frame has no localStorage. Keep it with Vizum.device.store (5.82: 64 keys of 4 KB each per device) and steer a YouTube/Vimeo player with the setVolume / mute / unmute / play / pause of the embed() handle. |
| βalready installed for the company β¦β or another company cannot see an app | An app a developer installs is available to the installer's company. A Settings administrator makes it available to every company, or to one, in Apps Manager (vizum.app.api_set_app_company, audited as platform:company_scope). Store apps and the first-party defaults are global by policy. |
Ship it
Package signing β who made a .vapp, and whether it changed
*Since capability 5.67.* A .vapp is a zip. Until now nothing in it said who built it or whether anyone changed it on the way. Now a package can carry signature.json, every install checks it, and the app's detail shows who vouches for it.
The short version
npx vzm keygen --publisher acme # once: writes vzm-signing.key (mode 600)
npx vzm pack dist --sign vzm-signing.key # pack the built dir + sign, in one step
npx vzm verify com.acme.app.vapp # what an instance will see
Upload the signed package to the store as usual. Keep the key file secret and backed up: the store pins your app to that key at its first signed version, and every later version must be signed with it β so an attacker who gets into your store account cannot ship a version signed by someone else, and cannot drop the signature either. To rotate keys, ask the store.
What a signature gives you. A signature is *integrity*: it proves the bytes are the ones the key holder signed and that nobody changed them on the way. It is not a review and it does not make an app safe; permissions are still approved by the administrator. As a third-party developer you sign with your own key (vzm keygen, vzm pack --sign), your app id is anything under your own reverse-DNS (com.yourcompany.*), and you never need Vizum's key.
com.vizumapps.* is reserved for Vizum. Apps in that namespace (the first-party apps) get their first-party standing only when the package is delivered by the store and carries a publisher signature made with the Vizum publisher key pinned in the platform (VIZUM_PUBLISHER_KEYS; a list so the key can rotate). The store refuses a com.vizumapps.* upload that is unsigned or signed by another key, and an instance does not give such an app its first-party methods on the strength of from_store alone. A third-party package under that namespace is refused at upload; choose your own prefix.
Signing is optional today. An unsigned package installs exactly as before, unless an instance's administrator demands signatures (below).
What an instance does with it
Every install verifies the package's signature before any other install gate runs (after the manifest itself is validated and the revocation list is checked):
| The package⦠| Result |
|---|---|
| has a signature that does not hold (a file changed, added, removed or renamed; signed for another app or version; a forged statement) | refused, always |
| was delivered by the Vizum Store | store_verified β the store signs exactly the bytes it delivers |
| is signed by its developer only (a private deployment) | publisher_signed |
| is store-signed, but this instance has no store key configured | unverified β or publisher_signed when your own signature still covers exactly these bytes |
has no signature.json | unsigned |
| was built here (AI Creator build, a bundled first-run app, a restore or rollback) | local |
The state, the package digest, the publisher and its key fingerprint are recorded on the app and returned by the installed-app detail (trust).
An administrator chooses what the instance accepts, in the system parameter vizum_apps_platform.package_trust:
| Value | Installs |
|---|---|
allow_unsigned (default β also when the parameter is absent) | everything that is not tampered |
require_signed | store_verified, publisher_signed, local |
require_store | store_verified, local |
A value that is none of these is read as require_store: a typo fails closed.
Tightening the policy disables what no longer meets it. The policy is not only read at install: writing the parameter re-checks every running app against the new value, using the trust recorded when it was installed (nothing is downloaded). An app that does not meet it is set to disabled β it is not uninstalled and none of its data is touched β an audit row trust:policy_disabled records the policy and the reason, every administrator gets a notification, and the app's health report says why it is disabled. Apps installed before signing existed count as unsigned.
- Preview before tightening. An administrator can call
vizum.app.api_package_trust_preview(policy)(anAccessErrorfor anyone else). It returns[{key, name, trust_state, reason}]for the running apps thatpolicywould disable and changes nothing; it uses the same evaluation as the enforcement, so the two cannot differ. - Loosen and reactivate. There is no settings screen for this parameter, so no vApp can lock the administrator out: change it in Settings > Technical > Parameters (key
vizum_apps_platform.package_trust) or withset_param. Loosening re-enables nothing by itself. After loosening, approve each app again (Apps Manager > the app > Approve); approving an app the current policy still refuses is rejected.
Under require_signed or require_store, a local package is accepted only from an administrator, and never replaces an installed app that came signed (an autoupdate rollback is the exception: its snapshot must still hash to the digest of the version it was taken from).
An update keeps its publisher key. When the installed version names a publisher key, a signed update must be signed by the same key, or the install is refused with both fingerprints. An administrator who expects the change accepts it explicitly. A signature proves the bytes are intact and who holds the key β not who the publisher is.
Store keys are a list. The instance verifies store signatures (packages, revocation lists, licence tokens) against every accepted key: the configured vizum_apps_platform.store_pubkey, the extra keys in vizum_apps_platform.store_pubkeys_accepted (a JSON list), and the next key the platform ships ahead of a rotation. Rotating the store's key therefore breaks nothing for instances that already carry the next one.
What the store does with it
- At upload it checks your signature against the bytes you uploaded, records your publisher name and key fingerprint, and pins the app to the key (first signed version that passes). A version signed by another key, or unsigned after the app was pinned, fails validation with the reason. An upload that carries a *store* signature is refused: upload the package you built, not one you downloaded.
- At download it signs the package it hands over. Paid and private downloads rewrite
manifest.json(they bind the copy to the buying database), so the store's signature covers the delivered bytes and carries yours along as provenance; the instance checks both.
Revocation β when a published version must be withdrawn
The store can revoke a version (a store administrator, with a reason written for instance administrators). From then on:
- the store no longer serves it (download answers
410 version_revoked) and no longer advertises it as the latest; - every instance that has the store's public key configured checks the store's revocation list every hour (an unreachable store changes nothing). The list is signed by the store β only the store can switch an app off, and a list older than the last one applied is ignored, so an old list cannot be replayed to undo a revocation. The signed list names the instance it was made for (its database uuid; null for the anonymous list): a list made for another instance is refused. An instance that has the version installed disables the app, marks it withdrawn, and notifies its administrators with the reason;
- a withdrawn app cannot be re-enabled, and the revoked version cannot be installed again. Installing a newer version clears the withdrawn mark; the app stays disabled until an administrator approves it again. A revocation is lifted only when the store lists the version under
lifted: a version merely leaving the list (the app was unpublished, or the instance's token was refused for a while) lifts nothing. After a lift the mark goes away and an administrator may enable the app again β it is never re-enabled automatically.
A revocation is listed whatever the app's release status (an app unpublished after a version was withdrawn still has that version disabled where it is installed). A private app's revocation is only listed to the instances that may see that app.
The format
vzm implements it; you only need this to write your own tooling.
- Digest β SHA-256 over one line per file, sorted by the path's UTF-8 bytes:
<path> NUL <sha256 hex of the file> LF.signature.jsonis left out. Writtensha256:<hex>. signature.jsonβ{"format": 1, "publisher": {...}, "store": {...}}, at least one of the two blocks. Each block is{"statement": {...}, "signature": "<base64>"}.- Publisher statement β exactly
format(1),app,version(must equal the manifest'sidandversion),digest,publisher(your name: letters, digits,. _ @ + -),public_key(base64 raw Ed25519),signed_at(unix seconds). - Store statement β exactly
format,app,version,digest,publisher,publisher_key(ed25519:+ the first 32 hex of the SHA-256 of your public key, or null),signed_at. - What is signed β Ed25519 over a domain prefix plus the statement's canonical JSON (sorted keys, no whitespace, UTF-8):
vizum.package.v1\npublisher\norvizum.package.v1\nstore\n. - Revocation list β
{"format": 1, "issued_at", "instance", "key_id", "revocations": [{app, version, reason, revoked_at}], "lifted": [{app, version}], "signature"}, signed overvizum.revocations.v1\nplus the canonical JSON of everything butsignature.key_idis the fingerprint of the store key that signed it.
The reference vector every implementation must reproduce: vizum_apps_platform/tests/data/package_trust_vector.json (its keys come from fixed seeds β never use them for anything).
Not yet
- Publisher certificates β today your *key* is your identity (pinned per app by the store); the
publishername is what you typed. - Tightening the policy does not re-check apps that are already installed.