CLI tool for scaffolding a new Voiden community plugin. Generates a fully wired project with a Vite build config, JSX runtime shim, zip packager, manifest, and typed TypeScript entry point — ready to build and install in Voiden in under a minute.
# Recommended
npm create @voiden/plugin my-plugin
# Using npx
npx @voiden/create-plugin my-plugin- Quick Start
- What It Creates
- CLI Prompts Reference
- Generated Files Explained
- Icon System
- Permissions System
- Capabilities
- Plugin API Reference
- Build, Zip, and Install Workflow
- CI / Automated Release
- Where to Start Making Changes
- Publishing Your Plugin
# Recommended — using npm create
npm create @voiden/plugin my-plugin
# Using npx (no install needed)
npx @voiden/create-plugin my-plugin
# Or run interactively without a name
npm create @voiden/plugin
npx @voiden/create-pluginThe CLI asks a series of questions, generates the project into a new folder, and prints the next steps.
cd my-plugin
npm install
npm run build # compile src/plugin.ts → dist/my-plugin.js + dist/my-plugin-main.cjs
npm run zip # package → dist/my-plugin.zip
Then in Voiden: Extensions → ⋯ → Install from file → dist/my-plugin.zip
my-plugin/
├── src/
│ ├── plugin.ts ← your plugin entry point (edit this)
│ ├── main-process.ts ← Electron IPC handlers (only if main process selected)
│ └── skill.md ← AI skill description (optional)
├── manifest.json ← plugin identity, permissions, capabilities
├── changelog.json ← release history
├── package.json ← npm metadata and scripts
├── tsconfig.json ← TypeScript config
├── build.mjs ← Vite build script (renderer bundle)
├── build-main.mjs ← esbuild script for the main-process bundle (always generated)
├── zip.mjs ← packages dist/ into an installable .zip
├── generate-manifest.mjs ← validates and logs manifest info
├── .gitignore
└── .github/
└── workflows/
└── release.yml ← GitHub Actions: build & publish on git tag
If you selected Runner, one extra pair of files is added:
├── build-runner.mjs ← esbuild script for voiden-runner CLI
└── src/
└── runner.ts ← RunnerFactory (pure Node.js, no browser APIs)
Every prompt shown during npm create @voiden/plugin is explained below.
| Prompt | What to enter | Notes |
|---|---|---|
| Plugin display name | Human-readable name shown in the Extensions browser (e.g. My HTTP Formatter) |
Required |
| Plugin ID | Kebab-case unique identifier. Auto-derived from the display name. Must start with a letter and contain only a-z 0-9 - (e.g. my-http-formatter) |
Required. Used as the output filename: dist/{id}.js, dist/{id}.zip |
| Description | One-line description shown in the Extensions browser and in manifest.json |
Optional |
| Author | Your name or team name. Defaults to Voiden Team |
Optional |
| Icon type | Choose how the plugin icon is displayed — see Icon System | Select one of four options |
| Initial version | SemVer starting version. Default: 1.0.0 |
Bump this before every release |
| Minimum Voiden version | SemVer range the plugin requires. Default: >=2.0.0 |
Use >=2.0.0 for all current features |
| Load priority | Integer. Lower = loads earlier relative to other plugins. Default: 30. Core extensions use 10–25; use 30+ for community plugins |
| Prompt | What it does |
|---|---|
| Runner | Generates src/runner.ts and build-runner.mjs for voiden-runner CLI headless support. Select this if you want your plugin to work in CI pipelines or automated scripts outside the Voiden desktop app |
| Main process | Generates src/main-process.ts with Electron IPC handler stubs. Select this if your plugin needs to access native OS features (file dialogs, native menus, shell commands) from the Electron main process. Note: build-main.mjs is always generated regardless of this choice — you can add src/main-process.ts later |
Each permission gates a specific group of context.* APIs. Enable every permission your plugin will use — the host enforces them at call time and shows a warning badge if a permission is missing.
| Prompt | APIs unlocked |
|---|---|
| File System | context.fs.read/write/create/delete/list/exists |
| Settings | context.settings.get/set/delete/onChange + context.ui.registerSettings |
| Events | context.events.on(...) for workspace lifecycle events |
| Command Palette | context.registerCommand(...) |
| Context Menus | context.registerContextMenu(...) |
The single source of truth for your plugin. Voiden reads this file when loading the plugin.
{
"id": "my-plugin",
"name": "My Plugin",
"description": "What this plugin does",
"version": "1.0.0",
"voidenVersion": ">=2.0.0",
"author": "Your Name",
"icon": "Globe",
"type": "community",
"priority": 30,
"readme": "Shown in the Extensions browser",
"mainProcess": false,
"permissions": ["filesystem", "events"],
"capabilities": {},
"features": []
}| Field | Purpose |
|---|---|
id |
Unique kebab-case identifier. Used as the file name for the built bundle (dist/{id}.js) and the zip (dist/{id}.zip). Must match the value set at scaffold time |
name |
Display name shown in the Extensions browser and sidebar |
version |
SemVer string. Bump this before publishing a new release |
voidenVersion |
Minimum Voiden app version required. Use >=2.0.0 for all current features |
icon |
See Icon System |
priority |
Load order relative to other plugins. Lower numbers load first. Range: 1–99. Core extensions use 10–25. Use 30+ for community plugins |
mainProcess |
Set to true if your plugin has a src/main-process.ts that registers Electron IPC handlers |
permissions |
Array of permission strings your plugin needs. See Permissions System |
capabilities |
Auto-populated at build time by build.mjs — do not edit manually. See Capabilities |
This is the main file you edit. It exports a single default function that receives a CorePluginContext and returns { onload, onunload, metadata }.
import type { CorePluginContext } from '@voiden/sdk/ui';
import manifest from '../manifest.json';
export default function createMyPlugin(context: CorePluginContext) {
return {
onload: async () => {
// Register everything here
},
onunload: async () => {
// Clean up subscriptions, listeners, etc.
},
metadata: manifest,
};
}onload — called once when the plugin is activated. Register TipTap nodes, slash commands, sidebar tabs, commands, event listeners, and context menus here.
onunload — called when the plugin is disabled or the app unloads. Cancel any subscriptions made in onload. Always store unsubscribe functions and call them here to avoid memory leaks.
metadata — pass manifest directly. Voiden uses this to display plugin info in the Extensions browser.
The entry file can be named plugin.ts or plugin.tsx (for JSX) — the build script detects both automatically.
Runs a Vite build that compiles your plugin entry point into a single ESM file at dist/{id}.js.
npm run build
# Builds: dist/{id}.js (renderer bundle)
# dist/{id}-main.cjs (main-process bundle, if src/main-process.ts exists)Entry point detection — build.mjs automatically selects the entry file in this priority order:
src/plugin.tsxsrc/plugin.tssrc/index.tsxsrc/index.ts
Use .tsx if your plugin includes JSX (React sidebar components). Use .ts for logic-only plugins.
How shimming works — all host-provided packages (react, @tiptap/core, lucide-react, etc.) are shimmed at build time. Instead of bundling React into your plugin, the build emits inline shim code that reads window.__voiden_shims__['react'] at runtime — the Voiden app injects these shims before loading any plugin. This keeps plugin bundles small (~5–15 kB) and ensures a single React instance across all plugins.
You should not need to edit build.mjs unless you want to add a shim for a package the host does not already provide.
Builds src/main-process.ts using esbuild into a CommonJS bundle at dist/{id}-main.cjs. This file is always generated regardless of whether you selected main process during scaffolding.
npm run build:main
# Builds: dist/{id}-main.cjsIf src/main-process.ts does not exist, the script prints No main-process.ts found — skipping and exits cleanly. This means you can add main-process support at any time by simply creating src/main-process.ts — no build config changes needed.
Node built-ins (fs, path, os, etc.) and electron are externalized — only your plugin logic is bundled.
Required file for the main-process bundle:
| Stage | Filename |
|---|---|
| Source | src/main-process.ts |
| Build output | dist/{id}-main.cjs |
| Inside zip | {id}-main.cjs (same name, no rename) |
| After installation on disk | main-process.js (Voiden saves it under this fixed name) |
| Loaded by Electron | from {installPath}/main-process.js |
Packages the build output into an installable .zip file.
npm run zip
# Output: dist/{id}.zipThe zip layout Voiden expects at the root:
my-plugin.zip
├── {id}.js ← required: the renderer bundle (same name as build output)
├── manifest.json ← required: plugin identity and permissions
├── changelog.json ← optional: release history
├── skill.md ← optional: AI skill description
└── {id}-main.cjs ← optional: main-process bundle (same name as build output)
Local image icons are embedded automatically. If manifest.icon points to a local file (e.g. "src/icon.png"), zip.mjs reads the file, converts it to a base64 data URL, and writes the result into manifest.json inside the zip. The original path reference stays in your source manifest.json. The image file itself is not included in the zip — only the embedded data URL in the manifest.
Requires
zipon your PATH. On macOS/Linux this is pre-installed. On Windows, install via WSL or 7-Zip CLI.
A lightweight validation script that reads manifest.json and prints its id and version. Runs as part of npm run release to confirm the manifest is valid JSON before publishing.
node generate-manifest.mjs
# Manifest ready: my-plugin v1.0.0Only generated when you select Main process during scaffolding. Register ipcMain handlers here. Keep this file focused on Node.js / OS-level work (file dialogs, native menus, shell commands).
import { ipcMain } from 'electron';
export function register() {
ipcMain.handle('my-plugin:do-something', async (_event, payload) => {
return { ok: true };
});
}You can add this file at any time after scaffolding — build-main.mjs picks it up automatically.
A Markdown file describing your plugin's capabilities to AI assistants (e.g. Claude). Bundled into the zip as skill.md. Edit it to describe what your plugin does, what block types it introduces, and how they should be used in a .void file.
Structured release history. Update this when you ship a new version. Voiden's Extension browser displays this to users. Also included in the zip and uploaded as a GitHub release asset.
[
{
"version": "1.0.0",
"date": "2026-01-01",
"title": "Initial release",
"description": "First release of My Plugin.",
"changes": {
"Added": ["Initial plugin scaffold"]
}
}
]Standard npm package file. Key scripts:
| Script | What it does |
|---|---|
npm run build |
Compile renderer bundle via Vite → dist/{id}.js, then compile main-process bundle via esbuild → dist/{id}-main.cjs (skips gracefully if src/main-process.ts absent) |
npm run build:main |
Compile main-process bundle only → dist/{id}-main.cjs |
npm run build:runner |
Compile runner bundle → dist/{id}-runner.js (only if runner selected) |
npm run zip |
Package dist/ into dist/{id}.zip for local testing |
npm run release |
Build all bundles and validate manifest — run before tagging a release |
The package name is @voiden/plugin-{id}.
TypeScript config pre-set for Voiden plugins:
| Option | Value | Why |
|---|---|---|
target |
ES2020 |
Voiden's Electron/Chromium supports all ES2020 features natively |
moduleResolution |
bundler |
Required for Vite's module resolver |
jsx |
react-jsx |
Enables <JSX /> syntax without importing React manually |
strict |
true |
Catches type errors early |
resolveJsonModule |
true |
Allows import manifest from '../manifest.json' |
Voiden supports three icon types for community plugins. Set the icon field in manifest.json to one of these formats:
Use any icon name from lucide-react in PascalCase. Voiden resolves the name to the actual icon component at render time.
"icon": "Plug"
"icon": "Globe"
"icon": "Zap"
"icon": "Database"This is the recommended option — no image hosting required, icons are always sharp at any size, and they adapt to the app theme.
During scaffolding, select Lucide icon name and enter the PascalCase name.
Reference a local file relative to your project root. During npm run zip, zip.mjs reads the file and embeds it as a base64 data URL directly into manifest.json inside the zip. The image file itself is not included in the zip.
"icon": "src/icon.png"
"icon": "src/icon.svg"Your source manifest.json keeps the file path as a human-readable reference. Only the zip's copy of manifest.json contains the embedded data URL — so the zip remains fully self-contained with no external dependencies.
During scaffolding, select Local image file and enter the path relative to the project root (e.g. src/icon.png).
A fully qualified https:// URL to a hosted image.
"icon": "https://cdn.example.com/my-plugin-icon.png"During scaffolding, select URL and enter the full URL.
If icon is omitted or the value cannot be resolved (e.g. an unrecognised Lucide name), Voiden shows a default icon: the Voiden logo for core extensions, and a generic people icon for community plugins.
icon value |
Resolved as |
|---|---|
Starts with http:// or https:// |
Image URL → <img src={icon}> |
Starts with data: |
Embedded data URL → <img src={icon}> |
| Anything else | Lucide icon name lookup → <LucideIcon name={icon}> |
Permissions gate specific context.* APIs. You must declare every permission your plugin uses in manifest.json. The host app enforces permissions at call time — calling a gated API without the right permission throws a PluginPermissionError and shows an amber "Needs Permission" badge in the Extensions browser.
| Permission | APIs unlocked | When to use |
|---|---|---|
filesystem |
context.fs.read(), .write(), .create(), .createDirectory(), .delete(), .list(), .exists() |
Reading or writing files in the active project |
settings |
context.settings.get/set/delete/onChange(), context.ui.registerSettings() |
Persisting plugin configuration and showing a settings panel |
events |
context.events.on() |
Reacting to workspace lifecycle changes |
commandPalette |
context.registerCommand() |
Adding entries to the command palette (⌘⇧P) |
contextMenus |
context.registerContextMenu() |
Injecting items into right-click context menus |
All paths passed to context.fs.* are relative to the active project root. There is no access to paths outside the open project.
Capabilities describe what your plugin contributes to the editor. Stored in manifest.json under capabilities and displayed in the Extensions browser.
You do not declare capabilities manually. The build.mjs script automatically detects them at build time by scanning the compiled bundle.
| Capability | Detected when your plugin calls | Shown in Extensions browser as |
|---|---|---|
blocks |
context.registerVoidenExtension() |
Block types the plugin owns |
slashCommands |
context.addVoidenSlashGroup() or context.addVoidenSlashCommand() |
Slash command groups |
requestPipeline |
context.pipeline.addHook() |
Pipeline hooks |
contextMenus |
context.registerContextMenu() |
Context menu items |
topBar |
context.registerTopBarItem() |
Top bar buttons |
sidebar |
context.registerSidebarTab() |
Sidebar tabs |
commandPalette |
context.registerCommand() |
Command palette entries |
help |
context.registerHelpCommand() |
Help commands |
For blocks, the build script also extracts node names from Node.create({ name: '...' }) calls and populates capabilities.blocks.owns automatically.
All APIs are available on the context object passed to your plugin factory. Gated APIs require the corresponding permission declared in manifest.json.
context.registerVoidenExtension(TiptapNode)
context.addVoidenSlashGroup({ name, title, commands: [{ name, label, slash, description, action }] })
context.getVoidenSlashGroups()
context.registerSidebarTab('right', { id, title, icon, component })
context.registerTopBarItem({ id, icon, tooltip, position, onClick })
context.ui.showToast(message, 'info' | 'success' | 'warning' | 'error')context.registerCommand({
id: 'my-plugin.action',
label: 'My Plugin: Do Something',
description: 'Optional subtitle',
shortcut: '⌘⇧M',
action: () => { ... },
})context.registerContextMenu({
id: 'my-plugin.tab-action',
label: 'My Plugin: Tab Action',
surface: 'tab' | 'file' | 'block',
when: (target) => true,
action: (target) => { ... },
})const unsub = context.events.on('tab:changed', ({ tabId, title }) => { ... })
const unsub = context.events.on('file:saved', ({ filePath }) => { ... })
const unsub = context.events.on('project:changed', ({ projectPath }) => { ... })
const unsub = context.events.on('request:sent', ({ request }) => { ... })
const unsub = context.events.on('response:received', ({ response }) => { ... })
// Always clean up in onunload:
cleanupFns.push(unsub)const text = await context.fs.read('config.json')
await context.fs.write('output.txt', 'hello world')
await context.fs.create('notes/new.md', '')
await context.fs.createDirectory('reports')
await context.fs.delete('temp.txt')
const entries = await context.fs.list() // [{ name, path, type }]
const exists = await context.fs.exists('file.md')const theme = await context.settings.get<string>('theme')
await context.settings.set('theme', 'dark')
await context.settings.delete('theme')
const unsub = context.settings.onChange((key, value) => { ... })
context.ui.registerSettings({
id: 'my-plugin-settings',
title: 'My Plugin',
fields: [
{ type: 'toggle', key: 'enabled', label: 'Enable feature', defaultValue: true },
{ type: 'text', key: 'apiKey', label: 'API Key', placeholder: 'sk-...' },
{ type: 'number', key: 'timeout', label: 'Timeout (ms)', defaultValue: 5000, min: 0 },
{ type: 'select', key: 'mode', label: 'Mode',
options: [{ label: 'Fast', value: 'fast' }, { label: 'Accurate', value: 'accurate' }],
defaultValue: 'fast' },
],
})voiden-runner is a headless CLI that executes .void files outside the Voiden desktop app — useful for CI pipelines, scripting, and automation.
If you select Runner during scaffolding, two extra files are generated:
├── build-runner.mjs ← esbuild script → dist/{id}-runner.js
└── src/
└── runner.ts ← RunnerFactory (pure Node.js, no browser APIs)
Electron app (src/plugin.ts) |
voiden-runner CLI (src/runner.ts) |
|
|---|---|---|
| Context | CorePluginContext |
RunnerContext |
| Environment | Browser (Chromium) | Node.js |
| Build output | dist/{id}.js (ESM, via Vite) |
dist/{id}-runner.js (CJS, via esbuild) |
| Distribution | Inside .zip |
GitHub release asset {id}-runner.js |
| Install method | Extensions → Install from file | voiden-runner plugin install {id} |
npm run build:runner
# → dist/{id}-runner.js
# Publish as a GitHub release asset named exactly "{id}-runner.js"
# (matches the naming convention used by core plugins)npm run build # compile renderer + main-process bundles
npm run zip # package into dist/{id}.zipThen in Voiden: Extensions → ⋯ → Install from file → dist/{id}.zip
npm run release # build all bundles + validate manifest
git tag v1.0.0
git push origin v1.0.0GitHub Actions builds, creates the release, and uploads all assets automatically.
The scaffold generates .github/workflows/release.yml. Triggered by pushing a version tag.
- Checks out the repo and sets up Node 20
- Installs dependencies (
npm install) - Builds the renderer bundle →
dist/{id}.js - Renames it to
dist/main.js(required name for the GitHub release asset) - Builds the main-process bundle →
dist/{id}-main.cjs(skips gracefully ifsrc/main-process.tsabsent) - Builds the runner bundle →
dist/{id}-runner.js(only if runner selected) - Creates a GitHub Release and uploads:
| Asset | Purpose |
|---|---|
manifest.json |
Plugin identity, read by the Extensions browser |
changelog.json |
Release history, displayed to users |
dist/{id}.js |
Renderer bundle — same filename as the build output |
src/skill.md |
AI skill description |
dist/{id}-main.cjs |
Main-process bundle (only if main process selected) |
dist/{id}-runner.js |
Runner bundle (only if runner selected) — matches the core-plugin naming convention |
git tag v1.2.0
git push origin v1.2.0- Plugin logic →
src/plugin.ts. Everything is inonload. Unsubscribe inonunload. - Name, icon, permissions →
manifest.json. Bumpversionevery release. - Add a block type → define a TipTap
Node, callcontext.registerVoidenExtension(YourNode). - Add a slash command → call
context.addVoidenSlashGroup(...)inonload. - Add a sidebar panel → write a React component, call
context.registerSidebarTab(...). - Add main-process support after the fact → create
src/main-process.ts. Runnpm run build:main. No other config changes needed. - React to workspace events → declare
"events"in permissions, callcontext.events.on(...).
# Fast iteration loop:
npm run build && npm run zip
# Reinstall the zip in Voiden: Extensions → ⋯ → Install from fileExtensions → ⋯ → Install from file → dist/my-plugin.zip
- Fork VoidenHQ/plugin-registry
- Add your entry to
extensions.json - Push at least one tagged release with
manifest.json,dist/main.js, andsrc/skill.mdattached - Open a pull request
{
"type": "community",
"id": "my-plugin",
"repo": "your-github-username/my-plugin-repo",
"name": "My Plugin",
"description": "One-line description.",
"version": "1.0.0",
"author": "Your Name",
"priority": 30,
"bundled": false,
"voidenVersion": ">=2.0.0",
"mainProcess": false,
"capabilities": {},
"features": []
}Apache-2.0 © Phurpa Tsering