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.notifications | Frontend sdk.platform.notifications.push | Frontend sdk.platform.toast.show | |
|---|---|---|---|
| Reaches | Any member(s), incl. offline | Current user, current device | Current user, current device |
| Durable | Yes — stored server-side, survives reload, syncs read state across devices | No — gone on reload | No — auto-dismisses |
| Capability | notifications.push / notifications.push_all | none | none |
| Use for | Mentions, invites, "something happened while you were away" | "Done" cards for the person already looking | Transient 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):
{
"permissions": ["notifications.push", "notifications.push_all"]
}notifications.push— required forsend()(explicit user ids).notifications.push_all— required forsendAll()(every member).
Calling without the matching grant rejects with CAPABILITY_DENIED.
send(options)
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. createdis the number of durable rows written;deliveredis how many recipients were online and got the live push immediately. Offline recipients receive the row automatically on their next connect.
sendAll(options)
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,errorandsystemare reserved for the platform and rejected. - Source:
source_type: "plugin"andsource_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;contextis a flat string→string record (≤ 16 entries, keys ≤ 64, values ≤ 256). A malformed deep link fails the whole send withnotifications/invalid_deep_link. - Rate limits (per plugin):
send30/min,sendAll5/min, separate buckets. Over-limit calls reject with codeRATE_LIMITEDand 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.
// 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)
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)
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.