Your first module
A short, hands-on walkthrough: scaffold, run, and see a result in the desktop.
hanabi.module.json requiredui/index.html requiredworker/main.py optionaltests/sample-inputs/ requiredREADME.md requiredBrand new to this? Perfect. By the end of this page you'll have built a real, working HanabiMatsuri module — one that picks a file and shows its text — and run it inside the platform. No prior module experience needed. Every line of code below is real and commented; nothing is invented.
You'll need: Node 18+ and this repo installed (npm install once, at the repo root). That's it. We'll write plain HTML — no build step, no framework.
Here's the whole journey:
- What you'll build (10-second tour)
- Scaffold it with one command
- Read the manifest — what each field means
- Write the UI — the small, real code, every line explained
- Try it — upload in the Developer Portal and watch it run
- What next
0. What you'll build #
A one-window module with a single button. Click it, and the module:
- lists the files in its own Imports folder,
- reads the first one,
- shows the text on screen.
That's it — but it exercises the real heart of the platform: a module's UI runs in a locked-down window and asks the platform to do things for it via small messages. You'll use three real messages: HANABI_READY, HANABI_PICK_FILE, and HANABI_READ_FILE. The only permission you need is the baseline files.read.
permissions-explained.md — but you can also just follow along and pick it up as you go. 1. Scaffold with module init #
From the repo root, run:
npm --workspace packages/module-cli run hanabi -- module init "Hello Reader" --dir .\hello-reader
This creates a starter package in .\hello-reader:
hello-reader/ hanabi.module.json # the manifest — what your module is and what it needs ui/index.html # your UI — already posts HANABI_READY on load worker/README.md # notes for an optional server half (ignore for now) tests/sample-inputs/sample.json # a sample file (the packager requires one) README.md
Useful flags if you need them: --id <slug> to set the id by hand, --force to overwrite an existing folder. To print just the manifest template any time:
npm --workspace packages/module-cli run hanabi -- module template
2. A tour of hanabi.module.json #
Open hello-reader/hanabi.module.json. The scaffold filled it with safe defaults. Here it is with every field explained (the comments are just for you — real JSON has no comments, so don't paste these in):
{ // Always "5.0" — the manifest format version. Don't change it. "schema_version": "5.0", // Your module's permanent id: a lowercase-with-dashes slug. This is its // identity forever, across every version. (Derived from the name you gave.) "id": "hello-reader", // The friendly name shown in the window title and the Module Store. "name": "Hello Reader", // Your version. Bump it (1.0.0 -> 1.0.1 …) each time you ship an update. "version": "0.1.0", // One-line description. Optional, but nice in the store. "summary": "Hello Reader module for HanabiMatsuri.", // Where the platform finds your UI inside the package. Must point at an HTML // file inside this folder. This is the page that opens when the module runs. "entrypoints": { "ui": "ui/index.html" }, // The capabilities your module is allowed to use. The scaffold lists three; // ours only needs files.read, but extras here are harmless until used. // (Full list + what each means: capability-api.md.) "permissions": ["files.read", "files.write", "jobs.create"], // Your module's private corner of the file system. It can NEVER see anything // outside this tree — that's the sandbox. Folders are created when a user // installs the module. "filesystem": { "root": "Modules/Hello Reader", "folders": [ // role "input" -> files the module reads (our button lists this one) { "id": "imports", "name": "Imports", "role": "input", "path": "Modules/Hello Reader/Imports", "create_on_install": true, "description": "Source files the module reads." }, // role "output" -> files the module writes { "id": "exports", "name": "Exports", "role": "output", "path": "Modules/Hello Reader/Exports", "create_on_install": true, "description": "Finished files the module writes." }, // role "workspace" -> scratch space { "id": "workspace", "name": "Workspace", "role": "workspace", "path": "Modules/Hello Reader/Workspace", "create_on_install": true, "description": "Temporary module workspace." } ] }, // What your module needs to run. We only need a modern browser; the optional // python runtime is for modules that ship a server "worker" (not us). "dependencies": { "runtimes": [ { "name": "browser", "version": "modern", "required": true }, { "name": "python", "version": ">=3.11", "required": false } ], "python": [], "node": [], "office": { "word": false, "excel": false, "powerpoint": false }, "services": [] }, // The window your UI opens in. Sizes are in pixels. "ui": { "window": { "default_width": 960, "default_height": 620, "min_width": 680, "min_height": 460, "resizable": true, "allow_multiple": false }, "popouts": [] } }
You don't have to change anything to follow this tutorial — the defaults already include files.read, which is all we use. (If you wanted to tidy up, you could drop files.write/jobs.create from permissions since we don't use them, but it's not required.)
Every field is documented in full in manifest-reference.md.
3. Edit ui/index.html — the real code, line by line #
Open hello-reader/ui/index.html and replace its contents with the file below. This is real module code: the bridge client and the message types are copied straight from the platform's own samples (see examples/modules/demo-files).
<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Hello Reader</title> <style> :root { color-scheme: dark; font-family: system-ui, sans-serif; } body { margin: 0; padding: px; background: #0d0f17; color: #e7e9f3; } h1 { font-size: px; margin: 0 0 px; } button { background: #ff5c8a; color: #1a0710; border: 0; border-radius: 8px; padding: px px; font-weight: 600; cursor: pointer; } pre { background: #161a26; border: 1px solid #262c3d; border-radius: 8px; padding: px; white-space: pre-wrap; margin-top: px; min-height: px; } .err { color: #ff9aa9; } </style> </head> <body> <h1>Hello Reader</h1> <button id="open">Pick a file from Imports and show it</button> <pre id="out">Click the button to read your first file.</pre> <script> // ── The bridge client ─────────────────────────────────────────────── // Your UI runs in a sandboxed window. It can't touch the platform // directly — it sends a message to the parent window and waits for the // reply. `call(type, payload)` does exactly that: it sends one request // and returns a Promise that resolves with the reply. Copy this verbatim // into any plain-HTML module. const PROTOCOL = 1, pending = new Map(); let n = 0; const call = (type, payload = {}) => new Promise((resolve, reject) => { const rid = `r${++n}`; // a unique id for this request pending.set(rid, { resolve, reject }); // remember how to finish it parent.postMessage({ v: PROTOCOL, type, rid, ...payload }, '*'); // send it // if no reply in 15s, give up so we never hang forever setTimeout(() => pending.has(rid) && (pending.delete(rid), reject(new Error('timed out'))), 15000); }); // When the platform replies, match it back to its request by id and // resolve (or reject) that Promise. addEventListener('message', (e) => { const m = e.data; if (!m || !m.rid) return; // ignore anything unrelated const s = pending.get(m.rid); if (!s) return; pending.delete(m.rid); m.ok ? s.resolve(m.data) : s.reject(new Error(m.error?.message || 'failed')); }); // Base64 -> text. The platform sends file bytes as base64 (safe to put in // a message); this turns those bytes back into readable text. const b64ToText = (b) => decodeURIComponent(escape(atob(b))); const out = document.getElementById('out'); // ── Tell the platform we've loaded ────────────────────────────────── // Every module sends HANABI_READY once on startup. The reply is your // "context" (your id, your folders, your granted permissions) — we don't // need it here, so we just send it and ignore the reply. call('HANABI_READY').catch(() => {}); // ── The button: pick a file, read it, show it ─────────────────────── document.getElementById('open').onclick = async () => { try { // 1) List the files in our own Imports folder. (Needs files.read.) const { files } = await call('HANABI_PICK_FILE'); if (!files.length) { out.textContent = 'No files in Imports yet — add a .txt there in File Explorer, then click again.'; return; } // 2) Read the first file's bytes by its id. (Also files.read.) const file = await call('HANABI_READ_FILE', { fileId: files[0].id }); // 3) Decode the bytes to text and show the first 1000 characters. out.textContent = `${file.name}:\n\n` + b64ToText(file.bytesB64).slice(0, 1000); } catch (e) { // If something's refused (e.g. a permission/scope issue), the error // carries a stable HANABI-… code — look it up in error-codes.md. out.innerHTML = `<span class="err">${e.message}</span>`; } }; </script> </body> </html>
That's the entire module. Notice the shape: send a message → await the reply. HANABI_PICK_FILE lists your own input folder, and HANABI_READ_FILE returns a file's bytes (as base64) plus its name and type. Both are scoped to your module's folders — that's the security model doing its job for free.
@hanabi/module-sdk wraps these exact messages in typed methods: await hanabi.pickFile() and await hanabi.readFile(id) do the same thing. See capability-api.md and the developer-guide.md. The raw bridge above needs nothing installed, so it's the quickest start.Quick local sanity check (optional)
You can open ui/index.html directly in a browser to confirm it renders and the console is clean. The button won't find files there (there's no platform to answer HANABI_PICK_FILE outside HanabiMatsuri) — that's expected. The real test is next.
Before packaging, it's worth validating the folder — this runs the exact same checks the upload will:
npm --workspace packages/module-cli run hanabi -- module validate .\hello-reader
Fix anything it reports (it points at the offending field). Then package it into an uploadable .zip:
npm --workspace packages/module-cli run hanabi -- module pack .\hello-reader --out .\hello-reader.zip
module pack validates first, then builds the zip — skipping junk and refusing secrets or executables. (More detail in packaging.md.)
4. Try it #
- Open the Developer Portal in HanabiMatsuri and create a new module ("New module"): give it the name Hello Reader, pick a category, and choose the
files.readpermission. - Upload `hello-reader.zip` onto the draft. The portal re-runs the same validation and stores your build. Because you own the draft, you can preview your own UI immediately — you don't have to wait for it to be public.
- Add a test file. In File Explorer, drop a small
.txtinto the module's Imports folder (Modules/Hello Reader/Imports). - Launch and click the button. You should see your file's text appear. 🎉
- If something's off, open the debugger. The Developer Portal's debug panel shows the messages going back and forth and any error code (like
HANABI-F001"file not found" orHANABI-P001"permission not declared"). Look the code up inerror-codes.md— it tells you the fix.
The full build → upload → review → publish → install path (and what's automatic vs. reviewed) is documented in the developer-guide.md, and packaging specifics in packaging.md.
5. What next #
You've got the core loop: a UI that sends messages and the platform answers, scoped to your module. From here:
- Add writing. Use
HANABI_WRITE_FILE(withfiles.write, already in your manifest) to save a result into your Exports folder. Thedemo-filessample does read and write in one tiny page. - Browse the single-task samples.
examples.mdlists one focused module per capability — notifications, settings, a little database (data.store), media streaming, and more. Each is one HTML file you can copy. - See every call.
capability-api.mddocuments every bridge message and its typed SDK method. - Understand the guardrails.
permissions-explained.mdexplains the four permission tiers and why your module is sandboxed. - Decode any failure.
error-codes.mdlists everyHANABI-…code with a plain-English fix.
Welcome aboard — you just shipped your first module.
ui/index.html, README, and a sample input. Unzip, open ui/index.html, and start building.