Build Your First Plugin
This guide walks you through building a Voiden plugin from scratch. We'll start with a minimal plugin and progressively add features so you can see how each API works.
Step 1: Set Up the Project
Create a new directory for your plugin and initialize it:
mkdir my-voiden-plugin
cd my-voiden-plugin
npm init -y
Install the SDK and dev dependencies:
npm install @voiden/sdk
npm install --save-dev typescript esbuild @types/react
Your plugin will also need React as a peer dependency (Voiden provides it at runtime):
Update your package.json:
{
"name": "my-voiden-plugin",
"version": "1.0.0",
"type": "module",
"main": "dist/main.js",
"scripts": {
"build": "node esbuild.config.mjs",
"dev": "node esbuild.config.mjs --watch"
},
"dependencies": {
"@voiden/sdk": "^1.0.6"
},
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"esbuild": "^0.24.0",
"typescript": "^5.9.3",
"@types/react": "^18.3.27"
}
}
Step 2: Configure TypeScript
Create a tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Step 3: Configure the Build
Create esbuild.config.mjs:
import { build, context } from "esbuild";
import { copyFileSync, mkdirSync } from "fs";
const isWatch = process.argv.includes("--watch");
const buildOptions = {
entryPoints: ["src/index.tsx"],
outfile: "dist/main.js",
bundle: true,
format: "esm",
platform: "browser",
target: "es2020",
// These are provided by Voiden at runtime — don't bundle them
external: [
"react",
"react-dom",
"react/jsx-runtime",
"@voiden/sdk",
"@voiden/sdk/ui",
],
logLevel: "info",
};
// Copy manifest.json to dist/
mkdirSync("dist", { recursive: true });
copyFileSync("src/manifest.json", "dist/manifest.json");
if (isWatch) {
const ctx = await context(buildOptions);
await ctx.watch();
console.log("Watching for changes...");
} else {
await build(buildOptions);
}
Voiden loads external plugins as single ESM bundles (main.js). esbuild bundles your TypeScript, React components, and dependencies into one file while keeping Voiden's runtime dependencies external.
Step 4: Create the Manifest
Create src/manifest.json:
{
"id": "my-voiden-plugin",
"type": "community",
"name": "My Voiden Plugin",
"description": "A simple plugin that adds a greeting slash command",
"version": "1.0.0",
"author": "Your Name",
"enabled": true,
"priority": 50,
"readme": "Adds a /hello slash command that inserts a greeting into your document.",
"capabilities": {
"slashCommands": {
"groups": [
{
"name": "Greetings",
"commands": ["Insert greeting"]
}
]
}
},
"dependencies": {
"core": "^1.0.0",
"sdk": "^1.0.0"
},
"features": [
"Slash command to insert a greeting"
]
}
Step 5: Write the Plugin
Create src/index.tsx:
import type { Plugin, PluginContext } from "@voiden/sdk";
export default function myPlugin(context: PluginContext): Plugin {
return {
onload(ctx: PluginContext) {
// Register a slash command group
ctx.addVoidenSlashGroup({
name: "greetings",
title: "Greetings",
commands: [
{
name: "hello",
label: "Say Hello",
description: "Insert a friendly greeting",
slash: "/hello",
icon: "Smile",
action: (editor) => {
editor.commands.insertContent({
type: "paragraph",
content: [
{
type: "text",
text: "Hello from my first Voiden plugin!",
},
],
});
},
},
],
});
},
onunload() {
// Nothing to clean up for this simple plugin
},
};
}
That's it! This plugin registers a /hello slash command that inserts a greeting paragraph into the editor.
Step 6: Build and Package
npm run build
This creates:
dist/main.js— your bundled plugindist/manifest.json— copied fromsrc/
Package as a ZIP
Voiden installs community plugins from .zip files. Package your dist/ output into a ZIP:
cd dist && zip -r ../my-voiden-plugin.zip manifest.json main.js && cd ..
You should now have a my-voiden-plugin.zip file at the root of your project containing both manifest.json and main.js.
Add a package script to your package.json so you can build and zip in one step:
{
"scripts": {
"build": "node esbuild.config.mjs",
"package": "npm run build && cd dist && zip -r ../my-voiden-plugin.zip manifest.json main.js"
}
}
Then just run npm run package.
Step 7: Install and Test in Voiden
Voiden has a built-in extension installer that lets you install plugins directly from a ZIP file — no manual file copying needed.
Install from ZIP
- Open Voiden
- Open the Extension Browser (click the puzzle piece icon in the sidebar, or go to Settings > Extensions)
- Click the "Install from file" button at the top of the extension browser
- Select your
my-voiden-plugin.zipfile from the file picker - Voiden will validate the ZIP, extract it, and install your plugin automatically
That's it — your plugin is now installed and active.
Verify It Works
- Open any
.voidfile (or create a new one) - Type
/helloin the editor - You should see your "Say Hello" command in the slash menu
- Select it — a greeting paragraph is inserted into the document
What Voiden Validates
When you install from ZIP, Voiden checks the following:
- The ZIP contains both
manifest.jsonandmain.js(either at the root or inside a single top-level folder) - The
manifest.jsonis valid JSON - The manifest includes the required fields:
id,name, andversion - The plugin ID does not conflict with a core extension
If any of these checks fail, Voiden will show an error message explaining what went wrong.
Where Plugins Are Stored
After installation, your plugin files live in Voiden's user data directory:
# macOS
~/Library/Application Support/Voiden/extensions/my-voiden-plugin/
├── manifest.json
└── main.js
# Linux
~/.config/Voiden/extensions/my-voiden-plugin/
# Windows
%APPDATA%/Voiden/extensions/my-voiden-plugin/
Voiden also maintains an installed.json registry file alongside the extension folders to track all installed community plugins.
Managing Your Plugin
Once installed, you can manage your plugin from the Extension Browser:
- Enable / Disable — Toggle your plugin on or off without uninstalling it
- Uninstall — Remove the plugin entirely
- Update — Install a new version by re-installing from a new ZIP file (same plugin ID will overwrite the previous version)
Iterating During Development
When you're actively developing, the workflow looks like this:
- Make changes to your source code
- Run
npm run package(builds and creates the ZIP) - In Voiden, click "Install from file" and select the new ZIP
- The plugin is updated in place — Voiden replaces the previous version
- Restart Voiden to pick up the changes
During development you can run npm run dev to have esbuild watch for changes and rebuild automatically. You'll still need to re-package and re-install the ZIP, but it saves the manual build step.
Going Further: Add a Sidebar Panel
Let's extend the plugin with a sidebar panel that shows a simple React component.
Update src/index.tsx:
import React, { useState } from "react";
import type { Plugin, PluginContext } from "@voiden/sdk";
// A simple sidebar component
function MySidebar() {
const [count, setCount] = useState(0);
return (
<div style={{ padding: "16px" }}>
<h3 style={{ marginBottom: "8px" }}>My Plugin</h3>
<p>Button clicked {count} times</p>
<button
onClick={() => setCount((c) => c + 1)}
style={{
padding: "6px 12px",
borderRadius: "4px",
border: "1px solid #444",
background: "#2a2a2a",
color: "#fff",
cursor: "pointer",
}}
>
Click me
</button>
</div>
);
}
export default function myPlugin(context: PluginContext): Plugin {
return {
onload(ctx: PluginContext) {
// Register the slash command
ctx.addVoidenSlashGroup({
name: "greetings",
title: "Greetings",
commands: [
{
name: "hello",
label: "Say Hello",
description: "Insert a friendly greeting",
slash: "/hello",
icon: "Smile",
action: (editor) => {
editor.commands.insertContent({
type: "paragraph",
content: [
{ type: "text", text: "Hello from my first Voiden plugin!" },
],
});
},
},
],
});
// Register a sidebar tab
ctx.registerSidebarTab("right", {
id: "my-plugin-sidebar",
title: "My Plugin",
icon: "Zap",
component: MySidebar,
});
},
onunload() {},
};
}
Rebuild and restart Voiden. You'll see a new tab in the right sidebar.
Going Further: Add a Status Bar Button
You can add a button to the bottom status bar that opens a custom tab:
import React from "react";
import type { Plugin, PluginContext } from "@voiden/sdk";
function MyExplorer() {
return (
<div style={{ padding: "16px" }}>
<h2>My Explorer</h2>
<p>This is a custom tab opened from the status bar.</p>
</div>
);
}
export default function myPlugin(context: PluginContext): Plugin {
return {
onload(ctx: PluginContext) {
// Register the component so tabs can find it
ctx.registerPanel("main", {
id: "my-explorer",
title: "My Explorer",
component: MyExplorer,
});
// Add a button to the status bar
ctx.registerStatusBarItem({
id: "my-explorer-btn",
icon: "Compass",
label: "Explorer",
tooltip: "Open My Explorer",
position: "left",
onClick: () => {
ctx.addTab("main", {
id: "my-explorer",
icon: "Compass",
title: "My Explorer",
props: {},
component: MyExplorer,
});
},
});
},
onunload() {},
};
}
Going Further: Hook Into the Request Pipeline
Plugins can modify requests before they're sent and process responses after they arrive. This is how plugins like Voiden Faker inject dynamic data.
import type { Plugin, PluginContext } from "@voiden/sdk";
export default function myPlugin(context: PluginContext): Plugin {
return {
onload(ctx: PluginContext) {
// Modify requests before sending
ctx.onBuildRequest(async (request, editor) => {
// Add a custom header to every request
if (!request.headers) request.headers = [];
request.headers.push({
key: "X-Plugin-Version",
value: "1.0.0",
enabled: true,
});
return request;
});
// Process responses after receiving
ctx.onProcessResponse(async (response) => {
console.log("Response received:", response.status);
});
},
onunload() {},
};
}
When modifying requests in onBuildRequest, never expand environment variables (text in {{double braces}}). Voiden handles variable substitution securely in a separate stage.
Going Further: Expose Helpers for Other Plugins
If your plugin provides utility functions that other plugins might need, you can expose them:
ctx.exposeHelpers({
formatTimestamp: (date: Date) => date.toISOString(),
parseCSV: (text: string) => text.split("\n").map((row) => row.split(",")),
});
Other plugins can access your helpers via:
const myHelpers = ctx.helpers.from("my-voiden-plugin");
if (myHelpers) {
const formatted = myHelpers.formatTimestamp(new Date());
}
Helpers should be pure functions — no side effects, no network calls, no file access. Think data transformation and parsing only.
Project Structure Recap
After following this guide, your project should look like:
my-voiden-plugin/
├── src/
│ ├── manifest.json # Plugin metadata
│ └── index.tsx # Plugin entry point
├── dist/
│ ├── manifest.json # Copied during build
│ └── main.js # Bundled output
├── package.json
├── tsconfig.json
└── esbuild.config.mjs
For larger plugins, you'll want to split into more files:
my-voiden-plugin/
├── src/
│ ├── manifest.json
│ ├── index.tsx # Plugin entry point
│ ├── components/ # React components
│ │ ├── Sidebar.tsx
│ │ └── Explorer.tsx
│ ├── lib/ # Utility functions
│ │ └── parser.ts
│ └── nodes/ # Custom TipTap nodes (advanced)
│ └── MyBlock.tsx
├── dist/
├── package.json
├── tsconfig.json
└── esbuild.config.mjs
Next Steps
- Plugin API Reference — Full reference for every method on
PluginContext - Manifest Reference — Complete schema for
manifest.json - Browse the core-extensions source code for real-world examples of advanced patterns like custom TipTap blocks and complex pipeline hooks