Files
Toju/docs-site/docs/developer/llm-plugin-builder-guide.md
Myx 0a714428f6 docs: improve doucmentation
improve doucmentation and fix small store changes
2026-04-30 01:16:48 +02:00

44 KiB

sidebar_position
sidebar_position
5

LLM Plugin Builder Guide

Copy this page into an LLM prompt when you want it to build a MetoYou plugin. It is intentionally explicit about the app, communication model, visual structure, manifest format, runtime rules, API types, and examples so the model has fewer gaps to invent around.

Task For The LLM

Build a MetoYou client plugin: a browser-safe JavaScript ES module with a toju-plugin.json manifest, loaded by the Angular renderer, running inside the user's local MetoYou app, using only browser APIs and the provided TojuClientPluginApi.

Return a plugin folder like this:

my-plugin/
  toju-plugin.json
  main.js
  README.md
  icon.svg

Hard Rules

  • Do not modify MetoYou core unless the user explicitly asks for a core code change.
  • Use plain browser ESM in main.js. Do not use Node APIs, require, fs, path, child_process, or build tooling unless explicitly requested.
  • Use toju-plugin.json as the manifest name.
  • Put every disposable returned by plugin APIs in context.subscriptions.
  • Request only capabilities used by the code.
  • Do not call moderation, delete, kick, ban, role, channel, server-setting, or other destructive APIs during activate. Put them behind visible user action and confirmation.
  • Prefer api.ui.* extension points. Use api.ui.mountElement only when there is no suitable contribution API.
  • Do not use api.ui.mountElement to add content to the server plugin sidebar. Use api.ui.registerSidePanel instead.
  • Do not assume route-specific DOM such as app-chat-messages, app-rooms-side-panel, or [data-testid="plugin-room-side-panel"] exists during activate. Those elements exist only when the user is on the matching route and Angular has rendered that view.
  • serverData is local per-user/per-server client data. It is not arbitrary remote server storage.
  • Server-installed plugins are requirement metadata plus local client downloads. The signaling server never executes plugin entrypoints.
  • Every event used with api.events.* must be declared in the manifest events array.

What MetoYou Is

MetoYou is a Discord-like chat and voice app:

  • toju-app/: Angular renderer and plugin runtime.
  • electron/: Electron desktop shell, preload bridge, local database, local REST API, local docs host.
  • server/: Node/TypeScript signaling and directory server.
  • docs-site/: Docusaurus documentation.

Users join chat servers. A server has text channels, voice channels, members, roles, permissions, messages, attachments, voice state, screen share state, camera state, presence, and optional server plugin requirements. Users can also use direct messages, but the plugin API is primarily shaped around the current server workspace.

Communication Model

There are three communication boundaries a plugin author must understand:

1. Signaling plane
   Angular renderer <-> WebSocket signaling server
   Used for identity, joining servers, presence, typing, plugin requirements,
   server-relayed plugin events, WebRTC offers, answers, and ICE candidates.

2. Peer plane
   Angular renderer <-> WebRTC peer connections <-> other clients
   Used for media and data-channel events: chat messages, message sync,
   attachments, voice state, screen/camera state, and plugin message bus data.

3. Desktop/local plane
   Angular renderer <-> Electron preload bridge <-> Electron main process
   Used for local SQLite, local files, cached plugin bundles, local REST API,
   local Docusaurus docs hosting, and desktop-only features.

Plugins run only in the renderer. They do not run in Electron main and do not run on the signaling server.

Choose communication APIs like this:

Need Use Notes
Visible normal chat message api.messages.send Persists locally, updates chat UI, broadcasts peer chat event.
Visible bot-style message api.server.registerPluginUser plus api.messages.sendAsPluginUser Requires users.manage and messages.send.
Plugin state sync between connected clients api.messageBus.publish and api.messageBus.subscribe P2P data-channel envelope, not a visible chat message.
Plugin state sync plus recent chat snapshot api.messageBus.publish with includeLatestMessages Also needs messages.read.
Metadata through signaling server api.events.publishServer and api.events.subscribeServer Event must be declared in manifest.
Low-level peer data api.p2p.broadcastData or api.p2p.sendData Prefer message bus for structured topics/subscriptions.
Local user preferences api.clientData User-scoped local storage/database.
Local per-server plugin data api.serverData User-scoped and current-server-scoped local storage/database.
App UI extension api.ui.* Prefer registered contributions over DOM mounting.
Audio/video/voice effects api.media.* Browser media APIs and voice facade.

How The App Looks

The main app is a dense chat workspace. The most important plugin context is /room/:roomId.

<app-root>
  <router-outlet></router-outlet>
  <!-- global dialogs, overlays, floating voice controls, desktop integrations -->
</app-root>

Main server page shape:

<app-chat-room>
  <app-servers-rail></app-servers-rail>
  <app-rooms-side-panel>
    <section>Text Channels</section>
    <section>Voice Channels</section>
    <section data-testid="plugin-room-side-panel">
      <app-plugin-render-host></app-plugin-render-host>
    </section>
    <section>Members</section>
  </app-rooms-side-panel>
  <main>
    <app-voice-workspace></app-voice-workspace>
    <app-chat-messages>
      <app-message-list></app-message-list>
      <app-typing-indicator></app-typing-indicator>
      <app-message-composer></app-message-composer>
      <app-klipy-gif-picker></app-klipy-gif-picker>
    </app-chat-messages>
  </main>
</app-chat-room>

Important routes:

