Skip to content

Noteboard (golden example)

A complete, minimal plugin: a shared sticky-note board. Where Getting started builds the smallest possible thing, Noteboard is the reference to copy from — it's still small, but it exercises the whole surface a real plugin needs: settings, permissions, the core user directory, the durable event bus driving live UI, surfaces, and a fully theme-aware frontend.

Source lives in the repo at examples/noteboard/. The manifest is validated and the backend typechecks against the SDK.

text
noteboard/
  manifest.json
  migrations/001_create_tables.sql
  backend/index.ts
  frontend/index.html

Manifest

Declares identity, the exact capabilities used, admin-configurable settings, the sidebar contribution, and the one table shared via public_schema.

json
{
  "name": "noteboard",
  "version": "1.0.0",
  "api_version": "^1.0",
  "author": "UnCorded",
  "description": "A shared sticky-note board.",
  "type": "standalone",
  "icon": "StickyNote",
  "backend": { "entry": "backend/index.ts" },
  "frontend": { "entry": "frontend/index.html" },
  "permissions": [
    "data.sql:self",
    "events.publish:noteboard.*",
    "events.subscribe:noteboard.*"
  ],
  "public_schema": {
    "notes": {
      "columns": ["id", "author_id", "body", "color", "created_at"],
      "description": "Every note on the board, newest first by created_at."
    }
  },
  "sidebar": { "contributes": true, "section": "Noteboard" },
  "settings": [
    { "key": "board_title", "label": "Board title", "type": "string", "default": "Team Noteboard", "max_length": 60 },
    { "key": "max_note_length", "label": "Max note length", "type": "number", "default": 280, "min": 40, "max": 1000, "step": 20 },
    { "key": "allow_member_delete", "label": "Members can delete their own notes", "type": "boolean", "default": true }
  ]
}

Note that notes is listed in public_schema with only the columns other plugins may read — and internal tables (_config, etc.) can't be listed here at all. See Security model.

Backend

Setup and a settings cache

createPlugin() once; register handlers synchronously. Settings are admin-configurable values stored in the plugin's _config table — reading them is a round-trip, so cache in memory and refresh on the config_changed delivery instead of reading per request.

ts
import { createPlugin } from "@uncorded/plugin-sdk";

const plugin = createPlugin();

let boardTitle = "Team Noteboard";
let maxNoteLength = 280;
let allowMemberDelete = true;

async function refreshSettings() {
  const s = await plugin.settings.getAll();
  if (typeof s["board_title"] === "string") boardTitle = s["board_title"];
  if (typeof s["max_note_length"] === "number") maxNoteLength = s["max_note_length"];
  if (typeof s["allow_member_delete"] === "boolean") allowMemberDelete = s["allow_member_delete"];
}
void refreshSettings();
plugin.settings.onChange(() => void refreshSettings()); // live-update, no restart

A write handler: validate → write → fan out

plugin.handle registers an action the frontend reaches with sdk.request. The handler gets the client's params (always unknown — validate it) and an authenticated user the runtime established from the session. Bind the row to user.id, never to anything from params.

ts
plugin.handle("addNote", async (params, user) => {
  const body = typeof params["body"] === "string" ? params["body"].trim() : "";
  if (!body) throw new Error("Note body is required.");
  if (maxNoteLength > 0 && body.length > maxNoteLength) {
    throw new Error(`Note is too long (max ${maxNoteLength} characters).`);
  }

  const note = {
    id: crypto.randomUUID(),
    author_id: user.id,            // trusted, from the session — never params
    body,
    color: /* validated against a fixed palette */ "default",
    created_at: Date.now(),
  };
  await plugin.db.run(
    "INSERT INTO notes (id, author_id, body, color, created_at) VALUES (?, ?, ?, ?, ?)",
    [note.id, note.author_id, note.body, note.color, note.created_at],
  );

  const decorated = { ...note, author_name: user.displayName, author_avatar: user.avatarUrl };

  // One publish does it all: durable bus for any backend subscribers AND a live
  // push to every open client (the frontend listens with sdk.subscribe).
  plugin.events.publish("noteboard.note.created", decorated);
  return decorated;
});

