Plugin guide

Building Plugins

Add sandboxed commands with narrow, host-enforced access to the editor, unlocked Markdown, approved HTTPS hosts, trusted forms, and plugin-owned Keychain secrets.

Community plugin directory

Search the directory below, then use Install in Moldavite to open the app directly on that plugin. Moldavite fetches the registry because you clicked the install link, shows the permissions before installation, and asks Rust to verify both SHA-256 hashes before the shared staged installer makes files visible. A newly installed plugin stays disabled until you grant its permissions. Changed files always require a fresh grant. You can also browse from Settings → Plugins.

Seeded directory · updates when this page loads Submit a plugin to the registry →

Publish to WordPress

Moldavite plugin

Publish the active note as a WordPress draft, with safe updates on re-publish.

editor ui notes.read net.fetch secrets host: public-api.wordpress.com
After every install

Moldavite opens a themed About this plugin dialog with the validated description, commands, setup instructions, permissions, and hosts. Reopen it later with the ⓘ action on the installed card, then enable and grant only if you trust the plugin.

Across Moldavite

Plugins ship alongside opt-in local semantic search, the read-first MCP and agent-ready Forge tools, external-edit conflict copies, and graph clustering that pulls connected components together while keeping orphan notes peripheral. The user guide covers those app features end to end.

Trust boundary

Each plugin executes as an ES module in its own Web Worker with no DOM, Zustand, and no raw Tauri IPC. Before plugin code is evaluated the worker scope is reduced to an allowlist: the language built-ins, console, timers, crypto, text encoding, URL, Blob, and the postMessage channel. Everything else is gone: fetch, XMLHttpRequest, WebSocket, EventSource, importScripts, nested workers, caches, indexedDB, and WebAssembly among them. Because it is an allowlist, capabilities shipped by future browser capabilities begin denied and remain outside the sandbox until they are allowlisted. Network access goes through the permissioned net.fetch method.

The worker proxy rejects undeclared calls early for useful errors, but Moldavite repeats permission and argument checks in the host. The host check is the security boundary. Unknown RPC methods are rejected.

A sandbox still needs trust

A granted plugin may read every unlocked note, send selected data to approved hosts, read the active editor, or store credentials, depending on its permissions. Users should enable only plugins they trust, and authors should request the smallest useful surface.

Quick start

Create a folder under the active Forge with exactly this shape:

<Forge>/.plugins/my-plugin/
├── manifest.json
├── plugin.js
└── README.md          # optional, recommended for distribution
  1. Make the folder name match the manifest id.
  2. Add a current manifest and a default register(api) export.
  3. Open or reopen Settings → Plugins.
  4. Enable the plugin and review the permission sheet.
  5. Run its commands from P or the editor slash menu.

There is no build or package requirement. A dependency-free plugin.js works as written. If you bundle dependencies, distribute the final self-contained ES module because Moldavite loads only that one entry file.

Manifest

{
  "id": "my-publisher",
  "name": "My Publisher",
  "version": "1.0.0",
  "author": "Your Name",
  "description": "Publishes the active note to an approved service.",
  "apiVersion": 2,
  "minAppVersion": "1.6.0",
  "permissions": [
    "editor",
    "ui",
    "notes.read",
    "net.fetch",
    "secrets"
  ],
  "allowedHosts": ["api.example.com"],
  "commands": [
    { "id": "configure-publisher", "label": "Configure publisher" },
    { "id": "publish-note", "label": "Publish active note…" }
  ],
  "instructions": [
    "Enable the plugin and approve its permissions.",
    "Press `Cmd+P` and run **Configure publisher** first.",
    "Open a note, then run **Publish active note…**."
  ]
}
Field Required Meaning
id Yes Must equal the folder name; lowercase ASCII letters, digits, and hyphens; begins with a letter or digit; maximum 64 characters.
name Yes User-facing plugin name shown in Settings and trusted prompt chrome.
version Yes User-facing version. Consent also pins exact file bytes, so a version bump alone is not the integrity boundary.
apiVersion Yes Use 2 for this reference. Older manifests remain compatible.
author No Display metadata.
description No Display metadata; explain what the plugin does and where data may go.
minAppVersion No Informational metadata. Moldavite leaves the semver value unenforced, so plugins need their own runtime guard when one is required.
permissions No Supported capability strings from the permission table below.
allowedHosts With net.fetch Non-empty unique array of exact lowercase public DNS hostnames. No scheme, port, path, IP, single-label name, localhost label, or wildcard.
commands No Up to 50 { "id", "label" } entries shown before enable. Each id must match api.commands.add; ids are limited to 128 characters, labels to 200, and duplicate ids are invalid.
instructions No Up to 20 setup/use step strings, 500 characters each. The post-install dialog renders inline **bold** and backtick code; other text remains literal.

Unknown top-level fields and incorrectly typed values invalidate the manifest. Host matching is exact. A grant for api.example.com covers only that hostname and its default port. The permission sheet displays every manifest host.

Dynamic-only networking still needs a manifest host

With the current API, declaring net.fetch requires at least one allowedHosts entry even if your plugin also asks for user-supplied site hosts at runtime. Runtime grants extend that manifest list, and the manifest must still pass validation.

