Writing a Module
Create a built-in or community module with defineModule and the host API.
A module is a plain ESM file that default-exports defineModule({ ... }). The
same shape runs everywhere: a built-in lives at src/sandbox-builtins/<name>.ts
inside this repo; a community module builds to a single self-contained ESM file
that lands on disk at ~/.mutka/modules/<id>/index.js.
Scaffold a project (recommended)
The fastest way to start a community module is the published scaffolder. It
generates a typed TypeScript project wired to the
@mutka-explorer/module
package, with a tsup build that outputs the single ESM file Mutka loads:
npm create @mutka-explorer@latest my-moduleIt prompts for the module id (author.name), display name, GitHub username, and
permissions, then generates src/index.ts (a typed skeleton with a working
command), a package.json with the build, and a mutka.config.json that points
GitHub discovery at dist/index.js.
Why a typed package?
@mutka-explorer/module is types plus one tiny runtime export —
defineModule, an identity function. It infers your commands[].ids so
host.onCommand only accepts ids you declared (a typo or stale id is a compile
error), and every host.* method is precisely typed. A bundler inlines the
call, so your built index.js stays import-free — exactly what Mutka loads.
A minimal module
import { defineModule } from "@mutka-explorer/module";
export default defineModule({
id: "author.my-module",
name: "My Module",
version: "1.0.0",
description: "What it does, in one line.",
icon: "data:image/png;base64,…", // optional card image (data: or https URL)
author: { name: "You", github: "you" }, // optional; defaults to the repo owner
permissions: ["dialog"], // declare every capability host.* uses
commands: [
{
id: "author.my-module.hello", // must start with the module ID
label: "Say Hello",
shortcut: "meta+shift+h",
contextMenu: true,
when: { selection: "any" }, // serializable visibility
},
],
setup(host) {
host.onCommand("author.my-module.hello", async (snapshot) => {
await host.dialog.confirm({
message: `Hello from ${snapshot.currentDirectory}!`,
});
});
},
});Build it (npm run build in a scaffolded project) and the single ESM file in
dist/ is what you publish. Prefer no runtime import at all? Use
import type { SandboxModuleDef } from "@mutka-explorer/module" and annotate the
definition — the same id inference with zero runtime import.
Built-ins differ only by the import
A built-in dropped in src/sandbox-builtins/<name>.ts is byte-identical
except it imports defineModule from the in-repo path
(../core/sandbox/authoring/defineModule) instead of the npm package. Built-ins are
auto-discovered by Vite's glob import — no registration step required.
Display metadata
These optional fields control how your module appears in the Modules panel. They are source-agnostic — nothing here is specific to GitHub or any other host:
| Field | Type | Notes |
|---|---|---|
icon | string? | Card image. Either an https:// URL or a data:image/... URI (base64 or URL-encoded SVG). |
author.name | string? | Display name on the card. Clicking it opens author.link. |
author.link | string? | Where the name points — any http(s) URL (a personal site, a profile page, anything). |
author.avatar | string? | Avatar image — same rule as icon: an http(s) URL or a data:image/... URI. |
tags | string[]? | Free-form, for discovery filtering (e.g. ["files", "viewer"]). |
export default defineModule({
id: "author.my-module",
name: "My Module",
version: "1.0.0",
// Two equivalent ways to set an image — a hosted URL or an inline data URI:
icon: "https://example.com/my-module.png",
// icon: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA…",
author: {
name: "Ada Lovelace",
link: "https://ada.example.com", // clicking the name opens this
avatar: "https://example.com/ada.png", // or a data:image/... URI
},
tags: ["files", "viewer"],
// ...
});Images are rendered via <img src> only and the source is scheme-checked — any
value that isn't an http(s) URL or a data:image/... URI is dropped, so there
is no injection vector.
The host API (all async)
| Group | What it gives you | Permission |
|---|---|---|
host.fs.* | readDir, openItem, copyFiles, moveFiles, trashItem, deleteItem, renameItem, createFile, createFolder | fs:read / fs:write |
host.board.* | readFiles, writeFiles(paths, "copy" | "cut") | clipboard:read / clipboard:write |
host.nav.* | navigate, goBack, goForward, goUp | navigation |
host.tabs.* | openTab, openTabInBackground, isActive | navigation |
host.dialog.* | prompt(opts) → Promise<string | null>, confirm(opts) → Promise<boolean> | dialog |
host.sys.homeDir | The user's OS home directory | fs:read |
host.sys.appVersion | The app's own version string (e.g. 1.0.0) | storage |
host.refresh() | Re-read the current directory after a mutation | fs:read |
A command's handler receives a serializable snapshot of
{ selectedItems, orderedItems, currentDirectory, clipboard }.
The table above is just the basics. The full host surface also covers
network, per-module storage, Keychain secrets, view/selection control, and the
system integrations (Quick Look, "Open With", native drag-out) — see the
capability reference for everything.
Declare before you call
If a host.* call needs a permission you didn't list in permissions, the
gateway throws. For worker (community) modules the capability is physically
unreachable — the worker has no invoke.
Beyond commands
A module isn't limited to commands and open handlers. It can contribute, each as
its own declarative entry plus an optional setup hook:
- A whole virtual file system for a URI
scheme (
fileSystemProviders) — remote/cloud/archive storage. - Declarative UI: side panels, settings
sections, modals, forms, and status-bar items (
panels,settingsSections,host.ui.*,host.statusbar.*). - Custom list columns and file icons
(
columns,fileIcons). - Left-sidebar "Places" entries (
sidebarItems/host.sidebar.set).
Installing manually
After building your module (npm run build in a scaffolded project), copy the
output file to the Mutka modules directory:
mkdir -p ~/.mutka/modules/author.my-module
cp dist/index.js ~/.mutka/modules/author.my-module/index.jsThe module loads on next launch into its own isolated worker. During development
you can also place modules in this repo's dev-modules/<id>/index.js — they are
loaded through the same isolated worker path without a manual install step.
Prefer the Modules panel for published modules
If a module is published to GitHub with the mutka-module topic, users can
browse and install it directly from the Modules overlay — no terminal
needed. Manual install is mainly useful during development.
Module ID convention
| Origin | Format | Example |
|---|---|---|
| Community | author.module-name | acme.git-status |
| Fork of existing | author.original-name-fork | bob.clipboard-fork |
| Built-in (reserved) | core.* | core.navigation |
IDs are permanent
Never rename a module ID after users install it — it breaks their
~/.mutka/config.json references and any other modules that depend on the id.
What's next
- Permissions & capabilities — the full vocabulary
- Open handlers — claim a double-click
- Virtual file systems — serve a scheme
- Declarative UI — render panels and forms
- Columns & file icons — enrich the list
- Events & file watching — react to changes
- Storage, network & secrets
- Publishing a module — name a
mutka-module-*repo and it's auto-discovered