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


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](#tier-b-in-ten-minutes)
and [The five things that will bite you](#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`:

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

```js
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`.

```js
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.
- `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.

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

```js
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.

```js
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:

```js
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?

```sh
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)

```sh
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).


---
Source: shared/services/knowledge/build-knowledge/build-a-plugin.md
Canonical: https://docs.leumas.tech/p/how-to/build-a-plugin