Every successful in-app install opens a themed About this plugin dialog from this validated metadata, and the installed card's ⓘ button reopens it at any time. Without instructions, Moldavite generates a short enable-and-palette flow from the description and command list. Legacy manifests remain compatible and use runtime-registered commands as a display fallback after enable.

Entry point and command lifecycle

plugin.js is an ES module whose default export receives the API. Register commands during startup; command handlers may be synchronous or asynchronous.

export default function register(api) {
  api.commands.add({
    id: 'inspect-active-note',
    label: 'Inspect active note',
    handler: async () => {
      const note = await api.editor.getActiveNote();
      if (!note) return;
      await api.ui.toast(`Open: ${note.path}`, 'success');
    },
  });
}

Moldavite namespaces command ids as <plugin-id>:<local-id> in the host. Keep local ids stable and unique within the plugin. Every API call except commands.add is an asynchronous RPC. A command invocation that never settles is rejected by the host after 30 seconds.

Complete Plugin API reference

interface PluginAPI {
  app: { version: string; apiVersion: 2 };

  commands: {
    add(command: {
      id: string;
      label: string;
      handler: () => void | Promise<void>;
    }): void;
  };

  editor: {
    getActiveNote(): Promise<{
      path: string;
      title: string;
      content: string; // editor HTML
    } | null>;
    insertText(text: string): Promise<void>;
  };

  ui: {
    toast(
      message: string,
      kind?: 'info' | 'success' | 'error'
    ): Promise<void>;
    prompt(options: {
      title: string;
      message?: string;
      fields: Array<{
        name: string;
        label: string;
        type: 'text' | 'password' | 'url';
        placeholder?: string;
        required?: boolean;
      }>;
      confirmLabel?: string;
    }): Promise<Record<string, string> | null>;
  };

  notes: {
    list(): Promise<Array<{
      path: string;
      title: string;
      kind: 'daily' | 'weekly' | 'standalone';
      folder: string | null;
    }>>;
    read(path: string): Promise<string>; // Markdown body
  };

  net: {
    requestHostAccess(host: string): Promise<boolean>;
    fetch(url: string, options?: {
      method?: string;
      headers?: Record<string, string>;
      body?: string;
    }): Promise<{
      status: number;
      headers: Record<string, string>;
      bodyText: string;
      bodyBase64?: string;
    }>;
  };

  secrets: {
    get(key: string): Promise<string | null>;
    set(key: string, value: string): Promise<void>;
    delete(key: string): Promise<void>;
  };
}

App and commands

api.app.version: string

The running Moldavite app version.

api.app.apiVersion: 2

The API version selected by this manifest.

api.commands.add({ id, label, handler }): void always

Registers a command in the palette and slash menu.

if (api.app.apiVersion !== 2) throw new Error('API v2 required');

api.commands.add({
  id: 'hello',
  label: 'Say hello',
  handler: () => api.ui.toast(`Hello from Moldavite ${api.app.version}`),
});

Editor

editor.getActiveNote(): Promise<ActiveNote | null> editor

Returns the active note's Forge-relative path, display title, and live editor HTML, or null when no note is open.

editor.insertText(text: string): Promise<void> editor

Inserts text at the current editor cursor. If no editor is active, Moldavite shows an error notification.

const active = await api.editor.getActiveNote();
if (active) {
  console.log(active.path, active.content); // content is HTML
  await api.editor.insertText('\nPublished from Moldavite.');
}

Key per-note state with path, since display titles can change. The current API has no general note-write method. insertText is an explicit active-editor action under the editor permission.

User interface

ui.toast(message, kind?): Promise<void> ui

Shows an app notification. Kind accepts info, success, or error.

ui.prompt(options): Promise<Record<string, string> | null> current API, no permission

Opens a user-mediated Moldavite form. Submit returns strings keyed by field name; Cancel or Escape returns null.

await api.ui.toast('Ready to publish', 'success');

const values = await api.ui.prompt({
  title: 'Configure publishing',
  message: 'Credentials are verified before saving.',
  fields: [
    { name: 'site', label: 'Site URL', type: 'url', required: true },
    { name: 'password', label: 'Application Password', type: 'password', required: true },
  ],
  confirmLabel: 'Verify and save',
});
if (!values) return;

The host allows one plugin prompt or host-consent dialog at a time and always displays Request from plugin: Plugin Name in trusted chrome above plugin-controlled copy. A form has 1–12 fields. Field names must be unique identifiers beginning with a letter; supported field types are text, password, and URL. Titles are limited to 200 characters, messages to 2,000, confirm labels to 80, names to 64, labels to 160, and placeholders to 300.

Notes

notes.list(): Promise<PluginNoteMetadata[]> notes.read

Lists daily, weekly, and standalone note metadata, including locked placeholders.

notes.read(path: string): Promise<string> notes.read

Reads the Markdown body of an exact listed, unlocked note path. Locked and unknown paths reject.

