Zenote Logo
Zenote Plugin Developer Docs
🧩 Developer Documentation

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-aware by default: as of Zenote's workspace model, plugins should treat {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 match name exactly β€” keeps the marketplace and disk layout predictable.
  • βœ“ Name = what it does, not who made it: TagCloud, not AcmeTagTool.
  • βœ• Avoid generic names that collide easily: Tool, Utils, Test.

Entrypoint files

  • βœ“ Prefer main.js for 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 the zenote API.
  • βœ• Don't hardcode absolute paths from your own machine β€” always resolve through {vault}/{workspace} or zenote.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.

Context properties (read-only)
zenote.vaultAbsolute path to the vault root.
zenote.workspaceAbsolute path to the active workspace β€” the default root for relative paths passed to the API below.
zenote.workspaceNameJust the workspace's name, e.g. "Personal".
zenote.noteAbsolute path to the open note, or "" if none is open.
zenote.noteRelNote path relative to the workspace, e.g. "Projects/Roadmap.md".
zenote.pluginDirAbsolute path to your plugin's own folder β€” use it to read/write private files like templates.
zenote.backupsAbsolute path to ~/ZenoteBackups β€” the only writable location outside the vault.
zenote.os"windows" / "macos" / "linux".
zenote.today / .time / .datetimeLocal date YYYY-MM-DD, time HH:MM, and full YYYY-MM-DD HH:MM:SS, computed once at plugin start.
Functions
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"
}
πŸ›‘οΈ Sandbox rule: every path you pass to 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:

ExtensionRunnerRequires on the user's machine
.jsZenote's embedded engine (in-process)nothing
.shsh (Linux/macOS only)POSIX shell + tools you call
.pypython3 / python on WindowsPython installed
.bat / .cmdcmd (Windows only)nothing extra
anything elseExecuted directlywhatever 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.

πŸ’‘ A good rule of thumb: reach for a compiled/scripted language other than JS only when you genuinely need a library or system tool JS can't reach (e.g. a Python data-science package, a Rust binary you already ship). Otherwise, .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.

πŸ§ͺ Test before you ship: run your plugin from the CLI with 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.