Clew Manual

Extending Clew

Vault plugins

Clew's plugins do not live in an app-wide plugin directory, and there is no marketplace. A plugin is a folder inside the vault — .clew/plugins/<id>/ — so it travels with the vault: share the folder and you have shared the behaviour, clone the vault and the plugin comes along. One manifest file declares up to three surfaces, one for each seam Clew already extends itself through: new syntax in the engine, decoration in the rendered preview, and commands in the app. Every plugin is off until you switch it on, per vault.

Plugins travel with the vault

The design follows from Clew's plain-text commitments. A vault is a folder that carries everything about itself — notes, attachments, settings — so it should carry its behaviour too. A research group sharing a vault can keep the group's custom fence syntax, note decorations, and palette commands in the vault itself; a vault synced between machines behaves identically on both. There is nothing to install, because copying the vault was the installation.

The counterweight is trust. A plugin is arbitrary code, so discovering a plugin in a vault never runs it: every plugin must be explicitly enabled in Settings → This vault, which lists each plugin found in .clew/plugins/ with its name, version, surfaces, and description, and a checkbox per plugin. The enabled set is stored in the vault's .clew/vault-settings.json as a "plugins" array of ids. Enabling a plugin is a statement of trust in the vault — the same posture as the Note API toggle that sits beside it.

Caution — there is no sandbox Engine surfaces run with full Node.js access inside the render worker; app surfaces run inside the app itself. The per-vault enable toggle is the security boundary — there is no other. Enable plugins only in vaults you wrote or trust, and read a plugin's code before enabling it in a vault you received. (The worker being a separate process does mean a crashing engine extension cannot take the app down — but that is robustness, not security.)

Installing a plugin once, for every vault

A plugin that travels with its vault is right for a plugin about that vault. It is the wrong shape for the plugin you wrote and want everywhere: copying the folder into each new vault, and re-copying it after every fix, is a tax on making vaults. So Clew looks for plugins in two places — the vault's own .clew/plugins/, and a global folder belonging to you rather than to any vault:

PlatformGlobal plugin folder
macOS ~/Library/Application Support/Clew/plugins/
Windows %APPDATA%\Clew\plugins\
Linux ~/.config/Clew/plugins/
iPad Clew › Plugins in the Files app — beside the demo vault, under On My iPad

A plugin folder there has exactly the same shape as a vault plugin — manifest.json plus its surfaces — and behaves identically once running. Settings → This vault has an Open global plugin folder button that creates the folder and opens it, so there is nothing to look up.

On iPad The global folder lives inside Clew's own folder in the Files app, next to the demo vault, because that is the one place you can put files into from outside the app. Open global plugin folder creates it and opens the Files app on it; copy a plugin folder in from iCloud Drive, from a Mac, or from another app. The iPad reads that folder when a vault opens, so a plugin added mid-session appears after you reopen the vault (or relaunch the app). Enabling is per-vault here too, in the same Settings list, where each plugin is marked this vault or global.
The Files app on an iPad, open on Clew's Plugins folder, which holds one plugin folder named hello-global
Open global plugin folder on the iPad opens the Files app on Clew › Plugins; each plugin is a folder there, as on the desktop.

The split to keep in mind is installing versus enabling. Installing globally is done once and offers the plugin in every vault. Enabling is still per-vault, in the same checkbox list and the same "plugins" array as before — so a global plugin runs in the vaults you switch it on for, and nowhere else. The trust boundary has not moved: opening a vault still runs no plugin code you did not enable there.

Both scopes appear in one list, each row marked this vault or global. If a vault contains a plugin with the same id as a global one, the vault's copy wins and the global one is hidden — one id is one plugin, and the more specific copy is the one you meant, which is how a vault pins its own version of a plugin you also use elsewhere.

Clew's Settings tab scrolled to the This vault section: a Global plugins row with an Open global plugin folder button, then four plugin rows with checkboxes, three marked this vault and one marked global
Settings → This vault over the demo vault, with a globally installed plugin listed beside the vault's own three. Each row's checkbox enables that plugin here, and only here.
Which scope for which plugin? Put it in the vault when the plugin is part of what the vault is — a custom fence its notes are written in, a tool a collaborator receiving the vault will need. Put it in the global folder when it is part of how you work — a command you reach for everywhere, a decoration you like in every vault. Vault plugins travel; global plugins do not, so a vault sent to someone else silently loses them.

