Skip to the content.

Plugin anatomy

← Back to index

One flat repository, one plugin

plugin.json        the manifest; validated by the bridge at install and by the host at boot
main.ts            default-exports definePlugin({...}); its id must equal plugin.json's
src/**             optional, imported from main.ts
README.md          optional

No build step, no dist/, no package.json needed for the bridge (the template has one for your own check script). The bridge imports main.ts as TypeScript straight from the clone and compiles it into the same bundle as the host. Anything you import must be in the repository: the bridge runs no package manager, and @vrcnext/plugin-api is aliased to the host’s own copy.

The manifest is documented on its own page: plugin.json.

The module contract

main.ts default-exports one object. The host verifies it has a string id equal to the manifest’s and a callable activate before doing anything else.

import { definePlugin, type PluginId, type SettingsSchema } from '@vrcnext/plugin-api';

export default definePlugin({
  id: 'my-plugin' as PluginId,
  settings,                 // optional
  activate(ctx) { /* … */ },
  deactivate() { /* … */ }, // optional
});

Why definePlugin

It is an identity function whose only job is to pin the settings schema generic. Without it, an inline plugin object widens S to SettingsSchema and every setting degrades to boolean | number | string. With it, ctx.settings.get('mode') keeps its literal union.

The id must match three times

id in plugin.json, id on the definePlugin object, and the clone directory ~/.vrcnext-plugins/plugins/<id> are the same string, [a-z0-9][a-z0-9-]{1,39}. It becomes a filesystem path on the bridge, which is why the format is narrow and the bridge checks it before touching disk.

Lifecycle

Phase What happens
Install You confirm on the desktop. The bridge clones, validates plugin.json, scans the source policy, rebuilds the bundle. Nothing is executed. Reload to load it.
Enable The consent modal lists the declared permissions. Enable → apiVersion checked → activate(ctx) called.
Disable deactivate() awaited → every ctx registration disposed → pending settings writes flushed.
Update Confirmed on the desktop. Fresh clone, re-validated, swapped in only if it passes, rebuilt. Reload to run it. Settings and saved grants survive.
Uninstall Confirmed on the desktop. Clone, state entries and settings removed, rebuilt. The card shows Removed — reload until you do.

If activate throws, the host disposes everything the plugin registered and rolls the enabled flag back, so the UI never shows a plugin as running when it is not.

Every gated capability on ctx throws a PermissionError when its category is not declared — it never silently no-ops, so a missing declaration is found on the first run rather than in a user’s bug report.

Disposal

Everything registered through ctx is tracked and reversed automatically:

activate(ctx) {
  ctx.events.on('friendTimelineEvent', handler);  // auto-removed
  ctx.ui.addNavTab({ /* … */ });                  // auto-removed
  ctx.router.get('stats', handler);               // auto-unmounted

  // Anything you create yourself, register explicitly:
  const timer = setInterval(tick, 30_000);
  ctx.disposables.add(() => { clearInterval(timer); });
}

Disposers run in reverse registration order, and one throwing does not strand the rest.

ctx.signal

Aborts on deactivate. ctx.http.fetch attaches it for you, so a request still in flight when the plugin is disabled is cancelled; pass it yourself to anything else long-lived:

const backlog = await ctx.gameLog.history(ctx.signal);

The context object

Member Permission Page
ctx.id, ctx.version — Manifest identity.
ctx.logger — Logging. .scoped('sync') for sub-scopes.
ctx.settings — Settings
ctx.permissions — Permissions: has(p), request(p).
ctx.events, ctx.deepLinks host:events Events & actions, Routes & deep links
ctx.bridge host:actions, host:intercept Events & actions
ctx.http network Permissions
ctx.ui — UI injection
ctx.notifications notifications Notifications
ctx.native native VRCNext Bridge
ctx.contextMenu context-menu Context menus
ctx.osc osc OSC
ctx.gameLog gamelog Game log
ctx.router routes Routes & deep links
ctx.clipboard clipboard Permissions
ctx.disposables, ctx.signal — Above.

← Getting started · plugin.json →