Plugin Development

Build and publish external plugins for Void Presence.

What is a plugin?

A plugin is a single .js file or a folder placed in the Void Presence plugins directory. It exports a standard object and receives a PluginContext with settings, storage and logging.

Browse existing plugins

See what the community has already built and install with one click.

Go to plugins

Submit your plugin

Open a PR to the void-web repository and add your plugin to the plugins/ folder.

GitHub → plugins/

Plugins run in the Electron main process. Avoid blocking the event loop — use async/await or spawn a worker.js.

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.

FieldTypeRequiredDescription
idstringUnique 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.
nameKeystringHuman-readable display name shown on the plugin card. For external plugins this is a plain string, not a translation key.
versionstringSemVer string, e.g. '1.0.0'. Shown in the UI and used for future upgrade detection.
builtinbooleanAlways set to false for external plugins. The app overrides it to false during loading anyway, but it must be present.
prioritynumberDetermines 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.
lockedboolean?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.
exclusiveboolean?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.
waitForWorkerboolean?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.
controlsPluginControl[]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> | voidCalled when the plugin is enabled. Receive a PluginContext for settings, storage and logging. Start timers, workers, or file watchers here.
stop()Promise<void> | voidCalled when the plugin is disabled or the app shuts down. Clean up all timers, workers, and listeners to avoid memory leaks.
onUpdate(cb)voidThe 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)voidOptional. 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 | nullReturn 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.

FieldTypeDescription
idstringPlugin identifier, same as VoidPlugin.id.
nameKeystringDisplay name passed through from the plugin object.
versionstringSemVer version string.
builtinbooleantrue for shipped plugins, false for anything loaded from the plugins/ folder.
prioritynumberCurrent priority. Can be edited by the user in the UI; the app calls setPluginPriority(id, value) to persist it.
lockedbooleanWhether the enable toggle is disabled in the UI.
enabledbooleanCurrent runtime enabled state from the in-memory enabledState map. Not persisted for builtin plugins — they re-evaluate from settings on next launch.
exclusivebooleanShown as a badge in the UI to indicate this plugin blocks lower-priority ones when active.
controlsPluginControl[]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.

FieldTypeDescription
sourcestringPlugin identifier, shown in logs
detailsstringTop text line in Discord
statestringSecond text line
activityType'playing' | 'streaming' | 'listening' | 'watching' | 'competing'Activity verb shown above the card
prioritynumberHigher wins. Builtin default = 0, hardware = 50
assetsobject?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 / PropertyReturnsDescription
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?)voidSend a message to the in-app log. Levels: 'info' | 'warn' | 'error' | 'success'
ctx.userDataPathstringAbsolute path to Electron userData directory
ctx.pluginDirstring | nullAbsolute 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 argumentFile on diskShapeNotes
'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.jsonSee 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 cleared

Practical rules:

  • Set priority higher than the plugin you want to override. Builtin values: default = 0, hardware = 50, smtc = 70. Use 75+ to beat everything.
  • Return null from getPayload() when your plugin has nothing to show — the engine falls through to the next one automatically.
  • exclusive: true means 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.json without node_modules/, npm install runs 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 .js file cannot have a node_modules/ next to it — the watcher only processes one file. To use npm packages, use a folder plugin with package.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/catch around all async operations and offload heavy work to a worker.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 to null after clearing it.
  • onConfigChanged keys 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 use input for all settings — it's the only type with a universal IPC handler (plugins:set-storage).
  • Discord limits on payload fields. details and state max 128 characters each. Buttons max 2, button label max 32 characters, URL must start with https://. Exceeding limits silently truncates or drops the field.
  • Hot-reload clears require cache. 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. Use ctx.writeConfig or the state file to survive reloads.
  • electron-rebuild runs only once per folder. A .rebuilt marker file prevents redundant rebuilds. If you update a native dependency version, delete .rebuilt manually to force a rebuild.

Publishing your plugin

  1. Add author, description, tags and preview.slides to your module.exports (or to manifest.json for folder plugins).
  2. Open a pull request to github.com/Devollox/void-web and place your .js file or folder inside plugins/.
  3. After merge, run npm run build:manifest — it auto-generates plugins-manifest.json from your source, including slides and metadata.
  4. Your plugin appears on the Plugins page and can be installed with one click via the deep link voidpresence://install-plugin?url=….