Anatomy of a plugin

A plugin is a folder whose name is its id, containing a manifest.json and one file per declared surface:

Layout

.clew/plugins/word-count/
├── manifest.json
├── engine.js      (optional)
├── preview.js     (optional)
└── app.js         (optional)

manifest.json

{
	"id": "word-count",
	"name": "Word Count",
	"version": "1.0.0",
	"description": "What it does, for the settings screen.",
	"apiVersion": 1,
	"surfaces": {
		"engine": { "file": "engine.js", "extensions": "myFence", "fences": ["myfence"] },
		"preview": "preview.js",
		"app": "app.js"
	}
}

The rules, all enforced at discovery time: the folder name must equal id, and ids are lowercase letters, digits, and hyphens (64 characters at most). A surface may be declared as a bare filename or as an object with a file key; either way the file must sit directly in the plugin's folder — no subdirectories, no path tricks — and a declared file that does not exist is dropped. A plugin whose apiVersion is newer than the running app understands (currently 2) is skipped entirely rather than half-loaded. A folder with a broken or missing manifest is not a plugin.

The three surfaces

engine — extend the language

The engine surface is a jmarkdown extension module loaded into the render worker — the same mechanism Clew itself uses to add [[wikilinks]] and the ```leaflet fence to the engine. It is the surface for syntax: new fences, inline directives, block transforms. The module exports marked-style extension objects, and the manifest's extensions key names the exports to register (comma-separated). When the plugin is enabled, Clew adds the module to the engine configuration it generates for the vault, and every render — reading mode and website export alike — picks it up. Because the worker is a separate process, a broken extension fails a render, not the app.

Two optional keys on the engine surface tell the editor what the engine code does, since it cannot look inside: "fences" lists fence languages the plugin renders ("fences": ["chart"] — live edit then draws those blocks rendered, not as code), and "numbered" lists environments it numbers ("numbered": ["exercise"], or {"name": "exercise", "counter": "problems", "refname": "exercise"}), so live edit's cross-reference numbers count them.

engine.js — a block extension

export const myFence = {
	name: 'myFence', level: 'block',
	start(src) { return src.match(/^```my/m)?.index; },
	tokenizer(src) { /* … */ },
	renderer(token) { return '<div class="my">…</div>'; },
};

preview — decorate the rendered note

The preview surface is a plain script injected into every rendered-note document, after Clew's own preview client. It works on the rendered DOM: restyle it, annotate it, add widgets. Because reading mode patches the document in place rather than reloading it, a preview script does its work once on load and re-applies it on the clew:render event, which fires after every live update. If the vault's Note API gate is also on, preview scripts can use window.clew like any note script.

app — add commands to Clew itself

The app surface runs in the app, against a small, versioned API — a deliberate contract, not a door into Clew's internals. The script receives a single binding named clew (this is the plugin API — unrelated to the Note API object of the same name inside rendered notes) with exactly these capabilities: register palette and hotkey commands, read and write vault files, list the vault, open notes, read the active tab's path, subscribe to vault and workspace events, and show notices. The reference lists every method. Registered commands appear in the command palette and the hotkey editor automatically, namespaced as plugin:<plugin-id>:<command-id>.

Lifecycle is strict and automatic: everything an app-surface plugin registers — commands, event subscriptions — is tracked and unwound when the vault closes, when a different vault opens in the window, or when the plugin is disabled. A plugin cannot outlive its vault.

Note — why app plugins load through a URL Clew's window forbids runtime code evaluation (its content security policy has no unsafe-eval), so app-surface code is not read-and-evaluated: it loads as a real script from an internal protocol namespace that serves only currently enabled plugins. You will see this as a one-line curiosity in devtools; otherwise it changes nothing about how you write the file.

The demo vault's own plugins, read closely

The demo vault ships three plugins, and all of them are meant to be copied: a preview surface (Note Headers), an app surface (Word Count), and an engine + preview pair (Charts) — one worked example per way of extending Clew.

