API reference
Everything exported from @vrcnext/plugin-api (version 0.2.0). Types are authoritative in
the package; this is the map. The package is types plus a little pure logic — manifest parsing,
the permission vocabulary, semver, settings defaults — with no DOM and no host dependency, so
you can unit-test against it.
Entry
function definePlugin<const S extends SettingsSchema>(plugin: VrcnextPlugin<S>): VrcnextPlugin<S>;
interface VrcnextPlugin<S> {
readonly id: PluginId; // must equal "id" in plugin.json
readonly settings?: S;
activate(ctx: PluginContext<S>): void | Promise<void>;
deactivate?(): void | Promise<void>;
}
PluginContext
| Member | Type | Permission |
|---|---|---|
id |
PluginId |
— |
version |
string |
— |
logger |
Logger |
— |
settings |
SettingsStore<S> |
— |
permissions |
PermissionsApi |
— |
events |
EventBus |
host:events, event names checked against events |
bridge |
Bridge |
host:actions (send, request; action names checked against actions), host:intercept (interceptOutbound) |
http |
HttpApi |
network, hosts checked against hosts |
ui |
UiApi |
— |
notifications |
NotificationsApi |
notifications |
osc |
OscApi |
osc |
native |
NativeApi |
native |
gameLog |
GameLogApi |
gamelog |
vrchat |
VrchatApi |
vrchat |
sql |
SqlApi |
sql |
deepLinks |
DeepLinkApi |
host:events with openDeepLink in events |
router |
RouterApi |
routes |
contextMenu |
ContextMenuApi |
context-menu |
clipboard |
ClipboardApi |
clipboard |
disposables |
DisposableBag |
— |
signal |
AbortSignal |
— |
A gated member called without its category throws PermissionError; a concrete target the user
has not confirmed prompts first. See Permissions.
Permissions
type Permission = 'host:events' | 'host:actions' | 'host:intercept' | 'network' | 'notifications'
| 'native' | 'osc' | 'gamelog' | 'vrchat' | 'context-menu' | 'routes' | 'clipboard';
type PermissionTone = 'low' | 'medium' | 'high';
interface PermissionInfo { readonly description: string; readonly tone: PermissionTone }
const PERMISSIONS: Readonly<Record<Permission, PermissionInfo>>;
const PERMISSION_NAMES: readonly Permission[];
function isPermission(value: unknown): value is Permission;
function permissionInfo(permission: Permission): PermissionInfo;
class PermissionError extends Error {
readonly permission: Permission;
readonly target: string | undefined; // host, action, event or service/method; undefined for a category
}
interface PermissionsApi {
has(permission: Permission): boolean; // cheap; safe per event
request(permission: Permission): Promise<boolean>; // optionalPermissions only; rejects otherwise
}
Manifest and identifiers
const MANIFEST_FILENAME = 'plugin.json';
const MANIFEST_LIMITS = { descriptionChars: 200, tags: 8 };
const PLUGIN_TAGS: readonly string[]; // suggested tags; any string is accepted
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]{1,39}$/;
type PluginId; // branded string
function isPluginId(value: string): value is PluginId;
function parsePluginId(value: string): PluginId | undefined;
interface PluginSummary {
readonly id: PluginId; readonly name: string; readonly version: string;
readonly description: string; readonly tags: readonly PluginTag[];
readonly permissions: readonly Permission[];
readonly optionalPermissions: readonly Permission[];
readonly actions: readonly string[]; readonly events: readonly string[];
readonly hosts: readonly string[];
}
interface PluginManifest extends PluginSummary {
readonly apiVersion: string; readonly author?: string; readonly homepage?: string;
readonly dependencies?: readonly PluginId[]; // activation order
}
function parsePluginManifest(raw: unknown): ManifestParseResult; // { manifest | undefined, errors }
Semver
The slice this project needs: plain MAJOR.MINOR.PATCH versions, and ranges that are exact,
^, ~ or a space-separated comparator list.
type Version = readonly [major: number, minor: number, patch: number];
function parseVersion(text: string): Version | undefined;
function isVersion(text: string): boolean;
function compareVersions(a: Version, b: Version): -1 | 0 | 1;
function parseRange(text: string): ((version: Version) => boolean) | undefined;
function isRange(text: string): boolean;
function satisfies(version: string, range: string): boolean; // malformed input never satisfies
Time
type TimeInput = string | number | Date;
interface Timestamped { readonly timestamp: TimeInput }
function timeAgo(iso: string, now?: number): string; // "just now", "3 minutes ago", "2 hours ago", "5 days ago"
function formatDuration(ms: number): string; // "45 minutes", "3 hours", "5 days", "4 months", "2 years"
function newestFirst<T extends Timestamped>(items: readonly T[] | undefined): readonly T[];
timeAgo(at, now?) takes an ISO string, an epoch or a Date, and reports one unit: just now,
3 minutes ago, 2 hours ago, 5 days ago, 3 months ago, 2 years ago. formatDuration(ms)
picks the same coarse unit for a length of time rather than a point in one, and is '' for zero.
newestFirst(items) orders anything carrying a timestamp — a VrcTimelineEvent, your own
records — newest first, and takes undefined for “nothing yet”. An item whose timestamp cannot
be parsed is left out rather than sorted to 1970, since it cannot be placed in the order at
all. Use it instead of sorting a timeline yourself: ctx.vrchat.userTimeline arrives in no
guaranteed order.
Languages
interface SpokenLanguage {
readonly tag: string; // language_deu
readonly label: string; // Deutsch
readonly flag?: string; // 🇩🇪 — absent where no country stands for the language
}
const LANGUAGE_LABELS: Readonly<Record<string, string>>;
const LANGUAGE_FLAGS: Readonly<Record<string, string>>;
function spokenLanguages(tags: readonly string[] | undefined): readonly SpokenLanguage[];
function languageLabels(tags: readonly string[] | undefined): readonly string[];
function languageFlags(tags: readonly string[] | undefined): readonly string[];
function languageFlag(tag: string): string | undefined;
A profile’s spoken languages are tags, not the languages array beside them: VRChat sends
language_deu, and VRCNext sends that array empty on every profile payload and builds its own
pills from the tags. Pass user.tags to any of these; non-language tags are ignored.
spokenLanguages is the one to reach for — a label and a flag per language, from one source.
languageFlags is the flags alone, skipping the unflagged ones, so the result is either a run of
flags or empty and can be used as a prefix without a dangling separator.
All three lists come back alphabetically by label, not in the order the profile lists its
tags. A profile’s order is whatever it was filled in as, which puts the same two languages in
different orders on two profiles and leaves a row of flags out of step with a list of names
elsewhere in the same message. By label rather than by tag, because the label is what a reader
sees: 日本語 sorts by its own text, not where jpn would put it.
The flags are countries, not languages, and the pairing matches VRCNext’s own table: English is 🇺🇸 rather than 🇬🇧, Portuguese 🇧🇷, Chinese 🇨🇳, Arabic 🇸🇦. It is a display convention, not a fact about a speaker. Languages whose speakers no single flag represents — Bengali, Telugu, Cantonese, Wu, Scots — carry a label and no flag, as do the sign languages and Toki Pona. A tag in neither table falls back to its upper-cased code, so a language VRChat adds later degrades instead of disappearing.
language_zxx is ISO 639-2’s “no linguistic content”, carried by a small number of real
profiles; it is labelled No language rather than left as the bare code, and named for what the
standard says rather than for any guess about why someone set it.
Two notes where this deviates from the app. VRCNext names 28 tags; 48 are in active use, so
Italian, Croatian, Greek, Bulgarian and others that it renders as a bare ITA are named here.
And VRCNext labels dse as DGS — but ISO 639-3 dse is Dutch Sign Language and DGS is
the German one, gsg; both are named correctly here.
Templates
type TemplateValue = string | number | boolean | null | undefined | TemplateValue[] | { [key: string]: TemplateValue };
function renderTemplate(template: string, values: Record<string, TemplateValue>, options?: { dropEmptyLines?: boolean }): string;
function validateTemplate(template: string): TemplateError | undefined;
function templatePlaceholders(template: string): readonly string[];
class TemplateError extends Error {}
A small, safe template language for user-editable messages — Jinja-shaped, evaluated by a walker over a fixed grammar, so a template can never run code or reach a prototype:
Player {name} joined {name} is short for {{ name }}
18+: {{ "yes" if ageVerified else "no" }} Python-style conditional
Rejoin: {{ rejoin ? "yes" : "no" }} C-style conditional
{% if inGroup == false %}NOT A MEMBER{% elif inGroup %}member{% else %}unknown{% endif %}
{{ pcRank | upper }} · {{ avatar | default: "unknown avatar" }} · {{ tags | join: "+" }}
Expressions: literals, dotted names, == != < <= > >=, and or not (or && || !), +/-,
both conditional forms, and filters upper lower capitalize trim length size default yesno join
replace truncate get (size is length under Liquid’s name; get: "key" reads a field). elseif
is accepted as a spelling of elif. A missing name renders empty; a line whose placeholders all rendered empty is
dropped (dropEmptyLines). A broken template throws TemplateError: an unknown filter, a tag
that is never closed ({{name} included), or nesting deeper than MAX_TEMPLATE_DEPTH (32); validateTemplate reports
it without rendering. Small pure helpers plugins keep needing live in the api package, not in
plugins.
Settings
SettingsSchema · SettingsValues<S> · SettingsStore<S> · SettingSpec · SettingBase ·
InferSetting<S> · SettingPredicate · SelectOption<V> · ObjectToggle · TOGGLE_KEY ·
defaultsFor() · defaultOf() · coerceSetting() · settingFlag() · defineCustomSetting()
See Settings for the prose. Fifteen kinds:
type SettingSpec =
| BooleanSetting | NumberSetting | StringSetting | ColorSetting | TimeSetting
| SelectSetting<V> | MultiSelectSetting<V>
| UserSetting | WorldSetting | AvatarSetting | GroupSetting | InstanceSetting // EntitySetting
| EmbedSetting | ObjectSetting<F> | ListSetting<I> | CustomSetting<T>;
interface SettingBase {
readonly label: string;
readonly description?: string;
readonly hidden?: SettingPredicate; // boolean, or (values) => boolean
readonly disabled?: SettingPredicate;
}
| Spec | Extra fields | Value |
|---|---|---|
BooleanSetting |
— | boolean |
NumberSetting |
min max step slider markers stickToMarkers integer unit |
number |
StringSetting |
placeholder multiline maxLength format: 'text' \| 'password' \| 'url' |
string |
ColorSetting |
— | '#rrggbb' |
TimeSetting |
— | 'HH:MM' |
SelectSetting<V> |
options |
V |
MultiSelectSetting<V> |
options min max |
readonly V[] |
EntitySetting |
multiple scopes placeholder |
an id, or readonly string[] |
EmbedSetting |
variables |
EmbedTemplate |
ObjectSetting<F> |
fields collapsed toggle (a switch on the header; state under TOGGLE_KEY, fields hidden while off) |
SettingsValues<F> |
ListSetting<I> |
item titleKey addLabel max |
readonly SettingsValues<I>[] |
CustomSetting<T> |
coerce render |
T |
interface SettingsStore<S> {
readonly values: SettingsValues<S>;
get<K extends keyof S>(key: K): SettingsValues<S>[K];
set<K extends keyof S>(key: K, value: SettingsValues<S>[K]): Promise<void>;
reset(): Promise<void>;
onChange(listener: (values: SettingsValues<S>) => void): () => void;
}
interface CustomSettingHost<T> {
readonly value: T;
setValue(next: T): Promise<void>; // rejects when `coerce` refuses
setError(message: string | undefined): void;
onChange(listener: (value: T) => void): () => void;
}
ENTITY_SCOPES lists every scope per kind; entityScopes(spec) resolves a spec’s to picker order,
and isEntityId(kind, id) checks one id.
Discord embeds
EmbedTemplate · EmbedField · EMBED_COLORS · EMBED_LIMITS · EMPTY_EMBED ·
completeEmbed() · coerceEmbed()
function renderEmbed(template: EmbedTemplate, values: TemplateValues,
options?: { at?: Date; onError?: (error: Error) => void }): DiscordEmbed | undefined;
function discordWebhookPayload(embed: DiscordEmbed,
options?: { username?: string; content?: string }): DiscordWebhookPayload;
function parseEmbedColor(text: string): number | undefined;
Every text of the template is rendered with the template engine, empty parts are dropped, URLs
that are not http(s) are refused, everything is cut to Discord’s limits, and undefined comes
back when nothing is left. discordWebhookPayload always sets allowed_mentions: { parse: [] }.
Posting to a Discord webhook
function isDiscordWebhookUrl(url: string): boolean;
function webhookFailure(status: number): string;
function postWebhook(options: {
http: HttpApi; logger: Logger; url: string;
payload: DiscordWebhookPayload; label?: string;
}): Promise<{ ok: boolean; status?: number; error?: string }>;
postWebhook is the whole send: it refuses a URL that is not a discord.com webhook without
making a request, writes the full payload to the log at debug before it goes out, turns a
refusal into a sentence the user can act on, and never rejects — a failed channel should not take
its siblings down with it. label names the sender in each line, for a plugin with more than one
webhook.
Declare discord.com in your manifest’s hosts. The webhook URL is a bearer credential for that
channel: it never appears in the log or in a failure message, so do not put it in one yourself.
The payload dump is not optional, and that is deliberate. When Discord does not render what you
meant, the only way to tell “Discord ignored it” from “I did not send it” is to see the bytes —
the two look identical from outside, a missing picture and a 204. It is debug, so it is
written only while Verbose debug logging is on.
Discord text
type DiscordTimeStyle = 't' | 'T' | 'd' | 'D' | 'f' | 'F' | 'R';
function discordCode(name: string): string; // `Club Neon`
function discordTimestamp(at: TimeInput, style?: DiscordTimeStyle): string; // <t:1790510400:R>
Discord applies its markdown to anything you interpolate, so a world called **Club** styles the
line it lands in. discordCode fences a name as a code span — and outgrows the backticks the name
itself contains, which is the only escape Discord honours.
discordTimestamp writes the <t:…> markup Discord renders in each reader’s own timezone and
language. The default R style keeps saying “3 hours ago” correctly however long the message
sits in the channel, which a formatted clock time cannot; it is '' for an instant it cannot
read, so the line drops instead of printing NaN.
VRChat data
ctx.vrchat, permission vrchat. See VRChat data.
VrchatApi · VrcUserSummary · VrcSelf · VrcUser · VrcFavoriteGroup · VrcModerationCounts · VrcAvatarSummary · VrcAvatar ·
VrcWorldSummary · VrcWorld · VrcGroupSummary · VrcGroup · VrcInstance ·
VrcInstanceUser · VrcFriendInstance · VrcTimelineEvent · VrcSearchPage<T> ·
VrcLookupOptions · VrcSearchOptions · PerformanceRank · PERFORMANCE_RANKS · rankIndex()
· rankLabel() · rankEmoji() · RANK_EMOJI · TrustRank · TRUST_RANKS · trustRank() ·
publicImageUrl() · isPublicImageUrl() · imageCacheKey() · imageCacheKeyFor() · ImageSubject
interface VrchatApi {
self(): VrcSelf | undefined; // synchronous, and the fullest object here
friends(o?: VrcLookupOptions): Promise<readonly VrcUserSummary[]>;
favoriteFriends(o?): Promise<readonly VrcUserSummary[]>;
favoriteFriendGroups(o?): Promise<readonly VrcFavoriteGroup[]>; // { name, displayName, userIds, users }
moderationCounts(o?): Promise<VrcModerationCounts>; // { blocked, muted, hiddenAvatar, interactOff, muteChat }
recentPlayers(o?): Promise<readonly VrcUserSummary[]>;
favoriteWorlds(o?): Promise<readonly VrcWorldSummary[]>;
recentWorlds(o?): Promise<readonly VrcWorldSummary[]>;
ownAvatars(o?): Promise<readonly VrcAvatarSummary[]>;
favoriteAvatars(o?): Promise<readonly VrcAvatarSummary[]>;
recentAvatars(o?): Promise<readonly VrcAvatarSummary[]>;
myGroups(o?): Promise<readonly VrcGroupSummary[]>;
currentInstance(o?): Promise<VrcInstance | undefined>;
friendInstances(o?): Promise<readonly VrcFriendInstance[]>;
user(id: string, o?): Promise<VrcUser | undefined>;
userBasic(id: string, o?): Promise<VrcUserSummary | undefined>;
userGroups(id: string, o?): Promise<readonly VrcGroupSummary[]>;
userTimeline(id: string, o?): Promise<readonly VrcTimelineEvent[]>;
instanceAvatar(userId: string, o?): Promise<{ avatarId: string; avatarName: string } | undefined>;
avatar(id: string, o?): Promise<VrcAvatar | undefined>;
world(id: string, o?): Promise<VrcWorld | undefined>;
group(id: string, o?): Promise<VrcGroup | undefined>;
searchUsers(query: string, o?: VrcSearchOptions): Promise<VrcSearchPage<VrcUserSummary>>;
searchWorlds(query: string, o?: VrcSearchOptions & { sort?: string }): Promise<VrcSearchPage<VrcWorldSummary>>;
searchGroups(query: string, o?): Promise<VrcSearchPage<VrcGroupSummary>>;
searchAvatars(query: string, o?): Promise<VrcSearchPage<VrcAvatarSummary>>;
originalImageUrl(subject: ImageSubject, o?): Promise<string>; // '' when VRCNext never cached it
}
type ImageSubject =
| { kind: 'user'; id: string; variant?: 'avatar' | 'pfp' | 'banner' }
| { kind: 'avatar'; id: string }
| { kind: 'group'; id: string; variant?: 'icon' | 'banner' }
| { kind: 'world'; id: string };
interface VrcLookupOptions { readonly cached?: boolean; readonly signal?: AbortSignal }
interface VrcSearchOptions { readonly offset?: number; readonly signal?: AbortSignal }
A lookup with no answer resolves undefined; unknown strings are '' and unknown tri-states
undefined. PERFORMANCE_RANKS = 'Excellent' | 'Good' | 'Medium' | 'Poor' | 'VeryPoor', and
rankIndex turns one into 0–4 (undefined for '').
A picture’s URL is usually VRCNext’s local image cache, which only loads inside the app. Before
putting one in an embed, a webhook or anything else that leaves, pass it through
publicImageUrl(url) — it gives back '' for an address only this machine can reach, so the field
drops rather than rendering blank. See VRChat data.
When it does give back '', originalImageUrl(subject) is the second chance: VRCNext recorded
where it downloaded each picture from, and this reads that address back through the bridge. Ask in
that order — what the lookup already handed you first, this only when every candidate turned out to
be local:
const fromLookup = publicImageUrl(avatar?.thumbnailImageUrl);
const url = fromLookup !== ''
? fromLookup // VRCNext fetched it anyway
: await ctx.vrchat.originalImageUrl({ kind: 'avatar', id }); // one lookup in VRCNext's database
Neither path asks VRChat for anything. '' covers every way it can come up empty — the picture was
never cached, the bridge is not connected, the id is not one — so treat '' as “leave the field
out”. Answers are cached for the session; absences are not, because the picture VRCNext has not
downloaded yet is the one your next report needs. Check the result anyway if you like paranoia: the
host already runs it through publicImageUrl on the way out.
imageCacheKeyFor(subject) builds the key VRCNext files a picture under, and imageCacheKey(url)
recovers it from a cache URL. Both are pure, and neither needs the bridge.
Show a rank with rankLabel(rank) — Very Poor, not VeryPoor, and Unknown for '' — and
rankEmoji(rank) for VRChat’s traffic-light colour (🟢🔵🟡🟠🔴, ⚪ unknown); RANK_EMOJI is that
map. Never print a raw rank: the worst one is spelled as one word and reads as a typo.
trustRank(tags) reads a trust rank out of a user’s tags and returns { label, short } —
Trusted User, Known User, User, New User or Visitor. VRChat’s tags are offset by one
from the label it shows, and this applies the same offset VRCNext’s own profile badge does, so a
plugin never disagrees with the page about someone’s rank.
Locations
function parseLocation(location: string): ParsedLocation;
function isGroupInstance(type: string): boolean;
function instanceTypeLabel(type: string): string;
interface ParsedLocation {
readonly worldId: string; // '' when the location is not an instance
readonly instanceId: string;
readonly key: string; // 'wrld_…:12345' — identifies the instance across visits
readonly instanceType: InstanceType | '';
readonly groupId: string; // group instances
readonly ownerId: string; // friends / friends+ / hidden / private instances
readonly region: string;
}
INSTANCE_TYPES = 'public' | 'friends+' | 'friends' | 'hidden' | 'private' | 'invite_plus' |
'group-public' | 'group-plus' | 'group-members', spelled as VRCNext spells them.
instanceTypeLabel gives the name the app puts on screen — “Public”, “Friends+”, “Group
Members” — from the same map its instance badges use, so an option list reads the way the rest
of VRCNext does. INSTANCE_TYPE_LABELS is that map. Use it rather than showing a raw type.
Events
interface EventBus {
on<T extends string>(type: T, listener: EventListener<T>): () => void;
once<T extends string>(type: T, listener: EventListener<T>): () => void;
onAny(listener: (envelope: HostEnvelope) => void): () => void; // needs "*"; never receives vrcPrefillLogin
next<T extends string>(type: T, signal?: AbortSignal): Promise<EventPayload<T>>;
}
VrcnextEventMap — verified payloads: setPlatform · log · toast · dbMigrationProgress ·
openDeepLink · friendTimelineEvent · customThemes. Anything else is unknown.
KnownEventType · EventPayload<T> · EventListener<T> · HostEnvelope · LogColor · LOG_COLORS
Bridge (VRCNext actions)
interface Bridge {
send(action: ActionName, args?: ActionArgs): void;
request<T extends string>(action: ActionName, args: ActionArgs | undefined,
options: RequestOptions & { readonly expect: T }): Promise<EventPayload<T>>;
interceptOutbound(fn: (action: ActionName, raw: string) => boolean | undefined): () => void;
}
interface RequestOptions { readonly expect: string; readonly timeoutMs?: number; readonly signal?: AbortSignal }
HTTP and clipboard
interface HttpApi {
/** The only fetch a plugin has. Asks about each host once; carries the plugin's abort signal. */
fetch(url: string | URL, init?: RequestInit): Promise<Response>;
}
interface ClipboardApi {
writeText(text: string): Promise<void>;
readText(): Promise<string>;
}
How ctx.http.fetch travels
While the bridge is connected, every request goes through its outbound
service rather than the page. The page may only read a cross-origin response the server agreed
to share, so an API that sends no CORS headers, such as the Steam Web API, is unreachable from
the page. The bridge is not a browser and reaches it. When the bridge is not connected, the
request falls back to the page’s own fetch and CORS applies again.
Through the bridge, a request has these limits:
| Limit | Value |
|---|---|
| Schemes | http and https |
| Methods | GET, HEAD, POST, PUT, PATCH, DELETE |
| Request body | a string, at most 1 MiB |
| Response body | UTF-8 text, at most 16 MiB after decompression |
| Headers | at most 32. host, content-length, connection, keep-alive, proxy-connection, te, trailer, transfer-encoding, upgrade and user-agent are refused as the connection’s own |
| Credentials | never leave this machine. Authorization, Proxy-Authorization, Cookie and Set-Cookie are refused, a URL carrying user:password@ is refused, and no request sends cookies — there is no cookie jar, and the page fallback is asked for credentials: 'omit'. Authenticate the way the API says to: a query parameter, or its own header such as X-Api-Key |
| Timeout | 30 s by default, 120 s at most |
| Redirects | never followed. A 3xx comes back as it was sent, Location and all; follow it with another ctx.http.fetch, which asks about the new host |
| Addresses | public internet only. Loopback, private ranges, link-local (including 169.254.169.254), CGNAT, multicast and the rest are refused, whether written in the URL or reached by resolving a name |
This machine holds two credentials a third party must never see — the bridge’s pairing token,
which grants everything the bridge can do, and the page’s VRChat session — so the rule is enforced
in three places rather than trusted: the host refuses the request before it is even put to the
user, the bridge refuses the header, and the finished request is checked once more on its way out.
CREDENTIAL_HEADERS and isCredentialHeader(name) are exported if you want to check first.
Aborting, through init.signal or by the plugin being disabled, rejects the promise with an
AbortError straight away. The bridge may still finish the request, but its answer is dropped.
response.url is the URL that was asked for.
UI
interface UiApi {
addNavTab(options: NavTabOptions): PanelHandle;
addDashboardCard(options: DashboardCardOptions): PanelHandle;
addSettingsCard(options: SettingsCardOptions): PanelHandle; // options.section files it under your own section
addSettingsSection(options: SettingsSectionOptions): SettingsSectionHandle;
addSettingsDivider(): PanelHandle;
addSidebarGroup(options: SidebarGroupOptions): PanelHandle;
injectCss(css: string): PanelHandle;
toast(options: ToastOptions): void;
pickEntity(options: EntityPickOptions): Promise<readonly string[] | undefined>; // the settings picker, on demand
readonly kit: UiKit;
createPanelLayout(): HTMLElement;
createCard(title: string, icon: IconName): HTMLElement;
createToggleRow(label: string, checked: boolean, onChange: (next: boolean) => void): HTMLElement;
}
interface PanelHandle extends Disposable {
readonly element: HTMLElement;
readonly visible: boolean; // is the user looking at this panel?
}
interface NavTabOptions {
readonly label: string;
readonly icon: IconName;
render(container: HTMLElement): void | Promise<void>; // once, lazily, on first open
readonly group?: string;
onVisibility?(visible: boolean): void; // every switch, including to VRCNext's own tabs
}
interface SettingsSectionHandle extends PanelHandle {
readonly sectionId: string; // the data-section VRCNext switches on: "<plugin id>.<id>"
readonly active: boolean;
attach(block: HTMLElement): void; // a card or container; removed with the section
open(block?: HTMLElement): void; // Settings tab → this section, scrolled to block
}
interface SidebarShortcut { id: string; label: string; icon: IconName; activate(): void }
NavTabOptions · DashboardCardOptions · SettingsCardOptions · SettingsSectionOptions ·
SidebarGroupOptions · PanelHandle · IconName · ToastOptions · EntityPickOptions
UI kit
ctx.ui.kit — declarative builders that emit VRCNext’s own markup. See UI injection.
type UiChild = Node | string | false | null | undefined; // falsy children are dropped
type UiBadgeTone = 'ok' | 'warn' | 'err' | 'accent' | 'cyan' | 'neutral';
interface UiKit {
layout(...children: readonly UiChild[]): HTMLElement;
grid(children: readonly UiChild[], options?: { min?: number }): HTMLElement;
pair(first: UiChild, second: UiChild): HTMLElement;
card(options: { title?: string; icon?: IconName; children?: readonly UiChild[]; span?: number }): HTMLElement;
statusCard(options: { tone: 'online' | 'warn' | 'offline'; label: string; action?: Node }): HTMLElement;
section(label: string, children: readonly UiChild[]): DocumentFragment;
row(options: { label: string; value?: UiChild; detail?: string }): HTMLElement;
toggleRow(options: { label: string; value: boolean; onChange: (next: boolean) => void; detail?: string }): HTMLElement;
buttonRow(...children: readonly UiChild[]): HTMLElement;
button(options: { label: string; icon?: IconName; onClick: () => void; active?: boolean; disabled?: boolean; round?: boolean }): HTMLButtonElement;
textField(options: { value: string; placeholder?: string; onCommit: (next: string) => void }): HTMLInputElement;
textArea(options: { value: string; placeholder?: string; rows?: number; onCommit: (next: string) => void }): HTMLTextAreaElement;
dropdown(options: { options: readonly { value: string; label: string }[]; selected: string; onChange: (next: string) => void }): HTMLSelectElement;
slider(options: UiSliderOptions): HTMLElement; // value readout, optional labelled markers
chips(options: UiChipsOptions): HTMLElement; // toggle buttons; the pressed ones are chosen
timeField(options: UiTypedFieldOptions): HTMLInputElement;
colorField(options: UiTypedFieldOptions): HTMLInputElement;
listItem(options: UiListItemOptions): HTMLElement; // VRCNext's compact profile row
badge(tone: UiBadgeTone, text: string): HTMLElement;
stat(options: { label: string; value: string; tone?: UiBadgeTone }): HTMLElement;
description(text: string): HTMLElement;
sectionLabel(text: string): HTMLElement;
valueText(text: string): HTMLElement;
emptyState(text: string): HTMLElement;
setChildren(parent: Node, children: readonly UiChild[]): void;
}
Notifications
interface NotificationsApi {
toast(options: ToastOptions): void;
notifToast(options: NotifToastOptions): void;
desktop(options: DesktopNotifyOptions): void; // tray + SteamVR overlay; Windows only
readonly desktopAvailable: boolean;
confirm(options: ConfirmOptions): Promise<boolean>;
}
NOTIFY_ACCENTS = 'accent' | 'info' | 'ok' | 'warn' | 'err'
NOTIF_TOAST_KINDS = 'invite' | 'friendRequest' | 'notification'
Context menu
interface ContextMenuApi {
contribute(provider: ContextMenuProvider): () => void;
contributeFor(selector: string, provider: ContextMenuProvider): () => void;
open(x: number, y: number, entries: readonly ContextMenuEntry[]): void;
}
ContextMenuEntry = ContextMenuItem | ContextMenuSubmenu | ContextMenuDivider · ContextMenuTarget
OSC
interface OscApi {
readonly available: boolean; // false on Linux — VRCNext filters every osc* action
connect(): void;
disconnect(): void;
send(name: string, kind: 'bool', value: boolean): void;
send(name: string, kind: 'int' | 'float', value: number): void;
sendRaw(address: string, kind: 'bool', value: boolean): void;
sendRaw(address: string, kind: 'int' | 'float', value: number): void;
onParam(listener: (event: OscParamEvent) => void): () => void;
onAvatarChange(listener: (event: OscAvatarChangeEvent) => void): () => void;
}
OSC_VALUE_KINDS · OscValueKind · OscValue · OscParamEvent · OscAvatarChangeEvent
VRCNext Bridge
interface NativeApi {
targets(): Promise<readonly NativeTarget[]>;
notify(options: NativeNotifyOptions): Promise<NativeNotifyResult>;
call(service: string, method: string, params?: unknown): Promise<unknown>;
}
interface NativeTarget {
readonly name: string; // 'wayvr', 'freedesktop', …
readonly description: string;
readonly health: 'up' | 'unknown' | 'down';
readonly honours: readonly string[]; // which fields this target actually uses
}
interface NativeNotifyOptions extends NativeNotifyFields {
readonly title: string; // required
readonly sinks?: readonly string[]; // omit for every target
readonly overrides?: Readonly<Record<string, NativeNotifyOverride>>;
}
// Shared by a request and its per-target overrides.
interface NativeNotifyFields {
readonly content?: string;
readonly timeoutSecs?: number;
readonly icon?: string;
readonly useBase64Icon?: boolean;
readonly sourceApp?: string;
readonly sound?: boolean;
readonly volume?: number;
readonly audioPath?: string;
readonly height?: number; // VR overlays only
readonly opacity?: number; // VR overlays only
readonly urgency?: NativeUrgency; // 'low' | 'normal' | 'critical'; desktop daemons only
readonly alwaysShow?: boolean; // VR overlays only
}
interface NativeNotifyResult {
readonly ok: boolean; // true if at least one target accepted
readonly delivered: readonly string[];
readonly failed: readonly { readonly sink: string; readonly error: string }[];
}
The bridge is connected whenever a plugin runs, so there is no available, status or probe.
notify() resolves with ok: false rather than rejecting when no target accepted; call()
reaches the notify service only (anything else rejects with a PermissionError), and rejects
with an error carrying the bridge’s own code when the bridge refuses. See VRCNext Bridge.
Game log
interface GameLogApi {
on(listener: (entry: GameLogEntry) => void): () => void;
onType(type: string, listener: (entry: GameLogEntry) => void): () => void;
history(signal?: AbortSignal): Promise<readonly GameLogEntry[]>;
}
SQL
ctx.sql, permission sql. Read-only SQL against VRCNext’s own databases, for the questions
the page cannot answer at any price — getTimelineForUser returns ten records, so how many
times you have met someone, and how many records exist at all, are not in the page.
Reach for it last. Everything VRCNext already holds is on ctx.vrchat and costs nothing; a
query that duplicates a page read is a slower, staler copy of it.
type SqlValue = string | number | boolean | null | { readonly blobBytes: number };
type SqlDatabase = 'vrcnext' | 'avatars';
interface SqlApi {
query(database: SqlDatabase, sql: string, params?: readonly SqlValue[]): Promise<SqlResult>;
rows(database: SqlDatabase, sql: string, params?: readonly SqlValue[]): Promise<readonly SqlRow[]>;
value(database: SqlDatabase, sql: string, params?: readonly SqlValue[]): Promise<SqlValue | undefined>;
databases(): Promise<readonly SqlDatabaseInfo[]>;
}
const meets = await ctx.sql.value(
'vrcnext',
'SELECT meet_again_count FROM user_tracking WHERE user_id = ?1',
[userId],
);
A plugin names an alias, never a path: the bridge owns which file each alias is. The
connection is opened SQLITE_OPEN_READ_ONLY with query_only set, so a statement that asks to
write is refused by SQLite itself. One statement per call — a trailing one is refused rather
than silently dropped — and parameters are bound, never interpolated. Results are capped at
10 000 rows and 8 MiB, a statement at 5 seconds, and a blob comes back as { blobBytes: n }
rather than as bytes. value() answers undefined when nothing matched, which is not 0.
Routes and deep links
interface RouterApi {
readonly base: URL;
route(method: string, pattern: string, handler: RouteHandler): () => void;
get(pattern: string, handler: RouteHandler): () => void;
post(pattern: string, handler: RouteHandler): () => void;
fetch(input: string | URL, init?: RequestInit): Promise<Response>;
}
interface DeepLinkApi {
on(listener: (event: DeepLinkEvent) => boolean | undefined): () => void;
onPrefix(prefix: DeepLinkPrefix, listener: (event: DeepLinkEvent) => boolean | undefined): () => void;
}
DEEP_LINK_PREFIXES = 'usr' | 'avtr' | 'wrld' | 'grp' | 'inst' | 'instjoin' · RouteRequest · RouteHandler
Disposal and logging
DisposableBag · Disposable · DisposeFn
Logger · LogLevel · LOG_LEVELS