Building a Leumas plugin
The end-to-end authoring reference. app-store.md next door describes the STORE — the three layers, the review desk, the screenshot lanes. This describes how to write the thing that goes in it.
Building a Leumas plugin
The end-to-end authoring reference. app-store.md next door describes the STORE — the three layers, the review desk, the screenshot lanes. This describes how to write the thing that goes in it.
If you are an agent and you read one section, read Tier B in ten minutes and The five things that will bite you. The second one is the difference between working first try and an hour of confusing crashes.
Two tiers, and which one you are
| Tier A · verified | Tier B · sandboxed | |
|---|---|---|
| Who | Leumas, in this repo | everyone else |
| Declared in | shared/apps/ | Plugin Studio, or a bundle you upload |
| Code runs | in the API process | in a sandboxed iframe in the browser |
| Actions | real run(args, ctx) functions | none — you call host capabilities over a bridge |
| Gets a Studio domain | yes (compiled in, install-gated) | no — a mount under /admin/p/<appId>/ |
You are almost certainly Tier B. Tier A means your code is in the Leumas monorepo and ships in the build; if you are writing a plugin for the store, your code runs in a browser and never in the API.
That is not a limitation bolted on afterwards — it is the reason the permission system means anything. A third-party module executing inside leumas-api would sit beside the capability facade that is supposed to constrain it, and could simply reach around it.
Tier B in ten minutes
A plugin is a folder. Minimum viable:
my-plugin/
surface.js ← required. Your entry point.
style.css ← optional; root-level .css files are linked into the shell for you
surface.js:
// window.leumas is already there — the shell loads the SDK before your module runs.
// The entry is a <script type="module">, so top-level await is legal.
const root = document.getElementById('root'); // #root exists in the generated shell
const ctx = await window.leumas.ready(); // resolves once the host says welcome
root.textContent = `Hello from ${ctx.appId}`;
// Your own storage. NOT localStorage — see the gotchas.
await window.leumas.db.create('notes', { text: 'first note' });
// EVERY call resolves { ok, result } or { ok:false, error, status }. It never throws and never
// hangs — so `const { result } = await …` structurally cannot tell success from failure. Check `ok`.
const r = await window.leumas.db.list('notes');
if (!r.ok) window.leumas.notify(`Could not load: ${r.error}`, 'danger');
// Tell the host how tall you are, so the frame fits your content.
window.leumas.autoResize();
You do not write index.html. You do not write leumas.app.json. Both are generated at publish time — the shell from a fixed template (so the SDK, the theme sheet and the CSP are never optional), and the manifest from your store listing row. If you ship either file it is replaced.
That is deliberate and it is worth understanding: it means the permissions your plugin holds cannot be claimed by a file you control. They come from what you declared in the listing and what the operator agreed to at install.
Publish: Studio → Plugin Studio (/admin/plugin-studio) writes into your sandbox and packs it. Then My Listings (/admin/store/mine) → Submit → staff review → published.
The five things that will bite you
Your plugin runs in an iframe with sandbox="allow-scripts allow-forms" and no allow-same-origin, so its origin is opaque. Everything below follows from that one fact.
1. localStorage throws. It does not return null — it throws.
localStorage.setItem('k', 'v'); // ✗ SecurityError
await leumas.storage.set('k', 'v'); // ✓ backed by your own db:own collection
Same for sessionStorage, indexedDB and document.cookie. leumas.storage is per-tenant, survives reloads, and is included in the archive if somebody uninstalls you.
2. fetch to the Leumas API will fail.
You have no cookie and the CSP's connect-src confines you to your own bundle directory. Use leumas.call(...) / leumas.db.* — the parent makes the request with the operator's session and exactly the permissions they granted you.
You can fetch an external host, but only through leumas.call('net.fetch', …) and only if you declared network:<host> and it was granted.
3. You cannot navigate Studio, open popups, or use alert/confirm.
alert('hi'); // ✗ allow-modals is withheld — it would freeze the operator's tab
leumas.notify('hi'); // ✓ surfaces in Studio's own notice lane
window.location = '/admin/media'; // ✗ goes nowhere
leumas.navigate('/admin/p/my-plugin/settings'); // ✓ — but ONLY inside your own mount
window.open(url); // ✗
leumas.openUrl('https://…'); // ✓ https: only, opened with noopener
leumas.navigate to anything outside /admin/p/<your-appId>/ is silently ignored. That is the phishing guard, not a bug.
4. You have no user id and no email.
ctx.session is exactly four fields: tenantId, role, displayName, locale. Nothing else crosses the bridge — no token, no user id, no permission list. If you need per-user data, key it on your own storage; the host already scopes your collections to the tenant.
5. Your height is not automatic.
An iframe has no intrinsic height. Call leumas.autoResize() once and the SDK reports your scrollHeight whenever it changes. Without it you get a fixed 600px box.
Styling: the complete token vocabulary
/plugins/_runtime/theme.css is linked into your shell automatically, and the host sets body[data-theme] on you — so use these and you follow the operator's light/dark toggle for free.
This is the whole list. A token that is not here is not defined, and var(--not-a-token) fails silently and cosmetically: packing cannot catch it and it only shows up when somebody squints at a hover state. If you need something outside this set, hard-code it with a fallback — var(--color-danger, #e5484d).
| Colour | Spacing | Other |
|---|---|---|
--color-bg | --space-1 (4px) | --radius-sm (6px) |
--color-bg-secondary | --space-2 (8px) | --radius-md (10px) |
--color-text | --space-3 (12px) | --text-sm (0.875rem) |
--color-text-muted | --space-4 (16px) | --text-lg (1.125rem) |
--color-border-subtle | --space-5 (24px) | --font-family |
--color-accent | ||
--color-danger --color-success --color-warning |
The shell also gives you a body with sensible margins, box-sizing: border-box everywhere, and #root to render into.
The complete window.leumas API
Every call resolves { ok: true, result } or { ok: false, error, status }. It never throws and never hangs — a call with no answer resolves { ok: false, error: 'timeout' } after ~31s. Check ok.
| Returns | Notes | ||||
|---|---|---|---|---|---|
ready() | Promise<ctx> | resolves on the host handshake. Await before anything else. | |||
.appId .theme .session .capabilities | value | theme is 'light'\ | 'dark'; capabilities is what the host will accept | ||
call(name, args) | `{ok, result\ | error}` | the raw capability call; everything below is sugar | ||
db.list(c, query?) | rows | query is exact-match on fields, e.g. { done: false } | |||
db.get(c, id) | row \ | null | null also means "another tenant's row" — deliberately indistinguishable | ||
db.create(c, payload) | the row, with id | an id you supply is honoured | |||
db.update(c, id, changes) | the row \ | null | null = not yours / not there | ||
db.remove(c, id) | {id, removed} | ||||
storage.get/set/list/remove(key) | — | a _kv collection under the hood; needs db:own | |||
navigate(path) | — | inside /admin/p/<your-appId>/ only; silently ignored otherwise | |||
openUrl(url) | — | https: or a Leumas path; opened with noopener | |||
notify(text, tone) | — | tone: 'info' (default) \ | 'success' \ | 'warning' \ | 'danger' |
commands(items) | — | contribute to Studio's ⌘K palette while mounted | |||
reportError(err) | — | surfaces the crash in Studio, not just your console | |||
on(name, fn) | unsubscribe | 'theme' fires on the toggle; host events arrive by name | |||
autoResize() | stop fn | reports scrollHeight on change. Call it or you get 600px. |
The rows with — in Returns are fire-and-forget. navigate, openUrl, notify, commands and reportError post a message and return nothing — so a REFUSED one is indistinguishable from a delivered one. openUrl('http://…') (plain http) and navigate('/admin/media') are both silently dropped, and your code has no way to notice. Validate before you send.
db details the shape above does not carry
- Rows come back unordered. There is no sort parameter. If order matters, write your own
timestamp ({ at: Date.now() }) and sort client-side — which is what "save a history" needs.
queryis exact match on top-level fields. No ranges, no operators, no text search.- Every row carries
id,createdAt,updatedAtandowneralongside what you wrote. - Collections are per-tenant unless the listing declared
scope: 'global'. You cannot see, edit
or delete another tenant's rows, and the API reports them as absent rather than forbidden.
Declaring what you need
Permissions are declared on your listing, shown to the operator before they install, and stored as a receipt. Ask for the minimum: the review desk flags fs, devices, compute and any network: host, and a permission you cannot justify is the most common reason a submission comes back.
| Permission | You get | Call it with |
|---|---|---|
db:own | your own namespaced collections | leumas.db.* |
db:read:<collection> | one named collection, read-only | leumas.call('db.read', …) |
storage | a prefixed file area | leumas.call('storage.put', …) |
llm | a metered model call | leumas.call('llm.complete', …) |
adapters:<system>.<fn> | exactly that adapter function | leumas.call('adapters.run', {id, args}) |
network:<host> | outbound fetch to that host only | leumas.call('net.fetch', …) |
jobs / triggers | enqueue work / fire a trigger | leumas.call('jobs.enqueue', …) |
fs / devices / compute | appliance-only, and role-gated on top | — |
Declaring a collection
Three shapes are accepted and mean the same thing:
collections: ['notes'] // shorthand
collections: [{ name: 'notes' }] // scope defaults to 'tenant'
collections: [{ name: 'notes', scope: 'tenant' }] // explicit — prefer this
scope: 'tenant' (the default) is what makes your rows invisible to other customers on the same instance. 'global' opts out and must be written deliberately — it means every tenant shares one table. This applies to tier B exactly as it does to tier A: the host builds your ctx.db from the install record, and scoping is enforced there, not in your code.
A permission you were not granted is ABSENT, not refused. The call returns 404 capability_absent. That is intentional: it means an audit is a property check rather than a code review, and it means you cannot probe what an operator holds by reading the difference between 403 and 404.
Adding a permission in a new version does not grant it. The operator sees a "Review permissions" prompt naming exactly what you added, and until they accept, your new version runs with the old grant. Plan for the capability being missing.
Pricing
Declare it on the listing; it is enforced as a PassNode rule written when staff publish you.
pricing: { model: 'free' }
pricing: { model: 'metered', price: 8, unit: 'render', meterTargets: ['plugin:my-app.render'] }
pricing: { model: 'subscription', planKey: 'my-app' }
Two rules learned the expensive way:
- Meter the expensive act, not the page.
meterTargetsshould name the render, the scan, the
generation — never "opening the dashboard". Charging somebody for looking at a thing they are paying for is how a metered product gets uninstalled.
- The manifest's
pricingis a REQUEST. What is enforced is apassnode_rulesrow that only the
review desk creates, with the payee taken from the stored listing. You cannot price yourself, and you cannot nominate someone else to be paid.
Leumas takes 15% by default (DEFAULT_FEE_BPS), taken inside the same token transfer that pays you — never a second call. Earnings appear in My Listings; cash-out is Payouts.
Tier A: a first-party app
Only if your code is in this repo. shared/apps/domains.js for a domain-backed app, or a folder under shared/apps/ for a hand-written one:
export const myApp = defineLeumasApp({
id: 'my-app',
name: 'My App',
version: '1.0.0',
permissions: ['db:own'],
collections: [{ name: 'items', scope: 'tenant' }], // → table app_my_app_items
actions: {
doThing: {
description: 'Does the thing',
inputs: [{ name: 'input', type: 'string' }],
http: { method: 'POST', path: '/do-thing' },
run: async ({ input }, ctx) => ctx.db.create('items', { input }),
},
},
pricing: { model: 'free' },
catalog: { summary: '…', tags: ['…'] },
});
One declaration emits a store listing, a public tools row, an MCP server, Leviathan functioncalls, a REST router at /api/apps/my-app and a generated SDK.
scope: 'tenant' is load-bearing. It is what makes ctx.db filter by tenant; a global collection is shared across every customer on the instance, which is occasionally right and usually not.
Making a domain install-gated
A Studio domain is install-gateable if and only if it has a record in shared/apps/domains.js. That is the whole rule, and it lived only in a source comment until this document existed. Add a record and the domain disappears when uninstalled; leave it out (declare the domain inline in nav.manifest.js) and it is always present.
Core domains — hosting, accounts, apps, chatbots, ai, commerce — deliberately have no record. Uninstalling your way into a Studio with no app store and no way back is the failure that rule prevents.
Testing your plugin
Does it pack?
node --input-type=module -e "
import { packBundle } from './shared/services/plugin-host/src/index.js';
import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path';
const r = await packBundle({
root: '/absolute/path/to/my-plugin',
listing: { appId:'my-plugin', name:'My Plugin', version:'0.1.0', permissions:['db:own'], collections:[{name:'notes'}] },
outRoot: mkdtempSync(join(tmpdir(),'pack-')),
});
console.log(r.buildId, r.files.map(f=>f.path).join(','));
console.log('warnings:', r.warnings.join(' | ') || '(none)');
"
warnings: (none) means your files are PERMITTED, not that your plugin works. The packer never parses your JavaScript, never checks that the collections you write to are the ones you declared, and never notices an unreferenced asset. It warns about exactly four things: a skipped symlink, a disallowed extension, missing JSX compilation, and replacing index.html / leumas.app.json. Do not read a clean pack as validation.
Does it run?
Studio's Plugin Studio gives a live preview on the same compile path the real bundle uses, so what you see there is what publishes. There is no headless way to run a plugin today — it needs a browser, because the whole point is that it executes in a sandboxed frame.
The host's own guards (these test Leumas, not you)
pnpm build:plugin-runtime # emit /plugins/_runtime/{leumas-plugin.js,theme.css}
pnpm smoke:plugin-proxy # the capability boundary, packing and signing
pnpm smoke:plugin-bridge # the postMessage protocol
pnpm check:sandbox # the iframe attribute and the CSP
Reference
| Thing | Where |
|---|---|
defineLeumasApp | shared/packages/app-kit/src/define.js |
| The capability facade | shared/packages/app-kit/src/host.js |
| The bridge protocol | shared/packages/plugin-bridge/src/protocol.js |
| The guest SDK | shared/packages/plugin-bridge/src/guest.js |
| Packing + signing | shared/services/plugin-host/src/{pack,signing}.js |
| The capability proxy | shared/services/plugin-host/src/proxy.js |
| Store, review, install | shared/services/marketplace/src/ |
| Start here (tier B) | node factory/scaffolder/index.mjs --id my-plugin |
| Start here (tier A) | node factory/scaffolder/index.mjs --id my-app --tier a |
| Boilerplate | factory/boilerplates/leumas-plugin/ — tier A shape; it imports @leumas/app-kit and calls useApi(), neither of which exists in a sandboxed frame |
Related: app-store.md (the store itself), programmability.md (triggers, actions, chatbots), ops/todos/app-store-completion-roadmap.md (why each piece is shaped this way).