Route Purpose
/search Search and join servers.
/room/:roomId Main server workspace with text, voice, members, and plugin panels.
/dm and /dm/:conversationId Direct-message workspace.
/settings App, voice, server, plugin, desktop, theme, and local API settings.
/plugin-store Browse and install plugins.
/plugins/:pluginId/:pageId Host for pages registered with api.ui.registerAppPage.

Direct DOM mounting is a last resort. Route-specific targets may not exist when activate runs. If api.ui.mountElement cannot find the target, it throws Plugin mount target not found: <selector> and plugin activation fails.

Stable direct-mount targets when necessary:

Selector Area
body Safest global target for overlays, badges, and modals. It exists during activation.
app-chat-messages Main text channel surface. Use only after checking the element exists.
app-rooms-side-panel Server side panel. Use only after checking the element exists. Prefer registerSidePanel for plugin sidebar content.

Do not mount directly into [data-testid="plugin-room-side-panel"]. That area is owned by the plugin side-panel registry and is rendered only on the server page. For server sidebar UI, use:

context.subscriptions.push(api.ui.registerSidePanel('control-panel', {
  label: 'Control Panel',
  order: 20,
  render: () => {
    const root = document.createElement('section');
    const button = document.createElement('button');

    button.type = 'button';
    button.textContent = 'Run Action';
    button.addEventListener('click', () => {
      api.logger.info('Side-panel action clicked');
    });

    root.append(button);
    return root;
  }
}));

Do not depend on Tailwind classes or internal styling classes.

Manifest

Minimal manifest:

{
  "schemaVersion": 1,
  "id": "example.my-plugin",
  "title": "My Plugin",
  "description": "Adds a focused MetoYou feature.",
  "version": "1.0.0",
  "kind": "client",
  "scope": "client",
  "apiVersion": "1.0.0",
  "compatibility": {
    "minimumTojuVersion": "1.0.0"
  },
  "entrypoint": "./main.js",
  "capabilities": ["ui.pages"]
}

Manifest type:

type TojuPluginInstallScope = 'client' | 'server';
type PluginEventDirection = 'clientToServer' | 'serverRelay' | 'p2pHint';
type PluginEventScope = 'server' | 'channel' | 'user' | 'plugin';

type PluginCapabilityId =
  | 'profile.read'
  | 'profile.write'
  | 'users.read'
  | 'users.manage'
  | 'roles.read'
  | 'roles.manage'
  | 'messages.read'
  | 'messages.send'
  | 'messages.editOwn'
  | 'messages.deleteOwn'
  | 'messages.moderate'
  | 'messages.sync'
  | 'channels.read'
  | 'channels.manage'
  | 'server.read'
  | 'server.manage'
  | 'p2p.data'
  | 'p2p.media'
  | 'media.playAudio'
  | 'media.addAudioStream'
  | 'media.addVideoStream'
  | 'audio.volume'
  | 'audio.effects'
  | 'ui.settings'
  | 'ui.pages'
  | 'ui.sidePanel'
  | 'ui.channelsSection'
  | 'ui.embeds'
  | 'ui.dom'
  | 'storage.local'
  | 'storage.serverData.read'
  | 'storage.serverData.write'
  | 'events.server.publish'
  | 'events.server.subscribe'
  | 'events.p2p.publish'
  | 'events.p2p.subscribe';

interface TojuPluginManifest {
  schemaVersion: 1;
  id: string;
  title: string;
  description: string;
  version: string;
  kind: 'client' | 'library';
  scope?: TojuPluginInstallScope;
  apiVersion: string;
  compatibility: {
    minimumTojuVersion: string;
    maximumTojuVersion?: string;
    verifiedTojuVersion?: string;
  };
  entrypoint?: string;
  capabilities?: PluginCapabilityId[];
  events?: {
    eventName: string;
    direction: PluginEventDirection;
    scope: PluginEventScope;
    maxPayloadBytes?: number;
    schema?: string;
  }[];
  data?: {
    key: string;
    scope: string;
    storage: 'local' | 'serverData';
    schema?: string;
  }[];
  pluginUser?: {
    displayName: string;
    label?: string;
    avatar?: string;
  };
  relationships?: {
    after?: string[];
    before?: string[];
    conflicts?: string[];
    requires?: { id: string; versionRange?: string }[];
    optional?: { id: string; versionRange?: string }[];
  };
  authors?: { name: string; email?: string; url?: string }[];
  homepage?: string;
  bugs?: string;
  license?: string;
  readme?: string;
  changelog?: string;
  settings?: Record<string, unknown>;
  ui?: Record<string, unknown>;
  bundle?: { url: string; entrypoint?: string };
  load?: { priority?: 'bootstrap' | 'high' | 'default' | 'low' };
}

Validation rules:

  • id must be lowercase dotted/dashed id style: starts and ends with lowercase letter or number, and may contain lowercase letters, numbers, dots, and hyphens.
  • version must look like semantic versioning: 1.0.0, 1.2.3-beta.1, or 1.2.3+build.4.
  • schemaVersion must be 1.
  • kind must be client or library.
  • scope is optional, client, or server.
  • compatibility.minimumTojuVersion is required.
  • kind: "client" needs entrypoint.
  • Every capability must be a known PluginCapabilityId.
  • Every api.events.* event must be declared in events.

Scope meanings:

Scope Meaning
client or omitted Installed globally for this local user/client.
server Installed for a specific chat server as local client plugin plus server requirement metadata.

Most generated plugins should use kind: "client". Use kind: "library" only for dependency metadata with no executable entrypoint.

Runtime Lifecycle

interface TojuPluginDisposable {
  dispose: () => void;
}

interface TojuPluginActivationContext {
  api: TojuClientPluginApi;
  manifest: TojuPluginManifest;
  pluginId: string;
  subscriptions: TojuPluginDisposable[];
}

