Notes is extensible through the same API its first-party note types use. Plugins are
local and trusted in the MVP (loaded in-process); a sandboxed, third-party marketplace is
future work. This guide documents the complete public @notes/plugin-host surface.
- Plugins are trusted and run in-process (no sandbox yet).
- Bundled plugins ship with the app (
apps/web/src/plugins/). Each exports aNotesPlugin. - Tome plugins are loaded from
.notes/plugins/inside your Tome at startup. Install them via Settings → Plugins (drag-drop or browse a.zip) or by dropping a plugin folder directly into.notes/plugins/via the OS file explorer. Both methods require a restart to take effect. Then enable the plugin from Settings → Plugins. - Enable/disable plugins from Settings (⚙ in the ribbon). The enabled set is scoped per
Tome (
notes.plugins.enabled:<tome>inlocalStorage), so each Tome remembers its own.
- Open Settings → Plugins.
- Drag a plugin
.ziponto the install zone, or click to open a file picker. - The app extracts and validates the ZIP, then notifies you to restart.
- After restart, toggle the plugin on from the plugin list.
The ZIP file must contain manifest.json and client.js either at the root or inside a
single top-level folder:
json-viewer.zip
└── manifest.json
└── client.js
Drop a plugin folder directly into .notes/plugins/ inside your Tome:
<your-tome>/
.notes/
plugins/
json-viewer/
manifest.json
client.js
Restart the app, then enable the plugin from Settings.
import type { NotesPlugin } from "@notes/plugin-host";
export const myPlugin: NotesPlugin = {
manifest: {
id: "my-plugin", // kebab-case, unique — [a-z0-9][a-z0-9-]*
name: "My Plugin",
version: "1.0.0",
description: "What it does.",
author: "You",
entry: { client: true }, // server: true reserved for future server half
permissions: [], // reserved capability model
},
activate(ctx) {
/* register contributions here */
},
deactivate() {
/* optional: extra cleanup; contributions are auto-disposed */
},
};The manifest is validated with zod (validateManifest). Invalid manifests are rejected and
surfaced in Settings with the error.
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"description": "What it does.",
"author": "You",
"entry": { "client": true },
"permissions": []
}The client.js must be vanilla JS (no bundler step required) and export the plugin as the
default export or as a named export with the same value as the plugin id:
const myPlugin = {
manifest: { /* same as manifest.json */ },
activate(ctx) { /* ... */ },
};
export default myPlugin;Because client.js is loaded via a blob URL dynamic import, it cannot use bare import
specifiers (e.g. import { foo } from "@notes/plugin-host" will fail). All logic must be
self-contained or use DOM APIs. The PluginContext passed to activate is the only bridge
to the host application.
activate(ctx)runs when the plugin is enabled. Register all contributions throughctx.deactivate()runs when disabled. You rarely need it: every contribution registered viactxreturns a disposer and is automatically removed on disable, so there are no leaks.- Activation errors are captured per-plugin (shown in Settings) and never crash the app.
All contributions return a disposer () => void. The plugin manager calls these
automatically when a plugin is disabled, so cleanup is handled for you unless you need
extra teardown logic.
The validated PluginManifest for this plugin (read-only).
Adds a command to the command palette.
interface PluginCommand {
id: string; // unique command id, e.g. "my-plugin.do-thing"
label: string; // shown in palette, e.g. "My Plugin: Do Thing"
run: () => void; // called when the user selects the command
defaultHotkey?: string; // optional default shortcut, e.g. "Mod+Shift+W"
}ctx.registerCommand({
id: "my-plugin.greet",
label: "My Plugin: Say hello",
defaultHotkey: "Mod+Shift+H",
run: () => console.log("Hello from my plugin!"),
});Users can rebind hotkeys in Settings → Hotkeys.
Adds a custom item to the status bar at the bottom of the screen. The mount function
receives a host HTMLElement and may return a disposer for its own subscriptions/timers.
interface StatusBarItem {
id: string;
mount: (element: HTMLElement) => Disposer | void;
}ctx.addStatusBarItem({
id: "my-plugin.status",
mount(el) {
el.textContent = "Hello!";
const unsub = ctx.document.subscribe((doc) => {
el.textContent = doc ? `📄 ${doc.path}` : "";
});
return unsub; // auto-called on disable
},
});Overrides a CSS design token on :root for the duration the plugin is enabled. Useful
for theme-altering plugins.
ctx.setThemeToken("--accent", "#e11d48");
ctx.setThemeToken("--font-mono", "'JetBrains Mono', monospace");Registers a file-type renderer for one or more file extensions. The handler takes over
rendering both in the full editor pane and in inline embeds (![[file.ext]]).
interface FileTypeHandler {
extensions: string[]; // lowercase with dot, e.g. [".json", ".jsonc"]
label: string; // short display label, e.g. "JSON"
supportsFrontmatter: boolean; // false → Properties panel hides frontmatter editing
mountEditor: (element: HTMLElement, props: FileViewProps) => Disposer | void;
mountEmbed?: (element: HTMLElement, props: FileViewProps) => Disposer | void;
}
interface FileViewProps {
path: string; // path of the file being rendered
content: string; // current raw text content
onChange?: (content: string) => void; // call to save updated content
}mountEditoris called (with the element pre-cleared) whenever the file is opened or its content changes. Return a disposer for cleanup.mountEmbedis the compact inline embed renderer. Falls back tomountEditorwhen omitted.supportsFrontmatter: falsetells the right-panel Properties section that this file type does not use YAML frontmatter — the panel shows a hint instead of the property editor.
ctx.registerFileHandler({
extensions: [".csv"],
label: "CSV",
supportsFrontmatter: false,
mountEditor(element, { content }) {
const pre = document.createElement("pre");
pre.textContent = content;
element.appendChild(pre);
},
});A reactive signal for the active document (the note currently open in the focused editor pane).
interface DocumentSignal {
get: () => ActiveDocument | null;
subscribe: (listener: (doc: ActiveDocument | null) => void) => Disposer;
}
interface ActiveDocument {
path: string; // e.g. "notes/my-note.md"
content: string; // raw text content
type: string; // "markdown" | "table" | "canvas" | "board" | "json" | …
}// Reactive — subscribe to changes:
const unsub = ctx.document.subscribe((doc) => {
console.log("Active doc:", doc?.path);
});
// Imperative — read the current value:
const doc = ctx.document.get();Per-plugin key/value storage backed by localStorage. Values are JSON-serialised.
interface PluginSettings {
get: <T>(key: string, fallback: T) => T;
set: (key: string, value: unknown) => void;
}// Read a setting (with fallback):
const enabled = ctx.settings.get("showBadge", true);
// Persist a setting:
ctx.settings.set("showBadge", false);Keys are namespaced automatically as notes.plugin.<id>.<key>, so there is no collision
between plugins.
The file must export a NotesPlugin as default (or as a named export matching the plugin id).
// .notes/plugins/my-plugin/client.js
const myPlugin = {
manifest: {
id: "my-plugin",
name: "My Plugin",
version: "1.0.0",
description: "Counts words in the status bar.",
author: "You",
entry: { client: true },
permissions: [],
},
activate(ctx) {
ctx.addStatusBarItem({
id: "my-plugin.word-count",
mount(el) {
const render = () => {
const doc = ctx.document.get();
el.textContent = doc ? `${doc.content.split(/\S+/g).length - 1} words` : "";
};
render();
return ctx.document.subscribe(render);
},
});
},
};
export default myPlugin;The first-party table, canvas, and board note types register through the same
NoteTypeProvider extension point (@notes/core's NoteTypeRegistry) that a plugin would
use — there is no special-casing in core.
For file-type renderers (non-markdown files like JSON, CSV, TOML, etc.), use
ctx.registerFileHandler as documented above.
The Tome ships a ready-to-install JSON viewer at .notes/plugins/json-viewer/ in the
dev-tome development fixture. Copy that folder into the .notes/plugins/ directory of
your own Tome, restart, and enable JSON Viewer in Settings.
What it does:
- Opens
.jsonfiles with syntax-highlighted pretty-printing in the full editor pane. - Renders compact
![[file.json]]embeds inline. - Declares
supportsFrontmatter: falseso the Properties panel shows a hint rather than trying to read/write YAML frontmatter on a JSON file.