MutkaMutka
Modules

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.

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-module

It 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 exportdefineModule, 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:

FieldTypeNotes
iconstring?Card image. Either an https:// URL or a data:image/... URI (base64 or URL-encoded SVG).
author.namestring?Display name on the card. Clicking it opens author.link.
author.linkstring?Where the name points — any http(s) URL (a personal site, a profile page, anything).
author.avatarstring?Avatar image — same rule as icon: an http(s) URL or a data:image/... URI.
tagsstring[]?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)

GroupWhat it gives youPermission
host.fs.*readDir, openItem, copyFiles, moveFiles, trashItem, deleteItem, renameItem, createFile, createFolderfs:read / fs:write
host.board.*readFiles, writeFiles(paths, "copy" | "cut")clipboard:read / clipboard:write
host.nav.*navigate, goBack, goForward, goUpnavigation
host.tabs.*openTab, openTabInBackground, isActivenavigation
host.dialog.*prompt(opts)Promise<string | null>, confirm(opts)Promise<boolean>dialog
host.sys.homeDirThe user's OS home directoryfs:read
host.sys.appVersionThe app's own version string (e.g. 1.0.0)storage
host.refresh()Re-read the current directory after a mutationfs: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.js

The 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

OriginFormatExample
Communityauthor.module-nameacme.git-status
Fork of existingauthor.original-name-forkbob.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

On this page