interface TojuClientPluginModule {
  activate?: (context: TojuPluginActivationContext) => Promise<void> | void;
  ready?: (context: TojuPluginActivationContext) => Promise<void> | void;
  deactivate?: (context: TojuPluginActivationContext) => Promise<void> | void;
  onPluginDataChanged?: (context: TojuPluginActivationContext, event: unknown) => Promise<void> | void;
  onServerRequirementsChanged?: (
    context: TojuPluginActivationContext,
    snapshot: PluginRequirementsSnapshot
  ) => Promise<void> | void;
}

Lifecycle behavior:

  • activate(context) runs after the plugin module is imported and capability grants are satisfied.
  • The runtime passes a frozen context.api; never mutate it.
  • ready(context) runs after the ready-plugin load-order pass.
  • deactivate(context) runs during unload or reload.
  • The host disposes context.subscriptions in reverse order after deactivate.
  • UI contributions are removed by plugin id on unload.
  • Activation state is remembered locally.

Good boilerplate:

export function activate(context) {
  const { api } = context;

  api.logger.info('Activating plugin', { pluginId: context.pluginId });

  const page = api.ui.registerAppPage('home', {
    label: 'My Plugin',
    path: '/plugins/example.my-plugin/home',
    render: () => {
      const root = document.createElement('section');
      const title = document.createElement('h1');
      const button = document.createElement('button');
      const status = document.createElement('p');

      title.textContent = 'My Plugin';
      button.type = 'button';
      button.textContent = 'Send hello';
      status.textContent = 'Ready.';

      button.addEventListener('click', () => {
        const channelId = api.context.getCurrent().textChannel?.id;
        const message = api.messages.send('Hello from My Plugin', channelId);
        status.textContent = `Sent message ${message.id}`;
      });

      root.append(title, button, status);
      return root;
    }
  });

  context.subscriptions.push(page);
}

export function ready(context) {
  context.api.logger.info('Plugin ready');
}

export function deactivate(context) {
  context.api.logger.info('Plugin deactivating');
}

Matching manifest capabilities for that boilerplate:

{
  "capabilities": ["ui.pages", "messages.send"]
}

Shared App Types

These are the main data shapes returned by plugin APIs.

type ChannelType = 'text' | 'voice';

interface Channel {
  id: string;
  name: string;
  type: ChannelType;
  position: number;
}

interface Room {
  id: string;
  name: string;
  description?: string;
  topic?: string;
  hostId: string;
  password?: string;
  hasPassword?: boolean;
  isPrivate: boolean;
  createdAt: number;
  userCount: number;
  maxUsers?: number;
  icon?: string;
  iconUpdatedAt?: number;
  slowModeInterval?: number;
  permissions?: RoomPermissions;
  channels?: Channel[];
  members?: RoomMember[];
  roles?: RoomRole[];
  roleAssignments?: RoomRoleAssignment[];
  channelPermissions?: ChannelPermissionOverride[];
  sourceId?: string;
  sourceName?: string;
  sourceUrl?: string;
}

interface RoomPermissions {
  adminsManageRooms?: boolean;
  moderatorsManageRooms?: boolean;
  adminsManageIcon?: boolean;
  moderatorsManageIcon?: boolean;
  allowVoice?: boolean;
  allowScreenShare?: boolean;
  allowFileUploads?: boolean;
  slowModeInterval?: number;
}

type UserStatus = 'online' | 'away' | 'busy' | 'offline' | 'disconnected';
type UserRole = 'host' | 'admin' | 'moderator' | 'member';

interface User {
  id: string;
  oderId: string;
  username: string;
  displayName: string;
  description?: string;
  profileUpdatedAt?: number;
  avatarUrl?: string;
  avatarHash?: string;
  avatarMime?: string;
  avatarUpdatedAt?: number;
  status: UserStatus;
  role: UserRole;
  joinedAt: number;
  peerId?: string;
  isOnline?: boolean;
  isAdmin?: boolean;
  isRoomOwner?: boolean;
  presenceServerIds?: string[];
  voiceState?: VoiceState;
  screenShareState?: ScreenShareState;
  cameraState?: CameraState;
  gameActivity?: unknown;
}

interface RoomMember {
  id: string;
  oderId?: string;
  username: string;
  displayName: string;
  description?: string;
  profileUpdatedAt?: number;
  avatarUrl?: string;
  avatarHash?: string;
  avatarMime?: string;
  avatarUpdatedAt?: number;
  role: UserRole;
  roleIds?: string[];
  joinedAt: number;
  lastSeenAt: number;
}

interface VoiceState {
  isConnected: boolean;
  isMuted: boolean;
  isDeafened: boolean;
  isSpeaking: boolean;
  isMutedByAdmin?: boolean;
  volume?: number;
  roomId?: string;
  serverId?: string;
}

interface Message {
  id: string;
  roomId: string;
  channelId?: string;
  senderId: string;
  senderName: string;
  content: string;
  timestamp: number;
  editedAt?: number;
  reactions: Reaction[];
  isDeleted: boolean;
  replyToId?: string;
  linkMetadata?: LinkMetadata[];
}

interface Reaction {
  id: string;
  messageId: string;
  oderId: string;
  userId: string;
  emoji: string;
  timestamp: number;
}

interface LinkMetadata {
  url: string;
  title?: string;
  description?: string;
  imageUrl?: string;
  siteName?: string;
  failed?: boolean;
}

