# Extending LobbyForge: bots, plugins and what you can build For people who run their own LobbyForge instance and want to add bots, games or tools to it. This guide was checked against the code on 2026-10-03 (`main` at `b8ec691`, branch `fix/security-followups`). Where another document says something different, the code wins; the differences are listed in [Appendix A](#appendix-a-docs-that-disagree-with-the-code). - [1. Quick answers](#1-quick-answers) - [2. Adding bots](#2-adding-bots) - [3. Building and installing your own plugin](#3-building-and-installing-your-own-plugin) - [4. What you can build: capabilities and ceilings](#4-what-you-can-build-capabilities-and-ceilings) - [5. How LobbyForge differs](#5-how-lobbyforge-differs) - [6. What's next](#6-whats-next) - [Appendix A: docs that disagree with the code](#appendix-a-docs-that-disagree-with-the-code) ## 1. Quick answers **How do I add a bot?** Open **Community Settings → Bots** (`/admin/settings/bots`). The Welcome and Moderation bots are built in, so you only switch them on and configure them. For your own bot, create a *custom bot* on the same page and copy its token (it is shown once). Then run your program anywhere that can reach your instance over HTTPS. The program calls `/api/bot/v1` with `Authorization: Bot lfb_…`. See [§2](#2-adding-bots). **How do I install my own plugin (game or activity)?** There are two ways: - **A: compile it in.** This is the dependable way today. You add `plugins//` to your fork, register it in `apps/web/lib/plugin-registry.ts`, and rebuild the Docker image. You get the full feature set: your own React panel, action policies, and hidden state per viewer. - **B: the marketplace.** This path is off by default and experimental. Since ADR-007 the plugin runs sandboxed: its `server.js` in a QuickJS WebAssembly VM in the plugin worker, its UI in a sandboxed iframe. It gets action policies, `validateAction` and per-viewer hidden state (`projectState`), and — only when the instance owner approves it at install — outside sites, its own site as the UI, timers, per-community storage and app chat lines; never Node APIs or imports ([§3.5](#35-path-b-marketplace-dynamic-plugin), [PLUGIN_PUBLISHING.md](PLUGIN_PUBLISHING.md)). **What is the most I can build?** Server-authoritative activities that run inside a voice channel. They can be turn-based, or "real-time" at the pace of a few actions per second for a whole room. Every viewer gets only the part of the state they are allowed to see. That covers social deduction games, Taboo-style word games, trivia with server-held answers, synced YouTube watch parties, polls, buzzers and scoreboards. Bots read and post in the channels they are given, register slash commands, answer publicly or privately, and receive events over a WebSocket stream or a signed HTTPS endpoint; incoming webhooks post into a channel. They cannot use voice, buttons, embeds or DMs yet. See [§4](#4-what-you-can-build-capabilities-and-ceilings). **How is this different from Discord?** You run all of it yourself (AGPL-3.0), and the data never leaves your server. Games and their secrets are held by your own server, not by a third-party app backend that Discord proxies to. The trade-off: the bot platform is thinner than Discord's (no buttons, embeds, voice bots or OAuth "add to server" yet). See [§5](#5-how-lobbyforge-differs). ## 2. Adding bots ### 2.1 The three kinds of bot | Type | Runs | Credential | Badge | |---|---|---|---| | `welcome` | inside the web app | none | Official | | `moderation` | inside the web app | none | Official | | `custom` | your program, anywhere | a bot token | Unverified | Every bot belongs to exactly one community (`bots.server_id`). A channel id from another community is simply "not found". If you want the same program in three communities, create three bots with three tokens. Everything a bot posts carries a **BOT** badge and a robot avatar. ### 2.2 Turning on the built-in bots 1. Open **Community Settings → Bots** (`/admin/settings/bots`). Managing bots needs *Manage Community* (`MANAGE_SERVER`, `requireBotManager` in `apps/web/lib/bots/admin.ts`). Switching the Moderation Bot on also needs *Manage Messages*, unless you are the owner (`apps/web/app/api/servers/[id]/bots/builtin/[type]/route.ts:64`). 2. **Welcome Bot:** pick a text channel and a greeting of up to 500 characters. `{user}` and `{server}` are filled in. It posts at most 10 greetings a minute per community, so raids do not flood the channel (`apps/web/lib/bots/welcome.ts:21`). 3. **Moderation Bot:** blocked words (Turkish-aware matching), a link policy, a mention cap, flood rules and repeat rules. It checks every member message and every edit before the message is stored. 4. With their default texts, both bots show each member their own language (stored with a fallback in the instance default language: the admin's choice, else `LOBBYFORGE_DEFAULT_LOCALE`, else English). A text you write yourself is shown exactly as written. The full rules are in [BOTS.md → Built-in bots](BOTS.md#built-in-bots). ### 2.3 Connecting your own bot, step by step **1. Create it.** On the Bots page, create a custom bot with a name of up to 32 characters and choose its permissions. Only two permissions do anything today: `read_messages` and `send_messages` (`BOT_API_PERMISSIONS`, `apps/web/lib/bots/catalog.ts:31`). You can only grant a permission you hold yourself. For example, `send_messages` needs *Send Messages* and `read_messages` needs *Read Message History* (`apps/web/lib/bots/permissions.ts:22`). A community can have up to 20 custom bots (`catalog.ts:47`). **2. Copy the token.** It looks like `lfb__<43 base64url chars>`. It is shown once and stored only as a SHA-256 hash. If you lose it, use *New token* (the old token stops working at once). *Revoke* keeps the bot but disables its token. Every one of these actions is written to the audit log. **3. Test it with curl.** ```sh TOKEN=lfb_... BASE=https://chat.example.com/api/bot/v1 curl -s -H "Authorization: Bot $TOKEN" $BASE/me curl -s -H "Authorization: Bot $TOKEN" $BASE/channels # pick a channel id curl -s -H "Authorization: Bot $TOKEN" "$BASE/channels/$CHANNEL/messages?limit=10" curl -s -X POST -H "Authorization: Bot $TOKEN" -H "Content-Type: application/json" \ -d '{"content":"Hello from my bot"}' $BASE/channels/$CHANNEL/messages ``` **4. Write the program.** The API is plain HTTPS and JSON, so you can use any language. Here is a complete Node 22 relay with no dependencies. It posts to a channel whenever an external status changes, for example your game server's status: ```js // status-relay.mjs: node status-relay.mjs const BASE = `${process.env.LOBBYFORGE_URL}/api/bot/v1`; const HEADERS = { Authorization: `Bot ${process.env.LOBBYFORGE_BOT_TOKEN}`, 'Content-Type': 'application/json' }; const CHANNEL = process.env.LOBBYFORGE_CHANNEL_ID; // from GET /channels const STATUS_URL = process.env.STATUS_URL; // adapt to whatever you watch async function post(content) { const res = await fetch(`${BASE}/channels/${CHANNEL}/messages`, { method: 'POST', headers: HEADERS, body: JSON.stringify({ content }), redirect: 'error', // never let the token follow a redirect to another host }); if (res.status === 429) { const { retryAfter } = await res.json(); await new Promise((r) => setTimeout(r, retryAfter * 1000)); return post(content); } if (!res.ok) throw new Error(`${res.status} ${(await res.json()).code}`); } let last = null; setInterval(async () => { try { const status = await (await fetch(STATUS_URL)).json(); const line = status.online ? `Server is up (${status.players} players)` : 'Server is down'; if (line !== last) { await post(line); last = line; } } catch (err) { console.error(err); } }, 60_000); ``` With TypeScript, use the SDK client instead ([§2.5](#25-the-bot-sdk)). **5. Run it.** Run it where it can reach your instance's public URL: a systemd unit, a cron job, or a container next to the stack. Keep the token in an environment variable or a secret store. The API sends no CORS headers and needs no `Origin`. It is meant for server-side programs, so never put a token in a web page. **Managing bots over HTTP** (the Bots page calls these): `POST /api/servers/{id}/bots` `{ name, permissions }` returns `201 { bot, token }`. You can also send `PATCH` / `DELETE` `/api/servers/{id}/bots/{botId}`, `POST` / `DELETE` `…/{botId}/token` (rotate or revoke) and `PUT …/bots/builtin/{welcome|moderation}`. These routes use a signed-in session cookie (`lf_guest`). The cookie stops working at most 30 days after sign-in, however often it is refreshed (`LOBBYFORGE_SESSION_MAX_AGE_DAYS`, `apps/web/lib/session-lifetime.ts`), so a script that holds one must sign in again. In production these routes also need an `Origin` header that matches your instance; the CSRF guard is `originGuard` in `apps/web/lib/security-headers.ts`. There is no API key for community administration. ### 2.4 Bot API v1 at a glance Base path `/api/bot/v1`, header `Authorization: Bot ` (no `Bearer`, no cookie, no query parameter; `apps/web/lib/bots/token.ts:65`). | Method | Path | Needs | Returns | |---|---|---|---| | GET | `/me` | none | `{ bot: { id, name, type, serverId, permissions } }` | | GET | `/channels` | `read_messages` or `send_messages` | text and announcement channels with no role gate | | GET | `/channels/{id}/messages?limit=1..100&before=` | `read_messages` | newest first | | POST | `/channels/{id}/messages` `{ "content": "…" }` | `send_messages` | `201 { message }`; 1–4000 chars, no other fields | | Limit | Value | Source | |---|---|---| | Posts | 30 / min per bot | `channels/[channelId]/messages/route.ts:96` | | Every other endpoint | 60 / min per bot, per endpoint | `me`, `channels`, messages GET | | Requests carrying a token, per client address | 600 / min | `apps/web/lib/bots/api.ts:46` | | Failed authentication, per client address | 30 / min | `api.ts:48` | | POST body | 16 KiB | `messages/route.ts:96` | Every error is `{ error, code, … }`. Branch on `code`, not on the message: `unauthorized`, `bot_disabled`, `missing_permission`, `mass_mention_forbidden`, `not_found`, `rate_limited` (with `retryAfter`), `maintenance` and so on. The full table is in [BOTS.md → Errors](BOTS.md#errors). Per-address limits are only as accurate as `LOBBYFORGE_TRUSTED_PROXY`. Behind nginx, set it to `x-forwarded-for`. **Polling budget.** Prefer events (Bot API v2: the WebSocket event stream or a signed HTTPS endpoint, [BOT_API_V2.md](BOT_API_V2.md)). A bot that polls instead shares one read limit across all channels: a bot that watches three channels can read each one about every 3 seconds. ### 2.5 The bot SDK `@lobbyforge/bot-sdk` (`packages/bot-sdk`) is `"private": true` and is **not on npm**. You can use it in two ways: - Inside your fork of the monorepo, as a workspace dependency. - Anywhere else, by copying `packages/bot-sdk/src/client.ts` into your project. The file has no imports. ```ts import { BotRateLimitError, createBotClient } from '@lobbyforge/bot-sdk'; // or './client' const bot = createBotClient({ baseUrl: process.env.LOBBYFORGE_URL!, token: process.env.LOBBYFORGE_BOT_TOKEN! }); const me = await bot.getMe(); const [channel] = await bot.listChannels(); const recent = await bot.readMessages(channel!.id, { limit: 20 }); // newest first await bot.sendMessage(channel!.id, `${me.name} is online.`); ``` The client checks its input before sending anything, refuses redirects, times out after 15 s by default, and throws typed errors: `BotAuthError`, `BotForbiddenError` (`.permission`), `BotNotFoundError`, `BotRateLimitError` (`.retryAfter`), `BotValidationError`, `BotServerError` and `BotNetworkError`. A complete `!ping` command bot is in [BOTS.md → Writing a bot with the SDK](BOTS.md#writing-a-bot-with-the-sdk). Do not build on `Bot`, `BotEvents`, `onMessage`, `onVoiceJoin` or `connect()` in `packages/bot-sdk/src/index.ts:37-55`. They are leftover types with no runtime behind them. ### 2.6 What a bot cannot do today Bot API v2 added events (WebSocket stream and signed HTTPS endpoints), slash commands with options, public and ephemeral answers with follow-ups, and incoming webhooks ([BOT_API_V2.md](BOT_API_V2.md)). Still missing: - **Buttons, embeds, attachments, reactions, replies, editing or deleting its own messages.** A post is plain `content`. - **DMs and voice/stage channels.** A role-gated channel opens to a bot only when someone with *Manage Channels* grants it in the bot's channel access (selected mode); see [BOTS.md](BOTS.md). - **Moderate.** It cannot delete messages, time out, kick or ban. `moderate_messages` is honoured only by the built-in Moderation Bot. - **Mention `@everyone` or `@here`.** These are refused with `403 mass_mention_forbidden`. - **OAuth.** There is no "add to server" flow and no outgoing webhook other than the bot's own event endpoint. - **Use the reserved permissions.** `join_voice`, `publish_audio`, `read_presence`, `manage_game_session`, `manage_music_queue` and `read_audit_log` can be granted but unlock nothing yet. Note also that bot messages **bypass the Moderation Bot**: only the mass-mention rule applies to them (`apps/web/lib/bots/messages.ts:164`). What your bot posts is your responsibility. ### 2.7 Security notes - A token is a password for the bot. Rotate it on any doubt; the old one dies immediately. - Grant the least you can. The grant rule stops managers from giving a bot more than they hold themselves, and no bot permission maps to Administrator. - Members see custom bots as **Unverified**, which is intentional. - If you write your own client, disable redirects as the SDK does, so the `Authorization` header never reaches another host. ## 3. Building and installing your own plugin ### 3.1 How an activity runs 1. A member with *Start Activities* starts an app in a **voice or stage channel**. Only one activity can run per channel: the start route answers `409` when `getActiveGameSessionForChannel` finds one (`apps/web/app/api/servers/[id]/channels/[channelId]/activities/route.ts:152-157`). The host calls `createInitialState` and stores the result in `game_sessions.state`. The creator becomes the host. 2. The panel (`renderClient`) calls `dispatch(action)`, which sends `POST …/activities/{sessionId}/actions` (`apps/web/app/api/servers/[id]/activities/[sessionId]/actions/route.ts`). 3. The host then processes the action: - It checks membership and channel visibility. - It applies the **action policy**: `host`, `member` or `player`. Unlisted action types are host-only (`actionPolicyFor`). - It overwrites `actorFields` with the caller's id. - It runs `validateAction`, then `migrateState`, then your pure `handleAction`. 4. Actions on one session run one at a time: the reducer runs and the new state is written while the host holds the session's write lock (a transaction-scoped Postgres advisory lock, `withGameSessionWriteLock` in `packages/db/src/queries/gameSessions.ts`), on the state as it stands, so concurrent actions all apply in turn. The write is still a compare-and-swap on `revision`, which refuses an ended session. If the lock is not granted within 10 s, the answer is a retryable `409`. If the reducer returned the *same object*, the action counts as refused: the state is still written back, but nothing joins the roster and no audit row is written. 5. A change notice goes out on Redis. It carries no state. The WebSocket gateway (and a panel's REST re-read) then load the row and **project it per viewer** with `projectActivityState` (`packages/core/src/activity-projection.ts`) — or, for a marketplace plugin, with its own `projectState` in the plugin worker (`apps/web/lib/plugin-projection.ts`; the gateway asks the web app). Every viewer, host included, gets only what they may see. ### 3.2 The contract (`packages/plugin-sdk/src/index.ts`) | Field | Required | What it does | |---|---|---| | `manifest` | yes | `id` (permanent; see *Choosing an id* below), `name`, `version`, `type` (`game`/`activity`/`utility`), `minAppVersion`, `permissions`, `locales`, `entryClient`, optional `catalog` (picker metadata, player config). `permissions` is declarative only: nothing enforces it today. | | `actionPolicies` | no | Per action type: `role`, `actorFields`, `joinsRoster`, `audit`, `allowOutsideVoice`. | | `restartActions` | no | "Play again" action types accepted once `state.phase === 'ended'`; declaring it makes the host refuse every other action on a finished game (409 `session_ended`). | | `onHostChange(state, change)` | no | Pure; called when the host hands the session to someone else because its host left the voice room. Return the state with the plugin's own host updated, or the same object. | | `createInitialState(ctx)` | yes | The initial state. | | `handleAction(ctx, state, action)` | yes | A synchronous reducer. Return the same object to refuse an action. Randomness and time belong here, on the server. | | `validateAction(action)` | no | Return an error string (the caller gets a 400) or `null`. The host itself only validates `{ type: string }`. | | `migrateState(raw)` | no | Runs on every read. It must be idempotent. Version your state. The host awaits it, so a Promise is allowed (marketplace plugins always return one). | | `renderClient(props)` | yes | Returns a React **element** for the panel. | **Choosing an id.** The SDK does not check the id, and the routes that check it disagree: - Enabling an app and starting an activity (`POST /api/servers/{id}/apps`, `POST …/channels/{channelId}/activities`) accept any string of 1–64 characters. - Marketplace submit and install accept `[a-z0-9][a-z0-9_-]*`, 2–128 characters, ignoring case. The installer, the install layout and the worker allow the same characters, up to 128 (`PLUGIN_ID_RE` in `apps/web/lib/plugin-install-layout.ts` and `apps/plugin-worker/src/bundle.ts`), and the plugin-storage proxy wants 2–128 characters. - Ids are compared exactly, so `Buzzer` and `buzzer` are two plugins. Use lowercase letters, digits and hyphens, starting with a letter or digit, 2–64 characters (`buzzer`, `team-trivia`). That passes every check. An id longer than 64 characters passes the marketplace and installs, but it can never be enabled or started. The **action policy** fields: - `joinsRoster: true` adds the actor to the visible player list when the action changes state. Use it for join or ready actions and for public actions, never for anonymous votes. - `audit` writes an `activity.action` audit row. It defaults to `true` for `host` actions and `false` for gameplay. Set it to `false` on any host action whose *type* alone gives a secret away (`shouldAuditAction`, `index.ts:167`). - `allowOutsideVoice: true` lets a `member` / `player` action through from someone who is not in the activity's voice room. It only matters for a plugin with `catalog.requiresVoiceRoom`, whose other gameplay actions the host refuses from outside the room (403 `voice_required`); use it for leaving. See [PLUGIN_SDK.md → Voice, hosting and play again](PLUGIN_SDK.md#voice-hosting-and-play-again). **Panel props.** The panel receives `state` (already projected for this viewer), `dispatch`, `actorUserId`, `hostUserId` and `players` (`{ userId, name }`, including people in the voice channel). In the lobby it also receives `cardPacks` (the `PluginSurface` props in `apps/web/app/lobby/LobbyActivityView.tsx`, lines 299-310 today). **What `ctx` really provides.** On the HTTP host, `ctx.players.list()` / `get()` work, `ctx.now` is the call's clock, and `ctx.storage` is a read-only snapshot (`get`, `keys`) of the plugin's Postgres storage in the community (with the `storage` permission). What a plugin wants the host to do — fire an action later, write storage, post a chat line — it returns as **effects** (`withEffects(state, effects)`; [PLUGIN_SDK.md → Effects](PLUGIN_SDK.md#effects-timers-storage-and-chat)), under the permissions in its `manifest.permissions`. No official plugin uses them yet. Several other parts of `ctx` do nothing (`apps/web/lib/plugin-context.ts`): - `ctx.messages.sendGameMessage` (deprecated) only writes to the server log; return a `chat.post` effect instead. - `ctx.timer` (deprecated), `cache`, `pubsub`, `votes` and `scores` are empty stubs. - `ctx.voice.getParticipants()` returns the user ids in the activity's voice room (from LiveKit, oldest first) for a plugin that requires voice, and `[]` for any other. **Timers.** There is no server tick. Store a *deadline* in state, and let a client send a `time-up` action that the reducer accepts only after the server clock passes the deadline. Quiz shows the pattern (the *Timing* note at the top of `plugins/quiz/src/actions.ts`). ### 3.3 Path A: compile your plugin into your image (recommended) You need Node 22, pnpm 10 (`corepack enable`) and Docker. The steps assume a fork of `https://github.com/Juanka-e/LobbyForge`. **Step 1: create the package.** Copy the shape of `plugins/dice-bot`. Below is a complete minimal plugin called **Buzzer**: the host opens a round, the first member to buzz wins, and the host resets. This exact code was type-checked and its tests run against the real `@lobbyforge/plugin-sdk` sources. ``` plugins/buzzer/ ├── package.json ├── tsconfig.json ├── vitest.config.ts ├── locales/en.json └── src/ ├── constants.ts ├── index.ts ├── renderClient.tsx ├── locales.generated.ts # written by `pnpm i18n:sync` └── __tests__/buzzer.test.ts ``` `package.json`: ```json { "name": "@lobbyforge/buzzer", "version": "0.1.0", "private": true, "type": "module", "main": "./src/index.ts", "types": "./src/index.ts", "exports": { ".": { "types": "./src/index.ts", "import": "./src/index.ts" } }, "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", "test": "vitest run", "lint": "eslint \"src/**/*.{ts,tsx}\"" }, "dependencies": { "@lobbyforge/plugin-sdk": "workspace:*" }, "devDependencies": { "@lobbyforge/config": "workspace:*", "@types/react": "^19.2.17", "react": "^19.2.7", "typescript": "^5.4.5", "vitest": "^4.1.11" } } ``` `tsconfig.json` and `vitest.config.ts` (the same as dice-bot's): ```json { "extends": "@lobbyforge/config/tsconfig.base.json", "compilerOptions": { "outDir": "./dist", "rootDir": "./src", "jsx": "react-jsx", "module": "ESNext", "moduleResolution": "Bundler" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } ``` ```ts import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', include: ['src/__tests__/**/*.test.ts', 'src/__tests__/**/*.test.tsx'] }, }); ``` `locales/en.json` (put `catalog.summary` here; the activity picker shows it in the viewer's language): ```json { "catalog.summary": "First to buzz wins the round.", "buzzer.title": "Buzzer", "buzzer.round": "Round {round}", "buzzer.idle": "Waiting for the host to open a round.", "buzzer.open": "Open round", "buzzer.buzz": "Buzz!", "buzzer.reset": "Reset", "buzzer.winner": "{name} buzzed first." } ``` `src/constants.ts` is a separate module so the panel can import the id without importing `./index`. A cycle would leave the constant uninitialised. ```ts export const BUZZER_PLUGIN_ID = 'buzzer'; ``` `src/index.ts`: ```ts import { createElement } from 'react'; import { CATALOG_SUMMARY_KEY, PluginPermission, loadPluginLocale, type GamePlugin } from '@lobbyforge/plugin-sdk'; import { BUZZER_PLUGIN_ID } from './constants'; import { LOCALE_TABLES, SHIPPED_LOCALES } from './locales.generated'; import { BuzzerPanel, type BuzzerPanelProps } from './renderClient'; // The host reads `catalog.summary` on the server, where the 'use client' panel never runs. loadPluginLocale(BUZZER_PLUGIN_ID, LOCALE_TABLES); export { BUZZER_PLUGIN_ID } from './constants'; export const BUZZER_STATE_VERSION = 1; export interface BuzzerState { version: number; phase: 'idle' | 'open' | 'locked'; round: number; winnerId: string | null; } export type BuzzerAction = | { type: 'open-round'; hostId: string } | { type: 'buzz'; playerId: string } | { type: 'reset'; hostId: string }; function initialState(): BuzzerState { return { version: BUZZER_STATE_VERSION, phase: 'idle', round: 0, winnerId: null }; } /** Runs before the reducer: the host itself only checks `{ type: string }`. */ export function buzzerValidateAction(action: unknown): string | null { if (typeof action !== 'object' || action === null) return 'Action must be an object.'; const a = action as Record; switch (a.type) { case 'open-round': case 'reset': return typeof a.hostId === 'string' && a.hostId !== '' ? null : `${a.type} needs hostId`; case 'buzz': return typeof a.playerId === 'string' && a.playerId !== '' ? null : 'buzz needs playerId'; default: return `Unknown action type: ${String(a.type)}`; } } export const buzzerPlugin: GamePlugin = { manifest: { id: BUZZER_PLUGIN_ID, name: 'Buzzer', version: '0.1.0', type: 'game', minAppVersion: '0.2.0', permissions: [PluginPermission.MANAGE_GAME_SESSION], locales: SHIPPED_LOCALES, entryClient: './renderClient.js', catalog: { category: 'game', summary: LOCALE_TABLES.en[CATALOG_SUMMARY_KEY], publisher: 'My Community', trustLevel: 'unverified', playerConfig: { minPlayers: 2, maxPlayers: 50, defaultMaxPlayers: 20, supportsSpectators: true }, requiresVoiceRoom: true, tags: ['party'], }, }, // Unlisted action types are host-only. The host overwrites actorFields with the caller's id. actionPolicies: { 'open-round': { role: 'host', actorFields: ['hostId'] }, buzz: { role: 'member', actorFields: ['playerId'], joinsRoster: true }, // public anyway reset: { role: 'host', actorFields: ['hostId'] }, }, createInitialState: () => initialState(), validateAction: buzzerValidateAction, migrateState: (raw) => { if (!raw || typeof raw !== 'object') return initialState(); const s = raw as Partial; if (s.version === BUZZER_STATE_VERSION) return s as BuzzerState; return { ...initialState(), ...s, version: BUZZER_STATE_VERSION }; // v0 -> v1 }, // Returning the SAME object means "refused": no roster join, no audit row. handleAction: (_ctx, state, action) => { if (buzzerValidateAction(action) !== null) return state; switch (action.type) { case 'open-round': return { ...state, phase: 'open', round: state.round + 1, winnerId: null }; case 'buzz': if (state.phase !== 'open') return state; return { ...state, phase: 'locked', winnerId: action.playerId }; case 'reset': return initialState(); default: return state; } }, // Return an ELEMENT. Calling BuzzerPanel(props) directly runs its hooks inside the host (React #310). renderClient: (props: unknown) => createElement(BuzzerPanel, props as BuzzerPanelProps), }; ``` `src/renderClient.tsx` is built with the UI kit. Plugin files sit outside the web app's Tailwind build, so class names would produce no CSS: ```tsx 'use client'; import { detectLocale, loadPluginLocale, pickBestLocale, tFor } from '@lobbyforge/plugin-sdk'; import { ActivityHeader, ActivityShell, Button, Callout, Panel, PhasePill, Row } from '@lobbyforge/plugin-sdk/ui'; import { BUZZER_PLUGIN_ID } from './constants'; import { LOCALE_TABLES } from './locales.generated'; import type { BuzzerAction, BuzzerState } from './index'; loadPluginLocale(BUZZER_PLUGIN_ID, LOCALE_TABLES); export interface BuzzerPanelProps { state: BuzzerState; // already projected for THIS viewer dispatch: (action: BuzzerAction) => void | Promise; actorUserId: string; hostUserId: string | null; players: Array<{ userId: string; name?: string | null }>; } export function BuzzerPanel({ state, dispatch, actorUserId, hostUserId, players }: BuzzerPanelProps) { const locale = pickBestLocale(BUZZER_PLUGIN_ID, detectLocale('en')); const t = (key: string, params?: Record) => tFor(BUZZER_PLUGIN_ID, locale, key, params, 'en'); const isHost = actorUserId === hostUserId; const winner = players.find((p) => p.userId === state.winnerId); return ( {t('buzzer.round', { round: state.round })}} /> {state.phase === 'idle' ? {t('buzzer.idle')} : null} {state.phase === 'locked' ? ( {t('buzzer.winner', { name: winner?.name ?? '?' })} ) : null} {isHost ? ( <> ) : null} ); } ``` `src/__tests__/buzzer.test.ts`: ```ts import { describe, expect, it } from 'vitest'; import { createTestHarness } from '@lobbyforge/plugin-sdk/testing'; import { buzzerPlugin, type BuzzerAction, type BuzzerState } from '../index'; describe('buzzer', () => { it('lets the first buzz win and refuses the second', async () => { const h = createTestHarness({ plugin: buzzerPlugin, players: ['host', 'p1', 'p2'] }); await h.startGame(); // The harness does not apply actionPolicies: pass the ids the host would inject. await h.performAction('host', { type: 'open-round', hostId: 'host' }); await h.performAction('p1', { type: 'buzz', playerId: 'p1' }); const afterFirst = h.getState(); await h.performAction('p2', { type: 'buzz', playerId: 'p2' }); expect(h.getState()).toBe(afterFirst); // same object = refused expect(afterFirst.winnerId).toBe('p1'); }); }); ``` **Step 2: wire it in and test locally.** ```sh pnpm i18n:sync # writes plugins/buzzer/src/locales.generated.ts (re-run after editing locales) # apps/web/package.json -> "dependencies": { "@lobbyforge/buzzer": "workspace:*", ... } # apps/web/lib/plugin-registry.ts: # import { buzzerPlugin } from '@lobbyforge/buzzer'; # ... registerGamePlugin(buzzerPlugin), // inside PLUGINS pnpm install # links the package AND updates pnpm-lock.yaml: commit it pnpm build:packages # @lobbyforge/plugin-sdk resolves from its dist/ pnpm --filter @lobbyforge/buzzer typecheck pnpm --filter @lobbyforge/buzzer test pnpm --filter @lobbyforge/web typecheck docker compose -f infra/docker/docker-compose.dev.yml up -d # postgres, redis, livekit pnpm dev # http://localhost:19520 ``` Commit `pnpm-lock.yaml`. The Docker build runs `pnpm install --frozen-lockfile` (`Dockerfile:8`) and fails if the lock file is stale. More languages: `pnpm i18n:add ` scaffolds a locale for every plugin ([TRANSLATING.md](TRANSLATING.md)). **Step 3: add what only core code can do, if you need it.** These hooks are per plugin id in host code, not in the SDK: | You need | Edit | |---|---| | Hidden information (roles, hands, answers): strip or rewrite it per viewer | add an `if (pluginId === 'buzzer')` branch to `packages/core/src/activity-projection.ts` (used by REST and the WS gateway). Without it, **every viewer receives the whole state**. | | Secret content loaded on the server (answers, decks) | `apps/web/lib/prepare-plugin-action.ts` (as Quiz and Hushle do) and a server-only subpath export ([PLUGIN_SDK.md → Server-only subpath exports](PLUGIN_SDK.md#server-only-subpath-exports-m18)) | | Phase checks before the reducer | `validateActionPhase` in the actions route | | Built-in content (packs, decks) | `apps/web/lib/component-migrations.ts` | | An embed other than YouTube | `frame-src` in `apps/web/proxy.ts:66` allows only `https://www.youtube-nocookie.com` | The WebSocket gateway projects the stored state **without** `migrateState` (`forwardProjectedActivity` in `apps/ws-gateway/src/server.ts`). Render older shapes defensively. **Step 4: build and deploy your image.** On the server, in the checkout that `install.sh` used: ```sh # 1. back up first (docs/BACKUP_DRILL.md shows the lfctl backup command) # 2. get your code git pull https://github.com//LobbyForge.git # 3. use a LOCAL image tag. `lfctl update apply` writes the signed upstream digest into # LOBBYFORGE_IMAGE, and compose cannot build onto a digest grep -q '^LOBBYFORGE_IMAGE=' .env.prod \ && sed -i 's|^LOBBYFORGE_IMAGE=.*|LOBBYFORGE_IMAGE=lobbyforge-web:custom|' .env.prod \ || echo 'LOBBYFORGE_IMAGE=lobbyforge-web:custom' >> .env.prod # 4. build and restart web, ws-gateway, plugin-worker and migrate (they share one image) docker compose -f infra/docker/docker-compose.prod.yml --env-file .env.prod up -d --build ``` **Step 5: enable it for a community.** Open **Admin → Apps** (`/admin/apps`), or send `POST /api/servers/{id}/apps` `{"pluginId":"buzzer","enabled":true}`; this needs *Manage Community*. Then open **Activities** in a voice channel and pick Buzzer. **Staying up to date.** Merge upstream into your branch and rebuild with step 4. Do **not** run `lfctl update apply` against the official manifest: it deploys the signed upstream image, which does not contain your plugin. Running sessions of your plugin would then answer `409 Plugin not registered`. If you want signed updates for your own build, publish your own manifest and key and use them (`lfctl update … --manifest --public-key `, or `LOBBYFORGE_RELEASE_MANIFEST`). **Trust.** Compiled-in code runs inside the web process with full access. The 5 s guard in `plugin-context.ts:218` cannot interrupt synchronous code, so a looping reducer blocks the server. Review anything you compile in the way you review your own code. ### 3.4 Choosing a path | | A: compiled in | B: marketplace (sandbox-v1) | |---|---|---| | Language and APIs | TypeScript, React, the whole SDK | plain JavaScript in `server.js`, no imports, no Node APIs | | Your own UI | a React panel in the app | `ui/` in a sandboxed iframe (no fetch, XHR, WebSocket, image or media requests beyond its own files and approved origins; WebRTC is not covered by CSP; sound, video and WebAssembly work), postMessage protocol v1 | | `actionPolicies`, `validateAction` | yes | yes: policies from `manifest.json`, `validateAction` in the sandbox | | Hidden state per viewer | yes, with a core projection branch | yes, with the plugin's own `projectState` | | Name and summary in each viewer's language | `catalog.name` / `catalog.summary` in `locales/.json` | the same keys in the bundle's `locales/.json` ([PLUGIN_PUBLISHING.md](PLUGIN_PUBLISHING.md#locales-optional-your-name-and-description-in-every-language)) | | Listed in the Apps page and the activity picker | yes | no: compiled-in lists only (enable and start through the API) | | Isolation | none (in-process, trusted code) | QuickJS WebAssembly VM per call, inside a hardened container on a network only web joins ([ADR-007](ARCHITECTURE_DECISIONS.md#adr-007-marketplace-plugins-run-sandboxed-supersedes-the-trust-limits-of-adr-001-and-the-target-of-adr-002)) | | Storage, timers, chat messages | effects (`timer.set`, `storage.set`, `chat.post`) under `manifest.permissions`; `ctx.storage` is a snapshot | the same effects, once the owner approved the manifest's `permissions` ([PLUGIN_SDK.md → Effects](PLUGIN_SDK.md#effects-timers-storage-and-chat)) | | Install | rebuild the image | owner approves and installs at runtime | | Status | used by all six official plugins | experimental: unit, route and sandbox tests, no end-to-end test on a running stack | ### 3.5 Path B: marketplace (dynamic) plugin Since ADR-007 a marketplace plugin runs **sandboxed**, so an instance owner no longer has to trust its author with the server ([ADR-007](ARCHITECTURE_DECISIONS.md#adr-007-marketplace-plugins-run-sandboxed-supersedes-the-trust-limits-of-adr-001-and-the-target-of-adr-002)). The full author's guide — bundle format, contract, limits, packing, migration from the old Node bundles — is [PLUGIN_PUBLISHING.md](PLUGIN_PUBLISHING.md). This section is the operator's view. What exists: a **per-instance** catalog. Users submit an entry, the instance owner reviews it, and approval downloads the bundle and pins its SHA-256 and size. Install verifies those bytes, refuses anything but a `sdk: "sandbox-v1"` bundle (`manifest.json` + `server.js`, optional `ui/` and `locales/`) whose id and version match the entry, extracts it, and has the `plugin-worker` load that exact version before recording it as active. How it runs: - **Server code** (`server.js`) runs in a QuickJS WebAssembly VM in the `plugin-worker` container: plain JavaScript, no `require`/`import`, no `process`, file system, network, timers or shared memory, and no host functions. Each call gets a fresh WebAssembly instance, 32 MB of VM memory, a 256 KiB stack and the call budget (`PLUGIN_CALL_BUDGET_MS`, default 2 s). The VM's interrupt can be outrun by one long native operation, so calls run in a small pool of executor threads (`PLUGIN_SANDBOX_THREADS`, default 2) and a thread that overruns is terminated. A runaway plugin fails its call, never the worker. - **The host's features apply**, as for official plugins: the manifest's `actionPolicies` (roles, `actorFields`, `joinsRoster`, `audit`) on the actions route — read by the web app from its own copy of the files, so plugin code cannot widen them; `validateAction`; a reducer that returns its input is a refused action; and `projectState` gives per-viewer hidden state on every read path (GET, the action response, and the WebSocket gateway, which asks web through the signed internal `POST /api/internal/activity-projection`). Without `projectState` the state is public and the lobby says so. - **UI** (`ui/`) is served from `/api/plugin-ui/{pluginId}/{version}/…` into `