← Plugin docs

Examples

Four working plugins, each demonstrating a different slice of the API.

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.

1. Word Counter

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.

What it does

Permissions

editor:selection

editor:selection is the v2.0 gate for both selection and full-content reads via api.editor.getContent().

Screenshot

[ Sidebar panel with live word count, character count, and characters-no-spaces. TODO: replace with real screenshot. ]

Key code excerpt

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.

Source: plugin-examples/word-counter/ →

2. Translator

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.

What it does

Permissions

editor:selection editor:write ai:invoke

Screenshot

[ Editor with text selected, translator toolbar button, settings page showing language picker. TODO: replace with real screenshot. ]

Key code excerpt

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/ →

3. Pomodoro

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.

What it does

Permissions

none

Empty permissions array. Storage and notify are unconditional.

Screenshot

[ Sidebar with 23:14 countdown, phase label, cycles completed counter; toolbar with start/pause/reset buttons. TODO: replace with real screenshot. ]

The sidebar interactivity note

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.

Key code excerpt

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/ →

4. Mermaid Preview

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.

What it does

Permissions

editor:selection

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.

Screenshot

[ Editor on left with a mermaid flowchart code block, sidebar on right showing the rendered diagram. TODO: replace with real screenshot. ]

Key code excerpt

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/ →

What's next