Appearance
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 numeric order at load (data-owning plugins)
002_add_column.sql
node_modules/ ← only if you use third-party deps (see Packaging)
package.json ← only if you use third-party depsOnly 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": "<path>" }. 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. Capability grammar: Permissions.
backend/
The backend is a Bun program the runtime spawns as a subprocess. It speaks the stdio JSON 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 for a full backend.
The createPlugin() handle exposes the whole backend surface — db, kv, settings, events, broadcast, sidebar, presence, schedule, fetch, core, data, permissions, resources, files, voice, net, and the preview companions broker. Reference: Backend SDK.
Importing types
Everything is fully typed; you rarely need to name types because the handle is inferred. When you do (e.g. shaping a function signature), import them from the SDK packages — never redeclare them:
| From | Examples |
|---|---|
@uncorded/plugin-sdk (backend) | PluginHandle, RequestHandler, CoreUser, PresenceEntry, FilesApi, SdkError |
@uncorded/plugin-sdk-frontend (panel) | PluginFrontend, NavigateEvent, PluginError, PLUGIN_THEME_TOKENS |
ts
import { createPlugin } from "@uncorded/plugin-sdk";
import type { CoreUser, PresenceEntry } from "@uncorded/plugin-sdk";The authenticated user passed to handlers is { id, displayName, avatarUrl, role }; core.getUser returns CoreUser ({ id, username, display_name, avatar_url, is_online, … }). Note the camelCase user arg vs snake_caseCoreUser — they come from different layers.
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
<script src="/sdk/plugin-frontend.js"></script>
<script type="module">
const sdk = await window.UncodedPlugin.createPluginFrontend();
// sdk.request(...), sdk.on(...), sdk.subscribe(...), sdk.files, sdk.proxy,
// sdk.platform.* — see the Frontend SDK reference.
</script>- Load
/sdk/plugin-frontend.jsfrom 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 returnedsdk.
Reference: Frontend SDK.
migrations/
Data-owning plugins (those with data.sql:self) get a private SQLite database. SQL files in migrations/ run 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.
The naming rule is strict and enforced, because a migration that silently doesn't run is far worse than one that fails loudly:
- Every file must match
NNN_description.sql. Anything else fails the load withINVALID_MIGRATION_FILENAME— the file is never quietly skipped. - Files run in numeric order (not lexical), and the numbers must be sequential from 1 with no gaps. Jumping from
003to005fails the load withMIGRATION_GAP. This is what stops a merge that drops a migration from looking healthy. - Applied migrations are recorded, so only new numbers run on later boots.
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.
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", "<backend entry>"], { cwd: "<plugin folder>" })On Linux that command is additionally wrapped by the sandbox launcher, which applies the seccomp + Landlock jail before exec (see Security model). The working directory, --smol, and the stdio wiring are the same either way.
There are two different kinds of import, and they resolve differently.
@uncorded/* — the runtime provides these. Do not install them.
The SDK packages are not published to npm; bun add @uncorded/plugin-sdk will not work. Instead, the runtime seeds them for you on every boot: it writes a tsconfig.json path shim next to your plugin and populates node_modules/@uncorded/* with links to the image's own packages. Your import resolves the ordinary way, to exactly the SDK version this runtime speaks — so the SDK can never be a version behind the server running it.
ts
import { createPlugin } from "@uncorded/plugin-sdk"; // just works, nothing to installYour own third-party dependencies — you ship these. The runtime does not run bun install for you, so anything else you import must resolve against a node_modules inside the installed folder. Commit it, or include a package.json + lockfile and run bun install in the folder before packaging.
sh
cd my-plugin
bun add zod # a third-party dep: yes, vendor itTwo things that will bite you:
- Shipping your own
tsconfig.jsonshadows the runtime's shim (nearest wins). If you need one, replicate the@uncorded/*path aliases in it, or vendor the packages yourself. - Vendoring your own
node_modulestakes precedence over the seeded one, which is fine — but then you own keeping the SDK version in step with the runtime.
A backend that imports nothing (raw stdio) loads without packaging at all.
The SDK packages
| Package | How you use it | Role |
|---|---|---|
@uncorded/plugin-sdk | createPlugin() | Backend runtime SDK (subprocess side). |
@uncorded/plugin-sdk-frontend | /sdk/plugin-frontend.js | Panel/iframe SDK (createPluginFrontend(), sdk.proxy, sdk.platform). |
@uncorded/shared | type import | Manifest schema (PluginManifest, ProxyMount) and the validator. |
@uncorded/protocol | type import | Wire types shared by runtime and SDK (IPC frames, user shape). |
The frontend SDK is the exception to "import it": panels load it from the runtime at /sdk/plugin-frontend.js rather than bundling it, so the shell and the panel can never disagree on the protocol version.
Where data lives
The runtime gives each plugin an isolated data directory (mode 0700) and exposes it through SDK APIs. The runtime's internal view is:
/data/plugins/<slug>/
<slug>.db ← the plugin's private SQLite (WAL mode)
<slug>.db-wal
<slug>.db-shm
uploads/ ← files POSTed to /upload, served via signed URLsA plugin can never open another plugin's database for writing. Cross-plugin reads go through the data.read capability, which opens the target DB read-only and enforces the target's public_schema.
Do not construct this path or translate it to a Docker volume/WSL/Windows path. Native and Docker hosting map storage differently. plugin.db, plugin.kv, and plugin.files are the portable contract.
Next: Lifecycle — exactly what happens from spawn to shutdown, including the readiness handshakes and the watchdog.