--- url: /guide/getting-started.md --- # Getting started This page builds a complete, working plugin end-to-end: a **guestbook** with a private SQLite table, a backend that lists and adds entries, a real-time broadcast when a new entry lands, and a sidebar panel that renders it. It uses every layer of the SDK in miniature, so by the end you can read any of the reference pages and know where each piece fits. If you only want to surface a self-hosted web app behind a panel (no data, no logic), skip this and read [Reverse-proxy plugins](/sdk/reverse-proxy) instead. ## 0. Prerequisites * A running UnCorded server container (launched from the desktop app). * Access to that server's data directory (`/plugins/`) and its `server.json`. * [Bun](https://bun.sh) on your machine for packaging the backend. ## 1. The folder A plugin is a single folder named exactly its slug. Create: ``` guestbook/ manifest.json backend/ index.ts frontend/ index.html migrations/ 001_init.sql ``` ## 2. The manifest `manifest.json` declares the slug, both entry points, and the **exact** capabilities the plugin uses. The runtime rejects any IPC call for a capability not listed here — declare them up front. ```json { "name": "guestbook", "version": "0.1.0", "api_version": "^1.0", "author": "you", "description": "A simple server guestbook.", "license": "MIT", "type": "standalone", "icon": "BookOpen", "backend": { "entry": "backend/index.ts" }, "frontend": { "entry": "frontend/index.html" }, "permissions": ["data.sql:self", "broadcast.clients"], "sidebar": { "contributes": true, "section": "Community" } } ``` * `type: "standalone"` — a third-party plugin that owns its own data. (Core plugins shipped by UnCorded use `"core"`; plugins that extend another plugin use `"extension"` + `extends`. See the [manifest reference](/reference/manifest).) * `data.sql:self` — read/write the plugin's own SQLite database. * `broadcast.clients` — push real-time events to connected clients. Full field-by-field detail is in the [manifest reference](/reference/manifest); the capability strings are in the [permissions reference](/reference/permissions). ## 3. The migration SQL files in `migrations/`, run in filename order at plugin load, build your schema. Timestamps are stored as Unix-ms integers by convention. ```sql -- migrations/001_init.sql CREATE TABLE entries ( id TEXT PRIMARY KEY, author_id TEXT NOT NULL, message TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE INDEX idx_entries_created ON entries(created_at); ``` ## 4. The backend The backend calls `createPlugin()` once, registers its request handlers **synchronously**, then does any async setup. Handlers receive `(params, user)` and return any JSON-serializable value; throwing surfaces an error to the caller. ```ts // backend/index.ts import { createPlugin } from "@uncorded/plugin-sdk"; interface Entry { id: string; author_id: string; message: string; created_at: number; } const plugin = createPlugin(); // Read: newest 100 entries. plugin.handle("listEntries", async () => { return plugin.db.query( "SELECT id, author_id, message, created_at FROM entries ORDER BY created_at DESC LIMIT 100", ); }); // Write: validate, insert, broadcast. plugin.handle("addEntry", async (params, user) => { const message = params["message"]; if (typeof message !== "string" || message.trim().length === 0) { throw new Error("message is required"); } if (message.length > 500) { throw new Error("message too long"); } const entry: Entry = { id: crypto.randomUUID(), author_id: user.id, message: message.trim(), created_at: Date.now(), }; await plugin.db.run( "INSERT INTO entries (id, author_id, message, created_at) VALUES (?, ?, ?, ?)", [entry.id, entry.author_id, entry.message, entry.created_at], ); // Push to every connected client. The frontend SDK receives this as // sdk.on("entry.added", ...) — the runtime namespaces it with the slug. await plugin.broadcast.toAll("entry.added", entry); return entry; }); // Tell the shell what to put in the sidebar. plugin.handle("sidebar.items", async () => ({ items: [ { id: "guestbook", label: "Guestbook", icon: "BookOpen", panelType: "plugin" as const, slug: "guestbook", // must equal manifest "name" section: "Community", }, ], })); ``` `user` is the authenticated caller (`{ id, displayName, avatarUrl, role }`). Use [`plugin.permissions`](/reference/backend-sdk#permissions) to gate writes by role when you need to — the guestbook lets any member post. ## 5. The frontend The panel is plain HTML served into a sandboxed iframe. It loads the frontend SDK from `/sdk/plugin-frontend.js` (served by the runtime — never bundle it), initializes, calls backend handlers with `sdk.request(action, params)`, and listens for broadcasts with `sdk.on(event, handler)`. ```html Guestbook
``` ## 6. Package the backend The runtime spawns your backend as its own subprocess with the plugin folder as the working directory. It does **not** run `bun install` for you. Any `import` (including `@uncorded/plugin-sdk`) must resolve against a `node_modules` present in the installed folder. From inside the plugin folder, add a `package.json` and install: ```sh cd guestbook bun init -y # creates package.json bun add @uncorded/plugin-sdk # installs the SDK + deps into node_modules ``` Ship the folder **with `node_modules` present**. (A backend that imports nothing loads without packaging, but real plugins use the SDK and must be packaged.) ## 7. Install & run Three things must be true, in order: 1. **Place the folder** under the server's plugin directory, named exactly the slug: ``` /plugins/guestbook/ ``` 2. **Register the slug** in the server's `server.json` — the runtime only loads plugins listed here: ```json { "installed_plugins": ["guestbook"] } ``` 3. **Restart through the desktop app** (not `docker restart`). The runtime reads `installed_plugins` only at boot, so the container must be recreated. The desktop app owns that lifecycle. > ⚠️ Never `docker restart` a server on an authenticated Cloudflare tunnel — > the tunnel token is piped in at container-create time and a bare restart > silently degrades the tunnel. Always go through the desktop app. Open the server, click **Guestbook** in the sidebar, and post. The message appears instantly in every open client. ## What you just used | Layer | This plugin | Reference | | --- | --- | --- | | Manifest + capabilities | `data.sql:self`, `broadcast.clients` | [Manifest](/reference/manifest) · [Permissions](/reference/permissions) | | Own database | `plugin.db.query` / `plugin.db.run` | [Backend SDK → db](/reference/backend-sdk#db) | | Request handlers | `plugin.handle("addEntry", …)` | [Backend SDK → handle](/reference/backend-sdk#handle-request) | | Real-time push | `plugin.broadcast.toAll` → `sdk.on` | [Backend SDK → broadcast](/reference/backend-sdk#broadcast) | | Panel UI | `createPluginFrontend()`, `sdk.request` | [Frontend SDK](/reference/frontend-sdk) | Next: [Plugin anatomy](/guide/plugin-anatomy) breaks down every file and how the runtime treats it. --- --- url: /guide/plugin-anatomy.md --- # Plugin anatomy A plugin is one folder, named exactly its slug, containing a manifest and one or both entry points. This page is the map: what each file is, how the runtime treats it, and the packaging rule that trips up most first attempts. ## The folder ``` my-plugin/ manifest.json ← required. Slug, entry points, capabilities, settings. backend/ index.ts ← backend entry (path is set by manifest.backend.entry) frontend/ index.html ← frontend entry (path is set by manifest.frontend.entry) migrations/ 001_init.sql ← SQL run in filename order at load (data-owning plugins) 002_add_column.sql node_modules/ ← REQUIRED if the backend imports anything (see Packaging) package.json ← how you install node_modules ``` Only `manifest.json` plus at least one entry point is mandatory. A frontend-only plugin omits `backend`; a headless plugin omits `frontend`; a reverse-proxy plugin needs both but the backend is a few lines. The folder name **must** equal the manifest `name`. That slug is the plugin's identity everywhere: the install directory, the `installed_plugins` entry, the DB filename, broadcast namespacing, and proxy/upload URLs. ## manifest.json The contract between your plugin and the runtime. It is validated at load; an invalid manifest means the plugin is skipped. The fields you'll touch most: | Field | Purpose | | --- | --- | | `name` | Slug. `^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$`. | | `version` / `api_version` | Plugin semver / runtime-API semver range (`^1.0`). | | `type` | `core` | `standalone` | `extension`. Third-party = `standalone`. | | `backend` / `frontend` | `{ "entry": "" }`. At least one required. | | `permissions` | The capabilities the runtime will allow. Undeclared = rejected. | | `settings` | Admin-configurable values, rendered as a form in Server settings. | | `sidebar` | `{ "contributes": true, … }` to put items in the client sidebar. | | `public_schema` | Tables/columns you expose for cross-plugin reads. | Full reference: [Manifest](/reference/manifest). Capability grammar: [Permissions](/reference/permissions). ## backend/ The backend is a Bun program the runtime spawns as a **subprocess**. It speaks the stdio JSON [IPC protocol](/reference/ipc-protocol), but you never touch that directly — `createPlugin()` from `@uncorded/plugin-sdk` wraps it into a typed handle. Structure every backend the same way: ```ts import { createPlugin } from "@uncorded/plugin-sdk"; const plugin = createPlugin(); // 1. Register handlers SYNCHRONOUSLY, at module top level, so they exist before // the runtime starts routing requests. plugin.handle("doThing", async (params, user) => { /* … */ }); plugin.handle("sidebar.items", async (_params, user) => ({ items: [/* … */] })); // 2. THEN do async setup: register permissions, subscribe to events, register // schedules, warm caches. await plugin.permissions.register("my-plugin.post", { description: "…", default_level: 10 }); await plugin.events.subscribe("runtime.cascade.user.deleted", async (e) => { /* … */ }); ``` Why the order matters: handler registration is local and instant; async setup involves IPC round-trips. Registering handlers first guarantees a request that arrives mid-startup has somewhere to land. See the [text-channels walkthrough](/examples/text-channels) for a full backend. The `createPlugin()` handle exposes the whole backend surface — `db`, `kv`, `settings`, `events`, `broadcast`, `presence`, `schedule`, `fetch`, `core`, `data`, `permissions`, `resources`, `files`, `voice`. Reference: [Backend SDK](/reference/backend-sdk). ### Two reserved handler actions | Action | Called by | Returns | | --- | --- | --- | | `sidebar.items` | the shell, to build the sidebar | `{ items: SidebarItem[], adminActions?: [] }` | | `schedule.tick` | the runtime, on a registered schedule | (handled for you by `plugin.schedule.every`) | Everything else is an action name you choose and the frontend calls by string. ## frontend/ The frontend entry (an HTML file) is served into a **sandboxed iframe** inside the client shell. It has no same-origin access to the shell; all communication goes through an origin-verified `postMessage` channel that the frontend SDK manages for you. ```html ``` * Load `/sdk/plugin-frontend.js` from the runtime. **Do not** bundle or vendor it — it's served and cache-busted by the runtime so it stays in lockstep. * The HTML is served as-is. **No build step** — inline your CSS and JS, or ship pre-built assets alongside `index.html`. * `createPluginFrontend()` resolves after the handshake completes; everything else hangs off the returned `sdk`. Reference: [Frontend SDK](/reference/frontend-sdk). ## migrations/ Data-owning plugins (those with `data.sql:self`) get a private SQLite database. SQL files in `migrations/` run **in filename order** at plugin load to build and evolve the schema: * `001_init.sql` — `CREATE TABLE` + any seed rows. * `002_*.sql`, `003_*.sql` — `ALTER TABLE`, new tables, backfills. Conventions from the core plugins: integer Unix-ms timestamps (`strftime('%s','now') * 1000` for seeds), explicit column lists in `SELECT` (don't `SELECT *` — it leaks columns added by a later migration before your wire contract catches up), and soft foreign keys checked in code rather than `REFERENCES` constraints across plugin boundaries. More on the database, KV, and events: [Data & events](/guide/data-and-events). ## Packaging — backends run as subprocesses The single most common reason a plugin won't load. The runtime executes your backend as its own subprocess with the plugin folder as the working directory: ``` Bun.spawn(["bun", "--smol", "run", ""], { cwd: "" }) ``` The runtime **does not** install your dependencies. So: * Any `import` resolves against the plugin folder's **own `node_modules`**. * **Ship `node_modules` in the installed folder** — either commit it, or include a `package.json` + lockfile and run `bun install` in the folder before installing the plugin. ```sh cd my-plugin bun add @uncorded/plugin-sdk # populates node_modules/ ``` > A backend that imports nothing (raw stdio) loads without packaging, but any > real plugin imports the SDK and therefore must be packaged with its deps. ## Where data lives on disk Inside the container, each plugin gets an isolated directory (mode `0700`): ``` /data/plugins// .db ← the plugin's private SQLite (WAL mode) .db-wal .db-shm uploads/ ← files POSTed to /upload, served via signed URLs ``` A plugin can never open another plugin's database for writing. Cross-plugin reads go through the [`data.read`](/reference/backend-sdk#data) capability, which opens the target DB read-only and enforces the target's `public_schema`. Next: [Lifecycle](/guide/lifecycle) — exactly what happens from spawn to shutdown, including the readiness handshakes and the watchdog. --- --- url: /guide/lifecycle.md --- # Lifecycle What the runtime does to your plugin, from boot to shutdown. Understanding this explains why handlers must register synchronously, when `serveReady()` matters, and why a crash loop quarantines a plugin. ## 1. Discovery At boot the runtime reads `server.json` → `installed_plugins: string[]`. For each slug it resolves a folder (core plugins first, then user plugins) and reads `manifest.json`. A slug with no resolvable manifest is **skipped with a warning** — it doesn't block boot. A plugin marked disabled in settings is also skipped. > The list is read **only at boot**. Adding a slug to `installed_plugins` > requires recreating the container (restart via the desktop app), not a hot > reload. ## 2. Database & migrations Before spawning, the runtime prepares the plugin's data directory (`/data/plugins//`, mode `0700`) and runs the SQL files in `migrations/` in filename order. The SQLite database opens in WAL mode on first use. If a migration throws, the plugin is skipped with an error. ## 3. Spawn The backend is launched as its own subprocess: ``` Bun.spawn(["bun", "--smol", "run", ""], { cwd: "", stdin: "pipe", stdout: "pipe", stderr: "pipe", env: { PLUGIN_SLUG, PLUGIN_API_VERSION, PLUGIN_DATA_DIR, NODE_OPTIONS: "--max-old-space-size=256", // memory guard (cgroup is authoritative) }, }) ``` * `--smol` runs Bun in low-memory mode (more frequent GC). * `stdin`/`stdout` are owned by the IPC transport — **don't read stdin** or write raw protocol to stdout. Unprefixed stdout and all stderr are captured as logs. * The plugin folder is the working directory, which is why imports resolve against the folder's own `node_modules` ([packaging](/guide/plugin-anatomy#packaging-backends-run-as-subprocesses)). ## 4. The ready handshake {#the-ready-handshake} `createPlugin()` sends `{ "type": "ready" }` to the runtime as the last thing it does. The runtime waits up to **30 seconds** for it; no ready frame in that window is a `HANDSHAKE_TIMEOUT` and the spawn fails. `ready` only proves the **process is alive and the SDK is wired** — not that your caches are warm. For most plugins that's enough and the runtime starts routing requests immediately. ## 5. The optional serve-ready handshake {#the-optional-serve-ready-handshake} If your plugin needs to hydrate state before it can answer requests (warm a cache, prefetch from an external service), opt into the two-stage handshake: ```json { "serve_ready_handshake": true } ``` With it set, the runtime registers the plugin as **not-ready-to-serve**: the client greys out the plugin's sidebar items until you signal completion: ```ts // after caches are loaded, member lists fetched, etc. plugin.serveReady(); ``` Without the opt-in, `serveReady()` is a harmless no-op (the plugin is treated as serve-ready the moment it spawns). Use it to avoid surfacing clickable rows the plugin can't yet answer — otherwise a freshly provisioned server can show a channel that silently fails to open. ## 6. Watchdog (ping / pong) Every **10 seconds** the runtime sends `{ "type": "ping" }` to each ready plugin. The SDK auto-responds with `{ "type": "pong" }` — you write no code for this. Miss **3 consecutive pings (30s)** and the runtime force-kills the subprocess as hung. A plugin that blocks the event loop (a long synchronous loop) can miss pongs and get killed. Keep handlers async and yield; offload heavy work or chunk it. ## 7. Crash, restart & quarantine When a subprocess exits unexpectedly, the runtime restarts it on a backoff schedule: **1s → 2s → 5s → 15s → 60s**. If a plugin crashes **5 times within 10 minutes** it is **quarantined** — no further restarts until manual intervention. This stops a broken plugin from pinning CPU in a tight crash loop. A graceful stop (below) or a clean exit does not count toward the crash budget. ## 8. Shutdown On unload (server stop, plugin disable, container teardown) the runtime stops the plugin gracefully: 1. Send `SIGTERM`, wait up to **5 seconds** for a clean exit. 2. `SIGKILL` if it hasn't exited. 3. Close the transport and fire unload callbacks (managed services released, etc.). To shut down cleanly, let your event loop drain — flush pending writes in handler paths, not in an exit hook, since `SIGKILL` after the grace window won't run one. ## Reference: the message frames You won't send these directly (the SDK does), but they're useful when reading logs or debugging: | Frame | Direction | Meaning | | --- | --- | --- | | `ready` | plugin → runtime | SDK initialized; begin routing. | | `serve_ready` | plugin → runtime | Caches warm; un-grey sidebar items. | | `ping` / `pong` | runtime ⇄ plugin | Watchdog heartbeat (auto-handled). | | `request` / `response` | runtime ⇄ plugin | A handler invocation and its result. | | `event.deliver` / `event.ack` | runtime ⇄ plugin | Event bus delivery + acknowledgement. | Full protocol: [IPC protocol](/reference/ipc-protocol). --- --- url: /guide/data-and-events.md --- # Data & events How a plugin stores state, reacts to things happening, and pushes updates to clients. Four mechanisms, each with a different shape: | Mechanism | For | Capability | | --- | --- | --- | | **SQLite** (`plugin.db`) | structured, queryable, durable data | `data.sql:self` | | **KV** (`plugin.kv`) | simple string key/value | `data.kv:self` | | **Event bus** (`plugin.events`) | plugin ↔ plugin / runtime, durable, acked | `events.publish` / `events.subscribe` | | **Broadcast** (`plugin.broadcast`) | backend → connected clients, fire-and-forget | `broadcast.clients` | ## SQLite — your own database Each data-owning plugin gets a private SQLite database (WAL mode). All access is routed through IPC, so every call is `async`: ```ts // SELECT → array of row objects const rows = await plugin.db.query( "SELECT id, name FROM channels WHERE category_id = ? ORDER BY position", [categoryId], ); // INSERT/UPDATE/DELETE → { changes, lastInsertRowid } const res = await plugin.db.run( "UPDATE channels SET name = ? WHERE id = ?", [name, id], ); // DDL / PRAGMA → void await plugin.db.exec("CREATE TABLE IF NOT EXISTS …"); // Multiple statements, atomically await plugin.db.batch([ { sql: "INSERT INTO messages (...) VALUES (...)", params: [...] }, { sql: "UPDATE messages SET reply_count = reply_count + 1 WHERE id = ?", params: [parentId] }, ]); ``` Always use **parameter placeholders** (`?`), never string interpolation. Use `batch()` when several writes must land together. Schema is built by [migrations](/guide/plugin-anatomy#migrations). Full method list: [Backend SDK → db](/reference/backend-sdk#db). ### Cross-plugin reads To read another plugin's data, that plugin must publish the table in its manifest `public_schema`, and you must declare `data.read:.`. You then get a read-only query builder: ```ts const channels = await plugin.data .read("text-channels", "channels") .where("category_id", "=", categoryId) .select(["id", "name"]) .orderBy("position") .limit(50) .exec(); ``` Only published columns are readable; the target DB is opened read-only. There is no cross-plugin write — ever. ## KV — string key/value Backed by a `_kv` table in your own SQLite. Values are **always strings** — serialize objects yourself. Requires `data.kv:self`. ```ts await plugin.kv.set("config:theme", JSON.stringify({ accent: "blue" })); const raw = await plugin.kv.get("config:theme"); // string | null const all = await plugin.kv.list("config:"); // prefix scan const many = await plugin.kv.getMany(["a", "b", "c"]); // one round-trip await plugin.kv.delete("config:theme"); ``` Reach for KV for small, flat state (a counter, a cached token, a feature flag). For anything you'd query or filter, use SQLite. ## Settings — admin-configurable values Settings declared in the manifest's `settings` array are editable by admins in Server settings and readable by your plugin. No capability needed — a plugin always reads its own settings. ```ts const len = await plugin.settings.get("max_message_length"); // value or manifest default const all = await plugin.settings.getAll(); // React to an admin changing a value while the plugin runs: plugin.settings.onChange((ev) => { console.error(`setting ${ev.key} → ${ev.value}`); // refresh your cache }); ``` The common pattern (from text-channels): cache settings at module scope, refresh on boot, and re-read in `onChange`. See the [example walkthrough](/examples/text-channels#settings). ## Event bus — durable, acked, plugin-to-plugin The event bus is for **state changes other plugins (or the runtime) care about**. Delivery is **at-least-once** with **per-(topic, subscriber) FIFO** ordering; each delivery is acknowledged by the SDK automatically. ```ts // Publish. Topic must be in your own namespace (your slug). Requires // events.publish:.* (or a specific topic). plugin.events.publish("text-channels.channel.created", channel); // Subscribe. Requires events.subscribe:. Returns once the // subscription is acknowledged. await plugin.events.subscribe("core.category.deleted", async (event) => { const id = (event.payload as { id: string }).id; await plugin.db.run("UPDATE channels SET category_id = NULL WHERE category_id = ?", [id]); }); ``` Key rules: * You publish only into your **own** namespace; the `runtime.*` namespace is reserved for the runtime. * Subscribe with a prefix wildcard (`text-channels.*`) or an exact topic. A bare `*` is not allowed for subscribe. * **Backpressure** (`SubscribeOptions.overflow_policy`): the default is `mark_unhealthy` — if your subscriber's queue fills, the subscription is marked unhealthy (failures are loud, not silently dropped). `drop_oldest` / `drop_newest` opt into lossy delivery instead. ### Runtime-published events you can subscribe to The runtime emits lifecycle events plugins commonly react to. These fire today: | Topic | Fires when | Typical use | | --- | --- | --- | | `runtime.cascade.user.banned` | Central reports a user banned (payload `{ user_id, reason }`) | revoke their access, close sessions | | `runtime.cascade.user.profile_changed` | a user's username/display name/avatar changes | refresh cached author profiles | | `runtime.presence.joined` / `.updated` / `.left` | a user connects/disconnects or scoped presence changes (also via `plugin.presence`) | live member state | | `core.category.created` / `.updated` / `.deleted` / `.reordered` | an admin manages sidebar categories (`.deleted` payload `{ id }`) | null soft-FKs, re-render groups | Subscribe to a family with a wildcard — `runtime.cascade.*`, `runtime.presence.*`, `core.category.*` — and switch on the exact topic inside the handler. Subscribing requires the matching `events.subscribe:` capability. ::: warning Reserved, not yet emitted `runtime.cascade.user.deleted` (account deletion) is a **reserved** topic: it is part of the contract and safe to subscribe to, but the runtime does not emit it yet — the delta handler is wired up once Central adds account-deletion to its heartbeat delta protocol. A subscription compiles and registers fine; the handler simply never fires until then. Don't rely on it as your only cleanup path for removed users today. (`core.user.deleted` is defined alongside it and is gated on the same Central work.) ::: ## Broadcast — push to connected clients Broadcast is the **backend → client** channel for real-time UI updates. It's fire-and-forget (no ack, not durable) and lands in the frontend SDK as `sdk.on(event, …)`. Requires `broadcast.clients`. ```ts await plugin.broadcast.toUser(userId, "notification", { text: "…" }); await plugin.broadcast.toUsers([u1, u2], "typing.updated", { users }); // ≤ 100 ids await plugin.broadcast.toAll("entry.added", entry); ``` The runtime namespaces the event with your slug on the wire (`"entry.added"` → `"guestbook.entry.added"`); the frontend SDK strips the prefix so you write `sdk.on("entry.added", …)`. See [Frontend SDK → on](/reference/frontend-sdk#on-broadcasts). ### Event bus vs. broadcast — which one? * **Other plugins or durability matter** → event bus (`plugin.events`). * **Just update open client UIs right now** → broadcast (`plugin.broadcast`). A common pairing: write to SQLite, `events.publish` for any plugin that's listening, and `broadcast.toAll` so open panels update instantly. ## Presence `plugin.presence` gives you connect/disconnect hooks (no capability) and scoped, ephemeral presence (who's "in" a channel, who's typing) folded under `broadcast.clients`: ```ts plugin.presence.onConnected((user) => { /* … */ }); // Inside a request handler (it infers the WS session from request context): const leave = await plugin.presence.join(`channel.${id}.typing`, user.id, { typing_until: Date.now() + 4000, }); const unwatch = await plugin.presence.watch(`channel.${id}.typing`, (entries) => { // broadcast the live list to viewers }, { coalesceMs: 50 }); ``` Scopes are auto-prefixed with your slug. `join`/`leave`/`update` must run inside a request handler's async context (they throw `PRESENCE_NO_SESSION_CONTEXT` otherwise). Full surface: [Backend SDK → presence](/reference/backend-sdk#presence). --- --- url: /reference/manifest.md --- # Manifest reference `manifest.json` is the contract between a plugin and the runtime. It is validated at load against [`packages/shared/src/manifest.ts`](https://github.com/UnCorded/uncorded-platform/blob/main/packages/shared/src/manifest.ts); an invalid manifest means the plugin is skipped. Unknown top-level fields are **rejected** (typo protection), so the table below is the complete allowed set. ## Minimal example ```json { "name": "guestbook", "version": "0.1.0", "api_version": "^1.0", "author": "you", "description": "A simple server guestbook.", "type": "standalone", "backend": { "entry": "backend/index.ts" }, "frontend": { "entry": "frontend/index.html" }, "permissions": ["data.sql:self", "broadcast.clients"] } ``` ## Top-level fields | Field | Type | Required | Notes | | --- | --- | --- | --- | | `name` | string | ✅ | Slug. Must match `^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$` — lowercase, starts with a letter, no leading/trailing/consecutive hyphens. This is the plugin's identity everywhere. | | `version` | string | ✅ | Strict semver `MAJOR.MINOR.PATCH`. No pre-release/build metadata. | | `api_version` | string | ✅ | Runtime-API compatibility range, e.g. `^1.0`. | | `author` | string | ✅ | Non-empty. | | `description` | string | ✅ | Non-empty. | | `type` | `"core"` | `"standalone"` | `"extension"` | ✅ | See [Plugin type](#plugin-type-extends). | | `permissions` | string\[] | ✅ | Capability strings the runtime will allow. May be empty for a frontend-only plugin. See [Permissions](/reference/permissions). | | `extends` | string | conditional | **Required** iff `type: "extension"`; forbidden otherwise. The base plugin's slug. | | `backend` | `{ entry: string }` | conditional | Backend entry path. At least one of `backend`/`frontend` required. | | `frontend` | `{ entry: string }` | conditional | Frontend entry path (HTML). | | `license` | string | optional | e.g. `"MIT"`. | | `icon` | string | optional | lucide-icon name (e.g. `"Hash"`). Max 64 chars. Unknown names render a placeholder. | | `settings` | `PluginSetting[]` | optional | Admin-configurable settings. See [Settings](#settings). | | `sidebar` | object | optional | Sidebar contribution. See [Sidebar](#sidebar). | | `public_schema` | `Record` | optional | Tables/columns exposed for cross-plugin reads. See [public\_schema](#public_schema). | | `dependencies` | `Record` | optional | Other plugins this one depends on. | | `resources` | `{ memory_mb?, cpu_weight?, disk_mb? }` | optional | Resource hints. Positive integers. | | `proxy_mounts` | `ProxyMount[]` | optional | Reverse-proxy mounts. See [proxy\_mounts](#proxy_mounts) and the [reverse-proxy guide](/sdk/reverse-proxy). | | `serve_ready_handshake` | boolean | optional | Opt into the two-stage readiness handshake. See [Lifecycle](/guide/lifecycle#the-optional-serve-ready-handshake). Default `false`. | | `client_capabilities` | string\[] | optional | Client platform requirements. V1: only `"client.browser"`. | | `runtime_capabilities` | string\[] | optional | Runtime opt-ins: `"voice.media"`, `"voice.screen_share"`, `"voice.moderation"`. Unknown values rejected. | | `managed_services` | string\[] | optional | Sidecar services the runtime supervises. Recognized: `"livekit"`. | ## Plugin type & `extends` | `type` | Meaning | `extends` | | --- | --- | --- | | `core` | Shipped by UnCorded (text-channels, voice-channels, members, moderation). | forbidden | | `standalone` | Third-party plugin with its own functionality and data. | forbidden | | `extension` | Third-party plugin that extends a base plugin. | **required** — the base plugin slug | Most third-party plugins are `standalone`. ## Settings Each entry in `settings[]` is rendered as a form field in Server settings and is readable via [`plugin.settings`](/reference/backend-sdk#settings). ```json { "key": "max_message_length", "label": "Max message length", "description": "Maximum characters allowed per message.", "type": "number", "default": 5000, "stops": [ { "value": 2000, "label": "2k" }, { "value": 5000, "label": "5k" }, { "value": 0, "label": "Unlimited" } ] } ``` | Field | Type | Applies to | Notes | | --- | --- | --- | --- | | `key` | string | all | Unique within the plugin. Max 256 chars. | | `label` | string | all | Shown in the admin panel. | | `description` | string | all | Optional help text. | | `type` | `"string"` | `"secret"` | `"number"` | `"boolean"` | — | `secret` values are redacted from logs and masked in the UI. | | `required` | boolean | all | Surfaced as a warning if unset. | | `default` | string | number | boolean | all | Must match `type`. Used when unset. | | `min` / `max` / `step` | number | `number` | Bounds and slider step (`step > 0`). | | `stops` | `{ value, label }[]` | `number` | Stepped slider with labelled positions. Stored value is the underlying number (e.g. `0` = "unlimited"). | | `max_length` | number | `string`/`secret` | Server-enforced length cap (positive). | | `enum` | string\[] | `string` | Renders a select; `default` must be a member. | Cross-field validation enforces `min ≤ default ≤ max`, `default` length ≤ `max_length`, `default` ∈ `enum`, and `default` matching a `stops` value. ## Sidebar ```json { "sidebar": { "contributes": true, "section": "Chat", "refresh_on": ["text-channels.channel.created"] } } ``` | Field | Type | Notes | | --- | --- | --- | | `contributes` | boolean | Required. `true` if the plugin returns sidebar items. | | `section` | string | Optional default group name for this plugin's items, used when an item doesn't set its own `section`. | | `refresh_on` | string\[] | Event topics that trigger a re-fetch of the plugin's sidebar items. | The items themselves come from the backend's `sidebar.items` handler, not the manifest. See [Plugin anatomy → reserved actions](/guide/plugin-anatomy#two-reserved-handler-actions). ## public\_schema Declares which of your tables and columns are readable by other plugins (via their [`data.read`](/reference/backend-sdk#data) capability): ```json { "public_schema": { "messages": { "columns": ["id", "channel_id", "author_id", "content", "created_at"], "description": "All messages across all channels." } } } ``` Only listed columns are readable; everything else stays private. ## proxy\_mounts ```json { "proxy_mounts": [ { "name": "demo", "upstream_setting": "demo_upstream_url", "access": "members" } ] } ``` | Field | Type | Notes | | --- | --- | --- | | `name` | string | Slug-safe, unique within the plugin. Appears in the URL `/proxy///*`. | | `upstream_setting` | string | Key of a `string`/`secret` setting **in this same manifest** holding the upstream URL. The manifest never carries the URL directly. | | `access` | `"members"` | `"owner"` | Optional, default `"members"`. | | `max_frame_bytes` | integer | Optional. Max **WebSocket** frame (message) size relayed in either direction, in bytes. A larger frame closes the socket with `1009`. Default **65536** (64 KiB); raise it for apps that bulk-sync over a socket (e.g. game state). Must be between **1024** (1 KiB) and **16777216** (16 MiB). | Declaring `proxy_mounts` requires at least one of `proxy.http:self` / `proxy.websocket:self` in `permissions`. Mounts are disabled until an owner approves them. Full guide: [Reverse-proxy plugins](/sdk/reverse-proxy). ## Validation rules (summary) * At least one of `backend` / `frontend`. * `type: "extension"` ⇒ `extends` present and a valid slug; `core`/`standalone` ⇒ no `extends`. * Every `permissions` entry matches the [capability grammar](/reference/permissions#grammar). * `proxy_mounts[].upstream_setting` references a declared `string`/`secret` setting; mount names unique; proxy permission present. * `proxy_mounts[].max_frame_bytes`, when present, is an integer in `[1024, 16777216]`. * Settings `default` consistent with `type`/`min`/`max`/`max_length`/`enum`/`stops`. * `resources.*` positive integers; `icon` ≤ 64 chars; unknown top-level or per-setting fields rejected. The tests in [`packages/shared/src/manifest.test.ts`](https://github.com/UnCorded/uncorded-platform/blob/main/packages/shared/src/manifest.test.ts) are the exhaustive, executable spec. --- --- url: /reference/permissions.md --- # Permissions reference Every IPC call a plugin makes is checked against the capabilities listed in its manifest `permissions` array. **Undeclared = hard reject** — there is no implicit trust and no runtime workaround. Getting this array right is where most first-attempt plugins fail, so this page maps each SDK feature to the exact string it needs. Two distinct concepts share the word "permission": 1. **Capabilities** (this page) — manifest strings that gate the plugin's access to runtime services. Enforced by the runtime on every IPC call. 2. **User permissions** — role/permission checks *your plugin* runs on its *users* via [`plugin.permissions`](/reference/backend-sdk#permissions) (e.g. "can this user post?"). Registered with `plugin.permissions.register()`. These are application logic, not manifest declarations. ## Grammar ``` resource.action[:scope] ``` Validated against: ```js /^[a-z][a-zA-Z0-9]*\.[a-zA-Z][a-zA-Z0-9]*(?::[a-z0-9*][a-z0-9.*:-]*)?$/ ``` `resource` and `action` are dotted identifiers; `scope` is optional and may contain dots, colons, and `*` wildcards. Scope conventions: * `:self` — the plugin's own resource (its own DB, its own files). * `:.
` — a specific cross-plugin target. * `:.*` — a prefix wildcard (events). * `:[:port]` — a network target (fetch). ## Capabilities by feature | Capability | Unlocks (SDK) | Scope rules | | --- | --- | --- | | `data.sql:self` | [`plugin.db`](/reference/backend-sdk#db) — own SQLite | `:self` only | | `data.kv:self` | [`plugin.kv`](/reference/backend-sdk#kv) — own key/value store | `:self` only | | `data.read:.
` | [`plugin.data.read`](/reference/backend-sdk#data) — read another plugin's published table | **no wildcards** — name an exact `plugin.table` | | `events.publish:.*` | [`plugin.events.publish`](/reference/backend-sdk#events) | prefix wildcard or exact topic; publish only to **your** namespace | | `events.subscribe:` | [`plugin.events.subscribe`](/reference/backend-sdk#events) | prefix wildcard (`x.*`) or exact topic; **bare `*` rejected** | | `broadcast.clients` | [`plugin.broadcast`](/reference/backend-sdk#broadcast) + scoped [`plugin.presence`](/reference/backend-sdk#presence) | no scope | | `storage.file:self` | [`plugin.files`](/reference/backend-sdk#files) + the `/upload` endpoint | `:self` only | | `http.fetch:[:port]` | [`plugin.fetch`](/reference/backend-sdk#fetch) — outbound HTTP to that host | exact hostname (+ optional port) | | `runtime.schedule` | [`plugin.schedule`](/reference/backend-sdk#schedule) — recurring tasks | no scope | | `runtime.log` | structured logging to the runtime collector | no scope | | `auth.currentUser` | current-user lookups | no scope | | `voice.tokens:self` | [`plugin.voice.createJoinToken`](/reference/backend-sdk#voice) | `:self` | | `voice.moderation:self` | [`plugin.voice.removeParticipant`](/reference/backend-sdk#voice) | `:self` | | `proxy.http:self` | reverse-proxy HTTP forwarding for a `proxy_mount` | `:self` | | `proxy.websocket:self` | reverse-proxy WebSocket forwarding (not implied by HTTP) | `:self` | | `resources.read:` | cross-plugin [`plugin.resources.check`](/reference/backend-sdk#resources) | exact owner-plugin slug | > Capabilities that need **no** declaration: reading your own > [`settings`](/reference/backend-sdk#settings), the [`core`](/reference/backend-sdk#core) > user/category cache, presence connect/disconnect hooks, and registering your > own [user permissions](/reference/backend-sdk#permissions). These are always > available. ## runtime\_capabilities (separate array) Voice features are opted into via the manifest's `runtime_capabilities` array, **not** `permissions`: | Value | Gates | | --- | --- | | `voice.media` | LiveKit-mediated audio (the voice-channels plugin). | | `voice.screen_share` | the plugin's ability to grant screen-share publish. | | `voice.moderation` | admin "Stop their share" via LiveKit `RemoveParticipant`. | Per-user authorization (e.g. `voice.screen_share.publish`) is a separate *user permission* your plugin registers and checks — see above. ## Wildcard rules at a glance | Pattern | `data.read` | `events.publish` | `events.subscribe` | | --- | --- | --- | --- | | exact (`x.y`) | ✅ | ✅ | ✅ | | prefix (`x.*`) | ❌ | ✅ | ✅ | | bare `*` | ❌ | ✅ | ❌ | ## Worked example The text-channels plugin declares: ```json "permissions": [ "data.sql:self", "events.publish:text-channels.*", "events.subscribe:runtime.cascade.*", "events.subscribe:runtime.presence.*", "events.subscribe:text-channels.*", "events.subscribe:core.category.*", "broadcast.clients", "storage.file:self", "runtime.schedule" ] ``` Reading top to bottom: it owns a database, publishes its own events, listens for user-deletion cascades, presence, its own events, and category deletions, pushes real-time updates to clients, stores uploaded files, and runs a scheduled orphan-GC sweep. Every SDK call it makes traces back to one of these lines. The capability checker and its test suite are the executable spec: [`runtime/src/capabilities/checker.ts`](https://github.com/UnCorded/uncorded-platform/blob/main/runtime/src/capabilities/checker.ts). --- --- url: /reference/backend-sdk.md --- # Backend SDK reference `@uncorded/plugin-sdk`. Call `createPlugin()` once at startup; it wires the stdio IPC transport, sends the `ready` handshake, and returns a `PluginHandle` exposing the entire backend surface. Source of truth: [`packages/plugin-sdk/src/types.ts`](https://github.com/UnCorded/uncorded-platform/blob/main/packages/plugin-sdk/src/types.ts). ```ts import { createPlugin } from "@uncorded/plugin-sdk"; const plugin = createPlugin(/* { onFileUploaded } */); ``` `createPlugin(options?)` accepts one option, `onFileUploaded`, a callback fired when a client finishes uploading a file to this plugin (see [files](#files)). All methods that cross to the runtime are `async`. Errors arrive as [`SdkError` / `SdkProtocolError`](#errors) with a stable `.code`. ## handle / request ```ts plugin.handle(action: string, handler: (params, user) => unknown | Promise): void plugin.request(action: string, params?: Record): Promise ``` * **`handle`** registers a handler for an inbound action. `params` is the caller's arguments; `user` is the authenticated caller (`{ id, displayName, avatarUrl, role }`). Return any JSON-serializable value; throwing surfaces an error to the caller. Register handlers **synchronously at startup** so they exist before the runtime routes requests. * **`request`** sends a request to the runtime (cross-plugin calls / runtime services). Two action names are special: `sidebar.items` (the shell calls it to build the sidebar) and `schedule.tick` (handled for you by [`schedule`](#schedule)). ```ts plugin.handle("getMessages", async (params, user) => { const channelId = params["channel_id"]; if (typeof channelId !== "string") throw new Error("channel_id required"); return plugin.db.query("SELECT * FROM messages WHERE channel_id = ?", [channelId]); }); ``` ## events Durable, acked, at-least-once event bus with per-(topic, subscriber) FIFO ordering. Requires `events.publish` / `events.subscribe` capabilities. ```ts plugin.events.publish(topic: string, payload: unknown, version?: number): void plugin.events.subscribe(topic: string, handler: (event) => void, options?: SubscribeOptions): Promise plugin.events.unsubscribe(topic: string): Promise ``` `SubscribeOptions`: `{ overflow_policy?: "mark_unhealthy" | "drop_oldest" | "drop_newest"; queue_size?: number }`. Default backpressure is `mark_unhealthy` (failures are loud). The handler receives an `event` with `{ topic, payload, version, ts, source_plugin }`. See [Data & events](/guide/data-and-events#event-bus-durable-acked-plugin-to-plugin). ## db The plugin's own SQLite. Requires `data.sql:self`. ```ts plugin.db.query(sql, params?): Promise // SELECT → rows plugin.db.run(sql, params?): Promise<{ changes, lastInsertRowid }> // INSERT/UPDATE/DELETE plugin.db.exec(sql): Promise // DDL / PRAGMA plugin.db.batch(statements): Promise // atomic multi-statement ``` Always pass values via `?` placeholders. Use `batch()` when writes must commit together. Schema is built by [migrations](/guide/plugin-anatomy#migrations). ## kv String key/value backed by a `_kv` table in your SQLite. Requires `data.kv:self`. Values are **always strings** — `JSON.stringify` complex values. ```ts plugin.kv.get(key): Promise plugin.kv.set(key, value): Promise plugin.kv.delete(key): Promise plugin.kv.list(prefix?): Promise<{ key, value }[]> // ordered by key plugin.kv.getMany(keys): Promise> // one round-trip ``` ## settings Read this plugin's admin-configurable settings (declared in `manifest.settings`) and react to admin changes. **No capability required.** ```ts plugin.settings.get(key): Promise // stored value or manifest default plugin.settings.getAll(): Promise> plugin.settings.onChange(handler: (ev: { key, value }) => void): () => void ``` `get` throws `UNKNOWN_SETTING` for an undeclared key. `onChange` fires once per admin config save while the plugin runs; returns a disposer. ## broadcast Push to connected WebSocket clients. Fire-and-forget, not durable. Requires `broadcast.clients`. The runtime namespaces the event with your slug; the frontend SDK strips it (so backend `"x"` ↔ frontend `sdk.on("x", …)`). ```ts plugin.broadcast.toUser(userId, event, payload): Promise plugin.broadcast.toUsers(userIds, event, payload): Promise // ≤ 100 ids plugin.broadcast.toAll(event, payload): Promise ``` ## presence Connect/disconnect hooks (no capability) plus scoped ephemeral presence (folded under `broadcast.clients`). ```ts plugin.presence.onConnected(handler: (user) => void): () => void plugin.presence.onDisconnected(handler: (user) => void): () => void plugin.presence.join(scope, userId, meta?): Promise<() => Promise> // returns a leave fn plugin.presence.leave(scope, userId): Promise plugin.presence.update(scope, userId, meta): Promise // never implicitly joins plugin.presence.watch(scope, cb: (entries) => void, { coalesceMs? }): Promise<() => void> // default 50ms, clamps [0,500] plugin.presence.list(scope): Promise ``` Scopes are auto-prefixed with your slug — don't add the prefix yourself. `join`/`leave`/`update` infer the originating WS session from request context, so they must be called **inside a request handler** (they throw `PRESENCE_NO_SESSION_CONTEXT` from a schedule tick or cross-plugin event handler). ## schedule Recurring tasks. Requires `runtime.schedule`. Schedules are named; re-registering a name replaces it. Minimum interval 1000ms. ```ts plugin.schedule.every(name, intervalMs, handler: (tick) => void, options?): Promise plugin.schedule.cancel(name): Promise ``` `options.timeout_ms` (default 30000) bounds how long the handler may block the IPC slot per tick; on timeout the tick resolves with an error but the handler keeps running in the background. ## fetch Outbound HTTP via the runtime proxy. Requires `http.fetch:` for each host. Redirects are **never** followed (a 3xx is returned as-is). ```ts const res = await plugin.fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ … }), // string only; base64-encode binary yourself }); res.status; // number res.headers; // Record res.text(); // sync — body is pre-buffered res.json(); // sync res.bytes(); // Uint8Array, sync ``` `.text()`/`.json()`/`.bytes()` are synchronous because the IPC round-trip already buffered the full body. ## core Read the Core Module's user-profile and category cache. **No capability required.** ```ts plugin.core.getUser(userId): Promise plugin.core.getUsers(userIds): Promise // missing ids omitted plugin.core.getOnlineUsers(): Promise plugin.core.listCategories(): Promise // admin-managed; reference by id (soft FK) ``` ## data Cross-plugin reads against another plugin's `public_schema`. Requires `data.read:.
`. Immutable builder — each method returns a new query. ```ts const rows = await plugin.data .read("text-channels", "messages") .where("channel_id", "=", id) .select(["id", "content", "created_at"]) .orderBy("created_at", "desc") .limit(50) .exec(); ``` Only published columns are selectable/filterable; the target DB is read-only. ## permissions Your plugin's checks on its **users** (roles and plugin-defined permissions). This is application logic — no manifest capability gates it. ```ts plugin.permissions.register(key, { description, default_level }): Promise plugin.permissions.check(userId, permission, scope?): Promise plugin.permissions.hasRole(userId, roleName): Promise plugin.permissions.hasMinLevel(userId, level): Promise plugin.permissions.getRole(userId): Promise<{ name, level }> plugin.permissions.canActOn(actorId, targetId): Promise // rank check for moderation ``` Register custom permission keys at startup; gate handlers with `check` / `hasMinLevel`. Role levels are numeric (higher = more privileged). ## resources Plugin resource permissions (per-resource ACLs). The runtime stamps your slug on every define/create/grant/revoke, so you can only manage your **own** resources. Cross-plugin `check` requires `resources.read:`. ```ts plugin.resources.define({ resourceType, … }): Promise plugin.resources.create({ resourceType, resourceId, parent?, owner? }): Promise plugin.resources.grant(resource, principal, action): Promise<{ aclVersion }> plugin.resources.revoke(resource, principal, action): Promise<{ aclVersion }> plugin.resources.check(userId, resource, action): Promise ``` The runtime returns `PLUGIN_RESOURCES_UNAVAILABLE` if booted without the resource backend. ## files Plugin file storage — the plugin's own `/uploads/`. Requires `storage.file:self`. Clients POST to `/upload` directly; the runtime then fires the `onFileUploaded` callback you pass to `createPlugin`. Use this API to stat/sign/delete those files. ```ts plugin.files.stat(filename): Promise<{ exists, size, mtime }> plugin.files.signUrl(filename, userId, ttlSeconds?): Promise<{ url, exp }> // default 1h, max 24h plugin.files.delete(filename): Promise<{ deleted: boolean }> plugin.files.list(): Promise<{ filename, size, mtime }[]> ``` `signUrl` returns a path-only URL (no host) bound to `userId`; the client prefixes its current server origin so the URL survives tunnel hostname changes. ```ts const plugin = createPlugin({ onFileUploaded(msg) { // msg: { filename, path, size, mimeType, uploadedBy, uploadedAt } }, }); ``` ## voice Voice bridge (LiveKit). Per-method capabilities; the runtime returns `VOICE_BRIDGE_UNAVAILABLE` if booted without voice support. ```ts // Capability: voice.tokens:self plugin.voice.createJoinToken({ channelId, userId, grants?, canPublishSources? }): Promise // Capability: voice.moderation:self plugin.voice.removeParticipant({ channelId, userId, reason? }): Promise<{ ok: true }> ``` `createJoinToken` returns `{ token, livekitUrl, expiresAt }`. The plugin is responsible for ACL checks (channel exists, user not banned, role gate) before minting. **Derive `canPublishSources` from the user's permissions** — never pass client-supplied values through. ## serveReady ```ts plugin.serveReady(): void ``` Signal that internal state is hydrated and the plugin can serve user requests. Effective only when the manifest sets `serve_ready_handshake: true`; otherwise a harmless no-op. See [Lifecycle](/guide/lifecycle#the-optional-serve-ready-handshake). ## errors ```ts import { SdkError, SdkProtocolError } from "@uncorded/plugin-sdk"; ``` Every error thrown across the SDK boundary is an `SdkError` (or subclass) with a stable machine-readable `.code` and optional `.context`. `SdkProtocolError` (a subclass) signals the runtime returned an error response or a payload that didn't match the expected shape. Catch on `.code`, never on message text. ## request context ```ts import { getCurrentSession, getRequestContext } from "@uncorded/plugin-sdk"; ``` Inside a request handler, `getCurrentSession()` returns the originating WS session id (or `undefined` for runtime-originated calls like a schedule tick). This is the mechanism `presence.join/leave/update` use to attribute themselves to the right session. --- --- url: /reference/frontend-sdk.md --- # Frontend SDK reference `@uncorded/plugin-sdk-frontend`. The browser side of a plugin — the code that runs inside the sandboxed iframe the shell renders for your panel. It talks to your backend over the shell via `postMessage`, never directly. Source of truth: [`packages/plugin-sdk-frontend/src`](https://github.com/UnCorded/uncorded-platform/blob/main/packages/plugin-sdk-frontend/src). ## Loading the SDK The runtime serves a prebuilt IIFE bundle at `/sdk/plugin-frontend.js`. Load it with a classic ` ``` `window.UncodedPlugin` exposes `createPluginFrontend` plus the avatar helpers (`createAvatar`, `avatarHtml`, `avatarColor`, `avatarInitial`, `isSafeAvatarUrl`). If you bundle your frontend instead, `import { createPluginFrontend } from "@uncorded/plugin-sdk-frontend"` works too. ## createPluginFrontend ```ts const sdk = await createPluginFrontend(options?: { handshakeTimeoutMs?: number }); ``` Performs the shell handshake and resolves to a fully-initialized `PluginFrontend`. **`await` it before calling anything else.** ### The handshake On call the SDK derives the shell origin from `document.referrer`, posts `uncorded.ready` (carrying its `SDK_API_VERSION`) to the parent, and waits for the shell's `uncorded.token` reply (`{ token, slug, runtimeCapabilities, itemId?, itemLabel? }`). Default timeout **5000ms** — override with `handshakeTimeoutMs`. Failure throws a [`PluginError`](#errors) with code `HANDSHAKE_FAILED` (no resolvable referrer — i.e. not running inside a shell) or `HANDSHAKE_TIMEOUT`. Every subsequent inbound message is origin-checked against the verified shell origin; every outbound message is targeted at it (never `*`). ## slug / token ```ts sdk.slug; // string — this plugin's slug, assigned by the runtime sdk.token; // string — the session bearer token (used by files/proxy internally) ``` ## request ```ts sdk.request(action: string, params?: Record): Promise ``` Calls a backend [`plugin.handle(action, …)`](/reference/backend-sdk#handle-request) and resolves with its return value. Correlated by id over `postMessage`. **Timeout 30s** → rejects with `PluginError` code `REQUEST_TIMEOUT`; a backend error rejects with that error's `.code`. ```ts const messages = await sdk.request("getMessages", { channel_id: id }); ``` ## subscribe (event bus) ```ts sdk.subscribe(topic: string, handler: (payload: T) => void): () => void ``` Subscribe to a server-side **event-bus** topic (the same topics the backend [`events`](/reference/backend-sdk#events) bus carries, e.g. `"text-channels.message.created"`). Sends a `subscribe` message to the shell so the runtime routes matching events to this iframe. Returns an unsubscribe function. ## on (broadcasts) ```ts sdk.on(event: string, handler: (payload: T) => void): () => void ``` Receive **broadcasts** pushed from your backend via [`plugin.broadcast`](/reference/backend-sdk#broadcast). The slug prefix is stripped transparently: the backend sends `broadcast.toAll("entry.added", …)` (on the wire `".entry.added"`) and you write: ```ts sdk.on("entry.added", (entry) => { /* prepend to the list */ }); ``` No subscribe message is sent — broadcasts are pushed directly to the WS connection and the shell routes all your slug-prefixed events here. Returns an unsubscribe function. > **subscribe vs on:** `subscribe` = durable event-bus topics (cross-plugin, > runtime). `on` = your backend's fire-and-forget UI pushes. Most live-UI > updates use `on`. ## onNavigate ```ts sdk.onNavigate(handler: (nav: { itemId: string; itemLabel: string }) => void): () => void ``` Fires when the user selects one of your sidebar items. If the iframe opened *onto* an item, the handler is invoked once on registration (next microtask) with the initial navigation, so you don't miss the first selection. Returns an unsubscribe function. ```ts sdk.onNavigate(({ itemId }) => loadChannel(itemId)); ``` ## files ```ts sdk.files.upload(file: Blob | File, options?: UploadOptions): Promise ``` Uploads a user-selected file to **your** plugin's storage (the runtime's `POST /upload`, authed with the session token and pinned to your slug — the backend needs [`storage.file:self`](/reference/permissions)). The server picks the on-disk filename; pass the returned `filename` back through your backend to record it. ```ts const res = await sdk.files.upload(file, { onProgress: ({ ratio }) => setBar(ratio), signal: controller.signal, maxBytes: 25 * 1024 * 1024, }); // res: { filename, size, mime, originalName } ``` * `UploadResult`: `{ filename, size, mime, originalName }`. * `UploadProgress`: `{ loaded, total, ratio }` (~10 Hz). * Files ≤ 50 MB go single-shot; larger use resumable chunked upload (5 GB hard ceiling, set by the runtime). * Failures throw [`UploadError`](#errors) with a `.code` (`ABORTED`, `PAYLOAD_TOO_LARGE`, `UNAUTHORIZED`, `FORBIDDEN`, `RATE_LIMITED`, `NETWORK_ERROR`, `TIMEOUT`, `UPLOAD_EXPIRED`, `INTEGRITY_FAILED`, …). The backend's [`onFileUploaded`](/reference/backend-sdk#files) callback fires once the upload lands, so the backend can record it in its own DB. ## proxy Two ways to render a proxied mount. They differ in **who owns the surface** — your iframe, or the shell. Pick one per panel; don't combine them for the same mount. Full guide, including which to choose: [Reverse-proxy plugins](/sdk/reverse-proxy#two-ways-to-render-a-mount). ```ts sdk.proxy.openMount(mount: string): Promise<{ iframeUrl: string; openUrl: string }> ``` **Self-embed.** *Your* panel owns a nested ` ``` `sdk.proxy.openMount(name)` returns a `ProxyMountSession`: | Field | Use | | --- | --- | | `iframeUrl` | Set as the panel iframe `src`. The proxy-session cookie is already minted. | | `openUrl` | Wire to an "Open in browser" link/`target="_blank"`. Navigating top-level re-mints the cookie first-party — **required where framed third-party cookies are blocked (Safari/WebKit)**, harmless elsewhere. | Always render the `openUrl` affordance. It's the only path that works when the framed cookie is blocked. ### Option B — host-owned surface with `reserveMount` The **shell** renders the proxied app — a hardened `` on desktop, a sandboxed `