Skip to content

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

0. Prerequisites ​

  • A running UnCorded server (native hosting or Docker fallback, launched from the desktop app).
  • Access to that server's data directory (<server-data>/plugins/) and its server.json.
  • Bun 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.)
  • 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; the capability strings are in the permissions reference.

3. The migration ​

SQL files in migrations/, run at plugin load, build your schema. Name them NNN_description.sql, numbered sequentially from 1 — the runtime runs them in numeric order and fails the load on a bad name or a gap, rather than silently skipping one. 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<Entry>(
    "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 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
<!-- frontend/index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Guestbook</title>
    <style>
      body { font-family: system-ui, sans-serif; margin: 0; padding: 1rem; }
      #list { list-style: none; padding: 0; }
      #list li { padding: 0.5rem 0; border-bottom: 1px solid #8883; }
      form { display: flex; gap: 0.5rem; margin-top: 1rem; }
      input { flex: 1; }
    </style>
  </head>
  <body>
    <ul id="list"></ul>
    <form id="form">
      <input id="msg" placeholder="Leave a message…" maxlength="500" />
      <button type="submit">Post</button>
    </form>

    <!-- Served by the runtime. Do not bundle it yourself. -->
    <script src="/sdk/plugin-frontend.js"></script>
    <script type="module">
      const sdk = await window.UncodedPlugin.createPluginFrontend();
      const list = document.getElementById("list");

      function prepend(entry) {
        const li = document.createElement("li");
        li.textContent = entry.message;
        list.prepend(li);
      }

      // Initial load.
      const entries = await sdk.request("listEntries");
      for (const e of entries) prepend(e);

      // Live updates pushed by the backend's broadcast.toAll("entry.added", …).
      sdk.on("entry.added", (entry) => prepend(entry));

      // Post a new entry.
      document.getElementById("form").addEventListener("submit", async (ev) => {
        ev.preventDefault();
        const input = document.getElementById("msg");
        if (!input.value.trim()) return;
        await sdk.request("addEntry", { message: input.value });
        input.value = "";
        // No manual insert needed — the broadcast round-trips back to us.
      });
    </script>
  </body>
</html>

💡 This panel uses plain hardcoded colors to stay minimal. A real plugin should read var(--uncorded-*) instead, so it tracks the user's light/dark theme and accent automatically — it's CSS-only. See Theming.

6. Package the backend ​

The runtime spawns your backend as its own subprocess with the plugin folder as the working directory.

You don't install the SDK. @uncorded/* is not on npm — the runtime seeds it next to your plugin on every boot, so import { createPlugin } from "@uncorded/plugin-sdk" resolves to exactly the version the server speaks. The guestbook above needs no packaging step at all.

You do ship your own third-party dependencies. The runtime does not run bun install for you, so anything else you import must resolve against a node_modules inside the installed folder:

sh
cd guestbook
bun init -y     # only if you have third-party deps
bun add zod     # vendored into node_modules/, shipped with the folder

Details and the two ways to trip over this (shipping your own tsconfig.json, or vendoring @uncorded/* yourself): Plugin anatomy → Packaging.

7. Install & run ​

Three things must be true, in order:

  1. Place the folder under the server's plugin directory, named exactly the slug:

    <server-data>/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/plugin-management flow. The runtime reads installed_plugins only at boot. The desktop owns the host-specific lifecycle, credentials, mounts, and reconciliation; do not invoke Docker, WSL, or systemd directly.

Open the server, click Guestbook in the sidebar, and post. The message appears instantly in every open client.

What you just used ​

LayerThis pluginReference
Manifest + capabilitiesdata.sql:self, broadcast.clientsManifest · Permissions
Own databaseplugin.db.query / plugin.db.runBackend SDK → db
Request handlersplugin.handle("addEntry", …)Backend SDK → handle
Real-time pushplugin.broadcast.toAll → sdk.onBackend SDK → broadcast
Panel UIcreatePluginFrontend(), sdk.requestFrontend SDK

Next: Plugin anatomy breaks down every file and how the runtime treats it.