Notifications
One function, many destinations. Call notify() from any server-side code — an action, an automation, a plugin — and the event lands in the user's in-app inbox and fans out to every registered channel. Ships with a bell-and-dropdown UI component that the host template drops into its header.
import { notify } from "@agent-native/core/notifications";
await notify(
{ severity: "info", title: "Booking confirmed", body: "Jane at 3pm" },
{ owner: "[email protected]" },
);
Severities
| Severity | Use for |
|---|---|
info |
Confirmations, progress milestones, FYI |
warning |
Something the user should look at soon |
critical |
Needs immediate attention |
Severity drives the badge styling in the dropdown and is passed through to channels so they can branch on urgency.
Built-in channels
| Channel | Delivery | Requires |
|---|---|---|
inbox |
Persists to the notifications table; drives the bell UI |
Always on — part of the primitive. |
webhook |
POST JSON to a configured URL | NOTIFICATIONS_WEBHOOK_URL env var set at startup. |
The webhook channel resolves ${keys.NAME} references in both the URL and NOTIFICATIONS_WEBHOOK_AUTH against the owner's ad-hoc secrets, so the raw value never enters the agent's context. Per-key URL allowlists are enforced — same rule the automations web-request tool uses.
API
notify(input, meta)
Deliver a notification. Always persists to the inbox unless explicitly excluded; additional registered channels run in parallel, best-effort.
await notify(
{
severity: "critical",
title: "Database offline",
body: "Primary dropped connections",
metadata: { runbookUrl: "https://runbooks/db-offline" },
channels: ["inbox", "webhook"], // optional allowlist; omit to run all
},
{ owner: "[email protected]" },
);
meta.owner is required — scopes the notification so only that user sees it in the bell.
registerNotificationChannel(channel)
Register a custom channel from any server plugin.
import { registerNotificationChannel } from "@agent-native/core/notifications";
registerNotificationChannel({
name: "slack-ops",
async deliver(input, meta) {
await fetch(process.env.OPS_SLACK_WEBHOOK!, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: `*${input.severity.toUpperCase()}* — ${input.title}\n${input.body ?? ""}`,
owner: meta.owner,
}),
});
},
});
Channel names are unique — re-registering replaces the prior channel. deliver() is best-effort; throwing logs the error but does not block other channels or the inbox row.
Listing and reading
import {
listNotifications,
countUnread,
markNotificationRead,
markAllNotificationsRead,
deleteNotification,
} from "@agent-native/core/notifications";
const rows = await listNotifications("[email protected]", {
unreadOnly: true,
limit: 50,
});
const unread = await countUnread("[email protected]");
await markNotificationRead(rows[0].id, "[email protected]");
await markAllNotificationsRead("[email protected]");
await deleteNotification(rows[0].id, "[email protected]");
Each function is owner-scoped — no cross-user reads, no cross-user writes.
The NotificationChannel interface
interface NotificationChannel {
name: string;
deliver(
input: NotificationInput,
meta: NotificationMeta,
): void | Promise<void>;
}
interface NotificationInput {
severity: "info" | "warning" | "critical";
title: string;
body?: string;
metadata?: Record<string, unknown>;
channels?: string[];
}
interface NotificationMeta {
owner: string;
}
HTTP API
Mounted at /_agent-native/notifications/* by the core-routes plugin. All routes are scoped to the authenticated session's email.
| Method | Path |
|---|---|
GET |
/_agent-native/notifications?unread=true&limit=50 |
GET |
/_agent-native/notifications/count |
POST |
/_agent-native/notifications/:id/read |
POST |
/_agent-native/notifications/read-all |
DELETE |
/_agent-native/notifications/:id |
UI component
import { NotificationsBell } from "@agent-native/core/client/notifications";
export function HeaderBar() {
return (
<header className="flex items-center gap-2">
{/* … */}
<NotificationsBell browserNotifications />
</header>
);
}
Bell icon with unread badge. Clicking opens a dropdown of recent notifications. Uses shadcn semantic tokens, adapts to the host template's light/dark theme.
Pass browserNotifications to also fire system new Notification(...) popups for every new unread item — useful when the user's tab is in the background. The dropdown renders an "Enable" prompt until the user grants permission; duplicates are prevented per-id via the Notification tag field.
Agent tools
Two native tools are registered in every template:
| Tool | Purpose |
|---|---|
notify |
Send a notification from an agent turn or automation |
list-notifications |
Show recent notifications to the agent for context |
Automations (see Automations) can call notify in their body — this is the canonical pattern for turning an external event into a user-visible alert.
Event bus
Every successful delivery emits notification.sent on the event bus:
{
"notificationId": "n-123",
"severity": "critical",
"title": "DB offline",
"body": "Primary dropped connections",
"deliveredChannels": ["inbox", "webhook"]
}
Automations can chain off this — e.g. "if a critical notification fires, also page on-call."
How it works
- Owner scoping — every row has an
ownercolumn; every query filters on it; every route uses the authenticated session's email. Users never see each other's notifications. - Poll integration — every mutation calls
recordChange()so templates usinguseDbSyncauto-invalidate without any extra wiring. - Best-effort fan-out — channel errors are caught and logged; one failing channel does not block others or the inbox write.
- Fire-and-forget —
notify()returns after the inbox write completes; custom channels run in the background.
What's next
- Automations — the most common caller of
notify() - Secrets — the
${keys.NAME}substitution that powers the webhook channel - Server plugins — where custom channels are registered at startup