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.
01What is a .vapp?
One zip file. One window. The full Vizum desktop experience for free.
Drag, resize, snap-tiling, Spaces, tabs, Mission Control — your app inherits all of it.
A per-user (or shared) key-value store in the customer's own Odoo database.
Read/write exactly the models and fields your manifest declares — nothing else.
Live theme tokens: accent color and dark mode follow the user's desktop automatically.
How it works
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
| Limit | Value |
|---|---|
| Zipped size | 5 MB |
| Unpacked size | 20 MB |
| File count | 200 |
| Paths | relative only — no .., no leading / |
permissions.net and call Vizum.net.fetch() —
everything else is blocked. Libraries and assets still belong inside the zip.
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"] }
}
}
| Field | Required | Meaning |
|---|---|---|
schema | yes | Manifest schema version — always 1 today. |
id | yes | Reverse-DNS, lowercase. Your app's permanent identity — never change it between versions. |
name | yes | Display name (window title, launcher, store). |
version | yes | Semver x.y.z. Re-installing the same version is rejected — bump it. |
min_platform | no | Minimum platform version (default "1.0"). |
entry | yes | The HTML file loaded into your window. |
icon | yes | PNG path inside the zip. |
window.w / h | no | Initial window size in pixels. |
window.singleton | no | true (default): opening again focuses the existing window. |
permissions | no | The contract — see the next section. |
05Permissions & install tiers
Declare exactly what you need. Admins approve exactly what you declared. The server enforces it on every call.
| Key | What it grants |
|---|---|
data | "user" (default): a private key-value store per user.
"global": one store shared by all users of the database. |
orm | A list of rules, one per model: model, ops
(search · read · read_group · write · create · unlink · call:<method>),
optional fields (reads return only these + id) and an optional
domain ANDed into every search — the literal "uid" becomes the
current user id server-side. |
ui | Which desktop UI calls you may make: notify,
openRecord, openList, setBadge,
addPaletteCommand. (confirm is always available — it only
asks the user.) |
events | Event names you may emit / listen to on the desktop bus. |
apps | App ids you may open with Vizum.apps.open() (intents). Zero-risk: it only opens apps the user already installed. |
net | External hosts Vizum.net.fetch() may reach:
exact hostnames ("api.frankfurter.app") or wildcards
("*.openweathermap.org"). Proxied server-side — redirects are not
followed, internal/IP hosts are blocked, responses cap at 2 MB. |
media | ["camera"] and/or ["microphone"] —
delegates the device to your sandboxed iframe so the standard
getUserMedia() works. The browser still prompts the user. |
secrets | Named API keys/tokens the app may use
(max 16, snake_case). Values live encrypted in Odoo, are typed by an
admin into a host dialog, can never be read back by the app, and are injected
server-side into net.fetch via $secret:name. |
oauth | OAuth2 providers (max 8) the app may connect
through Vizum.oauth. Providers are configured once per database
by an admin; tokens are per-user, encrypted, refreshed automatically and
never visible to the app — requests are proxied with the token injected. |
The two install tiers
| Tier | Manifest asks for… | Who can install |
|---|---|---|
| ZERO-RISK | nothing beyond data: "user" |
Any desktop user, instantly. |
| PRIVILEGED | orm, 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. |
fields is also marketing.Write actions ask the user
Every mutating ORM call (create, write,
unlink, call: methods) pops a
host-rendered confirmation the sandboxed app cannot fake or skip:
“AppName wants to create 1 account.move record — Allow / Deny”.
Creates, updates and method calls ask once per app + operation + model and are
then remembered; deletions ask every time. Users reset an app's remembered
grants from Vizum Apps (↺ on the card). Denied calls reject with
Error("UserDenied") — handle it gracefully.
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:
| Hook | What it does |
|---|---|
--vizum-accent | CSS variable on :root — the user's accent color. |
.vizum-dark | Class toggled on <html> when the desktop is in dark mode. |
With vizum-glass.css you also get --vz-bg · --vz-ink ·
--vz-card · --vz-muted · --vz-border plus ready-made
.vz-card · .vz-btn · .vz-input classes:
.my-panel { background: var(--vz-card); color: var(--vz-ink); }
.my-cta { background: var(--vizum-accent); }
/* nothing else to do — light/dark/accent switches reach you instantly */
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);
});
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 '.*'
| Action | How |
|---|---|
| Install | Dock 🧩 → “+ Install .vapp” → pick the file. Zero-risk apps are ready instantly. |
| Update | Same id, higher semver, install again. Widened permissions wait for the admin; otherwise it swaps in immediately (reopen the window to load fresh files). |
| Uninstall | Apps manager → Uninstall (installer or admin). App windows close, KV data is removed. |
Developing comfortably
console.log from your app shows in the browser console.
Files are cached for an hour — close and reopen the window after updating.
Test permission failures on purpose: call something you did not declare and make
sure your UI degrades gracefully.
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.
| Step | What happens |
|---|---|
| 1 · Create the app | On the publisher backend (Vizum Store → Apps): key, summary, category, screenshots, pricing (free / one-time per database / subscription). |
| 2 · Upload a version | Drop the .vapp — version, minimum platform and the permission summary are extracted and validated from the manifest automatically. |
| 3 · Publish | The app appears in every connected desktop's Store window. Updates: upload the next version, users see an “Update” pill. |
| Paid apps | One purchase licenses the whole customer database. Licenses are ed25519-signed tokens verified offline by the platform; subscriptions get a 14-day offline grace. |
13Troubleshooting
| Symptom | Cause → fix |
|---|---|
| “manifest field 'x' is required” | Fill every required field from §04. |
| “illegal path in package” | Your zip contains ../ or absolute paths — zip from inside the app folder. |
| “Version x.y.z already installed” | Bump version. |
| App stuck “needs approval” | It declares orm/net/data:"global" — an administrator must approve it (dock 🧩 → Approve). |
PermissionDenied at runtime | The call isn't covered by the approved manifest. Widening needs a version bump + re-approval. |
| Reads return fewer fields than asked | Field filtering at work — only declared fields (+id) come back. |
| Unexpectedly empty search results | A manifest domain, or Odoo record rules for that user, narrow the query. |
| Styles ignore dark mode | Use the CSS variables from §07 instead of hardcoded colors. |
Vizum.net.fetch rejects | The host is missing from permissions.net (exact hostname or *.wildcard), or the app update widened it and awaits re-approval. |