Docs
/

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 · verifiedTier B · sandboxed
WhoLeumas, in this repoeveryone else
Declared inshared/apps/Plugin Studio, or a bundle you upload
Code runsin the API processin a sandboxed iframe in the browser
Actionsreal run(args, ctx) functionsnone — you call host capabilities over a bridge
Gets a Studio domainyes (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).

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

ReturnsNotes
ready()Promise<ctx>resolves on the host handshake. Await before anything else.
.appId .theme .session .capabilitiesvaluetheme 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?)rowsquery is exact-match on fields, e.g. { done: false }
db.get(c, id)row \nullnull also means "another tenant's row" — deliberately indistinguishable
db.create(c, payload)the row, with idan id you supply is honoured
db.update(c, id, changes)the row \nullnull = 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 fnreports 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.

  • query is exact match on top-level fields. No ranges, no operators, no text search.
  • Every row carries id, createdAt, updatedAt and owner alongside 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.

PermissionYou getCall it with
db:ownyour own namespaced collectionsleumas.db.*
db:read:<collection>one named collection, read-onlyleumas.call('db.read', …)
storagea prefixed file arealeumas.call('storage.put', …)
llma metered model callleumas.call('llm.complete', …)
adapters:<system>.<fn>exactly that adapter functionleumas.call('adapters.run', {id, args})
network:<host>outbound fetch to that host onlyleumas.call('net.fetch', …)
jobs / triggersenqueue work / fire a triggerleumas.call('jobs.enqueue', …)
fs / devices / computeappliance-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. meterTargets should 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 pricing is a REQUEST. What is enforced is a passnode_rules row 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

ThingWhere
defineLeumasAppshared/packages/app-kit/src/define.js
The capability facadeshared/packages/app-kit/src/host.js
The bridge protocolshared/packages/plugin-bridge/src/protocol.js
The guest SDKshared/packages/plugin-bridge/src/guest.js
Packing + signingshared/services/plugin-host/src/{pack,signing}.js
The capability proxyshared/services/plugin-host/src/proxy.js
Store, review, installshared/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
Boilerplatefactory/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).

Source shared/services/knowledge/build-knowledge/build-a-plugin.md (no-git)markdownjson
Generated from the Leumas repository. Every page cites the file it came from.leumas.techllms.txt