type RoomPermissionKey =
  | 'manageServer'
  | 'manageRoles'
  | 'manageChannels'
  | 'manageIcon'
  | 'kickMembers'
  | 'banMembers'
  | 'manageBans'
  | 'deleteMessages'
  | 'joinVoice'
  | 'shareScreen'
  | 'uploadFiles';

type PermissionState = 'allow' | 'deny' | 'inherit';
type RoomPermissionMatrix = Partial<Record<RoomPermissionKey, PermissionState>>;

interface RoomRole {
  id: string;
  name: string;
  color?: string;
  position: number;
  isSystem?: boolean;
  permissions?: RoomPermissionMatrix;
}

interface RoomRoleAssignment {
  userId: string;
  oderId?: string;
  roleIds: string[];
}

interface ChannelPermissionOverride {
  channelId: string;
  targetType: 'role' | 'user';
  targetId: string;
  permission: RoomPermissionKey;
  value: PermissionState;
}

Full Plugin API Types

interface PluginApiProfileUpdate { displayName: string; description?: string }
interface PluginApiAvatarUpdate { avatarUrl: string; avatarMime: string; avatarHash: string }
interface PluginApiChannelRequest { name: string; id?: string; position?: number }
interface PluginApiServerSettingsUpdate {
  name?: string;
  description?: string;
  topic?: string;
  isPrivate?: boolean;
  password?: string;
  maxUsers?: number;
}
interface PluginApiPluginUserRequest { displayName: string; id?: string; avatarUrl?: string }
interface PluginApiMessageAsPluginUserRequest { pluginUserId: string; content: string; channelId?: string }
interface PluginApiAudioClipRequest { url: string; volume?: number }
interface PluginApiCustomStreamRequest { stream: MediaStream; label?: string }

type PluginApiActionSource = 'composerAction' | 'toolbarAction' | 'profileAction' | 'manual';
interface PluginApiActionContext {
  source: PluginApiActionSource;
  user: User | null;
  server: Room | null;
  textChannel: Channel | null;
  voiceChannel: Channel | null;
}
interface PluginApiTypingEvent extends Omit<PluginApiActionContext, 'source'> {
  channelId: string;
  displayName: string;
  isTyping: boolean;
  serverId: string;
  userId: string;
}
interface PluginEventEnvelope<TPayload = unknown> {
  type: 'plugin_event';
  pluginId: string;
  serverId: string;
  eventName: string;
  payload: TPayload;
  eventId?: string;
  emittedAt?: number;
  sourceUserId?: string;
  sourcePluginUserId?: string;
}
interface PluginApiEventSubscription {
  eventName: string;
  handler: (event: PluginEventEnvelope) => void;
}

interface PluginApiMessageBusEnvelope {
  eventId: string;
  pluginId: string;
  roomId: string;
  sentAt: number;
  topic: string;
  channelId?: string;
  payload?: unknown;
  messages?: Message[];
  sourcePeerId?: string;
  sourceUserId?: string;
}
interface PluginApiMessageBusLatestRequest {
  targetPeerId?: string;
  channelId?: string;
  topic?: string;
  limit?: number;
  sinceTimestamp?: number;
  includeDeleted?: boolean;
}
interface PluginApiMessageBusPublishRequest extends PluginApiMessageBusLatestRequest {
  topic: string;
  payload?: unknown;
  includeLatestMessages?: boolean;
  includeSelf?: boolean;
}
interface PluginApiMessageBusSubscription {
  topic?: string;
  channelId?: string;
  replayLatest?: boolean;
  latestMessageLimit?: number;
  handler: (event: PluginApiMessageBusEnvelope) => void;
}

interface PluginApiPageContribution { label: string; path: string; render: () => HTMLElement | string }
interface PluginApiSettingsPageContribution { label: string; settingsKey?: string; order?: number; render: () => HTMLElement | string }
interface PluginApiPanelContribution { label: string; order?: number; render: () => HTMLElement | string }
interface PluginApiChannelSectionContribution { label: string; type?: 'audio' | 'video' | 'custom'; order?: number }
interface PluginApiActionContribution { label: string; icon?: string; run: (context: PluginApiActionContext) => Promise<void> | void }
interface PluginApiEmbedRendererContribution { embedType: string; render: (payload: unknown) => HTMLElement | string }
interface PluginApiDomMountRequest { target: Element | string; element: HTMLElement; position?: InsertPosition }