Note Headers — a preview surface

The banner across the top of the demo vault's Welcome note — visible in the screenshot in the introduction — is not a feature of Clew. It is rendered by the vault's Note Headers plugin: a manifest and one preview script, about ninety lines with comments. Its manifest declares a single surface:

.clew/plugins/header/manifest.json

{
	"id": "header",
	"name": "Note Headers",
	"version": "1.2.0",
	"description": "Banner headers for notes: set header-image, header-title, and header-subtitle in a note's frontmatter and reading mode opens with a full-width image banner; header-height, header-position, and header-align tune its size, focal point, and text placement. header-html puts a live HTML page behind the title instead — a canvas animation, CSS keyframes, anything a browser can draw — sandboxed, and kept running across re-renders.",
	"apiVersion": 1,
	"surfaces": {
		"preview": "preview.js"
	}
}

The contract it implements is frontmatter-driven — any note in the vault can opt into a banner, and tune it — header-height sets the banner's height in pixels (170 if unsaid), header-position chooses which part of the image shows (any CSS background-position), and header-align places the text block at the top, center, or (the default) bottom:

In any note's frontmatter

---
header-image: "[[banner.jpg]]"
header-title: A Grand Title
header-subtitle: with a subtitle
header-height: 240
header-position: center 30%
header-align: bottom
---

header-image takes a wikilink or a plain vault path — both work, and a bare name is looked for in Attachments/. Be aware that the wikilink form is a convention here rather than a tracked link: Clew's indexer deliberately skips frontmatter when scanning for links, so an image referenced this way does not appear in the graph or in backlinks, and renaming it will not rewrite the reference. The banner simply stops loading. Nothing goes wrong with the note; it just goes blank at the top.

A living background

header-html replaces the image with an ordinary .html file from the vault, loaded into an iframe that fills the banner behind the title. Anything a browser can draw will do — a <canvas> animation, CSS keyframes, an SVG, a gradient that drifts. Write it as a standalone page sized to 100% width and height; the banner is its viewport. If both keys are present, header-html wins.

In any note's frontmatter

---
header-html: "[[matrix-rain.html]]"
header-title: The Matrix
header-subtitle: an animated HTML background
header-height: 220
---

The page runs sandboxed, with allow-scripts and nothing else, so it gets its own opaque origin: it can animate, but it cannot read the note, reach the vault, touch the surrounding document, or use the note API. A decoration should not have the run of the place. It is also pointer-events: none, so it never swallows a click or a scroll meant for the note.

The interesting part is what happens on the next render. A preview re-renders whenever the note is saved, and the morphdom pass that patches the document would ordinarily discard a banner the incoming HTML knows nothing about — restarting the animation every few seconds as you type. Two things prevent that: the banner carries data-clew-keep, which tells the preview client to leave it alone, and the plugin compares the frontmatter it just read against what the existing banner was built from, reusing it untouched when nothing has changed. The animation runs continuously, as though the note were never re-rendered at all.

The script is a working tour of preview-surface technique. It works out which note it is running in from the document's own URL, fetches the raw note text over the preview protocol, and parses the header-* keys out of the frontmatter:

.clew/plugins/header/preview.js (excerpts)

// /<sid>/<note path>.html → the raw note is the same URL minus ".html".
const notePath = decodeURIComponent(location.pathname).replace(/\.html$/i, '');

const apply = async () => {
	const text = await (await fetch(notePath)).text();
	const spec = parseHeader(text);
	document.querySelector('.clew-note-header')?.remove();
	if (!spec) return;
	header = document.createElement('div');
	header.className = 'clew-note-header';
	// … builds the banner: background image or gradient plate,
	// title and subtitle with a scrim, then …
	document.body.prepend(header);
	// Titles may carry $math$ — MathJax is already in the document.
	window.MathJax?.typesetPromise?.([header]).catch(() => {});
};

document.addEventListener('clew:render', apply);
apply();