events.publish vs broadcast — pick one per event. Both end up in the frontend, so sending a change through both delivers it twice:

  • events.publish writes to the durable, server-side event bus (at-least-once, ordered per subscriber) and the runtime delivers it to every connected client. So one publish reaches backend subscribers and live UIs — use it for durable state changes (rows created/edited/deleted). The frontend listens with sdk.subscribe("noteboard.note.created", …) (needs events.subscribe:noteboard.*).
  • broadcast.toAll / toUsers is a fire-and-forget, non-durable push that lands as sdk.on. Its niche is ephemeral or per-user signals (typing, a toast to one person) — things the event bus can't target. Noteboard has no such signal, so it doesn't use broadcast at all.

Because event delivery is at-least-once, keep client handlers idempotent (key by id and upsert), so a redelivery is harmless.

Authorization in the backend

Hiding a delete button in the UI is convenience, not security. The real check is here: a moderator may delete anything; an author may delete their own note only if the admin left member-delete on.

ts
plugin.handle("deleteNote", async (params, user) => {
  const id = typeof params["id"] === "string" ? params["id"] : "";
  if (!id) throw new Error("Note id is required.");

  const note = (await plugin.db.query("SELECT * FROM notes WHERE id = ?", [id]))[0];
  if (!note) return { deleted: false };

  const isMod = await plugin.permissions.hasMinLevel(user.id, 60);
  const isOwnDeletable = allowMemberDelete && note.author_id === user.id;
  if (!isMod && !isOwnDeletable) throw new Error("You don't have permission to delete this note.");

  await plugin.db.run("DELETE FROM notes WHERE id = ?", [id]);
  plugin.events.publish("noteboard.note.deleted", { id });
  return { deleted: true };
});

Resolving users via the core module

getNotes joins author ids to current names/avatars through the core module — the runtime's built-in user directory. core.* needs no capability.

ts
const notes = await plugin.db.query("… ORDER BY created_at DESC LIMIT 200");
const users = await plugin.core.getUsers([...new Set(notes.map((n) => n.author_id))]);
const byId = new Map(users.map((u) => [u.id, u]));
// attach u.display_name / u.avatar_url to each note

Frontend: theme-aware from the first paint

The panel is plain HTML in a sandboxed iframe. It loads the SDK from /sdk/plugin-frontend.js (served by the runtime — never bundle it), and its CSS reads var(--uncorded-*) so it tracks the user's theme automatically.

css
:root {
  --bg:     var(--uncorded-bg, #15181d);   /* fallback = standalone look */
  --page:   var(--uncorded-page, #1c2026);
  --text:   var(--uncorded-text, #e8eaed);
  --border: var(--uncorded-border, #2a2f37);
  --cta:    var(--uncorded-cta, #6aa3ff);
  --cta-fg: var(--uncorded-cta-fg, #0b1020);
  --danger: var(--uncorded-danger, #ff5c5c);          /* stable status meaning */
  --font:   var(--uncorded-font, system-ui, sans-serif);
  --radius: var(--uncorded-radius, 12px);
  --raised: color-mix(in oklab, var(--text) 6%, var(--page)); /* derived, light+dark correct */
}

The full theming rationale (token table, light/dark branching, what to keep fixed) is in the Theming guide.

js
const { createPluginFrontend, avatarHtml } = window.UncodedPlugin;
const sdk = await createPluginFrontend();        // theme already applied before this resolves

sdk.subscribe("noteboard.note.created", (n) => upsert(n));   // live: backend events.publish
sdk.subscribe("noteboard.note.deleted", ({ id }) => removeNote(id));
sdk.onNavigate(() => focusComposer());                       // sidebar click lands here

const data = await sdk.request("getNotes", {});
render(data.title, data.notes);                              // each note → avatarHtml({ userId, displayName, avatarUrl })

Note the symmetry: the backend's events.publish("noteboard.note.created", …) arrives on the frontend as sdk.subscribe("noteboard.note.created", …). upsert keys by id, so the poster's optimistic add and the echoed event collapse to one — the idempotency that at-least-once delivery calls for.

What this example teaches

ConceptReference
Capabilities & the manifestManifest · Permissions
Own database + migrationsBackend SDK → db
Settings + live onChangeBackend SDK → settings
AuthorizationBackend SDK → permissions · Security model
Event bus vs broadcastData & events
Core user directoryBackend SDK → core
Theme-aware UITheming
Surfaces (modal & rail) + native confirmSurfaces
Error handlingError handling

The frontend also demonstrates surfaces: the Activity button opens a rail surface, each note's Open opens a modal surface, and delete goes through a native modal.confirm — all from the one bundle, routed by sdk.surface.itemId and kept in sync by the same backend events. See the Surfaces guide.