Build a Zenote Plugin
A plugin is just a folder: a manifest that describes it, and an entrypoint that runs. No SDK to install, no build step required. This page covers the full contract β manifest fields, naming rules, the embedded JS host API, how to reach for other languages, and the design rules that make a plugin feel native to Zenote.
Quickstart
Drop a folder into your vault's _plugins/ directory. Zenote discovers it automatically β no restart needed, just hit Refresh Plugins in the Plugins menu (or run zenote plugins from the CLI).
_plugins/WordCounter/plugin.json
{
"name": "WordCounter",
"description": "Counts words in the
current note.",
"version": "1.0.0",
"entrypoint": "main.js",
"args": []
}
_plugins/WordCounter/main.js
if (!zenote.note) {
zenote.warn("Open a note first.");
zenote.exit(1);
}
const text = zenote.read(zenote.note);
const words = text.split(/\s+/)
.filter(Boolean).length;
zenote.log(`π ${words} words`);
That's a complete, working plugin. Run it from the Plugins menu, or from the CLI with zenote run WordCounter --note path/to/note.md.
plugin.json manifest
Every plugin needs exactly one plugin.json at the root of its folder. Malformed manifests are logged and skipped β they never crash the app.
| Field | Required | Type | Description |
|---|---|---|---|
| name | required | string | Display name in the Plugins menu. See naming rules below. |
| entrypoint | required | string | Relative path (inside the plugin's own folder) to the file Zenote runs. Must exist, or the plugin is skipped. |
| description | optional | string | One or two sentences shown in the plugin list / marketplace. Say what it does and what it needs (a note open? a network call?). |
| version | optional | string | Free-form, but semver (1.2.0) is strongly recommended. |
| args | optional | string[] |
Command-line arguments for non-JS entrypoints. Ignored for .js plugins (they get everything via the zenote object instead). Supports three placeholders β see below.
|
{vault}
Absolute path to the vault root (all workspaces, all plugins). Use for vault-wide operations like backups.
{workspace}
Absolute path to the active workspace only. Almost every plugin should scope its work here, not to {vault}.
{note}
Absolute path to the currently open note, or an empty string if none is open. Always check for empty before using it.
{workspace} as their working root. Reaching into {vault} should be a deliberate choice (backups, cross-workspace dashboards, git sync) β document it clearly in your description if you do.
Naming rules
Folder & name
- β PascalCase, no spaces:
DailyDigest,LinkChecker. - β The folder name under
_plugins/should matchnameexactly β keeps the marketplace and disk layout predictable. - β Name = what it does, not who made it:
TagCloud, notAcmeTagTool. - β Avoid generic names that collide easily:
Tool,Utils,Test.
Entrypoint files
- β Prefer
main.jsfor the embedded engine β it's the portable default. See JS API. - β Other languages:
run.sh/run.py/run.batβ the extension picks the interpreter automatically. - β Keep helper/data files inside the plugin's own folder (e.g.
templates/) β never reach for paths outside it except through thezenoteAPI. - β Don't hardcode absolute paths from your own machine β always resolve through
{vault}/{workspace}orzenote.vault/zenote.workspace.
The embedded JS engine & zenote API
A .js entrypoint runs in-process on Zenote's built-in JavaScript engine β identical behavior on Windows, macOS, and Linux, with zero installed dependencies. Your script gets one global object, zenote, plus a familiar console.log / console.error. File access is sandboxed: paths can only resolve inside the active workspace/vault or the backups folder β path traversal (../) is rejected.
| zenote.vault | Absolute path to the vault root. |
| zenote.workspace | Absolute path to the active workspace β the default root for relative paths passed to the API below. |
| zenote.workspaceName | Just the workspace's name, e.g. "Personal". |
| zenote.note | Absolute path to the open note, or "" if none is open. |
| zenote.noteRel | Note path relative to the workspace, e.g. "Projects/Roadmap.md". |
| zenote.pluginDir | Absolute path to your plugin's own folder β use it to read/write private files like templates. |
| zenote.backups | Absolute path to ~/ZenoteBackups β the only writable location outside the vault. |
| zenote.os | "windows" / "macos" / "linux". |
| zenote.today / .time / .datetime | Local date YYYY-MM-DD, time HH:MM, and full YYYY-MM-DD HH:MM:SS, computed once at plugin start. |
| zenote.log(msg) | Writes a line to stdout β shown live in Zenote's plugin console. Same as console.log. |
| zenote.warn(msg) | Writes a line to stderr, rendered as a warning/error in the console. Same as console.error. |
| zenote.exit(code) | Stops the script immediately with the given exit code. 0 = success, anything else marks the run as failed in the UI/CLI. |
| zenote.notes() | Returns every .md note in the active workspace (skips _- and .-prefixed folders) as an array of file entries. |
| zenote.files(dir) | Returns every file under a directory (workspace-relative or absolute), no filtering β useful for _attachements/ or your own plugin's data folder. |
| zenote.read(path) | Reads a file as UTF-8 text. Throws if it doesn't exist. |
| zenote.write(path, text) | Writes text to a file, creating parent directories as needed. |
| zenote.exists(path) | Returns true/false. Never throws. |
| zenote.mkdir(path) | Creates a directory, and any missing parents. |
| zenote.remove(path) | Deletes a single file (not directories). |
| zenote.stat(path) | Returns a file entry for one path, or null if it doesn't exist. |
| zenote.exec(cmd, args?) | Runs an external program (e.g. git) with the vault root as its working directory. Returns {'{'} code, stdout, stderr {'}'}. Use only when a real external tool is unavoidable β this is the one place portability depends on the user's machine. |
| zenote.fetchStatus(url, timeoutSecs?) | Built-in HTTP client β makes a GET request and returns just the status code (0 on timeout/unreachable). No curl needed. |
| zenote.archive(src, dest, excludes?) | Built-in tar.gz compression β no tar binary needed. Returns the archive size in bytes. |
File entry shape
Returned by notes(), files(), and stat():
{
abs: "/β¦/Workspaces/Default/Projects/Roadmap.md",
rel: "Projects/Roadmap.md", // relative to the queried root
name: "Roadmap", // filename, no extension
file: "Roadmap.md", // filename with extension
size: 255, // bytes
mtimeMs: 1751616000000, // last-modified, epoch ms
mtimeDate: "2026-07-04", // last-modified, YYYY-MM-DD
weekday: "Saturday"
}
read/write/exists/mkdir/remove/stat/files/archive must resolve inside zenote.vault or zenote.backups. Relative paths are resolved against the active workspace. Any attempt to escape with ../ throws instead of silently failing β catch it if you expect user-influenced paths.
Building in another language
JavaScript is the recommended path because it needs nothing installed on the user's machine β but it isn't the only one. Zenote picks the runner from your entrypoint's extension and spawns it as an external process, exactly like before:
| Extension | Runner | Requires on the user's machine |
|---|---|---|
| .js | Zenote's embedded engine (in-process) | nothing |
| .sh | sh (Linux/macOS only) | POSIX shell + tools you call |
| .py | python3 / python on Windows | Python installed |
| .bat / .cmd | cmd (Windows only) | nothing extra |
| anything else | Executed directly | whatever it needs |
Non-JS plugins get their context via command-line arguments, positioned exactly as listed in the manifest's args array (typically ["{workspace}", "{note}"]), and communicate back to Zenote purely through stdout / stderr (shown live in the console) and the process exit code (0 = success). There is no other host API for these β you're on your own for file I/O, but you're also free to use any library your language offers.
.js gets your plugin running identically for every user, on every OS, with no setup step to document or troubleshoot.
Design rules for a good plugin
These aren't enforced by the engine β they're what separates a plugin that feels native to Zenote from one that feels bolted on. Follow the pattern used by the 11 bundled plugins.
Console output is your UI
There's no custom UI surface β everything you print to stdout/stderr is what the user sees, live, in Zenote's plugin console. Open with a separator + title, close with a clear "Done" line, and use short emoji prefixes (β β β οΈ π) to make scanning output fast.
Respect the workspace
Scope your work to the active workspace unless the whole point of your plugin is vault-wide (backup, sync, cross-workspace dashboard) β and say so explicitly in the description if it is.
Fail loud, fail with a code
If a precondition is missing (no note open, network unreachable, external tool not installed), warn() a clear one-line reason and exit(1). Never let a script continue silently after a real error.
Never touch internal folders
Skip _plugins/, _attachements/, and dot-prefixed folders (.git, .workspace.json) when walking notes β zenote.notes() already does this for you, so prefer it over rolling your own walk.
Idempotent by default
Users will run your plugin more than once, often by accident. Running it twice in a row should never duplicate content, corrupt a note, or crash β check exists() before creating one-time files, like the bundled TemplateInserter does for its template folder.
Prefer built-ins over shelling out
Use zenote.fetchStatus() instead of curl, zenote.archive() instead of tar. Reach for zenote.exec() only for tools that genuinely can't be replaced (git, for example) β every exec() call is a portability compromise, so keep them rare and explicit in your description.
Real recipes from the bundled plugins
The 11 plugins shipped with Zenote (in _market_plugins/) are real, working examples of every pattern above β copy from whichever is closest to what you're building.
π VaultStats
Reading + aggregating every note in a workspace, formatting a dashboard with bars and tables.
πΈοΈ OrphanFinder
Cross-referencing [[wiki links]] against every note, and unused files in _attachements/.
π° DailyDigest
Filtering by mtimeDate, then writing a brand-new generated note.
π LinkChecker
Using fetchStatus() for network checks β no curl required, works identically on every OS.
π GitSync
The one legitimate use of exec() β shelling out to real git, checked gracefully if it's missing.
ποΈ VaultBackup
Using archive() for tar.gz, then pruning old files with files() + remove().
π ExportHTML
A small hand-rolled markdownβHTML renderer β no dependency needed even for "real" conversion work.
π TemplateInserter
Seeding editable data files into zenote.pluginDir on first run, idempotently.
π·οΈ TagCloud
Parsing YAML frontmatter by hand (inline arrays and list style) without a YAML library.
zenote run YourPlugin --note some/note.md against a scratch vault first. It's the fastest way to see real stdout/stderr and exit codes before wiring it into the app.