Skip to content

Notifications

UnCorded servers keep a durable, per-user notification store (the bell panel in the shell). Plugins produce into it from the backend via sdk.notifications, and can additionally raise client-local feedback (toasts, local bell cards) from the frontend via sdk.platform.toast / sdk.platform.notifications. Source of truth: packages/plugin-sdk/src/types.ts (backend) and packages/plugin-sdk-frontend/src/types.ts (frontend).

Which API do I want?

Backend sdk.notificationsFrontend sdk.platform.notifications.pushFrontend sdk.platform.toast.show
ReachesAny member(s), incl. offlineCurrent user, current deviceCurrent user, current device
DurableYes — stored server-side, survives reload, syncs read state across devicesNo — gone on reloadNo — auto-dismisses
Capabilitynotifications.push / notifications.push_allnonenone
Use forMentions, invites, "something happened while you were away""Done" cards for the person already lookingTransient confirmations / errors

Cross-user and durable delivery must go through the backend capability path — the frontend variants are deliberately local-only, and the shell stamps their source from your iframe's slug (a plugin cannot impersonate another).

Backend: sdk.notifications

Capabilities

Two separate manifest permissions (the split is deliberate — a server owner can allow targeted pings without allowing server-wide attention grabs):

json
{
  "permissions": ["notifications.push", "notifications.push_all"]
}
  • notifications.push — required for send() (explicit user ids).
  • notifications.push_all — required for sendAll() (every member).

Calling without the matching grant rejects with CAPABILITY_DENIED.

send(options)

ts
const outcome = await plugin.notifications.send({
  targets: ["user-id-1", "user-id-2"], // ≤ 100 per call, deduped
  kind: "mention",                     // "info" (default) | "mention"
  title: "@Dakota in #general",        // ≤ 200 chars, plain text, required
  body: "hey, can you look at…",       // ≤ 1000 chars, plain text
  deepLink: { context: { channelId: "ch-1" } },
  tag: "mention:ch-1",                 // ≤ 64 chars, collapse key
});
// outcome: { created: 2, delivered: 1, skippedUnknown: 0 }
  • Targets that are not members of the server are skipped and counted in skippedUnknown — no row is ever stored for someone who can't read it.
  • created is the number of durable rows written; delivered is how many recipients were online and got the live push immediately. Offline recipients receive the row automatically on their next connect.

sendAll(options)

ts
await plugin.notifications.sendAll({
  title: "Maintenance tonight",
  body: "The server restarts at 21:00 UTC.",
  tag: "maintenance",
});

Same options minus targets. The member roster expands inside the runtime — including offline members — and is never exposed to the plugin.

Rules the runtime enforces

  • Kinds: plugins may send "info" and "mention" only. warning, error and system are reserved for the platform and rejected.
  • Source: source_type: "plugin" and source_slug: <your slug> are stamped server-side from the authenticated IPC transport. Nothing you put in the payload can change them.
  • Caps: title ≤ 200, body ≤ 1000, tag ≤ 64, targets ≤ 100/call. Plain text only — control characters are rejected (newlines allowed in body).
  • Deep links: { panel?, context? } only; context is a flat string→string record (≤ 16 entries, keys ≤ 64, values ≤ 256). A malformed deep link fails the whole send with notifications/invalid_deep_link.
  • Rate limits (per plugin): send 30/min, sendAll 5/min, separate buckets. Over-limit calls reject with code RATE_LIMITED and a retry hint in the message.

Tags, duplicates, and retries

A new notification with the same (recipient, tag) supersedes the previous live one server-side — the old card is dismissed in the same transaction, so a user never accumulates duplicates for one logical event.

One send() call writes at most one row per recipient. Do not retry a timed-out send (it may have landed); pick a stable tag so a deliberate re-send replaces rather than duplicates.

ts
// Mention pattern (see the mention convention below):
await plugin.notifications.send({
  targets: mentionedIds,          // validated member ids, self filtered out
  kind: "mention",
  title: `@${user.displayName} in #${channel.name}`,
  body: preview,                  // first ~120 chars of the message
  deepLink: { context: { channelId: channel.id, messageId: message.id } },
  tag: `mention:${channel.id}`,   // one live mention card per channel
});

The shell routes deepLink.context.channelId through its standard channel-open path when the user activates the card — that key name is the documented mention convention.

Errors

All failures reject the promise with an SdkProtocolError carrying a stable code: CAPABILITY_DENIED, RATE_LIMITED, NOTIFICATIONS_UNAVAILABLE (runtime booted without the notification backend), or a notifications/invalid_* validation code (invalid_kind, invalid_title, invalid_body, invalid_tag, invalid_deep_link, invalid_targets). Obvious argument misuse (empty targets, over-long title) throws a local SdkError with code invalid_argument before anything hits the wire.

Frontend: toasts and local cards

Both are fire-and-forget, need no manifest declaration, and affect only the current user's current device. The shell validates and length-caps every field and silently drops anything malformed — never partially applied.

sdk.platform.toast.show(options)

ts
sdk.platform.toast.show({ message: "Board saved", severity: "info" });
  • message — plain text, ≤ 300 chars (trimmed; empty/over-long is dropped).
  • severity"info" (default, 4s) | "warning" (6s) | "error" (7s).

sdk.platform.notifications.push(options)

ts
sdk.platform.notifications.push({
  kind: "warning",                 // "info" (default) | "warning" | "error"
  title: "Export finished with warnings",
  body: "2 files were skipped.",
  deepLink: { context: { channelId: "ch-1" } },  // optional "OPEN" action
  tag: "export",                   // re-push replaces the previous card
});

Adds a card to the bell panel with the source chip stamped from your plugin's slug. mention is intentionally not accepted here — mentions are durable cross-device records and belong to the backend API. A valid deepLink gives the card an OPEN action routed through the same navigation path durable cards use, scoped to your plugin.

Reading notifications

Plugins don't read the notification store — the shell owns consumption (bell panel, unread badge, toast/OS escalation, read-state sync across devices). Your plugin's job ends at producing well-tagged records.