Three habits here are the ones to copy. First, the last two lines: run once on load, and re-run on every clew:render — that pair makes the plugin immune to live updates. Second, apply() is idempotent: it either removes its previous output before building again or, when the frontmatter it read is unchanged, leaves the existing banner exactly where it is. Re-running it any number of times converges on the same document — and, for the HTML background above, that second branch is what keeps an animation alive. Third, it composes with what is already in the document: MathJax is there, so a banner title may carry mathematics. The rest of the script is ordinary DOM construction with inline styles.

Word Count — an app surface

The second plugin is the smallest useful app-surface plugin, and its own first line says "copy me". This is the entire code file:

.clew/plugins/word-count/app.js

// Word Count — the smallest useful app-surface plugin; copy me.
clew.commands.register({
	id: 'count',
	name: 'Word count of the active note',
	hotkeys: ['Mod-Alt-w'],
	run: async () => {
		const path = clew.workspace.activePath();
		if (!path) { clew.ui.notice('No active note'); return; }
		const text = await clew.vault.read(path);
		const words = (text.match(/\S+/g) ?? []).length;
		const lines = text.split('\n').length;
		clew.ui.notice(`${words.toLocaleString()} words · ${text.length.toLocaleString()} characters · ${lines.toLocaleString()} lines`);
	},
});

With the plugin enabled, the command appears in the palette as any other command does, ⌘⌥W triggers it, and the hotkey can be rebound in the hotkey editor — the command system does not distinguish plugin commands from built-in ones.

Charts — an engine + preview pair

The third plugin adds syntax: the ```chart fence of Obsidian's Charts plugin, reimplemented for Clew from its documented YAML format and drawn with a bundled copy of the MIT-licensed Chart.js. The demo vault's own Charts guide note holds a live example of every form; the shortest is this:

In any note

```chart
type: bar
labels: [Monday, Tuesday, Wednesday]
series:
  - title: Grades
    data: [83, 95, 62]
```

type is one of bar, line, pie, doughnut, radar or polarArea, and the documented modifiers — width, stacked, indexAxis, tension, fill, bestFit, beginAtZero, axis titles and bounds, legendPosition, transparency, labelColors — do what they do in Obsidian. What the plugin does not support it refuses by name rather than half-drawing: a chart that silently dropped its stacked: would be a wrong chart, which is worse than no chart. (The one documented modifier refused outright is time: — date axes need a date adapter the plugin does not ship.)

As a plugin to read, Charts is the worked example of a fence that needs a browser to finish the job, and of the two surfaces cooperating through the document. The engine surface does all the thinking in the render worker — parses the YAML, maps the modifiers, refuses what it must — and emits only a placeholder <div> carrying the finished Chart.js configuration as JSON. The preview surface does all the drawing: it loads Chart.js from the plugin's own folder on demand (a note without charts never pays for it), instantiates a canvas per placeholder, and colors labels and grid lines from the document's computed styles so charts follow the app theme. The same two habits as the banner keep charts alive across live re-renders: the canvas carries data-clew-keep, and the script compares each placeholder's configuration against what it already drew — an unchanged chart is left exactly where it is, so editing the prose around one does not restart its animation.

With Run dataviewjs blocks also enabled, the plugin supplies Obsidian's renderChart(config, element) to ```dataviewjs scripts — a raw Chart.js configuration in, a chart in the output; see the queries chapter.

Writing your first plugin, step by step

  1. In your vault, create the folder .clew/plugins/hello/.
  2. Add a manifest.json: { "id": "hello", "name": "Hello", "version": "0.1.0", "apiVersion": 1, "surfaces": { "app": "app.js" } }. The id must match the folder name.
  3. Add an app.js — start by copying Word Count above and changing the run function.
  4. Open Settings → This vault; your plugin is listed with its surfaces. Tick its checkbox.
  5. Close and reopen the vault (or View → Reload) — app surfaces load when a vault opens, so ticking the checkbox alone does not load one. Then open the command palette and run your command. Iterate on app.js the same way: edit, reload, run.

For a preview surface, the loop is the same with "preview": "preview.js" in the manifest; note that toggling a plugin re-renders open previews, but if a preview surface does not appear, close and reopen the note's tab. For an engine surface, declare "engine": { "file": "engine.js", "extensions": "myFence" } and export the named extension objects; the engine's own documentation covers the extension format.

Vault scripts — the lighter mechanism

