Vizum Apps developer guide
Vizum Apps developer guide

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.

sdk-next Β· TypeScript Β· Vite Β· React Odoo 17 Β· 18 Β· 19 Β· 20 Sandboxed & permissioned Server-side hooks & guards Installs in seconds

Start here

What is a .vapp?

One zip file. One window. The full Vizum desktop experience for free.

A .vapp running on the Vizum desktop
Top Customers β€” a 60-line .vapp β€” running in a floating window next to real Odoo windows.
πŸͺŸA real desktop window

Drag, resize, snap-tiling, Spaces, tabs, Mission Control β€” your app inherits all of it.

πŸ’ΎPersistence built in

A per-user (or shared) key-value store in the customer's own Odoo database.

πŸ—„οΈOdoo data, safely

Read/write exactly the models and fields your manifest declares β€” nothing else.

🎨Native look & feel

Live theme tokens: accent color and dark mode follow the user's desktop automatically.

How it works

Your .vapp sandboxed iframe opaque origin no cookies, no parent window.Vizum (SDK) Vizum host capability gate window plumbing theme push Odoo server manifest enforcement runs as the real user KV + ORM bridge postMessage checked calls
The security model in one sentence Your app runs as the logged-in user, inside a sandbox, and every call is checked twice β€” by the host and by the server β€” against the permissions an administrator approved. The manifest can only narrow access, never widen it.

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 moduleVizum .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
⚑Ship in an afternoon

HTML + JS + a manifest. No scaffolding, no server, no review queue.

πŸ›‘οΈCan't break Odoo

Sandboxed and double-checked β€” the worst a vapp can do is what its approved permissions allow.

♾️One build, every version

The same .vapp runs on Odoo 17, 18, 19 and 20 β€” no per-version ports.

πŸš€Update with zero downtime

Publish and the desktops refresh. No redeploy, no restart, no maintenance window.

Rule of thumb If you're changing Odoo itself, write a module. If you're building an app on top of Odoo's data, write a vapp β€” you keep the data and the permissions, and drop the server, the restarts and the per-version porting.

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
TemplateWhat you get
vanilla-tsVite + TypeScript, no framework. The smallest starting point.
react-tsReact 18 + TypeScript + Tailwind (Vizum preset) + Zustand + TanStack Query.
vanilla-multiNo 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:

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

