How a module works
From clicking Launch to a result on screen — the sandbox, the bridge, and the round-trip.
- 1 declared
- 2 granted
- 3 scoped
- 4 REST call
New to web development? Start here. This page walks through exactly what happens from the moment a user clicks Launch to the moment your module shows a result — no prior knowledge of browsers, servers, or security models assumed. You should know basic programming words (function, file, JSON); everything else is explained as we go.
We'll keep one running example: your module reads a file the user picked. By the end you'll be able to trace that single action all the way across the platform and back.
For the precise message list and method signatures, see capability-api.md. For the big-picture design, see architecture.md. For diagrams of everything below, see architecture-visual.md.
The cast of characters #
Before the story, meet the four things involved. Keep these names in mind — they recur throughout.
| Name | Plain-English meaning |
|---|---|
| Your module | Three things you ship: a manifesthanabi.module.json — the file that names your module and declares everything it needs. (hanabi.module.json — a settings file that describes your module), a UI (ui/index.html — the web page the user sees and clicks), and an optional worker (a small Python program for heavy work). |
| The UI | Your ui/index.html and its JavaScript. It's what the user looks at. It runs locked in a box (explained below) and can't touch anything outside that box on its own. |
| The platform (the "shell") | The HanabiMatsuri desktop itself — the trusted program that opens windows, holds the user's login, and owns all their files. Your module is a guest inside it. |
| The bridge | A trusted piece of the platform (module-bridge.ts) that sits between your locked UI and everything valuable. Your UI asks the bridge for things; the bridge decides whether to allow them and does the actual work. Think of it as the receptionist at a secure building: you don't wander the halls, you make requests at the desk. |
Step 1 — You click Launch #
The user opens your module (from the Module Store, the desktop, or "Open with" on a file). The platform looks at your manifesthanabi.module.json — the file that names your module and declares everything it needs. to learn how big the window should be, what permissions you asked for, and where your UI lives. Then it opens a window.
But it doesn't just show your page. It puts your page inside a special, locked container called a sandboxed iframe. The next step explains what that is and why it matters — it's the single most important idea on this page.
Step 2 — The platform opens a locked iframe (and why) #
An iframe is just "a web page embedded inside another web page" — a frame on the platform's screen that displays your ui/index.html. Normally an embedded page can be somewhat chummy with the page that hosts it. The platform deliberately does the opposite: it makes the iframe a sandboxThe locked-down frame your UI runs in — no cookies, no desktop, no network unless granted..
In code (the real attribute, from ModuleHost.svelte) it looks like:
<iframe sandbox="allow-scripts allow-downloads" src="/runtime/modules/<your-id>/app/ui/index.html">
Two parts matter:
- `sandbox="allow-scripts allow-downloads"` — your JavaScript may run, and the user may download files you produce. Nothing else is allowed by default.
- *What's deliberately missing: `allow-same-origin`. Leaving this out gives the iframe an opaque origin. "Origin" is the browser's word for "which website this is" (roughly: the domain). An opaque origin means "a website that belongs to nobody"* — a unique, throwaway identity that matches no other page.
What an opaque-origin sandbox means for your UI, concretely:
- It cannot read the platform's login cookie. (A cookie is the little token that proves "this browser is signed in as Alice." Your UI never gets to see it.)
- It cannot read the platform's data (
localStorage, the surrounding page's contents, etc.). - It cannot reach the platform's server directly. Even a normal "fetch this URL" call from your code is blocked from touching platform endpoints.
- It cannot read or poke the platform's actual web page (the desktop around your window).
Why go to all this trouble? Because the platform runs modules from outside developers — code it didn't write and can't fully trust. If a buggy or malicious module could read the login cookie or call the server freely, it could impersonate the user, steal other people's files, or wreck the workspace. The sandbox makes that structurally impossible: your UI starts with zero power. Anything it can do, it does by politely asking the bridge — which checks every request. (The design calls this the "security spine"; see `architecture.md` §5.2 and ../security-model.md.)
Your assets are served from /runtime/modules/<your-id>/app/<asset> (so ui/index.html, ui/styles.css, and so on). First-party modules are served publicly; drafts and third-party packages are login-protected. Either way they're read-only and carry a strict Content-Security-Policy — a browser rule that further limits what the page may load and connect to. (More on serving in `architecture.md` §5.3.)
Step 3 — Your UI loads and says "ready" #
Your page loads inside the box. The very first thing a well-behaved module does is tell the platform it's alive and ask who am I? — what permissions did I get, which folders are mine, was I opened with a file? It does this by sending the HANABI_READY message (covered next), and the bridge replies with your module's context.
With the SDK (@hanabi/module-sdk) that's one line:
import { createHanabiClient } from '@hanabi/module-sdk'; const hanabi = createHanabiClient(); const ctx = await hanabi.getContext(); // { id, name, permissions, folders, openedFile, ... }
If you'd rather not bundle anything, every sample module ships the same ~20-line client inline — copy it from examples/modules/capability-demo/ui/index.html. Either way, the protocol underneath is identical, and that protocol is the heart of the next step.
Step 4 — How your UI talks to the platform: postMessage #
Your UI can't call the server, but the browser gives it exactly one way to talk to the page that hosts it: a built-in function called `postMessage`. It does one thing — hand a chunk of data to another window. That's the single wire in and out of the box.
So the whole conversation is just two windows passing notes:
- Your UI → the platform:
parent.postMessage(request, '*')— "here is what I want." (parentis the platform's window; your UI is the child.) - The platform → your UI: the bridge sends a note back the same way, and your UI listens for it with
window.addEventListener('message', ...).
Every note follows the same shape, called the envelopeThe JSON shape every bridge message uses: { v, type, rid, …payload }.:
{ "v": 1, "type": "HANABI_READ_FILE", "rid": "r7", "fileId": "abc123" }
- `v` — the protocol version (currently
1). Lets the platform evolve safely. - `type` — what you want. Always starts with
HANABI_. (HANABI_READ_FILE,HANABI_PICK_FILE,HANABI_WRITE_FILE,HANABI_NOTIFY, …) The full list is incapability-api.md. - `rid` — a request id: a short label you invent for this one request (
"r7","r8", …). This is the trick that lets you have several requests in flight at once. The platform copies the sameridonto its reply, so when a note comes back you know which question it answers. (Like a coat-check ticket: you get number 7, and number 7 is what you hand over to get your coat back.) - everything else is the payload — the details for this request (here, which file:
fileId).
The reply uses the same envelope plus a verdict:
// success { "v": 1, "type": "HANABI_READ_FILE_RESULT", "rid": "r7", "ok": true, "data": { "id": "abc123", "name": "sales.csv", "bytesB64": "..." } } // failure { "v": 1, "type": "HANABI_READ_FILE_RESULT", "rid": "r7", "ok": false, "error": { "code": "HANABI-F002", "message": "File outside module scope" } }
- the type gains a `_RESULT` suffix,
- `ok` is
trueorfalse, - on success the answer is in `data`; on failure it's in `error`, which carries a stable `code` (like
HANABI-F002) you can look up or branch on — seeerror-codes.md.
.** postMessage's second argument normally says "only deliver to this exact origin," as a safety check. But your iframe's origin is *opaque* (Step 2) — it belongs to nobody, so no real origin string can name it. The bridge therefore posts replies to ''` ("any origin"), but it always sends them to only your iframe's specific window object, never broadcasting. So the box stays sealed; `'' is a consequence of the strong isolation, not a hole in it. (See the comment in [module-bridge.ts](../../apps/web/src/lib/module-bridge.ts) around the postTarget` constant.)Step 5 — What the bridge does with your request (the three checks) #
When your note arrives, the bridge doesn't just do what you asked. First it verifies the note even came from your window: it checks event.source === iframe.contentWindow and ignores anything else — so another page can't impersonate your module.
Then, before doing any real work, it applies three checks. A request only succeeds if all three pass. This is the rule that keeps modules honest:
- Declared — Did your manifest ask for this? To read files, your
hanabi.module.jsonmust list thefiles.readpermission. A capability you didn't declare simply doesn't exist for you. (Miss this →HANABI-P001.) - Granted — Was it actually approved? Declaring isn't the same as receiving. First-party modules are auto-granted; third-party modules get baseline permissions on install, while bigger ones need user consent or admin review. (Miss this →
HANABI-P002/HANABI-P003.) - ScopedClamped to your own Modules/<Name>/ folders for the current user — never another module or user. — Are you staying in your own yard? File operations are clamped to your module's own folders for this user. You can't name a file id or path that belongs to another module or another person. (Cross this line →
HANABI-F002.)
Think of the three checks as: did you ask for the key (declared) → were you given the key (granted) → does the key only open your own door (scoped)?
Only after all three pass does the bridge — which does hold the user's real, logged-in session — call the platform's normal server (a FastAPI backend) on the user's behalf, get the answer, and post it back to you. Your UI never sees raw storage keys, the cookie, or anyone else's data; it only sees the clean result. (The permission tiers — baseline / consent / admin / admin-HIGH — are laid out in capability-api.md.)
Step 6 — Follow one call end to end: readFile #
Let's trace the running example all the way through. Your UI wants the bytes of a file the user picked. In your code you write one line:
const file = await hanabi.readFile('abc123'); // abc123 = the file's id console.log(file.name, file.bytesB64); // -> "sales.csv", "PHN2Zy..."
Here is everything that happens between calling readFile and that console.log running:
- Your UI builds an envelope. The SDK mints a fresh request id (say
r7) and callsparent.postMessage({ v:1, type:"HANABI_READ_FILE", rid:"r7", fileId:"abc123" }, '*'). Then it waits — it parks a promise underr7and keeps listening. - The note crosses the boundary. It leaves the locked iframe and lands in the platform's window — the one place your UI is allowed to reach.
- The bridge receives it. It confirms the message came from your iframe (
event.sourcecheck), then readstype: "HANABI_READ_FILE". - Three checks run. Declared? (manifest lists
files.read— yes). Granted? (the install granted it — yes). Scoped? (doesabc123live in your folders for this user — yes). Any "no" here turns into anok:falsereply with aHANABI-*code, and we'd skip to step 7. - The bridge does the real work. Using the user's authenticated session, it asks the platform's backend for that file's bytes and metadata. (This is the part your UI is not allowed to do itself — which is the whole point.)
- The bridge replies. It posts back to your iframe's window:
{ v:1, type:"HANABI_READ_FILE_RESULT", rid:"r7", ok:true, data:{ id, name:"sales.csv", mime_type, size_bytes, bytesB64 } }. - Your UI matches the reply to the request. Its message listener sees
rid:"r7", finds the parked promise, and resolves it withdata. Yourawaitunblocks, andconsole.logruns.
That round trip — envelope out → boundary → bridge → three checks → backend → reply in → matched by `rid` — is the same for every capability. pickFile, writeFile, notify, getSetting: only the type and the payload change. Learn it once and you understand the entire API. (bytesB64 is the file content encoded as Base64 — a way to carry raw bytes as plain text through a JSON message. For large or seekable media, use openMedia instead, which hands back a streaming URL so the bytes don't travel through the bridge at all — see capability-api.md.)
Step 7 — The optional worker (when a module needs real horsepower) #
Reading and writing files is light work the bridge handles directly. But some modules need heavy or native work: converting images, building spreadsheets, transcoding video. The browser sandbox can't (and shouldn't) do that. So a module can ship an optional workerAn optional Python program that does heavy or native work off the browser, in a sealed container. — a small Python program — and ask the platform to run it.
Crucially, the worker is even more isolated than your UI. When your UI calls createJob(...), here's what the platform does:
- It gathers the job's input files (from your module's folders) as plain bytes.
- It writes them into a hardened, no-network container — a throwaway, sealed environment. The container has no network (
--network none), no access to the database, no session, no other users' files, nothing but your inputs. - Inside, it runs your worker's one function. The simple contract is
run(inputs, options)—inputsis{ filename: bytes }, and youreturnyour output files the same way. (For multi-gigabyte files there's a path-basedrun_streaming(in_dir, out_dir, options)variant.) Seeworker-guide.md. - It collects whatever files your worker returned and saves them into your module's Exports folder, ready for the user.
Because the worker is "bytes in → bytes out" and can touch nothing else, the platform can safely run code it didn't write. For long jobs, the worker can `yield` progress, which streams back to your UI as live progress events (NDJSON — one small JSON update per line) so you can show a progress bar. Running a third-party worker at all is a privileged, admin-approved capability, and the container sandbox ships off by default — full reasoning in worker-sandbox-design.md.
Step 8 — Output, and the user sees a result #
The bridge's replies feed your UI (so you render the data, show a preview, update the screen); any worker outputs land in your Exports folder for the user to open or download. From the user's point of view they clicked a button and got a result — all the locking, checking, and message-passing happened invisibly underneath.
That's the full journey: Launch → locked iframe → UI loads → `postMessage` → bridge runs the three checks → backend (or sealed worker) → reply → result.
And if it fails… #
Things go wrong — a file id is stale, a permission wasn't granted, a worker hits its memory limit. The platform is built so failures are legible, not mysterious:
- Every error has a stable code like
HANABI-F002(file outside scope),HANABI-P001(permission not declared), orHANABI-W004(worker timed out). The code never changes, so you can search it, branch onerror.codein your module, or look it up. The full catalogue with "what it means / how to fix it" is inerror-codes.md. - The developer debugger streams these codes live. While you build, the Developer Portal's debug console shows each request, reply, and error as it happens — so you can watch the exact
HANABI_*round trip from Step 6 and see precisely which of the three checks said no.
A failed reply is just the same envelope with ok: false and an error — so your "and if it fails" handling is one if away from your success handling:
try { const file = await hanabi.readFile(id); render(file); } catch (e) { // e.message came from error.message; the code (e.g. HANABI-F002) is in the debugger showProblem(e.message); }
Where to read next #
architecture-visual.md— these same ideas as labeled diagrams.examples.md— learn by example: the sample modules, easiest first, with a beginner path.capability-api.md— everyHANABI_*message and SDK method, and the permission tiers.architecture.md— the full platform design and the boundary your UI lives behind.worker-guide.md— the server half: therun(inputs, options)worker contract.error-codes.md— every error code, what it means, and how to fix it.