interface TojuClientPluginApi {
  readonly context: { getCurrent: () => PluginApiActionContext };
  readonly logger: {
    debug: (message: string, data?: unknown) => void;
    info: (message: string, data?: unknown) => void;
    warn: (message: string, data?: unknown) => void;
    error: (message: string, data?: unknown) => void;
  };
  readonly profile: {
    getCurrent: () => User | null;
    update: (profile: PluginApiProfileUpdate) => void;
    updateAvatar: (avatar: PluginApiAvatarUpdate) => void;
  };
  readonly users: {
    getCurrent: () => User | null;
    list: () => User[];
    readMembers: () => RoomMember[];
    setRole: (userId: string, role: UserRole) => void;
    kick: (userId: string) => void;
    ban: (userId: string, reason?: string) => void;
  };
  readonly roles: {
    list: () => RoomRole[];
    setAssignments: (assignments: RoomRoleAssignment[]) => void;
  };
  readonly server: {
    getCurrent: () => Room | null;
    registerPluginUser: (request: PluginApiPluginUserRequest) => string;
    updatePermissions: (permissions: Partial<RoomPermissions>) => void;
    updateSettings: (settings: PluginApiServerSettingsUpdate) => void;
  };
  readonly channels: {
    list: () => Channel[];
    select: (channelId: string) => void;
    addAudioChannel: (request: PluginApiChannelRequest) => void;
    addVideoChannel: (request: PluginApiChannelRequest) => void;
    rename: (channelId: string, name: string) => void;
    remove: (channelId: string) => void;
  };
  readonly messages: {
    readCurrent: () => Message[];
    send: (content: string, channelId?: string) => Message;
    sendAsPluginUser: (request: PluginApiMessageAsPluginUserRequest) => void;
    setTyping: (isTyping: boolean, channelId?: string) => void;
    subscribeTyping: (handler: (event: PluginApiTypingEvent) => void) => TojuPluginDisposable;
    edit: (messageId: string, content: string) => void;
    delete: (messageId: string) => void;
    moderateDelete: (messageId: string) => void;
    sync: (messages: Message[]) => void;
  };
  readonly events: {
    publishServer: (eventName: string, payload: unknown) => void;
    subscribeServer: (subscription: PluginApiEventSubscription) => TojuPluginDisposable;
    publishP2p: (eventName: string, payload: unknown) => void;
    subscribeP2p: (subscription: PluginApiEventSubscription) => TojuPluginDisposable;
  };
  readonly messageBus: {
    publish: (request: PluginApiMessageBusPublishRequest) => PluginApiMessageBusEnvelope;
    sendLatestMessages: (request?: PluginApiMessageBusLatestRequest) => PluginApiMessageBusEnvelope;
    subscribe: (subscription: PluginApiMessageBusSubscription) => TojuPluginDisposable;
  };
  readonly p2p: {
    connectedPeers: () => string[];
    broadcastData: (eventName: string, payload: unknown) => void;
    sendData: (peerId: string, eventName: string, payload: unknown) => void;
  };
  readonly media: {
    playAudioClip: (request: PluginApiAudioClipRequest) => Promise<void>;
    addCustomAudioStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
    addCustomVideoStream: (request: PluginApiCustomStreamRequest) => Promise<void>;
    setInputVolume: (volume: number) => void;
    setOutputVolume: (volume: number) => void;
  };
  readonly clientData: {
    read: (key: string) => Promise<unknown>;
    write: (key: string, value: unknown) => Promise<void>;
    remove: (key: string) => Promise<void>;
  };
  readonly serverData: {
    read: (key: string) => Promise<unknown>;
    write: (key: string, value: unknown) => Promise<void>;
    remove: (key: string) => Promise<void>;
  };
  readonly storage: {
    get: (key: string) => unknown;
    set: (key: string, value: unknown) => void;
    remove: (key: string) => void;
  };
  readonly ui: {
    registerAppPage: (id: string, contribution: PluginApiPageContribution) => TojuPluginDisposable;
    registerSettingsPage: (id: string, contribution: PluginApiSettingsPageContribution) => TojuPluginDisposable;
    registerSidePanel: (id: string, contribution: PluginApiPanelContribution) => TojuPluginDisposable;
    registerChannelSection: (id: string, contribution: PluginApiChannelSectionContribution) => TojuPluginDisposable;
    registerComposerAction: (id: string, contribution: PluginApiActionContribution) => TojuPluginDisposable;
    registerProfileAction: (id: string, contribution: PluginApiActionContribution) => TojuPluginDisposable;
    registerToolbarAction: (id: string, contribution: PluginApiActionContribution) => TojuPluginDisposable;
    registerEmbedRenderer: (id: string, contribution: PluginApiEmbedRendererContribution) => TojuPluginDisposable;
    mountElement: (id: string, request: PluginApiDomMountRequest) => TojuPluginDisposable;
  };
}

API Details And Examples

Context And Logger

Capabilities: none.

const current = api.context.getCurrent();
api.logger.info('Current context', {
  userId: current.user?.id,
  serverId: current.server?.id,
  textChannelId: current.textChannel?.id,
  voiceChannelId: current.voiceChannel?.id
});

context.getCurrent() returns local snapshots for the current user, current server, active text channel, and the user's current voice channel. logger.debug/info/warn/error writes to plugin logs in plugin management UI. Do not log secrets.

Profile

Capabilities: profile.read, profile.write.

const currentUser = api.profile.getCurrent();

api.profile.update({
  displayName: 'Ludde the Builder',
  description: 'Building plugins for MetoYou.'
});

api.profile.updateAvatar({
  avatarUrl: 'https://example.com/avatar.png',
  avatarMime: 'image/png',
  avatarHash: 'sha256:0e5751c026e543b2e8ab2eb06099daa1'
});

Users And Roles

Capabilities: users.read, users.manage, roles.read, roles.manage.

const knownUsers = api.users.list();
const currentMembers = api.users.readMembers();
const roles = api.roles.list();

api.users.setRole('5749584c-4ae6-44c1-b901-81ed4a80be63', 'moderator');

api.roles.setAssignments([
  {
    userId: '5749584c-4ae6-44c1-b901-81ed4a80be63',
    oderId: '5749584c-4ae6-44c1-b901-81ed4a80be63',
    roleIds: ['moderator']
  }
]);

Moderation examples, only behind explicit user action:

api.users.kick('5749584c-4ae6-44c1-b901-81ed4a80be63');
api.users.ban('5749584c-4ae6-44c1-b901-81ed4a80be63', 'Repeated spam after warning.');

Server

Capabilities: server.read, server.manage, users.manage for plugin users.

const server = api.server.getCurrent();

api.server.updateSettings({
  name: 'Friday Build Room',
  description: 'A server for focused testing sessions.',
  topic: 'Plugin QA and voice checks',
  isPrivate: false,
  maxUsers: 24
});

