Vizum Apps Platform · Developer Guide

Build desktop apps for Odoo.
In an afternoon.

A .vapp is a tiny web app — plain ES6, HTML and CSS — 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. No Odoo module. No build step. No server deploy.

Plain ES6 — any editor Odoo 17 · 18 · 19 Sandboxed & permissioned Installs in seconds

01What 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.

02Quickstart — your first app in 5 minutes

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.

03Package 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.

04The 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": "1.0",
  "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_platformnoMinimum platform version (default "1.0").
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.

05Permissions & 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.
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.
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.
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.

The two install tiers

TierManifest asks for…Who can install
ZERO-RISKnothing beyond data: "user" Any desktop user, instantly.
PRIVILEGEDorm, net 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.

06The SDK — window.Vizum

Load two files, call connect(), and everything below is yours (within your approved permissions).

<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 newId = await Vizum.orm.create("crm.lead", { name: "From my vapp" });
await Vizum.orm.write("crm.lead", newId, { priority: "2" });
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.

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; }

Vizum.window — your own window

Vizum.window.setTitle("Pomodoro — 12:25 left");
Vizum.window.resize(480, 600);
Vizum.window.close();

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.

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);   // ≤5 MB/file
const f = await Vizum.files.get("export.png");    // {b64, mimetype, size}
const all = await Vizum.files.list();             // ≤50 files, ≤25 MB per app
await Vizum.files.del("export.png");

Backed by real attachments, scoped like your KV, never reachable through the public run URL. Stop smuggling base64 blobs through data.set.

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

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.

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.

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.

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.

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.

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.

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 Settings → Vizum Apps → 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 Settings → Vizum Apps → 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.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.

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.

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.

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)

/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));

07Theming — 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 */

08Worked example — Sticky Todo 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 every user gets an isolated store, two colleagues using Sticky Todo never see each other's tasks — without you writing a single line of access control.

09Worked example — Top Customers PRIVILEGED

A narrow, read-only window into res.partner with two UI capabilities. Ships as demo_apps/top_customers/.

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

10Worked 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)

11Packaging, 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).
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.

12Publish 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.
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.
Embedded purchase
The embedded purchase sheet — checkout happens without leaving the desktop.

13Troubleshooting

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.