Skip to content

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 deps

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:

FieldPurpose
nameSlug. ^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$.
version / api_versionPlugin semver / runtime-API semver range (^1.0).
typecore | standalone | extension. Third-party = standalone.
backend / frontend{ "entry": "<path>" }. At least one required.
permissionsThe capabilities the runtime will allow. Undeclared = rejected.
settingsAdmin-configurable values, rendered as a form in Server settings.
sidebar{ "contributes": true, … } to put items in the client sidebar.
public_schemaTables/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:

FromExamples
@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 ​

ActionCalled byReturns
sidebar.itemsthe shell, to build the sidebar{ items: SidebarItem[], adminActions?: [] }
schedule.tickthe 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.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.

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 with INVALID_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 003 to 005 fails the load with MIGRATION_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 install

Your 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 it

Two things that will bite you:

  • Shipping your own tsconfig.json shadows 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_modules takes 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 ​

PackageHow you use itRole
@uncorded/plugin-sdkcreatePlugin()Backend runtime SDK (subprocess side).
@uncorded/plugin-sdk-frontend/sdk/plugin-frontend.jsPanel/iframe SDK (createPluginFrontend(), sdk.proxy, sdk.platform).
@uncorded/sharedtype importManifest schema (PluginManifest, ProxyMount) and the validator.
@uncorded/protocoltype importWire 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 URLs

A 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.