api.server.updatePermissions({
  allowVoice: true,
  allowScreenShare: true,
  allowFileUploads: true,
  slowModeInterval: 0
});

const botUserId = api.server.registerPluginUser({
  id: 'example.my-plugin:announcer',
  displayName: 'Plugin Announcer',
  avatarUrl: 'https://example.com/plugin-announcer.png'
});

registerPluginUser creates a locally visible plugin-owned user record and returns its id. Reuse that id with messages.sendAsPluginUser.

Channels

Capabilities: channels.read, channels.manage.

const channels = api.channels.list();
const textChannels = channels.filter((channel) => channel.type === 'text');
const voiceChannels = channels.filter((channel) => channel.type === 'voice');

api.channels.select('general');
api.channels.addAudioChannel({ id: 'standup-voice', name: 'Standup Voice', position: 200 });
api.channels.addVideoChannel({ id: 'demo-stage', name: 'Demo Stage', position: 300 });
api.channels.rename('standup-voice', 'Daily Standup');
api.channels.remove('demo-stage');

addAudioChannel creates a voice channel in room state. addVideoChannel registers a plugin video channel section contribution, not a normal Channel model entry.

Messages And Typing

Capabilities: messages.read, messages.send, messages.editOwn, messages.deleteOwn, messages.moderate, messages.sync.

const visibleMessages = api.messages.readCurrent();

const sent = api.messages.send(
  'Build completed successfully. Docs are ready for review.',
  'general'
);

api.messages.edit(sent.id, 'Build completed successfully. Docs and plugin examples are ready.');
api.messages.delete(sent.id);

Plugin-user message:

const botUserId = api.server.registerPluginUser({
  id: 'example.my-plugin:status-bot',
  displayName: 'Status Bot'
});

api.messages.sendAsPluginUser({
  pluginUserId: botUserId,
  channelId: 'general',
  content: 'Voice check starts in 5 minutes.'
});

Typing state:

api.messages.setTyping(true, 'general');

setTimeout(() => {
  api.messages.setTyping(false, 'general');
}, 3000);

const typingSubscription = api.messages.subscribeTyping((event) => {
  api.logger.info('Typing event', {
    channelId: event.channelId,
    displayName: event.displayName,
    isTyping: event.isTyping,
    serverId: event.serverId,
    userId: event.userId
  });
});

context.subscriptions.push(typingSubscription);

messages.send creates a message, persists it locally, dispatches it into the local message store, and broadcasts a chat-message peer event. edit and delete update local persistence and broadcast peer edit/delete events. moderateDelete should require explicit confirmation. sync injects an array of Message objects into state and should be used only by plugins that intentionally bridge or restore messages.

Events

Capabilities: events.server.publish, events.server.subscribe, events.p2p.publish, events.p2p.subscribe.

Manifest declaration is required:

{
  "events": [
    {
      "eventName": "example.my-plugin.poll-vote",
      "direction": "serverRelay",
      "scope": "server",
      "maxPayloadBytes": 4096,
      "schema": "{\"type\":\"object\",\"required\":[\"pollId\",\"optionId\"]}"
    }
  ]
}
api.events.publishServer('example.my-plugin.poll-vote', {
  pollId: 'poll-2026-04-29-standup',
  optionId: 'ship-it',
  votedAt: Date.now()
});

const serverEventSubscription = api.events.subscribeServer({
  eventName: 'example.my-plugin.poll-vote',
  handler: (event) => {
    api.logger.info('Poll vote received', event.payload);
  }
});

context.subscriptions.push(serverEventSubscription);

Important runtime detail: subscribeServer listens to signaling messages and calls the handler. subscribeP2p currently records/logs the subscription; for rich peer-to-peer plugin synchronization, prefer api.messageBus.

Message Bus

Capabilities: events.p2p.publish, events.p2p.subscribe; also messages.read when including or replaying latest messages.

The message bus sends plugin-message-bus data-channel events. It does not create normal chat messages.

const subscription = api.messageBus.subscribe({
  topic: 'example.my-plugin.checklist-state',
  channelId: 'general',
  replayLatest: true,
  latestMessageLimit: 25,
  handler: (event) => {
    api.logger.info('Checklist bus event', {
      topic: event.topic,
      latestMessageCount: event.messages?.length ?? 0,
      sourceUserId: event.sourceUserId
    });
  }
});

context.subscriptions.push(subscription);

const envelope = api.messageBus.publish({
  topic: 'example.my-plugin.checklist-state',
  channelId: 'general',
  includeSelf: true,
  includeLatestMessages: true,
  limit: 20,
  payload: {
    items: [
      { id: 'docs', label: 'Review docs', done: true },
      { id: 'voice', label: 'Test voice join', done: false }
    ],
    updatedAt: Date.now()
  }
});

api.logger.debug('Published bus envelope', envelope);

Latest message snapshots default to 50 messages and are clamped to 1..250.

P2P

Capabilities: p2p.data.

const peerIds = api.p2p.connectedPeers();

api.p2p.broadcastData('example.my-plugin.presence-ping', {
  status: 'reviewing-docs',
  sentAt: Date.now()
});

if (peerIds.length > 0) {
  api.p2p.sendData(peerIds[0], 'example.my-plugin.private-nudge', {
    message: 'Can you check the voice channel?'
  });
}

connectedPeers() returns ids from the voice/WebRTC connection facade.

Media

Capabilities: media.playAudio, media.addAudioStream, media.addVideoStream, audio.volume.

await api.media.playAudioClip({
  url: 'https://example.com/sounds/ding.mp3',
  volume: 0.35
});

api.media.setInputVolume(0.8);
api.media.setOutputVolume(0.6);

