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.
noteboard/
manifest.json
migrations/001_create_tables.sql
backend/index.ts
frontend/index.htmlManifest
Declares identity, the exact capabilities used, admin-configurable settings, the sidebar contribution, and the one table shared via public_schema.
{
"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.
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 restartA 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.
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.publishwrites 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 withsdk.subscribe("noteboard.note.created", …)(needsevents.subscribe:noteboard.*).broadcast.toAll/toUsersis a fire-and-forget, non-durable push that lands assdk.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.
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.
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 noteFrontend: 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.
: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.
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
| Concept | Reference |
|---|---|
| Capabilities & the manifest | Manifest · Permissions |
| Own database + migrations | Backend SDK → db |
Settings + live onChange | Backend SDK → settings |
| Authorization | Backend SDK → permissions · Security model |
| Event bus vs broadcast | Data & events |
| Core user directory | Backend SDK → core |
| Theme-aware UI | Theming |
| Surfaces (modal & rail) + native confirm | Surfaces |
| Error handling | Error 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.