Often you do not need a plugin at all. If all you want is shared JavaScript available in every rendered note — a custom element, a few helper functions — drop .js files into .clew/scripts/. Every rendered note loads them, in alphabetical order, with no manifest and no enable step beyond the vault's existing trust surface (they are the moral equivalent of the inline scripts notes can already carry). They are the JavaScript twin of the .clew/snippets/ CSS convention described in Theming and CSS snippets, and — unlike plugin surfaces — they ship with the vault on website export and run on the published pages.

Custom elements are the natural fit: define <my-timeline> once in a vault script, use the tag in any note. Clew's live updates respect custom elements — a re-render keeps your element in place and syncs its attributes (firing attributeChangedCallback) rather than wiping its DOM. A single note can also pull in one script for itself via jmarkdown's own metadata header (Script: ./my-element.js).

Plugins or the Note API?

The Note API is the in-note story: one note that behaves like an application, its logic living in the note itself. Plugins are the vault-wide story: behaviour that applies to every note (preview, engine) or to the app around them (app surface). Reach for a note script when the behaviour is the note's content — a tracker, a dashboard, a game. Reach for a plugin when the behaviour should apply everywhere — new syntax, a banner convention, a command. They compose: a preview-surface script may use window.clew, and a scripted note works in a vault full of plugins.

Reference

Where plugins live

ScopeFolderNotes
This vault <vault>/.clew/plugins/<id>/ Travels with the vault; wins over a global plugin of the same id.
Global <app data>/Clew/plugins/<id>/ (on iPad, Clew › Plugins in the Files app) — Settings has an Open global plugin folder button Installed once, offered in every vault; does not travel when you share a vault.
Either way, enabling is per-vault: "plugins" in <vault>/.clew/vault-settings.json.

Manifest fields

FieldRequiredMeaning
idyes Must equal the folder name; lowercase letters, digits, hyphens; at most 64 characters.
nameno Display name for the settings screen (defaults to the id).
versionno Shown in settings (defaults to 0.0.0).
descriptionno One or two sentences for the settings screen.
apiVersionno Plugin API version the plugin targets (defaults to 1). Plugins targeting a newer version than the app supports are skipped.
surfacesyes Any of engine, preview, app — each a filename in the plugin folder, or an object with file. The engine surface's object form takes extensions: a comma-separated list of the module's exports to register.

The three surfaces

SurfaceRunsGood forKey facts
engine In the render worker (separate process, Node available) New syntax: fences, directives, transforms Exports named in extensions are added to the vault's engine configuration; applies to previews and website export.
preview In every rendered-note document, after the preview client Decorating rendered output; widgets Re-apply on clew:render; may use the Note API if that gate is on; ships with website export (the plugin folder is copied under assets/plugins/).
app In the app, against the versioned plugin API Commands, small workflow automations Receives the clew binding below; everything registered unwinds on vault change or disable.

The app-surface API (clew, version 2)

MemberBehaviour
clew.apiVersion The API version, currently 2. Version 2 added clew.toolbar; a version-1 plugin runs unchanged.
clew.pluginId Your plugin's id.
clew.vaultName The open vault's name.
clew.commands.register(command) Registers a palette/hotkey command ({ id, name, hotkeys?, run }). The id is namespaced to plugin:<plugin-id>:<id>; returns the full id. Unregistered automatically on cleanup.
clew.vault.read(path) Resolves to a vault file's text.
clew.vault.write(path, content) Writes a vault file.
clew.vault.list() Every file path in the vault's tree.
clew.workspace.open(path, opts?) Opens a note in the workspace.
clew.workspace.activePath() The active tab's path, or null.
clew.events.on(name, fn) Subscribes to 'vault-changed', 'tree-changed', 'index-changed', 'active-changed', or 'layout-changed'; returns an unsubscribe function and is cleaned up automatically.
clew.ui.notice(text, ms?) A transient toast, three seconds by default.
clew.toolbar.addButton(spec) A button on the editor toolbar (version 2): { command, label, icon, group? } — command a command id (usually one registered with clew.commands.register), icon an <svg> string, group a toolbar group (insert by default). Returns a remover; removed automatically on cleanup.

See also