Minimal plugin
Drop a .js file into the plugins/ folder inside your Void Presence userData directory. The app hot-loads it instantly — no restart required.
// my-plugin.js
module.exports = {
id: 'my-plugin', // unique id
nameKey: 'My Plugin', // display name
version: '1.0.0',
builtin: false,
priority: 60, // higher = takes over lower-priority plugins
locked: false,
controls: [],
start(ctx) {
// ctx.readSettings(), ctx.sendLog(), ctx.writeConfig(), etc.
},
stop() {},
onUpdate(cb) {
this._cb = cb
},
getPayload() {
return {
source: 'my-plugin',
details: 'Hello from plugin',
state: 'it works!',
activityType: 'playing',
priority: 60,
}
},
}VoidPlugin — all fields
Every plugin must export an object that satisfies the VoidPlugin interface. Below is a complete reference of every property and method.
| Field | Type | Required | Description |
|---|---|---|---|
id | string | ✓ | Unique plugin identifier. Must be lowercase, no spaces. Used as storage key and in IPC messages. Conflicts with a builtin id are rejected at load time. |
nameKey | string | ✓ | Human-readable display name shown on the plugin card. For external plugins this is a plain string, not a translation key. |
version | string | ✓ | SemVer string, e.g. '1.0.0'. Shown in the UI and used for future upgrade detection. |
builtin | boolean | ✓ | Always set to false for external plugins. The app overrides it to false during loading anyway, but it must be present. |
priority | number | ✓ | Determines which plugin's payload is shown when multiple are active. Higher value wins. Built-in defaults: default = 0, hardware = 50, smtc = 70. Use a value above 70 to override everything. Can be changed at runtime from the UI. |
locked | boolean? | — | When true, the plugin is always enabled and cannot be toggled off by the user. The default plugin uses this. Omit or set false for user-controllable plugins. |
exclusive | boolean? | — | When true, if this plugin returns a non-null payload it completely blocks all lower-priority plugins — they are not evaluated at all. The hardware plugin uses this so hardware stats can't be mixed with default presence. Omit for normal (non-exclusive) behaviour. |
waitForWorker | boolean? | — | Hint for the app: do not send the first presence update until the plugin has received at least one message from its worker thread. The hardware plugin uses this to avoid showing an empty card on startup. Optional. |
controls | PluginControl[] | ✓ | Array of UI controls rendered on the plugin card. Use [] if your plugin has no configurable options. See the Controls section below for all control types. |
start(ctx) | Promise<void> | void | ✓ | Called when the plugin is enabled. Receive a PluginContext for settings, storage and logging. Start timers, workers, or file watchers here. |
stop() | Promise<void> | void | ✓ | Called when the plugin is disabled or the app shuts down. Clean up all timers, workers, and listeners to avoid memory leaks. |
onUpdate(cb) | void | ✓ | The app passes a throttled callback here. Call it whenever your payload changes so the app re-reads getPayload() and pushes the update to Discord. |
onConfigChanged?(key) | void | — | Optional. Called by the app when a shared config file changes (e.g. 'buttons','imageCycles', 'cycles', 'party'). Use it to reset cycle indices and refresh your payload so changes take effect immediately without a restart. |
getPayload() | PresencePayload | null | ✓ | Return the current payload to show in Discord, or null if the plugin has nothing to display right now. When null, the next lower-priority active plugin takes over. |
PluginInfo — what the UI receives
The main process serialises each registered plugin into a PluginInfo object and sends it to the renderer via plugin:list-updated. This is the shape the Plugins page works with — not the full VoidPlugin.
| Field | Type | Description |
|---|---|---|
id | string | Plugin identifier, same as VoidPlugin.id. |
nameKey | string | Display name passed through from the plugin object. |
version | string | SemVer version string. |
builtin | boolean | true for shipped plugins, false for anything loaded from the plugins/ folder. |
priority | number | Current priority. Can be edited by the user in the UI; the app calls setPluginPriority(id, value) to persist it. |
locked | boolean | Whether the enable toggle is disabled in the UI. |
enabled | boolean | Current runtime enabled state from the in-memory enabledState map. Not persisted for builtin plugins — they re-evaluate from settings on next launch. |
exclusive | boolean | Shown as a badge in the UI to indicate this plugin blocks lower-priority ones when active. |
controls | PluginControl[] | Passed verbatim so the renderer can render the correct control widgets without knowing the plugin internals. |
External plugin enabled state is persisted separately in external-plugins-state.json inside userData and read back on the next startAll() call.
PresencePayload fields
Return this object from getPayload(). Return null when the plugin has nothing to show — a lower-priority plugin will take over.
| Field | Type | Description |
|---|---|---|
source | string | Plugin identifier, shown in logs |
details | string | Top text line in Discord |
state | string | Second text line |
activityType | 'playing' | 'streaming' | 'listening' | 'watching' | 'competing' | Activity verb shown above the card |
priority | number | Higher wins. Builtin default = 0, hardware = 50 |
assets | object? | large_image, large_text, small_image, small_text |
buttons | { label, url }[]? | Up to 2 Discord buttons |
party | { size: [current, max] }? | Party size indicator |
PluginContext API
The ctx object passed to start(ctx) gives you access to app internals:
| Method / Property | Returns | Description |
|---|---|---|
ctx.readSettings() | Promise<Settings> | Full app settings (clientId, intervals, toggles…) |
ctx.readFiltersState() | Promise<ConfigState> | Active filters state (musicFilter, hardwareMonitorEnabled…) |
ctx.readConfig(name) | Promise<object | null> | Read a JSON config. Checks plugin-private storage first, then shared userData (buttons, imageCycles, etc.) |
ctx.writeConfig(name, data) | Promise<void> | Write JSON to plugins-data/{pluginId}/{name}.json |
ctx.sendLog(msg, level?) | void | Send a message to the in-app log. Levels: 'info' | 'warn' | 'error' | 'success' |
ctx.userDataPath | string | Absolute path to Electron userData directory |
ctx.pluginDir | string | null | Absolute path to this plugin's folder (null for single-file plugins) |
Shared config files — readConfig reference
ctx.readConfig(name) first looks for plugins-data/{pluginId}/{name}.json, then falls back to the root userData directory. This means you can read the user's own app config from any plugin — useful for reusing images, buttons, and activity type the user already set up.
All files live in the Electron userData directory (ctx.userDataPath). Below are all shared files you can read:
| name argument | File on disk | Shape | Notes |
|---|---|---|---|
'image-cycles' | image-cycles.json | { cycles: [{ largeImage, largeText, smallImage, smallText }] } | Images the user configured on the Config page. Each entry is one slide. Any field can be null. |
'buttons-config' | buttons-config.json | { pairs: [{ label1, url1, label2?, url2? }] } | Button pairs. Each pair can have 1–2 buttons. Discord allows max 2 buttons total per activity. |
'cycles-config' | cycles-config.json | { entries: [{ details: string, state: string }] } | The user's custom presence text slides used by the default plugin. Read-only from external plugins. |
'activity-type' | activity-type.json | { type: 'playing' | 'watching' | 'listening' | 'competing' } | The activity verb shown above the Discord card. Defaults to 'playing'. |
'timer-config' | timer-config.json | { updateIntervalSec: number | null, updateIntervalSecStatus: number | null } | Global update interval in seconds. Min value enforced: 5. Use this to respect the user's refresh rate setting instead of hardcoding your own. |
'party-config' | party-config.json | { entries: [{ sizeCurrent: number | null, sizeMax: number | null }] } | Party size cycles. Only valid when both values are finite and sizeCurrent > 0. |
'timestamp-config' | timestamp-config.json | { mode, rangeMin, rangeMax, persistOffsetSec, nowMode, timeCycles } | Timestamp display mode set by the user. Mostly relevant to the default plugin. |
'settings' | settings.json | See ctx.readSettings() | Use ctx.readSettings() instead — it has built-in validation and defaults. |
Example — read the user's image cycles and activity type in your plugin:
async start(ctx) {
// Read shared image cycles
const imgCfg = await ctx.readConfig('image-cycles')
const cycles = imgCfg?.cycles ?? []
const img = cycles[0] ?? {}
// Read the activity type the user chose globally
const typeCfg = await ctx.readConfig('activity-type')
const activityType = typeCfg?.type ?? 'playing'
// Read the global update interval
const timerCfg = await ctx.readConfig('timer-config')
const intervalMs = ((timerCfg?.updateIntervalSec ?? 30) * 1000)
this._payload = {
source: 'my-plugin',
details: 'Hello',
state: 'world',
activityType,
assets: {
large_image: img.largeImage ?? undefined,
large_text: img.largeText ?? undefined,
},
priority: 60,
}
}Write your own config with ctx.writeConfig(name, data) — it saves to plugins-data/{pluginId}/{name}.json and never touches shared files. Use it to persist plugin-specific settings like API tokens or user preferences.
// Save
await ctx.writeConfig('my-state', { apiToken: 'abc123', lastFetch: Date.now() })
// Read back (reads plugin-private storage first)
const state = await ctx.readConfig('my-state')
const token = state?.apiToken ?? ''Plugin controls
Controls are rendered automatically on the plugin card in the Plugins page. Three types are supported:
controls: [
// Toggle — wired to an IPC method
{
type: 'toggle',
id: 'my-toggle',
labelKey: 'Enable feature',
hintKey: 'Turns the feature on or off',
storageKey: 'myFeatureEnabled', // localStorage key
ipcMethod: 'setMyFeature', // window.electronAPI method name
defaultValue: false,
},
// Select — renders a button group
{
type: 'select',
id: 'my-mode',
labelKey: 'Mode',
storageKey: 'myMode',
ipcMethod: 'setMyMode',
defaultValue: 'fast',
options: [
{ value: 'fast', labelKey: 'Fast' },
{ value: 'normal', labelKey: 'Normal' },
{ value: 'slow', labelKey: 'Slow' },
],
},
// Input — text field, auto-saved after 600 ms
{
type: 'input',
id: 'my-token',
labelKey: 'API token',
hintKey: 'Your secret token',
storageKey: 'myToken',
placeholder: 'paste token here',
defaultValue: '',
},
]Folder plugin + worker.js
For CPU-intensive tasks, ship a worker.js in the same folder. The app detects it via ctx.pluginDir and runs it in a Worker thread. The worker communicates via parentPort.postMessage.
// my-plugin/index.js
const { Worker } = require('worker_threads')
const path = require('path')
let _worker = null
let _updateCb = null
let _payload = null
module.exports = {
id: 'my-plugin',
// ...
start(ctx) {
const workerPath = path.join(ctx.pluginDir, 'worker.js')
_worker = new Worker(workerPath)
_worker.on('message', msg => {
if (msg.type === 'result') {
_payload = { details: msg.value, priority: 60 }
_updateCb?.()
}
})
},
stop() { _worker?.terminate(); _worker = null },
onUpdate(cb) { _updateCb = cb },
getPayload() { return _payload },
}
// my-plugin/worker.js
const { parentPort } = require('worker_threads')
setInterval(() => {
parentPort.postMessage({ type: 'result', value: 'data from worker' })
}, 5000)Native npm modules
Add a package.json to your plugin folder. The app runs npm install automatically. If native .node files are detected, it runs electron-rebuild to recompile them against the correct Electron ABI.
// my-plugin/package.json
{
"name": "my-void-plugin",
"version": "1.0.0",
"dependencies": {
"some-native-module": "1.2.3"
}
}A .rebuilt marker file is created after a successful rebuild so it only happens once. Delete it to force a rebuild.
manifest.json (for publishing)
Add a manifest.json to your folder plugin so the auto-manifest script can pick up your metadata without evaluating your code:
// my-plugin/manifest.json
{
"id": "my-plugin",
"title": "My Plugin",
"description": "What it does",
"author": "YourGitHubUsername",
"version": "1.0.0",
"tags": ["tag1", "tag2"],
"preview": {
"activityType": "playing",
"slides": [
"First preview line",
"Second preview line",
"Third preview line"
]
}
}How the active plugin is chosen
Every tick the engine picks exactly one plugin to show in Discord. The algorithm runs in two passes over all enabled plugins sorted by priority descending:
Pass 1 — exclusive plugins only
for each plugin (highest priority first):
if plugin.exclusive && plugin.getPayload() !== null
→ use this payload, stop
Pass 2 — all plugins
for each plugin (highest priority first):
if plugin.getPayload() !== null
→ use this payload, stop
If nothing returned a payload → Discord presence is clearedPractical rules:
- Set
priorityhigher than the plugin you want to override. Builtin values:default = 0,hardware = 50,smtc = 70. Use75+to beat everything. - Return
nullfromgetPayload()when your plugin has nothing to show — the engine falls through to the next one automatically. exclusive: truemeans your plugin blocks pass 2 entirely if it returns a payload. Only use it if you want to prevent any fallback (like hardware does).- Priority can be changed by the user at runtime from the Plugins page — do not assume your initial value is permanent.
Hot-reload behaviour
The app watches the plugins/ directory with fs.watch. When a file or folder changes:
- File added or changed — the old instance is stopped and unregistered, the new file is
require()d fresh (cache is cleared). If loading succeeds the plugin is registered and a toast appears. - File removed — the plugin is stopped, unregistered, and removed from the UI. No restart needed.
- Load error — the error is logged, the plugin stays unregistered. The previously running version is already gone. Fix the file and save again to retry.
- Changes are debounced by 500 ms to handle editors that write files in multiple steps.
- If a folder plugin has a
package.jsonwithoutnode_modules/,npm installruns automatically before loading. This can take a few seconds on first install — the Logs view opens automatically so you can see progress.
Install deep link
Plugins can be installed directly from a URL via the custom protocol. The app handles the link, downloads the file or folder, and hot-loads it — no manual file copying needed.
# Single .js file voidpresence://install-plugin?url=https://raw.githubusercontent.com/you/repo/main/plugins/my-plugin.js # Folder plugin (GitHub tree URL — app uses GitHub API to download all files) voidpresence://install-plugin?url=https://github.com/you/repo/tree/main/plugins/my-plugin
The Plugins page on this site generates these links automatically from plugins-manifest.json. If you publish via PR, the install button appears for free once your plugin is merged.
For folder plugins the app calls the GitHub Contents API to walk the directory tree and download every file individually. The folder is saved to userData/plugins/{pluginId}/. Existing node_modules/ and .rebuilt are preserved across reinstalls if the folder already exists.
Input controls and storage
input controls are the only control type that works reliably for external plugins. When the user types, the renderer saves the value automatically via plugins:set-storage IPC after a 600 ms debounce — no extra wiring needed.
toggle and select controls call window.electronAPI[ipcMethod](value) directly. For that to work the method must be registered in the app's ElectronAPI. Builtin plugins have their methods registered (e.g. setHardwareMonitor, setBarStyleConfig). External plugins have no way to register new IPC handlers, so toggle and select will render but clicking them will silently do nothing. Use input for all user-facing settings in external plugins.
Values are stored in userData/plugin-{pluginId}-state.json under the storageKey you specified. Read it back in your plugin like this:
// In start(ctx) or in your poll function:
const fs = require('fs')
const path = require('path')
function readState(ctx, key, fallback = '') {
try {
const file = path.join(ctx.userDataPath, `plugin-${module.exports.id}-state.json`)
const data = JSON.parse(fs.readFileSync(file, 'utf-8'))
return data[key] ?? fallback
} catch {
return fallback
}
}
// Usage
const token = readState(ctx, 'myToken', '')
const url = readState(ctx, 'steamUrl', 'https://steamcommunity.com/id/me')Alternatively use ctx.readConfig(name) which does the same lookup asynchronously and is already built into the context.
Limitations and gotchas
- No native modules in single-file plugins. A bare
.jsfile cannot have anode_modules/next to it — the watcher only processes one file. To use npm packages, use a folder plugin withpackage.json. - id must not conflict with builtins. The reserved ids are
default,smtc,hardware. Plugins with these ids are rejected at load time with a warning in the log. - Plugins run in the main process. A crash or infinite loop in your plugin can hang the entire app. Use
try/catcharound all async operations and offload heavy work to aworker.js. - Do not block the event loop. Synchronous CPU work longer than ~50 ms will stutter the UI. Use
async/await,setTimeout, or a Worker thread. - Always implement
stop()properly. Timers and workers that are not cleaned up keep running after the plugin is disabled and cause double-update bugs. Set every handle tonullafter clearing it. onConfigChangedkeys are limited. The app only notifies on three keys:'imageCycles','buttons','cycles'. Changes to other shared configs (timer, party, activity type) are not notified — read them fresh on each tick instead.- toggle and select controls don't work in external plugins. They render correctly but clicking them calls
window.electronAPI[ipcMethod]which only exists for builtin-registered methods. For external plugins useinputfor all settings — it's the only type with a universal IPC handler (plugins:set-storage). - Discord limits on payload fields.
detailsandstatemax 128 characters each. Buttons max 2, button label max 32 characters, URL must start withhttps://. Exceeding limits silently truncates or drops the field. - Hot-reload clears
requirecache. On each hot-reload all module-level variables are reset to their initial values. If you persist state only in memory it will be lost on every save. Usectx.writeConfigor the state file to survive reloads. - electron-rebuild runs only once per folder. A
.rebuiltmarker file prevents redundant rebuilds. If you update a native dependency version, delete.rebuiltmanually to force a rebuild.
Publishing your plugin
- Add
author,description,tagsandpreview.slidesto yourmodule.exports(or tomanifest.jsonfor folder plugins). - Open a pull request to github.com/Devollox/void-web and place your
.jsfile or folder insideplugins/. - After merge, run
npm run build:manifest— it auto-generatesplugins-manifest.jsonfrom your source, including slides and metadata. - Your plugin appears on the Plugins page and can be installed with one click via the deep link
voidpresence://install-plugin?url=….