Every example below is a real, working plugin shipped in plugin-examples/ in the main Projelli repo. They're MIT-licensed; fork freely.
Read them in order. They get progressively more involved.
The simplest possible "tick + render" pattern. A good first read: it touches just enough of the API to be interesting (commands, toolbar, sidebar, editor read, notify) without dragging in AI, settings, or storage.
hash) that fires a notification with the current count.word-counter.count for the command palette.editor:selection is the v2.0 gate for both selection and full-content reads via api.editor.getContent().
const refresh = async () => {
const content = await api.editor.getContent();
const stats = computeStats(content);
api.sidebar.addPanel({
id: 'word-counter-panel',
title: 'Word Counter',
html: renderPanelHtml(stats),
});
};
api.commands.register('word-counter.count', async () => {
const stats = await refresh();
api.notify.info(`${stats.words} words, ${stats.characters} characters`);
});
api.toolbar.addButton({
id: 'word-counter-button',
icon: 'hash',
tooltip: 'Count words in the active document',
command: 'word-counter.count',
});
setInterval(() => { void refresh(); }, 500);
Note: addPanel with an existing id replaces the panel content. That's how the sidebar updates without flicker.
The canonical AI plugin demo. Uses BYOK AI, a settings page for user-configurable target language, a command, and a toolbar button. Touches the most permissions: editor:selection, editor:write, ai:invoke.
translator.translate.api.settings.addPage({
id: 'translator-settings',
title: 'Translator',
schema: {
targetLanguage: {
type: 'select',
default: 'Spanish',
label: 'Target language',
choices: SUPPORTED_LANGUAGES,
},
},
});
api.commands.register('translator.translate', async () => {
const selection = await api.editor.getSelection();
if (!selection || !selection.text.trim()) {
api.notify.warn('Translator: select some text first.');
return;
}
const targetLanguage = await getTargetLanguage(api);
const translated = await api.ai.invoke({
system: 'You are a precise translator. Output only the translation. Preserve formatting, punctuation, and inline Markdown.',
prompt: `Translate the following text into ${targetLanguage}:\n\n${selection.text}`,
});
await api.editor.replaceSelection(translated.trim());
api.notify.info(`Translated to ${targetLanguage}.`);
});
Note the system prompt: precise instructions matter. Without "Output only the translation," models often add commentary or quotation marks that end up replacing the user's selection with junk.
Source: plugin-examples/translator/ →State persistence with no permissions. Demonstrates that the unconditional capabilities (commands, toolbar, sidebar, storage, notify) are enough to ship a complete productivity plugin. Also documents the v2.0 sidebar interactivity limitation honestly.
api.storage.Empty permissions array. Storage and notify are unconditional.
Sidebar panels are sandboxed iframes. Scripts inside the iframe can't message the worker in v2.0, so a button rendered inside the panel can't invoke a plugin command. Pomodoro works around this by putting all controls in the toolbar, where button clicks reach the worker normally. This is the honest pattern for any sidebar-driven plugin until v2.1 ships panel-to-worker messaging.
const stored = await api.storage.get(STORAGE_KEY);
let state = isValidState(stored) ? stored : defaultState();
const persistAndRender = async () => {
await api.storage.set(STORAGE_KEY, state);
api.sidebar.addPanel({
id: 'pomodoro-panel',
title: 'Pomodoro',
html: renderPanel(state),
});
};
api.commands.register('pomodoro.start', async () => {
state = { ...state, running: true, lastResumedAt: Date.now() };
await persistAndRender();
});
api.toolbar.addButton({
id: 'pomodoro-start',
icon: 'play',
tooltip: 'Start pomodoro',
command: 'pomodoro.start',
});
Source: plugin-examples/pomodoro/ →
The worker/iframe split for DOM-heavy libraries. Mermaid renders SVG, which needs a DOM. Plugin code runs in a Web Worker, which has no DOM. This plugin shows the standard pattern: extract data in the worker, render in the iframe, load heavy dependencies (mermaid) from a CDN inside the iframe rather than bundling them.
```mermaid blocks.<pre class="mermaid"> blocks.Only editor:selection, used to read the active document. The plugin worker itself doesn't need network: the CDN fetch happens inside the sidebar iframe, which the host's CSP allows for the sandboxed sidebar context.
const FENCE = /```mermaid\s*\n([\s\S]*?)```/g;
function extractDiagrams(content) {
const out = [];
let match;
while ((match = FENCE.exec(content)) !== null) {
if (match[1] && match[1].trim()) out.push(match[1].trim());
}
return out;
}
const html = diagrams
.map((d) => `<pre class="mermaid">${escapeHtml(d)}</pre>`)
.join('') + `<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid/+esm';
mermaid.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'strict' });
mermaid.run({ querySelector: 'pre.mermaid' });
</script>`;
api.sidebar.addPanel({ id: 'mermaid-panel', title: 'Mermaid Preview', html });
Loading dependencies from a CDN inside the iframe keeps the plugin bundle tiny (the worker doesn't ship mermaid at all) and keeps the worker CPU free. v2.1 may add a worker-side rendering API; until then, this is the cleanest pattern for any plugin that needs a DOM-heavy library.
Source: plugin-examples/mermaid-preview/ →