No-build authoring index.html + js/*.js (ES modules) hand-written Β· Assistant Β· Vapp Creator Compiled authoring TypeScript Β· React Β· Tailwind vite build (dev machine) vzm pack a normal .vapp install & run unchanged Β· Odoo 17 Β· 18 Β· 19 Β· 20

"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

CommandWhat it does
vzm introspectOdoo 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 checkManifest 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-poTurns 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.

🧬Typed runtime & types

@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.

πŸ› οΈThe 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).

🎨Design tokens + Tailwind preset

@vizum/vapp-ui β€” host-theme-aware CSS variables (accent + dark mode) and a Tailwind preset, so every app looks modern and consistent.

πŸ“ŠCharts

@vizum/vapp-charts β€” an ECharts wrapper themed from the Vizum tokens; mountChart(el, option) with auto-resize.

🧾Form engine

@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.

πŸ”Live data

@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.

🧊3D data reports

@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.

πŸ•ΉοΈGame kit

@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).

βœ‹Hand tracking / vision

@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.

15 example apps ship with the toolchain β€” every one verified running in the desktop.

πŸ“ˆSales Cockpit

Live revenue KPIs + an SVG revenue chart, CRM pipeline, recent orders, top customers. Β· vanilla Β· typed ORM

πŸ“ŠExecutive Dashboard

Revenue trend, invoices donut, top-salespeople & pipeline β€” real ECharts; click a bar to drill in. Β· React Β· ECharts

πŸ‘€Customer 360

Search a customer, drill into orders / invoices / opportunities; click anything to open it. Β· vanilla Β· master-detail

πŸ—‚οΈPipeline Board

Drag CRM opportunities between stages β€” optimistic write, host-confirmed. Β· React Β· DnD Β· TanStack

πŸ“‹Project Board

Project tasks by stage with a project switcher; drag to move. Β· React Β· DnD

🧾Quick Intake

Create leads/contacts from declarative JSON-Schema forms (validation + many2one pickers). Β· vanilla Β· forms

⌘Command Palette

A ⌘K global search across contacts/orders/leads/invoices/products, keyboard nav. · vanilla

πŸ“¦Inventory Pulse

On-hand KPIs, top products, stock-by-location, low-stock table; bar β†’ drill. Β· React Β· ECharts

πŸ‘₯Team Pulse

Headcount by department/position + a searchable employee directory. Β· React Β· ECharts

β˜€οΈMy Day

Today's meetings + my activities + my open tasks, overdue in red. Β· vanilla

πŸ“Rich Notes

A mini-Notion notebook with a TipTap rich-text editor + autosave. Β· vanilla Β· TipTap

🏬Warehouse 3D

A real WebGL warehouse β€” each location a bin sized/colored by stock; orbit, hover, click. Β· vanilla Β· Three.js

🎟️Helpdesk Console

Triage tickets β€” stage filters, list + detail, related tickets. Β· vanilla Β· master-detail

πŸ“Field Ops Map

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
LimitValue
Zipped size5 MB
Unpacked size20 MB
File count200
Pathsrelative only β€” no .., no leading /
Network access is allowlist-only Sandboxed apps cannot fetch arbitrary URLs. Declare the exact hosts you need in 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"] }
  }
}
FieldRequiredMeaning
schemayesManifest schema version β€” always 1 today.
idyesReverse-DNS, lowercase. Your app's permanent identity β€” never change it between versions.
nameyesDisplay name (window title, launcher, store).
versionyesSemver x.y.z. Re-installing the same version is rejected β€” bump it.
min_platformno, but set itThe 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.
entryyesThe HTML file loaded into your window.
iconyesPNG path inside the zip.
window.w / hnoInitial window size in pixels.
window.singletonnotrue (default): opening again focuses the existing window.
permissionsnoThe contract β€” see the next section.
offlinenoThe 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.
i18nnoThe 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.

KeyWhat 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.
ormA 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.
uiWhich desktop UI calls you may make: notify, openRecord, openList, setBadge, addPaletteCommand. (confirm is always available β€” it only asks the user.)
eventsEvent names you may emit / listen to on the desktop bus.
appsApp ids you may open with Vizum.apps.open() (intents). Zero-risk: it only opens apps the user already installed.
netExternal 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_limitsRaises 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_routesDerived 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.
secretsNamed 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.
oauthOAuth2 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.
sessionsDevice/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.
audittrue 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.
assistanttrue 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

TierManifest asks for…Who can install
ZERO-RISKnothing beyond data: "user" Any desktop user, instantly.
PRIVILEGEDorm, 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.
The Vizum Store shows your permissions as privacy cards
Your manifest, as users see it: the Vizum Store renders permissions as Apple-style privacy cards. Declaring narrow fields is also marketing.
Crucial security fact ORM calls run as the logged-in user β€” never as admin. Odoo's own ACLs and record rules still apply on top of your manifest. If the user can't see a record in Odoo, your app can't see it either.

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.

Your vapp sandboxed iframe Vizum.orm.create( "x_vzm_…", vals) VappHost Β· browser β‘  manifest allowlist β‘‘ trust gate β€” user approves writes vizum.app.api_orm β‘’ approved perms β‘£ runs as YOU β€” ACL + record rules Odoo ORM + PostgreSQL postMessage result flows back the same path β€” renamed for your Odoo version on the way out Two independent checkpoints. The manifest can only NARROW access, never widen it.

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.Vizum contract 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_id became group_ids in 19, and 20 keeps it). The orm proxy 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.
your code (write once) res.users Β· groups_id Vizum field-alias shim Β· per running Odoo Odoo 17 β†’ groups_id Odoo 18 β†’ group_ids Odoo 19 β†’ group_ids Odoo 20 β†’ group_ids

Three rules to stay future-proof

  1. Never read group fields off res.users directly. Use Vizum.user / Vizum.roles (below) β€” they speak stable group XMLIDs, which never change between versions.
  2. Stick to standard fields. id, name, login, lang, tz, company_id, currency_id, create_date, write_date, display_name are stable everywhere.
  3. Branch on the version only if you truly must β€” Vizum.session.odoo_version gives 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

CapabilityWhat it adds
5.86OFFLINE: 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.85A 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.84NATIVE 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.83the shared vector scope of a user-scoped app.
5.82two asks of a paired-device page.
5.81two server-computed facts a guard could not learn, and two limits lifted.
5.80the offline data layer.
5.79Vizum.identity.confirm: step-up authentication.
5.78group_ops.
5.77V.tr.
5.76Vizum.app.key.
5.75V.sha256.
5.74NETWORK 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.73SERVER-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.72RESUMABLE 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.71LARGE 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.70PAIRED 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.69UPDATES 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.68A MODULE THE SIZE OF ELEV8DM AS A vAPP (its 1766 tests run on the vApp; plan 2026-10-02-plataforma-5.68-elev8dm).
5.67THE 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.66A WHOLE ODOO MODULE AS A vAPP (psi_dsd β†’ PSI DSD Core, its 818 tests green on the vApp).
5.65Vizum.team COVERS WHAT A USER-PROVISIONING MODULE DID (united_insurance_team).
5.64A 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.63THE 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.62A 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.61NATIVE 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.60Vizum.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.59A vAPP CAN DEFEND A FIELD OF ITS OWN MODELS (closes the 2026-09-01 field-ceiling concern).
5.58WHAT 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.57A 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.56A 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.55PUBLIC 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.54A 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.53ROUTES A vAPP DECLARES (native.routes), portal and internal only.
5.52Vizum.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.51AN 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.50MENUS 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.49AN 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.48MAP 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.47WORKFLOW 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.46RETIRING 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.45THE 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.44RAG 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.43COMPARE-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.42vizum.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.41db.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.40V.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.39Vizum.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.38Vizum Instances pass: Vizum.instances.pass() -> {token, expiresAt} behind the new boolean grant permissions.instances_api (privileged; gaining it widens).
5.37Report Builder 1.0 (native visual editing of Odoo reports, Β§3.3 of its spec): four small platform changes, all four lines.
5.36native.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.35assistant.send accepts the CALLING APP's model / reasoning effort / knowledge base ({model, effort, namespace}).
5.34an 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.33V.db β€” the app's own Vizum.db TABLES reached from a server-side script.
5.32enforcing, delta-aware script triggers.
5.29native.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.28V.commit() in the JOB runtime + one savepoint/commit PER APP in the job cron.
5.27Vizum.files.publicUrl β€” an unguessable but PUBLIC https link to one of the app's own files (permissions.files_public).
5.26the 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.25Vizum.ai.image β€” image GENERATION for vapps (managed mode only).
5.24Vizum.devices.location β€” HOST-mediated one-shot GPS fix (devices:["location"]).
5.21Vizum.ui.openUrl β€” the host opens an external http(s) URL in a new tab.
5.20server-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.5Vizum.audio (transcription β€” local Whisper default / managed metered-per-minute gateway).
5.4Vizum.ocr (OCR β€” local Tesseract default / managed metered-per-page gateway)
5.3AI tools β€” manifest ai_tools + Vizum.ai.tools.handle (client-handled) + Vizum.ui.openApp(key, payload)
5.2Vizum.docs (raw-document store for RAG re-indexing) + Vizum.vector.pgv* (Mode-3 local-pgvector setup)
5.1Vizum.domain (Visual Domain Builder)
5.0Vizum.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

VersionWhat it isRead it from
capabilityThe 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_buildThe 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}
sdkThe version of the in-iframe runtime SDK (static/sdk/vizum-sdk.js) served by the platform: <capability>.<patch>.Vizum.sdk.version
apiThe 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_schemaThe manifest format ("schema": 1 in manifest.json; static/sdk/manifest.schema.json). New optional keys do not change it.manifest.schema
appThe 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

LevelMeaningWhat may change
stableWill not change incompatibly inside this api version. Safe for every app.additive only (new optional arguments, new result keys)
betaFeature-complete and supported; its shape may still change in a minor capability, announced in the capability notes.shape, with notice
experimentalMay change or disappear in any capability. Do not ship a store app that depends on it without a feature check (Vizum.supports).anything
deprecatedStill 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
removedNo 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.

CodeCategoryRetryableMeaningWas (legacy message)
PERMISSION_DENIEDpermissionnoThe app's approved manifest permissions do not cover this call (refused by the host gate before reaching the server).PermissionDenied
UNSUPPORTED_CALLunsupportednoThis platform does not serve that namespace or method (a newer SDK, a removed member, a typo). Check Vizum.supports() first.UnsupportedCall, Unknown call
ACCESS_DENIEDauthorizationnoThe server refused: the user (or the app's grant) may not do this on this record or model.β€”
AUTHENTICATION_FAILEDauthenticationnoCredentials were refused.β€”
SESSION_EXPIREDauthenticationnoThe Odoo session ended; the user must sign in again.β€”
OFFLINE_USER_MISMATCHauthenticationnoA 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_FAILEDvalidationnoThe arguments or the data are invalid.β€”
BUSINESS_RULEvalidationnoA business rule refused the operation (an Odoo UserError, or V.fail() in a server-side hook). The message is meant for the user.β€”
NOT_FOUNDnot_foundnoThe record, app or object does not exist (or no longer does).β€”
CONFLICTconflictnoThe data changed since it was read (compare-and-set expect, collection rev). Re-read, then decide.rev_conflict
CONCURRENT_UPDATEconflictyesTwo transactions touched the same rows at once; retrying the same call is safe for idempotent operations.β€”
IDEMPOTENCY_IN_PROGRESSconflictyesAn operation with the same idempotency key is still running. Retry later with the same key.β€”
IDEMPOTENCY_MISMATCHvalidationnoThe idempotency key was already used for a DIFFERENT operation or arguments.β€”
USER_CANCELLEDuser_cancellednoThe user declined a confirmation or closed a picker.UserDenied, NoFile
NETWORK_ERRORnetworkyesThe request did not reach the server or the answer was lost. Retry only idempotent operations (or pass an idempotency key).β€”
OFFLINEnetworkyesThe device is offline.β€”
CONNECTIVITY_CHANGEDnetworkyesConnectivity changed while the user was confirming; nothing was sent.ConnectivityChanged
TIMEOUTtimeoutyesNo answer within the time budget.β€”
RATE_LIMITEDrate_limityesToo many calls; wait and retry.β€”
QUOTA_EXCEEDEDrate_limitnoA metered quota is exhausted (AI balance, storage); retrying will not help until it is raised.β€”
APP_NOT_INSTALLEDdependencynoThe app this call targets is not installed or not ready.AppNotInstalled
LICENSE_REQUIREDauthorizationnoThe app's licence does not cover this.LicenseRequired
SERVER_ERRORservernoThe server failed unexpectedly. Not retryable by default: the operation may have run.β€”
INTERNAL_ERRORservernoThe host failed while serving the call (a bug in the platform's browser side). Not retryable by default.β€”
UNKNOWN_ERRORservernoAn 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:

OperationRetry it blindly?With {idempotencyKey}
Reads (orm.search/read/readGroup, data.get, meta.*, …)yes β€” nothing changesnot needed
orm.writeapplies the same values again (last writer wins); use {expect} (5.43) to refuse a stale writenot needed
orm.createno β€” a second recordcreated at most once; the retry gets the first ids
actions.runno β€” the action runs twiceruns at most once; the retry gets the first result
backend.enqueueno β€” a second taskone task per key
offline.runreplays are keyed automatically (the queued op's id), so a reconnect, a second tab or a lost answer never runs it twicethe app's key becomes the op's id
net.fetch, ai.*, oauth.fetchno β€” external side effects and costnot 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.

PermissionShapePrivilegedWhat it grants
data"user" | "global"when globalWhere Vizum.data / collections live: per user, or shared by every user of the app.
schema{models, create_models}yesLegacy native schema declaration.
logic"declarative" | "script"when scriptServer-side code: native.script hooks, jobs that run scripts, V.* bridge.
orm[{model, ops, fields?, write_fields?, domain?, chatter_only?, group_ops?}]yesOdoo 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]noHost UI operations beyond the always-allowed confirm/openHtml/openDocument/exitFullscreen (fullscreen, 5.85, needs its entry).
events{emit: [name], listen: [name]}noBrowser-local app-to-app events (see Event contracts).
net[host | *.wildcard | $setting:key | "*"]yesHosts 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]noApps this app may open with an intent.
reports[report name | "<prefix>.*" | "*"]when *Odoo reports the app may render.
media["camera" | "microphone"]noHost-mediated camera / microphone (the browser still prompts the user).
devices["print" | "scan" | "location" | "serial"]yesHost-mediated devices.
secrets[name]noVault secret NAMES the app may ask for (values never reach the iframe; configure is admin-only).
oauth[provider]yesOAuth providers; tokens stay server-side.
oauth_sharedbooleannoShared (company) OAuth connections usable by every user and by jobs. Needs oauth.
oauth_paths{provider: [path prefix]}noNarrows each provider to the API paths the app calls. Needs oauth.
aiboolean | [model id]yesThe managed / BYOK AI gateway (metered).
ai_imagebooleannoImage generation, metered per image. Needs ai.
ai_adminbooleanyesInstance-mutating AI store ops (BYOK key, billing, instance settings, RAG migration). Needs ai.
store_accountbooleanyesLinks/unlinks the instance-wide Vizum store account.
vector"user" | "global"when globalThe server-side vector store, per user or shared.
ragbooleanyesServer-side RAG indexing jobs (Knowledge).
assistantbooleanyesRuns the AI Assistant agent as the real user.
ocrbooleanyesManaged OCR (metered).
audiobooleanyesManaged transcription (metered).
editablebooleanyesAI live-document control between editor apps and the assistant.
auditbooleanyesThe platform's old→new change audit on the app's declared models.
cutoverbooleanyesLicence cutover (adopt / release), admin-only.
sessions[op]yesNative session revoke / login-alert policy, admin-only.
db_cross_readbooleanyesRead other apps' shared Vizum.db rows the user can already see.
files_publicbooleanyesMint public https links to the app's own files.
instances_apibooleanyesVizum Instances pass (store-signed identity for an external service).
teambooleanyesVizum.team: managers create and edit the users who play the app's roles.
team_apps[app id]yesSame-vendor apps a team role may be let into. Needs team.
team_adopt"dedicated"yesTake over the dedicated accounts that already play the app's roles. Needs team.
adopt[model]yesCopy the rows of a replaced module's models into the app's own (native.adopt).
private_calls["<model>._<method>"]noPrivate 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]noSystem 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>"]noAnother app's GLOBAL data rows, read-only, from SERVER code. Needs logic. Only reachable from server scripts, which need logic: "script" (privileged).
actions[name]yesThe approved action catalogue. *Derived from actions.*
tables[name]yesThe app's Vizum.db tables. *Derived from tables.*
workflows[name]yesThe app's workflows. *Derived from workflows.*
webhooks[name]noThe app's inbound webhooks (they need data: "global", which is privileged). *Derived from webhooks.*
native_bindings[name]yesAction-menu bindings on native models. *Derived from native.bindings.*
routes[descriptor]yesPortal / internal / public pages. *Derived from native.routes.*
public_routes[descriptor]yesThe public (anonymous) pages among them. *Derived from native.routes.*
public_route_menus[descriptor]noWebsite menu entries of public pages. *Derived from native.routes.*
methods["<model>.<method>"]yesNative methods and overrides the app declares. *Derived from native.methods.*
public_methods["<model>.<method>"]noMethods opened to every signed-in user. *Derived from native.methods.*
native_aclsha256noFingerprint of the native ACLs / rules / groups: any change is re-approved. *Derived from native.*
device_routes[route name]yesThe 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}yesRaise 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}yesThe 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}yesDerived 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.

FeatureWhatSinceDesktopOdoo actionSolo
3d-kitvizum-3d-kit runtime4.2βœ“βœ“βœ“
actionsVizum.actionsβ€”βœ“βœ“βœ“
activityVizum.activityβ€”βœ“βœ“βœ“
aiVizum.aiβ€”βœ“βœ“βœ“
ai-toolsmanifest ai_tools + Vizum.ai.tools5.3βœ“βœ“βœ“
ai-tool-contractsmanifest 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 them5.67βœ“βœ“βœ“
app-budgetsper-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_EXCEEDED5.67βœ“βœ“βœ“
audioVizum.audio5.5βœ“βœ“βœ“
auditVizum.auditβ€”βœ“βœ“βœ“
backendVizum.backendβ€”βœ“βœ“βœ“
badgeui.setBadgeβ€”βœ“βœ“βœ“
callOnorm.callOnβ€”βœ“βœ“βœ“
chartsvizum-charts runtime4.2βœ“βœ“βœ“
collectionsVizum.data.collection (named JSON collections with rev)β€”βœ“βœ“βœ“
compositionrequires with version ranges and contracts, optional dependencies, Vizum.apps.status(), uninstall protection for required apps5.67βœ“βœ“βœ“
confirmui.confirmβ€”βœ“βœ“βœ“
contractVizum.platform / Vizum.sdk / Vizum.features() / Vizum.supports()5.67βœ“βœ“βœ“
dbVizum.dbβ€”βœ“βœ“βœ“
devicesVizum.devicesβ€”βœ“βœ“βœ“
docsVizum.docs raw-document store5.2βœ“βœ“βœ“
editableVizum.editable (AI live-document control)5.19βœ“β€”β€”
errorsVizum.errorsβ€”βœ“βœ“βœ“
event-contractsmanifest event_contracts (versioned payload schemas) enforced on emit, meta {producer, version} to listeners, Vizum.events.catalog()5.67βœ“β€”β€”
file-integrityVizum.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βœ“βœ“βœ“
filesVizum.files (app-scoped binary storage)β€”βœ“βœ“βœ“
formatVizum.format helpersβ€”βœ“βœ“βœ“
healthVizum.apps.health(): healthy/degraded/failing from approval state, dependencies, runtime errors and failed tasks; administrators get api_health and api_update_preflight5.67βœ“βœ“βœ“
idempotencyan 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_MISMATCH5.67βœ“βœ“βœ“
i18ni18n/<lang>.json + Vizum._tβ€”βœ“βœ“βœ“
importVizum.importβ€”βœ“βœ“βœ“
instancesVizum.instances.pass5.38βœ“β€”β€”
intentslaunch intents (Vizum.intent, apps.open payloads)β€”βœ“βœ“βœ“
jobsVizum.jobsβ€”βœ“βœ“βœ“
licenseVizum.licenseβ€”βœ“βœ“βœ“
lifecycleonVisibility/onPause/onResume/onClose5.7βœ“βœ“βœ“
mailVizum.mailβ€”βœ“βœ“βœ“
mediahost-mediated camera/microphoneβ€”βœ“βœ“βœ“
metaVizum.metaβ€”βœ“βœ“βœ“
netVizum.net.fetch (server proxy)β€”βœ“βœ“βœ“
net-anypermissions.net "*": any public website, download-only (privileged)5.74βœ“βœ“βœ“
net-imageVizum.net.fetchImage / V.net.fetch_image: one image, redirects re-checked, byte cap, PNG/JPEG/GIF/WEBP by magic number5.74βœ“βœ“βœ“
notificationsVizum.notificationsβ€”βœ“βœ“βœ“
oauthVizum.oauthβ€”βœ“βœ“βœ“
ocrVizum.ocr5.4βœ“βœ“βœ“
offlineVizum.offline outboxβ€”βœ“βœ“βœ“
openListui.openListβ€”βœ“βœ“βœ“
openRecordui.openRecordβ€”βœ“βœ“βœ“
paletteui.addPaletteCommandβ€”βœ“βœ“βœ“
panelswindow.openPanelβ€”βœ“βœ“βœ“
permissionsVizum.permissionsβ€”βœ“βœ“βœ“
pgvectorMode-3 pgvector setup (vector.pgv*)5.2βœ“βœ“βœ“
read_grouporm.readGroupβ€”βœ“βœ“βœ“
realtimeVizum.realtime (live receive needs the desktop bus)β€”βœ“β€”β€”
reportsVizum.reportsβ€”βœ“βœ“βœ“
rolesVizum.rolesβ€”βœ“βœ“βœ“
runtimeself-hosted no-build runtime (/vizum/runtime/*)4.2βœ“βœ“βœ“
scopeVizum.scope()4.1βœ“βœ“βœ“
secretsVizum.secretsβ€”βœ“βœ“βœ“
sessionVizum.session (version-safe identity, company, currency)β€”βœ“βœ“βœ“
settingsVizum.settingsβ€”βœ“βœ“βœ“
task-retrymanifest 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βœ“βœ“βœ“
telemetryVizum.telemetryβ€”βœ“βœ“βœ“
uikitvizum-ui.js kitβ€”βœ“βœ“βœ“
userVizum.user (version-safe identity/groups)4.1βœ“βœ“βœ“
vectorVizum.vectorβ€”βœ“βœ“βœ“
versionVizum.session.odoo_version4.1βœ“βœ“βœ“
watchorm.watch / orm.live / Vizum.refresh (live re-read)β€”βœ“β€”βœ“
webhook-deliveriesinbound webhook ledger: delivery-id dedupe, timestamp window, statuses, ack/fail, dead letter and retry5.67βœ“βœ“βœ“
webhooksVizum.webhooksβ€”βœ“βœ“βœ“
workflowVizum.workflowβ€”βœ“βœ“βœ“
update-code-approvalan update that changes server code returns the app to approval; api_update_preflight names permission and code changes item by item5.69βœ“βœ“βœ“
task-statusVizum.backend task rows carry status: retrying / expired / dead_letter5.69βœ“βœ“βœ“
contract-versionsprovides name@x.y.z and requires {contract, version}5.69βœ“βœ“βœ“
caller-appcaller_app() in a method called through a call: grant: the calling app's key, verified by the platform5.69βœ“βœ“βœ“
device-routesVizum.device β€” a paired-device page5.70β€”β€”β€”
paired-devicesVizum.pairedDevices β€” pair, list and revoke an app's devices5.70βœ“βœ“βœ“
large-filesVizum.files above 200 MB: chunked putBlob, streamed getBlob/getRange, permissions.file_limits.max_mb up to 5125.71βœ“βœ“β€”
upload-resumeA chunked putBlob survives a dropped connection: same token, the part sent again with backoff, status then next_part, an explicit abort when it gives up5.72βœ“βœ“β€”
native-i18nNative 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 needed5.84βœ“βœ“βœ“
server-i18nServer-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-sha256V.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 signature5.75βœ“βœ“βœ“
script-trV.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 source5.77βœ“βœ“βœ“
orm-group-opsgroup_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_ops5.78βœ“βœ“βœ“
offline-datapermissions.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-deletedA 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-upVizum.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-suThe 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.815.81βœ“βœ“βœ“
script-service-userV.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.815.81βœ“βœ“βœ“
company-uniqueA 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.815.81βœ“βœ“βœ“
jobs-user-dataA 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.815.81βœ“βœ“βœ“
device-embed-controlVizum.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.825.82β€”β€”β€”
device-storeVizum.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.825.82β€”β€”β€”
vector-scopeVizum.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.835.83βœ“βœ“βœ“
ui-fullscreenVizum.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, %s filled in order.
  • readonly app: { readonly key: string | null } β€” SDK 5.76 β€” this app's own key, sent by the host in the handshake: key is set once connect() resolves (null before, and on a host older than 5.76).
  • capabilities(): Vizum.Capabilities
  • connect(): 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 of capabilities().features).
  • readonly intent: any
  • readonly 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 | null
  • supports(name: string): boolean β€” SDK 5.67 β€” true when this platform AND this host serve name: 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.67 idempotencyKey: 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… β€” refs is the app's own Vizum.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 with ref (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 ~ms of 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.67 idempotencyKey: 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: users may 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 for url, 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 route route (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 route route, 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 base64 data), 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): () => void
  • publish(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") or is(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 β€” retry fn while 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 manifest event_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 β€” length bytes (at most 32 MB) from offset.
  • 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_in seconds, 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): string
  • datetime(d: Date | string): string
  • money(n: number | string): string
  • number(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][] }>): HTMLButtonElement
  • show(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:name placeholders β€” 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 with camera: 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 in offline.writes with op create).
  • datasets(): Promise<Vizum.OfflineDataset[]> β€” SDK 5.80 β€” the datasets the manifest's offline.datasets declares, 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): void
  • onConflict(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): void
  • onSync(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 two groupby fields (date fields with :day|week|month|quarter|year), aggregates field: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_mine sends it again over the server's values, keep_server drops it and shows the server's values, discard drops 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 in offline.writes with op write).

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.67 idempotencyKey: 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.Query
  • read(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.Watcher
  • write(model: string, ids: number | number[], vals: object, opts?: { expect?: Record<string, unknown>, context?: Vizum.OrmContext }): Promise<boolean | { updated: boolean; current: Record<string, unknown> }> β€” With opts.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 device file route plays); checked like write(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 after hours (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 (no id) or update a member; role is 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, never window.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: number
  • inGroup(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: boolean
  • isSystem: boolean
  • login: string
  • name: 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 whose meta.gen is not keep_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 to query (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 PageRank rank + 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 for namespace, 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 matching query.
  • 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.Subscription
  • start(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's v.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.

MemberSinceAt 5.86
Vizum.ocr.*5.4βœ“
Vizum.audio.*5.5βœ“
Vizum.onClose5.7βœ“
Vizum.onPause5.7βœ“
Vizum.onResume5.7βœ“
Vizum.onVisibility5.7βœ“
Vizum.audio.recordClip5.10βœ“
Vizum.vision.*5.11βœ“
Vizum.editable.*5.19βœ“
Vizum.ui.openUrl5.21βœ“
Vizum.devices.location5.24βœ“
Vizum.ai.image5.25βœ“
Vizum.files.publicUrl5.27βœ“
Vizum.files.revokePublic5.27βœ“
Vizum.phone.*5.31βœ“
Vizum.settings.set5.35βœ“
Vizum.instances.*5.38βœ“
Vizum.instances.pass5.38βœ“
Vizum.map.tileUrl5.48βœ“
Vizum.assistant.kbSearch5.52βœ“
Vizum.team.*5.60βœ“
Vizum.team.whoami5.65βœ“
Vizum.apps.health5.67βœ“
Vizum.apps.status5.67βœ“
Vizum.errors.catalog5.67βœ“
Vizum.errors.is5.67βœ“
Vizum.errors.normalize5.67βœ“
Vizum.errors.retry5.67βœ“
Vizum.events.catalog5.67βœ“
Vizum.features5.67βœ“
Vizum.platform5.67βœ“
Vizum.sdk5.67βœ“
Vizum.supports5.67βœ“
Vizum.webhooks.ack5.67βœ“
Vizum.webhooks.deliveries5.67βœ“
Vizum.webhooks.fail5.67βœ“
Vizum.webhooks.retry5.67βœ“
Vizum.device.blobUrl5.70βœ“
Vizum.device.cache5.70βœ“
Vizum.device.call5.70βœ“
Vizum.device.embed5.70βœ“
Vizum.device.embeds5.70βœ“
Vizum.device.fileUrls5.70βœ“
Vizum.device.fullscreen5.70βœ“
Vizum.device.info5.70βœ“
Vizum.device.onReload5.70βœ“
Vizum.files.putBlob5.70βœ“
Vizum.orm.writeBlob5.70βœ“
Vizum.pairedDevices.claim5.70βœ“
Vizum.pairedDevices.link5.70βœ“
Vizum.pairedDevices.list5.70βœ“
Vizum.pairedDevices.reload5.70βœ“
Vizum.pairedDevices.rename5.70βœ“
Vizum.pairedDevices.revoke5.70βœ“
Vizum.files.getBlob5.71βœ“
Vizum.files.getRange5.71βœ“
Vizum.net.fetchImage5.74βœ“
Vizum.app5.76βœ“
Vizum.identity.*5.79βœ“
Vizum.identity.confirm5.79βœ“
Vizum.offline.conflicts5.80βœ“
Vizum.offline.create5.80βœ“
Vizum.offline.datasets5.80βœ“
Vizum.offline.onConflict5.80βœ“
Vizum.offline.onSync5.80βœ“
Vizum.offline.query5.80βœ“
Vizum.offline.readGroup5.80βœ“
Vizum.offline.resolve5.80βœ“
Vizum.offline.sync5.80βœ“
Vizum.offline.write5.80βœ“
Vizum.device.store5.82βœ“
Vizum.vector.moveScope5.83βœ“
Vizum.vector.sharedNamespaces5.83βœ“
Vizum.ui.exitFullscreen5.85βœ“
Vizum.ui.fullscreen5.85βœ“
Vizum.ui.onFullscreenChange5.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 }
  ]
}
EntryMeans
"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_versions says 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:v1 is a name, matched exactly, by every platform.
  • Vizum.apps.find(name) takes the contract name (payments, or payments:v1).
  • Removal protection counts only providers in the dependent's range: uninstalling the last provider of ^1.2 is refused even while another app provides payments@3.0.0.
  • vzm diff reads 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:

RangeAccepts
^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, 1that prefix
1.2.3 / =1.2.3exactly that version
>=1.2.0 <2.0.0both

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"]}]}
  • ops is what everyone who runs the app gets. Each group_ops entry adds its ops only for the calling user (never the installer) who is in at least one of its groups. fields, write_fields, domain and chatter_only stay the grant's own and bound both lists.
  • A group is "@name" (one of this app's native.groups), "<app-key>/@name" (a group of an app in requires, resolved the way that app's own rules resolve it) or a real xmlid such as sales_team.group_sale_manager. base.group_system, base.group_erp_manager and 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 and read_group all honour it. In Solo mode the access group every Solo user holds is minted from ops only, never from group_ops: a technician who is not in the owner's admin group keeps the owner's own read-only ACL. The group named in group_ops has to carry the Odoo ACL for the model itself (the owner's native.models[].access, or a module).
  • An op is in ops or in group_ops, not both. For the approval: moving an op from ops into group_ops narrows the grant (the update applies with no new approval and replaces the approved snapshot); adding a group_ops op nobody had, or naming one more group for it, widens it. Vizum.app.info-style introspection (api_meta models) lists the ops the current user holds.
  • min_platform 5.78 or later. An older platform ignores the key, so the app would lose the group's ops silently; the validator, the Store gate and vzm 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 version 1–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. A pattern that 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_FAILED naming the producer. Adding an event to permissions.events.emit in an update is a permission widening, like adding one to listen β€” 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 emit rejects with a VizumError VALIDATION_FAILED naming 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. version is null when the producer declares no contract for that event; meta is null for 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

LifecycleMeans
runningapproved, and every required dependency is ready
blockedapproved, but a required dependency is missing, disabled or the wrong version (composition)
pending_approvalinstalled; 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)
disabledan administrator switched it off
withdrawnthe 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:

Checkhealthydegradedfailing
stateapproved and runningβ€”waiting for approval, disabled, or withdrawn
dependenciesall readyan optional one is nota required one is not
runtime_errorsnone1–23 or more
background_tasksnone failed1–2 failed3 or more failed
schemathe database matches the manifesta 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's package_trust policy, 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 to pending_approval until an administrator approves it.
  • permissions.detail β€” the change, item by item, against what this database APPROVED: hosts, secrets, devices and every other list grant as added / removed; scalars as from / to; nested grants (OAuth providers and scopes) one level down; orm per model β€” new models, and for existing ones the operations, fields and write_fields gained (dropping a field list reads as fields: ["*"], every readable field) and a changed domain.
  • 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, manifest actions, and what the server evaluates in native.ui β€” view expressions (QWeb t-*, domain, context, attrs/invisible/readonly/required, groups, decoration-*), action domains and context, server-action code, report expressions β€” as added / 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 to pending_approval like 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.db rows, 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:

  1. declare a new field with the new type or comodel;
  2. fill it from the old one β€” {"op": "copy", "model": "job", "from": "hours", "to": "duration"} in this version's native.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);
  3. 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

MechanismWhat it isBest forVisible 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
The decision rule Use 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.
Vizum.data JSON keyβ†’value blob Vizum.db JSON rows (app-private) native.* + Vizum.orm real Odoo table x_vzm_<app>_<model> Odoo ORM boundary Native Odoo reports Β· pivots Β· list/form automations Β· other modules visible βœ“
Only 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.

manifest.json
"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.

manifest.json
"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.

manifest.native models[] / fields[] + schema.create_models install + approve privileged β†’ admin OKs reconcile() runs real Odoo schema ir.model + ir.model.fields x_vzm_<app>_<model> table NEW model? restart Odoo once so the registry sees it app reads/writes Vizum.orm against x_vzm_… uninstall = teardown fields archived Β· own models dropped

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
JSON rows are invisible to Odoo reporting 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 makes your app privileged Any 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:

HookWhat it does
--vizum-accentCSS variable on :root β€” the user's accent color.
.vizum-darkClass 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).

β–Ά Try it live β€” the SDK Playground Run 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:

ModeWhere vectors + documents liveCost / 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 callWhat 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, with or match, and no underscore attributes (V._env is 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 its hook_* 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), and V.commit() in jobs only (5.28). Each run has a call budget: 50 net, 10 ai and 20 vector calls. Since 5.67 a script's V.net and V.files.put also count against the per-app budgets an administrator sets (QUOTA_EXCEEDED, reliability.md), and a script's V.files cannot pass sha256 or expires_in: its public links get the one-day default (files.md). Since 5.74 V.net.fetch_image(url, {max_bytes}) downloads one image (redirects re-checked, byte cap, PNG/JPEG/GIF/WEBP only; net.md) and counts as one net call.
  • V.orm.read and V.orm.search_read return a binary or image field (image_1920, datas) as a base64 str, or False when it is empty, on every Odoo line β€” the same value Vizum.orm.read gives the iframe. Odoo itself reads it as bytes on 17/18/19 and as {content, size} on 20; the platform normalises it, so a script never branches on the line. A bin_size read 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 with context={"bin_size": True} instead of pulling the bytes.
  • A hook ends by setting result, which is what Vizum.script.run resolves to.

A hook runs in one of three ways:

How it runsDeclared byRuns as
App-invoked: Vizum.script.run(name, payload) from the iframethe hook alonethe calling user, floored by that user's record rules
Trigger: on a create, write or (5.56) delete of a modeltrigger: {model, on, enforce?}the app's job user
Job: on a schedulejobs[]: {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.

KeySinceContent
idβ€”the record id
event5.32"create", "write" or (5.56) "unlink"
changed5.32sorted names of the written fields; [] on a create or a delete
old5.32pre-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
new5.54post-image: the written fields on a write, every stored content field on a create; {} on a delete
uid5.54env.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
owner5.54the row's create_uid, or False
installing5.56True while the app's own install or update runs (its migrations). The guard is not skipped then: it is told, and decides
su5.81True 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 triggerrec["uid"]rec["su"]
a user's own write (the form, V.orm, an RPC)that userFalse
a method of your app that wrote with self.sudo() / .sudo().create(...)the user who called the methodTrue
platform or Odoo code that wrote with sudo()the user in that environmentTrue
the superuser (a cron, a migration, uid 1)1True

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.fail is 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 KeyError in a numbering hook stops every save of that model. Write non-guard hooks defensively.
  • enforce: true makes 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. enforce needs 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 null at 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_hook or 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, orm within 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 a native.models[].fields[] entry) is protected only by the bridge, because Odoo does not apply groups to a manual x_ field. The bridge resolves every hop of a dotted path (x_rel.x_secret) in domains, order, fields, groupby, aggregates, any / not any sub-domains and watch hashes, 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_id is the platform's (5.51). On a company_scoped native model the platform synthesizes x_company_id; never declare a company_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() returns caps.orm_native: true. The platform computes it; declaring it in the manifest does nothing.

5. Naming: what your fields are called in Odoo

You declareOdoo 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 modelx_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" } ] }
]
  • one2many needs inverse: the manifest name (no x_) of a many2one declared on the model in relation, which must be one of your own models, pointing back at this one.
  • ondelete on a many2one: set null (the default, as before 5.57), cascade (the line goes with its order) or restrict (a master still referenced cannot be deleted). PostgreSQL enforces it, so it holds for the backend, sudo and 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 needs cascade or restrict.
  • On a shared model a one2many may list your rows ({"model": "res.partner", "name": "orders", "type": "one2many", "relation": "x_vzm_<app>_order", "inverse": "partner"}), but required and cascade are 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 β€” from Vizum.orm or V.orm. A version may add, change or drop any of this; dropping ondelete returns the field to set null.
  • A vApp that uses inverse, ondelete or required sets min_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 an ir.sequence the 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 by exc.diag.constraint_name finds it. Since 5.81 a unique of a company_scoped model may list x_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, so nulls_not_distinct works as for any other. A unique that names it on a model that is not company_scoped is refused (there is no such column), and so is a declared field called x_company_id named in the same list (it would be ambiguous). An app that uses it must declare min_platform 5.81; the install gate and vzm check enforce it, and Vizum.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's CHECK (quantity >= 1), and a write that breaks it fails in the database (an IntegrityError), 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 a constraints method.
  • order (5.68, on a model): its default order, as a module's _order β€” "order": "code_id, sequence, id", in your field names, each optionally asc/desc and nulls first/nulls last; besides your declared fields it may name id, create_date, write_date, create_uid and write_uid.
  • pair (5.68, many2many between two of your own models): "pair": "manual.model_ids" on model.manual_ids, and "pair": "model.manual_ids" back on manual.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 one relation table. 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).
  • chatter gives the model messages, followers and activities. tracking posts a field's changes there.
  • formula reads the record's own fields by their manifest names; its dependencies are worked out for you. related reads through one of the model's many2one, and can go on from there: order.customer.email on 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": true to store it. aggregate totals 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" }
]
  • writers lists who may set the field on create or write: "@server", "@<a group you declare>" or a group xmlid. server_only: true is ["@server"]. computed_by names 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.run runs 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 to base.group_user and 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 passes x_company_id explicitly. 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_only and computed_by are refused at install (and by vzm 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 with V.orm.write, and the result field says computed_by that 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) and invite(id).
  • Only a member of a managers group, 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 with permissions.team.
  • Each role names exactly one of base.group_user / base.group_portal and 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.
  • remove takes away your app's groups only. The person keeps whatever else they do in the company.
  • A vApp that calls Vizum.team sets min_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. role is 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 in native.team.roles wins. Member rows report role the 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.
  • save changes for an existing member:
    • it takes a phone;
    • it reactivates a member your app manages;
    • it refuses to change the caller's own role.
  • 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: true it also appears on the phone launcher. Each app must be listed in permissions.team_apps, which an administrator approves. Nothing is ever taken away.
  • adopt_existing: "dedicated" (approved as permissions.team_adopt) runs on install and on every update. It takes over the accounts that already play your roles and hold nothing else: only base.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": true on a char, text or html field keeps one value per language, as translate=True does 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.fields a many2one that the app adds to that model, pointing at your model, and name it as the inverse. For example, res.partner.visit β†’ x_vzm_<app>_visit, listed by the visit's partner_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: the context.lang the 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_419 then es), 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 lang only 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. A with_context(lang=X) inside your method changes the language tr() answers in.
  • Locale names: the files, the manifest's i18n list and context.lang all use Odoo's own language codes: es, es_419, ar_001, pt_BR, sr@latin, sr@Cyrl. es-419, ES or a file i18n/es-419.json is refused.
  • Install-time rules (the instance, the Store's upload and vzm check apply 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 literal 50% done is 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" and Vizum.supports("server-i18n") tells the screen whether the host serves it (the Desktop, native and Solo hosts all do: the language of the call is context.lang, sent on the same orm.* bridge call in each). The module translator turns a module's i18n/*.po into these files (fuzzy and msgctxt entries 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):

KeyWhat 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>#helpits 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:appthe 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_419 then es). en_US is 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:native with 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 call update_field_translations on a metadata model.
  • vzm check warns, 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-i18n makes 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. source is positional-only, so V.tr("%(source)s", source=x) works.
  • A plain tr() stays method-side only. V.tr also works inside a method, where it equals tr (plus lang=).
  • A vApp that names V.tr (or a name bound to V, W = V; W.tr(...)) in a method, a hook or native.script.lib must declare min_platform 5.77 or later; the install gate and vzm 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 from exists, each of its rows is copied once into your model. vizum.app.adopt.map records 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 route points at the adopted route). Links to shared models such as res.partner keep their ids.
  • Every from must be listed in permissions.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.json writes the adopt map 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 / write overrides and your constraints stay 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. Seed records and 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.attachment on 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.
  • refs translates 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 an applied guard on the model is not reached by a read.
  • A link to ir.model or ir.model.fields whose target is a model (or a field) you adopted becomes a link to your own model (field x_<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.methods are installed as real methods of the Odoo class, so a button, a cron, another app's call: or plain Odoo code calls them like any method. Inside one, self and env[...] are *governed* records: your orm grants, 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 with super_(...). Overrides are listed in permissions.methods, which the administrator approves.
  • A private method _assert_manager is called as self.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_calls lists the private Odoo methods your code calls (sale.order._create_invoices) β€” never the platform's own vizum.* models, which the validator, the store and the runtime all refuse (vizum.app._store_checkout would buy with the saved card). permissions.params lists the ir.config_parameter keys it reads and writes. permissions.peer_data lists "<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); Decimal becomes a number (a float); bytes become UTF-8 text when decodable; sets and tuples become arrays; a top-level recordset becomes its list of ids. The whole result falls back to true, 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), bytes that 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 of data as 64 lowercase hex characters, in script hooks, jobs and native methods alike. data is a str (hashed as UTF-8), bytes, or, with encoding="base64", a standard base64 str of the bytes (what V.orm.read and V.files.get hand back for a binary field). It refuses with a UserError anything 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 no hashlib, 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.75 hashlib extra of a ported module (not metered; it goes once the apps that call it move to V.sha256). V.sha256 or a name bound to V (W = V) that reads .sha256 must declare min_platform 5.75 or later; hashlib.sha256 and any other base do not count. The install gate and vzm check (MIN_PLATFORM_TOO_LOW) enforce it. A hash is not a signature. V.sha256 is 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 and store_verified (see package-signing.md).
  • caller_app() (SDK 5.69) β€” in a method another app invoked through its call: grant from the bridge, the key of THAT app, verified by the platform (a marker JSON-RPC cannot forge); None for 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 or native.script.lib part that names caller_app must declare min_platform 5.69 or later, and both the install gate and vzm 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 own call: grant, your method sees that app, never the outer caller.
  • native.aliases: true makes each declared field name a real field of the ORM. Your code, the other apps, views, reports and tests keep saying driver_id, not x_driver_id.
  • native.records, native.rules, native.ui (views, actions, menus, QWeb reports with vetted expressions, the vzm_geo_map widget) and groups[].implies carry 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.adopt also 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 in permissions.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.py translates a module into these keys, and tools/module2vapp_port_tests.py ports 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" } ] }
    ] } }
  • seed upserts rows by a natural key on every install and update, in list order. A value {"$ref": "<model>:<key>"} points at a row seeded earlier. "update": false only creates what is missing, so a user's later edits survive your updates. Rows go inline, or in a .json file inside your bundle.
  • migrations run on the update that crosses to. A first install migrates nothing. The platform remembers the version it last reconciled, even when an update waited for approval.
  • rename_field keeps the column and its data under the new name. copy fills a new, empty column from an old one. hook runs one of your script hooks as the app's job user, with rec = {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 def and while are allowed. A function may not be nested in another (so there are no closures) and may not carry decorators. Recursion works.
  • native.script.lib is {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, while or lib sets min_platform: "5.62".

Tooling for your own models

  • vzm check manifest.json reports, field by field, what the platform will refuse in native (a one2many without its inverse, a default of the wrong type, tracking without chatter…) and the min_platform your declarations need. vzm pack runs it first.
  • vzm introspect --manifest manifest.json --out src/own-models.ts writes typed interfaces of your own models with the names the platform creates (x_vzm_<app>_<model>, x_<field>), computed fields marked readonly.
  • @vizum/vapp-types exports cmd (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, and ownModel(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 as Vizum.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.
  • auth is "portal" (signed-in users of groups) or "user" (internal users only).
  • Approval: declaring or editing a route needs approval, and an app with routes needs install_scope: "admin" and min_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, and api_vapp_source for the app's own key.
  • First-party only (they act on other apps): api_install and api_build (com.vizumapps.vappcreator); api_admin_app_list, api_set_app_access, api_set_app_native_menu, api_set_app_solo and api_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_state store_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 targets vizum.app (an action passes the caller's own keyword arguments to the method).

7. Before you publish

  • Set min_platform to 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).

authsincekindswho opens itcaps on the page
public5.55pageanyone on the internet, no sign-innone
portal5.53page, jsonsigned-in users of the route's groupsthe app's data grant, bounded by the visitor's rules
user5.53page, jsoninternal users (of groups, if given)the same
public5.68filewhoever holds a link the platform signed for one usernone: 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/pricing opens for anyone, signed in or not. The page sub-path and query string reach the app as Vizum.route.sub / Vizum.route.params.
  • With the website module installed, "Pricing" appears in the top menu of every website of the app's company (every website when the app has no company). Without website, no menu is created and the page still serves (Odoo's plain frontend layout). Installing website after the app was approved adds the menu entry only at the app's next reconcile (approving it again, or an update).
  • sitemap: true lists 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 (the sequence is 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 json route ("public json routes do not exist") β€” server data for anonymous callers is not part of this version;
  • groups on a public page;
  • website_menu or sitemap on a portal or user route;
  • website_menu.parent (entries go in the top menu), unknown keys, a text over 40 characters, a sequence outside 0–10000;
  • a hand-written permissions.public_routes is 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 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 403 that 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) with Access-Control-Allow-Credentials: true and Vary: 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), carry X-Content-Type-Options: nosniff and no cookie; a type the browser runs (HTML, SVG, scripts, XML) is a download with Content-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 json and file routes with that token. Each call runs the route's hook with V.device set; 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"] }
    ]
  }
}
keyonmeaning
auth: "device"page, json, filea paired device of THIS app opens it; nobody else
device.embedsdevice pagethird-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_mbdevice pageoffline media cache ceiling, 64–4096, default 1024
model, fieldsfilethe ONE model and the binary fields the route may stream
hookjson, filethe native.script.hooks entry that answers it
  • kind: "file" exists only with auth: "device" in 5.70.
  • A device file route reads as the job user, so it needs what an orm grant needs: the manifest must carry permissions.orm with an explicit rule on that model (the "*" read wildcard does not count: it is scoped to the viewing user, and this route runs as the job user) whose ops include read and whose fields (empty means all) cover the route's fields, and the platform's orm policy must let read through 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 without read refuses even beside a "*" rule. The rule's domain is 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 is x_vzm_…, granted like any other). Native fields the app owns on a SHARED model are readable through V.orm without 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 user vzm-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 check warns (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, sitemap or portal_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 changing embeds, model or fields, is a widening the administrator approves again.

Pairing (the platform does it, the app does not)

  1. 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.
  2. 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's claim, link and revoke first 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. link must be none (null or "") 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, rename and reload do not ask.
  3. 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 gets expired and 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.embeds lists are accepted (PERMISSION_DENIED otherwise), ids are validated (VALIDATION_FAILED), and the player is www.youtube-nocookie.com/embed/<id> with fixed safe parameters (playsinline, rel=0, enablejsapi=1 with origin= the page's own origin, autoplay, mute, loop, start) or player.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 a sandbox that 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() on pagehide.
  • 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.embed rejects with PERMISSION_DENIED and 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 targetOrigin the 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__) rejects NOT_FOUND and 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.update reloads the player.
  • Bridge ops: embedSetVolume, embedMute, embedUnmute, embedPlay, embedPause. Feature device-embed-control. Set min_platform 5.82, or detect it with Vizum.supports("device-embed-control"). (vzm check cannot see a method on a handle: it checks Vizum.device.store and 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 }
Keys1 to 64 characters of a-z A-Z 0-9 _ . : -
ValuesJSON, at most 4096 bytes of compact JSON (UTF-8 bytes, not characters)
Countat most 64 keys per device (256 KB), QUOTA_EXCEEDED past a limit; overwriting a key or reusing a freed slot always works
Scopethe paired device, and so its app; another device, or another app's device with the same key, reads nothing
Reached byPOST /vizum/device/<slug>/_store with the device's own token: `{op: getsetdeleteall, key, value}`. A body cannot name another device
Lifetimeuntil the device is revoked (its keys go with it); an administrator can read the rows, nobody edits them by hand
Offlinea 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 that Service-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:

  1. 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) with display: "fullscreen", so the installed app opens full screen, with no tap, every time.
  2. 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.
  3. 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 } }
keydefaultplatform ceiling
max_mb64200 (512 from 5.71, see Large files)
count505000
total_mb12816384
  • 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) (a Blob or File): the host sends the bytes to /vizum/apps/files/<key>/put with the user's session β€” no base64 from the browser, no JSON. The server reads the body into memory (up to max_mb, at most one streamed upload per user at a time: a second one answers 429 until the first finishes), so keep max_mb as low as the app needs. Vizum.files.put(name, base64) keeps working for small files; above 16 MB use putBlob. 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-Length checked, 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 as putBlob), raw β€” no base64 in the browser, no JSON-RPC body limit.
  • The write is api_orm's, as the user: it passes exactly what Vizum.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 use Vizum.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 approved max_mb before 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's max_mb but within the ceiling is the route's JSON 400. write_blob checks 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 passes api_orm with 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 from Content-Length, so a retry never writes an empty field.
  • The field must be a binary or image field; the device file route 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. Without proxy_mode, every request comes from the proxy and shares one bucket.
  • Streamed uploads hold memory. files/put and write_blob hold 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 moduleIn the vApp
class X(models.Model) with _namenative.models[] (x_vzm_<app>_<name>)
fields added to an Odoo model (_inherit)native.fields[], columns x_vzm_<app>_<field>
field nameskept: native.aliases: true makes them real (related) fields
model methods, overrides with super()native.methods[], overrides call super_()
_private() helpersp_private() (underscore names stay forbidden in the sandbox)
@api.depends computes, defaults, @api.constrains, SQL CHECKcomputes, defaults and constraints by method
ir.model.access.csv, ir.rule, res.groupsmodel access, rules, native.groups (with implies)
XML data, sequencesnative.records (each adopts the module's xmlid)
ir.cronjobs with "method": "<model>.<name>"
views, actions, menus, QWeb reportsnative.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 portedrequires {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.poIn 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_xone 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 and code: strings (they serve tr(), V.tr() and Vizum._t too).
  • When the same English text has two different translations, and the manifest's native.models / native.fields can 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: #, fuzzy entries, empty msgstr, 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.orm grants, by Odoo's access rights and by record rules. sudo() lifts the access rights and rules, never the grants.
  • Grant write only 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_parameter keys, in permissions.params;
    • another app's data rows, read-only, in permissions.peer_data.
  • 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:

CheckCodeLevel
The manifest against the platform's JSON schema (every key, every type)SCHEMAerror
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 needsNATIVEerror
A Vizum.* member the platform does not have (a typo, an invented API, a mock left in)UNKNOWN_APIerror (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_UNDECLAREDwarning
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_URLwarning
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 windowPASSWORD_INPUT_IN_BUNDLEwarning
V.identity in server code with min_platform below 5.79MIN_PLATFORM_TOO_LOWerror
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 handOFFLINE_INVALIDerror
offline with min_platform below 5.80MIN_PLATFORM_TOO_LOWerror
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_GRANTEDerror
V.is_service_user in server code, or rec["su"] in a trigger hook, with min_platform below 5.81MIN_PLATFORM_TOO_LOWerror
V.vector.shared_namespaces in server code with min_platform below 5.83MIN_PLATFORM_TOO_LOWerror
A unique that names x_company_id (a company_scoped model), or jobs beside permissions.data: "user", with min_platform below 5.81NATIVEerror
Vizum.device.store (or another member the contract dates 5.82) with min_platform below 5.82MIN_PLATFORM_TOO_LOWerror
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_LOWerror
Vizum.ui.fullscreen while permissions.ui does not list "fullscreen" (the bridge drops the call)PERMISSION_MISSINGwarning
An i18n/<name>.json not named after an Odoo language code (es, es_419; not es-419)I18N_LOCALEerror
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 ruleINVALID_ROUTEerror
A member newer than min_platformMIN_PLATFORM_TOO_LOWerror β€” 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_LOWerror
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 applyI18N_JSON / I18N_VALUE / I18N_PLACEHOLDERS / I18N_MARKUP / I18N_SIZEerror
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 EnglishI18N_CATALOGUE_MISSINGerror
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_XMLwarning (error with --strict-i18n)
min_platform above every platform the SDK knowsMIN_PLATFORM_UNKNOWNerror
A deprecated / removed / experimental memberDEPRECATED_API / REMOVED_API / EXPERIMENTAL_APIwarning / 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_LIMITEDwarning
A permission that does not existUNKNOWN_PERMISSIONerror
A grant that needs another (ai_image needs ai)PERMISSION_REQUIRESerror
A grant the platform builds itself (tables, actions…) written by handDERIVED_PERMISSIONwarning
A namespace the code calls without the grant it needsPERMISSION_MISSINGwarning
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_REFUSEDerror
A privileged grant no scanned code usesPERMISSION_UNUSEDinfo
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 runsUNDEFINED_NAMEerror (the Store refuses a new upload with the same finding)
A requires entry the platform cannot read (bad range, unknown keys)INVALID_REQUIRESerror
An event_contracts entry outside the platform's rules (version 1–999, its schema keywords)INVALID_EVENT_CONTRACTerror
An event the code emits that has no contractEVENT_UNDOCUMENTEDinfo
An AI tool contract outside the server's rules (risk, side_effects, confirm)INVALID_AI_TOOL_CONTRACTerror

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 .poIn 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 nameableone plain msgid (the majority) and an AMBIGUOUS line in the report
#, fuzzy, empty msgstr, executable markup, differing placeholders, equal to the sourceskipped, 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:

flageffect
--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:

levelmeansexamples
SAFEnothing stored changes meaning, nobody loses an abilitya model, field, table or column added; a selection value added; a label, help, default or index changed; a declared rename_field
WARNINGdata is kept, behaviour changesa 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
BREAKINGthe installed schema no longer matches, or the update failsa 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
DESTRUCTIVEstored data is deleted on every instancethe 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)
SDKa hand-written stand-inthe real vizum-sdk.js, on the real bridge protocol
Permissionsnonethe platform's own bridge gate, with the caps the platform derives from your manifest
ORMsucceedsapplies the server's per-model, per-operation rule (permissions.orm, the "*" read grant, the app's own native models)
Errorsnonethe platform's error results: VizumError with code, category, retryable, correlationId
Failuresnoneinject any error code, go offline, add latency
Unknown callsucceedsfails "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:

CallDoes
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, .notificationsthe 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).

StatusMeans
receivedqueued for the app
processedthe app acknowledged it
failedthe app reported a failure; it is queued again
dead_letterit failed max_attempts times; kept 30 days from when it was received, for an administrator to inspect and retry
rejectedrefused 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:

BudgetCountsWindow
ai_token_capAI tokens, all users of the app togetherrolling 30 days
net_requests_per_dayVizum.net.fetch callsUTC day
storage_mb_capmegabytes 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 to max more times (1–5). It waits backoff_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 same retryable an app sees in a VizumError: RATE_LIMITED, CONCURRENT_UPDATE, a database serialization conflict, a dropped connection. A validation, access or business error β€” or QUOTA_EXCEEDED β€” fails at once: it would fail the same way again. Declare retry only for an action that is safe to run twice (see idempotency in platform-contract.md); only you know.
  • timeout_minutes (1–1440) β€” the run's deadline. A batch action reporting Vizum.backend.progress() gets cancel_requested: true once 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_approval and queued tasks together may not pass 500 per app nor 100 per user of that app. Vizum.backend.enqueue() beyond either limit rejects with QUOTA_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_approval after 7 days becomes cancelled with last_error: "approval expired". It is cancelled, never deleted, so the audit trail keeps it; enqueue it again if it is still wanted. A queued task (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 check jobs_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:

statusstateMeans
retryingqueueda run failed with a retryable error and the next attempt waits its backoff
expiredfailedthe run passed its timeout_minutes deadline
dead_letterfailedthe action declared retry and every attempt failed
(the same word)any otherwaiting_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

ChangeRefetches
A record of a watched model is created, written or deleted β€” by anyoneits queries (keyFor(model))
A write outside a watch's domains entrynothing
A data: global collection changesits queries (collectionKey(name))
A per-user collection changesnothing β€” only this user writes it, through this app: invalidate after your own write
The desktop tab comes back into viewthe 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 })

FieldRule
actionRequired. 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".
reasonOptional plain text, at most 200 characters. Bidirectional and control characters are removed.
meaningOptional 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):

CodeWhen
USER_CANCELLEDThe user closed the dialog, or the desktop locked.
RATE_LIMITEDThe user is locked out (details.retryAfter seconds), or the app asked too often (see below).
PERMISSION_DENIEDA portal or public user, an app the user cannot run, a device page, or the app is not the active window.
UNSUPPORTED_CALLVizum Solo (see below).
VALIDATION_FAILEDA 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 consume and 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

ThreatDefence
A fake password box inside an app windowThe 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 itselfThe 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 token256 random bits, only the sha256 stored, single use, 120 s, bound to the user, the app, the action and the session.
Brute forceThe 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 passwordThe 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

HostResult
Desktop (Vizum window manager)Served.
A vApp opened as an Odoo windowServed.
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 pagePERMISSION_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

PartWhat it isDeclared in
Datasetsnamed, approved slices of the user's own Odoo data that the device keeps and Vizum.offline.query reads with no networkoffline.datasets
Queued writescreates and writes the app may record with no network, replayed when the network is backoffline.writes
Actionsapproved 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 read for 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 fields stay inside the grant's field list. Write fields stay inside its write ceiling. A chatter_only grant has no field to write offline.
  • At most 12 datasets (names [a-z][a-z0-9_]{1,40}, unique), limit at most 20 000, max_mb at most 100 (default 20), at most 24 write entries, at most 40 conditions in a domain.
  • A dataset domain uses only these placeholders, each as a whole value: {uid}, {company_ids}, {today}, {today-N}, {today+N}. The server expands them. Any other {...} is refused.
  • expect belongs to a write entry with create. It names computed fields (a total, a price) the server compares at replay.
  • min_platform is 5.80 or later.
  • Every actions[].model is 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:

ChoiceEffect
keep_minethe same change is queued again over what the server holds now (a new base, a new expect)
keep_serverthe change is dropped; the server's value stays
discardthe 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:

ChoiceEffect on a deleted issue
keep_minerecreate: 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, discardthe 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 changedWhat the replay does
the record was deleteda deleted issue (above)
the user lost the model's access, or a record rule now forbids the createACCESS_DENIED, a rejected issue; nothing is created
the user lost access to the company the op was queued inACCESS_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 approvalthe 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 changedthe 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 changedthe 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 devicetheir 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 raises OFFLINE_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: true until 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

LimitValue
datasets per app12
rows per datasetlimit, at most 20 000
space per app and usermax_mb, default 20, at most 100 (rows, queue and issues together)
one queued op1 MB (shrink photos to about 100 KB before queuing)
records per queued write1 (a write of N ids queues N ops)
age before a copy is hidden7 days
idempotency memory on the server8 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

CodeWhen
PERMISSION_DENIEDthe call is not in the approved offline declaration (model, op, field, expect)
VALIDATION_FAILEDa field outside the dataset, an operator the device cannot evaluate, an op over 1 MB, a malformed call
NOT_FOUNDa dataset that is not declared; a conflict that is already resolved
OFFLINEsync with no connection
QUOTA_EXCEEDEDthe 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 asContentResult
photo.pnga PNGstored as image/png
photo.pnga WebP or JPEGstored, typed by its content (image/webp) β€” it still displays
photo.jpga HEIC (an iPhone photo)stored, typed image/heic
photo.pngHTML, SVG, a PDF, or nothing recognisablerefused, 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.stlanything elsestored 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.

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_in is 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.
  • revokePublic still 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. begin also 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&n takes 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 from Content-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 after Retry-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?token checks size and sha256 and stores the file; abort?token drops 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?token checks 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 of put (the bytes are what the name says) runs on the first 16 KB.
  • abort?token drops 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 appconnect-src, img-src, media-src
declares no permissions.netthe platform itself ('self'), data: and blob: only
declares hoststhose 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 audioalso 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):

RuleWhat happens
AddressesOnly 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).
URLshttp and https only, no credentials in the URL, no IP literals in any notation (a dotted quad, 134744072, 0x08080808, [::1]).
Ports80 and 443 only.
MethodsGET and HEAD only; no multipart upload.
HeadersOnly 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.
SecretsA $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 webhooksA 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:

  1. Checks the URL: http/https, no credentials, the host is in permissions.net (or "*").
  2. Resolves the name and connects to a validated public IP (always public-only; image mode ignores net_allow_private).
  3. Follows at most 3 redirects; each target goes through steps 1 and 2 again. A redirect from https to http is refused.
  4. 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.
  5. 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" }
]
FieldValuesMeans
riskread Β· create (default) Β· update Β· destructiveWhat the tool does to existing content
side_effectstext, ≀ 300 charactersOne sentence the user reads before approving. Required for update and destructive
confirmbooleanValidated only: update and destructive tools are always confirmed, and confirm: false is refused for them. It changes nothing for read / create tools
idempotentbooleanRunning it twice with the same values does no more than running it once

What the platform does with it

  1. Install. The contract is validated (vapp_validation._valid_ai_tool_contract), and an invalid one refuses the install. A tool with model (the platform creates the record for it) is always create.
  2. The model is told. The Assistant's prompt lists each update / destructive tool with its side_effects and an instruction to use it only when the user clearly asked for that change. Idempotent tools are marked as safe to retry.
  3. The user confirms. When the Assistant calls an update or destructive tool, the host asks the user before your app is opened, showing side_effects. If the user declines, the call rejects with USER_CANCELLED and Vizum.ai.tools.handle never fires. The check is in the host, on every way one app opens another with a tool call β€” Vizum.apps.open and Vizum.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 server api_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 risk is create, 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 check therefore reports MIN_PLATFORM_TOO_LOW when a manifest declares a contract with min_platform below 5.67: raise min_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

Pattern, not the shipped app. This example uses the no-build SDK to show the pattern. The Top Customers app on the Vizum Store has since been rebuilt on sdk-next, so its source differs from what is shown here.

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);
});
Security habit shown here Record values are untrusted input. Build DOM with 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

Pattern, not the shipped app. This example uses the no-build SDK to show the pattern. The Rent Roll app on the Vizum Store has since been rebuilt on sdk-next, so its source differs from what is shown here.

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
}
Why native here Units and leases are first-class Odoo records, not blobs locked inside the vapp β€” they're shared across the whole team (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

Pattern, not the shipped app. This example uses the no-build SDK to show the pattern. The Work Orders app on the Vizum Store has since been rebuilt on sdk-next, so its source differs from what is shown here.

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.

one native row create_uid = owner x_visibility = private|shared|public x_shared_with = [user ids] read domain: owner=me OR public OR me ∈ shared_with (the app scopes what it shows; rights fall out per row) Owner (create_uid) _can_edit βœ“ Β· _can_delete βœ“ In shared_with sees it Β· _can_edit βœ“ Β· delete βœ— Everyone else sees it only if public Β· read-only
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 });
}
Per-row sharing on native columns

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.

Privileged tier β€” install-time approval & a one-time restart

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.

channel("taskboard") ephemeral pub/sub room Client A orm.write β†’ DB CH.publish("changed") Client B on("changed") β†’ reload() Client C on("changed") β†’ reload() The ping carries no data β€” it just says "go look again". The native model is the source of truth.

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");
  }
}
Native + realtime, together

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
WhatWhereRead
An optional dependency on a contract (customers:v1)manifest.json requirescomposition.md
Events with a declared, versioned shape; an emit that breaks it is refusedevent_contracts, createOrderevents.md
A collection every user shares (data: global), refreshed live in every open copyindex.htmllive-data.md
A concurrent change handled: a stale rev is read again and retried oncecancelOrderplatform-contract.md
A file stored with the checksum of its bytesattachReceiptfiles.md
An AI tool that changes content declares it; the host asks the user firstai_tools, registerAiToolsai-tools.md
Offline and other failures arrive as a VizumError with retryabletestsreliability.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 calltestscli.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 '.*'
ActionHow
InstallDock 🧩 β†’ β€œ+ Install .vapp” β†’ pick the file. Zero-risk apps are ready instantly.
UpdateSame 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.
UninstallApps 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.

The Vizum Store
The Vizum Store: featured hero, category rails, ratings β€” and your app's icon on it.
StepWhat happens
1 Β· Create the appOn the publisher backend (Vizum Store β†’ Apps): key, summary, category, screenshots, pricing (free / one-time per database / subscription).
2 Β· Upload a versionDrop the .vapp β€” version, minimum platform and the permission summary are extracted and validated from the manifest automatically.
3 Β· PublishThe 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 appsOne 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.

Embedded purchase
The embedded purchase sheet β€” checkout happens without leaving the desktop.

Ship it

Troubleshooting

SymptomCause β†’ 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 runtimeThe call isn't covered by the approved manifest. Widening needs a version bump + re-approval.
Reads return fewer fields than askedField filtering at work β€” only declared fields (+id) come back.
Unexpectedly empty search resultsA manifest domain, or Odoo record rules for that user, narrow the query.
Styles ignore dark modeUse the CSS variables from Β§07 instead of hardcoded colors.
Vizum.net.fetch rejectsThe 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 permissionSince 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 offlineThe server-side lease is 7 days without an authenticated call: pair it again. See Device routes.
Device calls answer 503The 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 EnglishSince 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 jobThe 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'sRead 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 refusedNot 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_MISMATCHThe 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 canCheck group_ops (5.78): the extra ops reach only users in the listed groups, and ops stay what everyone gets.
Vizum.vector.moveScope refusedAdministrator 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 reloadAn 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 appAn 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 Storestore_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 configuredunverified β€” or publisher_signed when your own signature still covers exactly these bytes
has no signature.jsonunsigned
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:

ValueInstalls
allow_unsigned (default β€” also when the parameter is absent)everything that is not tampered
require_signedstore_verified, publisher_signed, local
require_storestore_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) (an AccessError for anyone else). It returns [{key, name, trust_state, reason}] for the running apps that policy would 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 with set_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.json is left out. Written sha256:<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's id and version), 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\n or vizum.package.v1\nstore\n.
  • Revocation list β€” {"format": 1, "issued_at", "instance", "key_id", "revocations": [{app, version, reason, revoked_at}], "lifted": [{app, version}], "signature"}, signed over vizum.revocations.v1\n plus the canonical JSON of everything but signature. key_id is 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 publisher name is what you typed.
  • Tightening the policy does not re-check apps that are already installed.
Vizum Apps Platform Β· the same .vapp runs unmodified on Odoo 17, 18, 19 and 20 Β· support@vizumapps.com Β· Β© 2026 Vizum Apps