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