Hanabi Developer Hub
/ Get started / Your first module
Get started

Your first module

A short, hands-on walkthrough: scaffold, run, and see a result in the desktop.

my-module/
hanabi.module.json required
ui/index.html required
worker/main.py optional
tests/sample-inputs/ required
README.md required
The anatomy of a module package.

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

  1. What you'll build (10-second tour)
  2. Scaffold it with one command
  3. Read the manifest — what each field means
  4. Write the UI — the small, real code, every line explained
  5. Try it — upload in the Developer Portal and watch it run
  6. What next

0. What you'll build #

A one-window module with a single button. Click it, and the module:

  1. lists the files in its own Imports folder,
  2. reads the first one,
  3. 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.

Want the big picture on why it works this way (the sandbox, the permission model)? Skim 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:

powershell
npm --workspace packages/module-cli run hanabi -- module init "Hello Reader" --dir .\hello-reader

This creates a starter package in .\hello-reader:

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

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

jsonc
{
  // 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).

html
<!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.

Prefer TypeScript with autocomplete? The published @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:

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

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

  1. 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.read permission.
  2. 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.
  3. Add a test file. In File Explorer, drop a small .txt into the module's Imports folder (Modules/Hello Reader/Imports).
  4. Launch and click the button. You should see your file's text appear. 🎉
  5. 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" or HANABI-P001 "permission not declared"). Look the code up in error-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.

First publish of a brand-new module is reviewed by an admin before it reaches the Module Store; later updates auto-approve once the automated checks pass. For just trying your own draft, the preview in step 2 is all you need.

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 (with files.write, already in your manifest) to save a result into your Exports folder. The demo-files sample does read and write in one tiny page.
  • Browse the single-task samples. examples.md lists 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.md documents every bridge message and its typed SDK method.
  • Understand the guardrails. permissions-explained.md explains the four permission tiers and why your module is sandboxed.
  • Decode any failure. error-codes.md lists every HANABI-… code with a plain-English fix.

Welcome aboard — you just shipped your first module.

Download a starter module
A ready-to-edit folder — manifest, ui/index.html, README, and a sample input. Unzip, open ui/index.html, and start building.