const notes = await api.notes.list();
const standalone = notes.find((note) => note.kind === 'standalone');
if (standalone) {
  const markdownBody = await api.notes.read(standalone.path);
  await api.ui.toast(`Read ${markdownBody.length} characters`);
}

Paths are Forge-relative, such as daily/2026-07-13.md or notes/Projects/roadmap.md. The list's folder is relative to notes/ for standalone notes and null otherwise. The read result is the parsed Markdown body; YAML frontmatter is not included. The plugin receives no arbitrary filesystem-read capability.

Network and runtime host consent

net.requestHostAccess(host: string): Promise<boolean> net.fetch

Returns true for an already approved exact host, otherwise asks the user. Denial returns false; malformed or non-public hostname forms reject.

net.fetch(url, options?): Promise<PluginFetchResponse> net.fetch

Makes a host-performed HTTPS request after checking the effective exact-host allowlist.

const site = new URL('https://notes.example.com');
const approved = await api.net.requestHostAccess(site.hostname);
if (!approved) return;

const response = await api.net.fetch(`${site.origin}/api/drafts`, {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ title: 'From Moldavite' }),
});
if (response.status >= 400) throw new Error(response.bodyText);

requestHostAccess uses the manifest hostname validator: no scheme, port, path, wildcard, IP literal, single-label name, or label named localhost. The consent dialog names the plugin and exact host. Existing manifest or runtime approval returns true without another prompt.

Runtime approval is stored in Moldavite's per-Forge plugin grant record, outside the plugin files. The effective allowlist is the union of manifest and user-approved hosts. Users can revoke a runtime host under Settings → Plugins → View permissions; the next request and every redirect hop immediately use the reduced union.

Fetch validation and redirect policy

Response headers are restricted to content-type, content-length, etag, last-modified, link, retry-after, x-wp-total, and x-wp-totalpages. set-cookie is never exposed. Text, JSON, XML, JavaScript, and form bodies are decoded in bodyText; non-text responses also include bodyBase64.

Keychain secrets

secrets.get(key): Promise<string | null> secrets

Returns the stored string or null.

secrets.set(key, value): Promise<void> secrets

Stores a string in this plugin's macOS Keychain namespace.

secrets.delete(key): Promise<void> secrets

Deletes the entry when present and otherwise succeeds.

await api.secrets.set('api-token', token);
const saved = await api.secrets.get('api-token');
if (saved) {
  // Use it through api.net.fetch, then remove it when no longer needed.
  await api.secrets.delete('api-token');
}

The Keychain service is Moldavite. The host constructs the account as plugin:<plugin-id>:<key>, so a worker cannot choose another plugin's namespace. Keys are 1–128 characters, begin with a letter or digit, and then use letters, digits, dots, underscores, or hyphens. Secret values are never listed or included in Forge, settings, plugin, ZIP, or encrypted-backup exports.

Permission What it grants
None app, commands.add, and the current ui.prompt.
editor Read active-note path/title/HTML and insert text at the cursor.
ui Show toast notifications.
notes.read List note metadata and read unlocked Markdown bodies.
net.fetch Request runtime hosts and ask Moldavite to call exact approved HTTPS hosts.
secrets Read, write, and delete this plugin's Keychain entries.

Consent is pinned to a SHA-256 hash over the raw manifest.json bytes, a separator, and the plugin.js bytes. Editing source, permissions, or allowedHosts therefore changes the hash and reopens the permission sheet even if version did not change.

Runtime host grants are deliberately separate: they survive a plugin version/hash re-grant, remain visible and individually revocable, and are forgotten with the plugin's consent record when it is uninstalled. They leave files and the content hash unchanged.

Disabling a plugin, uninstalling it, switching Forges, a worker crash, or an unreadable worker message terminates the worker, removes its commands, and rejects pending command invocations. Malformed manifests are marked invalid and never executed.

Worked reference: Publish to WordPress

Moldavite bundles a dependency-free first-party plugin under src-tauri/example-plugin/moldavite-wordpress/. Install it from Settings → Plugins, enable it, and inspect its manifest, source, and README as a complete reference.

  1. Configure WordPress publishing uses ui.prompt for an HTTPS site URL, username, and Application Password.
  2. It derives the site hostname, calls net.requestHostAccess, and verifies /wp-json/wp/v2/users/me?context=edit before saving configuration through secrets.set.
  3. Publish note to WordPress… uses editor.getActiveNote and sends the live editor HTML to /wp-json/wp/v2/posts as a draft.
  4. A Keychain-backed Forge-path-to-post-id map makes later publishes of the same path use PUT to update the existing post. The success notification includes the edit URL.
Supported WordPress sites

Self-hosted WordPress and WordPress.com Jetpack/Atomic sites work when they expose the standard REST API and Application Passwords. WordPress.com Simple sites require OAuth with a registered client ID; the reference plugin intentionally does not embed or fake one and does not support those sites.

Distribution, updates, and cleanup

Older manifests

Existing manifests with "apiVersion": 1 remain valid and receive the original app, commands, editor, and ui.toast surface, with api.app.apiVersion === 1. They do not need a source or manifest migration. Use "apiVersion": 2 for trusted prompts, Forge note reads, networking, and Keychain secrets.