Create and contribute a browser MediaStream:

const audioContext = new AudioContext();
const oscillator = audioContext.createOscillator();
const destination = audioContext.createMediaStreamDestination();

oscillator.frequency.value = 440;
oscillator.connect(destination);
oscillator.start();

await api.media.addCustomAudioStream({
  label: 'Generated tone',
  stream: destination.stream
});

addCustomAudioStream currently sets the local voice stream through the voice facade. addCustomVideoStream registers/logs a video contribution; do not assume custom video rendering is complete unless the target app version confirms it. Audio clip volume is clamped to 0..1.

Storage

Capabilities: storage.local, storage.serverData.read, storage.serverData.write.

Use async APIs for new plugins:

await api.clientData.write('preferences', {
  compactMode: true,
  favoriteChannelIds: ['general', 'standup-voice'],
  updatedAt: Date.now()
});

const preferences = await api.clientData.read('preferences');

await api.serverData.write('server-checklist', {
  items: [
    { id: 'setup', label: 'Create server channels', done: true },
    { id: 'invite', label: 'Invite test user', done: false }
  ],
  updatedAt: Date.now()
});

const checklist = await api.serverData.read('server-checklist');
await api.serverData.remove('server-checklist');

Legacy synchronous local storage:

api.storage.set('lastPanelTab', 'overview');
const lastPanelTab = api.storage.get('lastPanelTab');
api.storage.remove('lastPanelTab');

Desktop uses Electron's local database when available, with renderer localStorage fallback. Browser-only clients use localStorage. serverData throws if no server is active.

UI

Capabilities:

Method Required capability
registerAppPage ui.pages
registerSettingsPage ui.settings
registerSidePanel ui.sidePanel
registerChannelSection ui.channelsSection
registerComposerAction ui.pages
registerProfileAction ui.pages
registerToolbarAction ui.pages
registerEmbedRenderer ui.embeds
mountElement ui.dom

Register side panel:

context.subscriptions.push(api.ui.registerSidePanel('summary', {
  label: 'Plugin Summary',
  order: 10,
  render: () => {
    const root = document.createElement('aside');
    const heading = document.createElement('h2');
    const text = document.createElement('p');

    heading.textContent = 'Plugin Summary';
    text.textContent = 'No active tasks.';
    root.append(heading, text);
    return root;
  }
}));

Use registerSidePanel for content that belongs in the server sidebar plugin area. Do not query [data-testid="plugin-room-side-panel"] and pass it to mountElement; that route-specific host may not exist while the plugin activates.

Register app page:

context.subscriptions.push(api.ui.registerAppPage('dashboard', {
  label: 'Build Dashboard',
  path: '/plugins/example.build-dashboard/dashboard',
  render: () => {
    const root = document.createElement('section');
    const title = document.createElement('h1');
    const button = document.createElement('button');
    const output = document.createElement('p');

    title.textContent = 'Build Dashboard';
    button.type = 'button';
    button.textContent = 'Send status';
    output.textContent = 'Idle.';

    button.addEventListener('click', () => {
      const message = api.messages.send('Build dashboard status: ready.');
      output.textContent = `Sent message ${message.id}`;
    });

    root.append(title, button, output);
    return root;
  }
}));

Register actions:

context.subscriptions.push(api.ui.registerComposerAction('insert-template', {
  label: 'Insert Template',
  icon: 'file-text',
  run: (actionContext) => {
    api.messages.send(
      'Template: Please review the latest build notes.',
      actionContext.textChannel?.id
    );
  }
}));

context.subscriptions.push(api.ui.registerToolbarAction('post-standup', {
  label: 'Post Standup',
  icon: 'megaphone',
  run: () => {
    api.messages.send('Standup starts now. Join the voice channel when ready.');
  }
}));

Mount DOM directly:

Use direct DOM mounting only for targets that exist now, or after your plugin has explicitly checked the target. body is safe during activation. Route-specific selectors are not safe during activation.

const banner = document.createElement('div');
banner.textContent = 'Plugin banner mounted in chat messages.';

const target = document.querySelector('app-chat-messages');

if (target) {
  context.subscriptions.push(api.ui.mountElement('chat-banner', {
    target,
    element: banner,
    position: 'afterbegin'
  }));
}

Global overlay example:

const badge = document.createElement('div');
badge.textContent = 'Plugin active';

context.subscriptions.push(api.ui.mountElement('global-badge', {
  target: 'body',
  element: badge,
  position: 'beforeend'
}));

mountElement tags the element with plugin ownership metadata, replaces duplicate mounts for the same plugin/id, and removes it on disposal/unload.

Capability Cheat Sheet

API call group Capabilities
profile.getCurrent profile.read
profile.update, profile.updateAvatar profile.write
users.getCurrent, users.list, users.readMembers users.read
users.kick, users.ban, server.registerPluginUser users.manage
roles.list roles.read
users.setRole, roles.setAssignments roles.manage
server.getCurrent server.read
server.updatePermissions, server.updateSettings server.manage
channels.list, channels.select channels.read
channels.addAudioChannel, channels.addVideoChannel, channels.rename, channels.remove channels.manage
messages.readCurrent, messages.subscribeTyping messages.read
messages.send, messages.sendAsPluginUser, messages.setTyping messages.send
messages.edit messages.editOwn
messages.delete messages.deleteOwn
messages.moderateDelete messages.moderate
messages.sync messages.sync
events.publishServer events.server.publish
events.subscribeServer events.server.subscribe
events.publishP2p events.p2p.publish
events.subscribeP2p events.p2p.subscribe
messageBus.publish events.p2p.publish, plus messages.read when includeLatestMessages is true
messageBus.sendLatestMessages events.p2p.publish, messages.read
messageBus.subscribe events.p2p.subscribe, plus messages.read when replayLatest is true
p2p.* p2p.data
media.playAudioClip media.playAudio
media.addCustomAudioStream media.addAudioStream
media.addCustomVideoStream media.addVideoStream
media.setInputVolume, media.setOutputVolume audio.volume
clientData.*, storage.* storage.local
serverData.read storage.serverData.read
serverData.write, serverData.remove storage.serverData.write
ui.registerAppPage, composer/profile/toolbar actions ui.pages
ui.registerSettingsPage ui.settings
ui.registerSidePanel ui.sidePanel
ui.registerChannelSection ui.channelsSection
ui.registerEmbedRenderer ui.embeds
ui.mountElement ui.dom

Complete Example Plugin

toju-plugin.json:

{
  "schemaVersion": 1,
  "id": "example.voice-notes",
  "title": "Voice Notes",
  "description": "Adds a server panel for posting voice-session notes and syncing draft state with peers.",
  "version": "1.0.0",
  "kind": "client",
  "scope": "server",
  "apiVersion": "1.0.0",
  "compatibility": {
    "minimumTojuVersion": "1.0.0"
  },
  "entrypoint": "./main.js",
  "capabilities": [
    "server.read",
    "channels.read",
    "messages.read",
    "messages.send",
    "events.p2p.publish",
    "events.p2p.subscribe",
    "storage.serverData.read",
    "storage.serverData.write",
    "ui.sidePanel",
    "ui.pages"
  ]
}

main.js:

const DRAFT_KEY = 'voice-notes-draft';
const BUS_TOPIC = 'example.voice-notes.draft';

export function activate(context) {
  const { api } = context;

  api.logger.info('Voice Notes activated');

  context.subscriptions.push(api.messageBus.subscribe({
    topic: BUS_TOPIC,
    replayLatest: false,
    handler: (event) => {
      api.logger.debug('Received voice notes draft update', event.payload);
    }
  }));

  context.subscriptions.push(api.ui.registerSidePanel('voice-notes-panel', {
    label: 'Voice Notes',
    order: 20,
    render: () => renderPanel(context)
  }));

  context.subscriptions.push(api.ui.registerAppPage('voice-notes', {
    label: 'Voice Notes',
    path: '/plugins/example.voice-notes/voice-notes',
    render: () => renderPanel(context)
  }));
}

function renderPanel(context) {
  const { api } = context;
  const root = document.createElement('section');
  const heading = document.createElement('h2');
  const meta = document.createElement('p');
  const textarea = document.createElement('textarea');
  const save = document.createElement('button');
  const post = document.createElement('button');
  const status = document.createElement('p');

  const current = api.context.getCurrent();
  heading.textContent = 'Voice Notes';
  meta.textContent = current.voiceChannel
    ? `Connected to ${current.voiceChannel.name}`
    : 'Not connected to a voice channel.';
  textarea.rows = 6;
  textarea.placeholder = 'Write notes from the current voice session.';
  save.type = 'button';
  save.textContent = 'Save Draft';
  post.type = 'button';
  post.textContent = 'Post Notes';
  status.textContent = 'Loading draft...';

  void api.serverData.read(DRAFT_KEY).then((value) => {
    if (value && typeof value === 'object' && typeof value.text === 'string') {
      textarea.value = value.text;
    }

    status.textContent = 'Draft loaded.';
  }).catch((error) => {
    api.logger.warn('Could not load voice notes draft', error);
    status.textContent = 'Could not load draft.';
  });

  save.addEventListener('click', async () => {
    const draft = {
      text: textarea.value,
      serverId: api.server.getCurrent()?.id ?? null,
      channelId: api.context.getCurrent().textChannel?.id ?? null,
      updatedAt: Date.now()
    };

    await api.serverData.write(DRAFT_KEY, draft);
    api.messageBus.publish({ topic: BUS_TOPIC, includeSelf: false, payload: draft });
    status.textContent = 'Draft saved.';
  });

  post.addEventListener('click', () => {
    const text = textarea.value.trim();

    if (!text) {
      status.textContent = 'Write a note before posting.';
      return;
    }

    api.messages.send(`Voice notes:\n\n${text}`, api.context.getCurrent().textChannel?.id);
    status.textContent = 'Posted to the current text channel.';
  });

  root.append(heading, meta, textarea, save, post, status);
  return root;
}

export function deactivate(context) {
  context.api.logger.info('Voice Notes deactivated');
}

Final Checklist For Generated Plugins

  • Manifest is valid JSON with no comments.
  • Manifest id is lowercase dotted or dashed.
  • Manifest capabilities exactly match API calls used in main.js.
  • Every api.events.* event name is declared in events.
  • main.js exports activate; ready and deactivate are optional.
  • Every subscription/disposable is pushed into context.subscriptions.
  • Plugin uses browser DOM APIs and browser globals only.
  • No destructive API runs automatically during activation.
  • UI uses real button, label, input, textarea, headings, and status text.
  • Async storage and media calls are awaited or handled with .then/.catch.
  • README explains behavior, capabilities, and installation.

More Reference

  • User-facing behavior: User Guide pages.
  • DOM structure: Developer Guide -> App Pages and DOM Structure.
  • Local REST API: Developer Guide -> Local REST API.
  • Plugin manifest: Plugin Development -> Manifest Model.
  • Capabilities: Plugin Development -> Capabilities.
  • Focused plugin API examples: Plugin Development -> API Reference and its API subpages.