---
title: "Notifications"
description: "In-app notifications with pluggable channels — inbox, webhook, or custom"
---

# 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.

Notifications are one-way alerts into the app's bell inbox (plus webhook fan-out). To _converse_ with your agent from Slack/email/Telegram/WhatsApp, see [Messaging](/docs/messaging).

```ts
import { notify } from "@agent-native/core/notifications";

await notify(
  { severity: "info", title: "Booking confirmed", body: "Jane at 3pm" },
  { owner: "steve@builder.io" },
);
```

<Diagram id="doc-block-16td6r3" title="One call, many destinations" summary={"notify() always writes the owner-scoped inbox row, fans out to every registered channel in parallel (best-effort), then emits notification.sent on the event bus."}>

```html
<div class="diagram-notify">
  <div class="diagram-node">
    notify(input, { owner })<br /><small class="diagram-muted"
      >any server code &middot; action, automation, plugin</small
    >
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</div>
  <div class="diagram-panel fan" data-rough>
    <div class="fan-row">
      <span class="diagram-pill accent">inbox</span>
      <div class="diagram-box" data-rough>
        notifications table &rarr; bell UI<br /><small class="diagram-muted"
          >always on &middot; owner-scoped</small
        >
      </div>
    </div>
    <div class="fan-row">
      <span class="diagram-pill">email</span>
      <div class="diagram-box" data-rough>
        Resend / SendGrid<br /><small class="diagram-muted"
          >always registered &middot; best-effort</small
        >
      </div>
    </div>
    <div class="fan-row">
      <span class="diagram-pill">slack</span>
      <div class="diagram-box" data-rough>
        Block Kit JSON to an incoming webhook<br /><small class="diagram-muted"
          >always registered &middot; best-effort</small
        >
      </div>
    </div>
    <div class="fan-row">
      <span class="diagram-pill">webhook</span>
      <div class="diagram-box" data-rough>
        POST JSON to NOTIFICATIONS_WEBHOOK_URL<br /><small class="diagram-muted"
          >always registered &middot; best-effort</small
        >
      </div>
    </div>
    <div class="fan-row">
      <span class="diagram-pill">custom</span>
      <div class="diagram-box" data-rough>
        registerNotificationChannel(...)<br /><small class="diagram-muted"
          >best-effort &middot; runs in parallel</small
        >
      </div>
    </div>
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</div>
  <div class="diagram-card">
    <span class="diagram-pill ok">notification.sent</span
    ><small class="diagram-muted"
      >event bus &middot; automations can chain</small
    >
  </div>
</div>
```

```css
.diagram-notify {
  display: flex;
  align-items: center;
  gap: 14px;
  flex-wrap: wrap;
}
.diagram-notify .fan {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 14px;
}
.diagram-notify .fan-row {
  display: flex;
  align-items: center;
  gap: 10px;
}
.diagram-notify .diagram-card {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 12px 14px;
}
.diagram-notify .diagram-arrow {
  font-size: 22px;
  line-height: 1;
}
```

</Diagram>

## Severities {#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 {#channels}

| Channel   | Delivery                                                  | Requires                                                                              |
| --------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `inbox`   | Persists to the `notifications` table; drives the bell UI | Always on — part of the primitive.                                                    |
| `email`   | Sends email via Resend/SendGrid                           | Per-notification `metadata.emailRecipients` and/or `NOTIFICATIONS_EMAIL_RECIPIENTS`.  |
| `slack`   | POST Slack Block Kit JSON to an incoming webhook          | Per-notification `metadata.slackWebhookUrl` and/or `NOTIFICATIONS_SLACK_WEBHOOK_URL`. |
| `webhook` | POST JSON to a configured URL                             | Per-notification `metadata.webhookUrl` and/or `NOTIFICATIONS_WEBHOOK_URL`.            |

Email, Slack, and webhook are all always registered alongside the inbox. Delivery prefers the per-notification
metadata URL/recipients, then falls back to the matching env var. If neither is set, that
channel no-ops for the emission.

The webhook and Slack channels resolve `${keys.NAME}` references in both the URL and `NOTIFICATIONS_*_AUTH` against the owner's ad-hoc [secrets](/docs/security), so the raw value never enters the agent's context. Per-key URL allowlists are enforced — same rule the automations `web-request` tool uses.

<Diagram id="doc-block-1lduyog" title="Severity drives the badge" summary={"Severity is passed through to every channel and drives the bell dropdown's badge color."}>

```html
<div class="diagram-channels">
  <div class="diagram-panel col" data-rough>
    <strong>Severity drives the badge</strong>
    <div class="sev-row">
      <span class="diagram-pill">info</span
      ><span class="diagram-muted">confirmations, FYI</span>
    </div>
    <div class="sev-row">
      <span class="diagram-pill warn">warning</span
      ><span class="diagram-muted">look at soon</span>
    </div>
    <div class="sev-row">
      <span class="diagram-pill accent">critical</span
      ><span class="diagram-muted">needs immediate attention</span>
    </div>
  </div>
</div>
```

```css
.diagram-channels {
  display: flex;
  gap: 16px;
  flex-wrap: wrap;
  align-items: flex-start;
}
.diagram-channels .col {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 14px;
  min-width: 240px;
}
.diagram-channels .sev-row {
  display: flex;
  align-items: center;
  gap: 10px;
}
```

</Diagram>

## API {#api}

### `notify(input, meta)` {#notify}

Deliver a notification. Always persists to the inbox unless explicitly excluded; additional registered channels run in parallel, best-effort.

```ts
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: "ops@company.com" },
);
```

`meta.owner` is required — scopes the notification so only that user sees it in the bell.

### `registerNotificationChannel(channel)` {#register}

Register a custom channel from any server plugin.

```ts filename="server/plugins/notifications-slack-ops.ts"
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 {#read}

```ts
import {
  listNotifications,
  countUnread,
  markNotificationRead,
  markAllNotificationsRead,
  deleteNotification,
} from "@agent-native/core/notifications";

const rows = await listNotifications("steve@builder.io", {
  unreadOnly: true,
  limit: 50,
});
const unread = await countUnread("steve@builder.io");
await markNotificationRead(rows[0].id, "steve@builder.io");
await markAllNotificationsRead("steve@builder.io");
await deleteNotification(rows[0].id, "steve@builder.io");
```

Each function is owner-scoped — no cross-user reads, no cross-user writes.

## The NotificationChannel interface {#channel-interface}

```ts
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 {#http}

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&before=ISO` |
| `GET`    | `/_agent-native/notifications/count`                           |
| `POST`   | `/_agent-native/notifications/:id/read`                        |
| `POST`   | `/_agent-native/notifications/read-all`                        |
| `DELETE` | `/_agent-native/notifications/:id`                             |

<Endpoint id="doc-block-12hm3kj" title="List notifications" summary={"The route behind listNotifications() — scoped to the authenticated session's email."} method="GET" path={"/_agent-native/notifications?unread=true&limit=50&before=ISO"} auth={"Authenticated session; results are scoped to the session's email."} params={[
  {
    "name": "unread",
    "in": "query",
    "type": "boolean",
    "required": false,
    "description": "When true, returns only unread notifications."
  },
  {
    "name": "limit",
    "in": "query",
    "type": "number",
    "required": false,
    "description": "Max rows to return."
  },
  {
    "name": "before",
    "in": "query",
    "type": "string",
    "required": false,
    "description": "ISO timestamp cursor — returns notifications created before this time, for pagination."
  }
]} responses={[
  {
    "status": "200",
    "description": "Owner-scoped notification rows, newest first."
  }
]}>

</Endpoint>

## UI component {#ui}

```tsx filename="app/components/HeaderBar.tsx"
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 {#agent-tools}

A single `manage-notifications` tool is registered in every template. The `action` parameter selects the operation:

| Action | Parameters                                                                    | Purpose                                                         |
| ------ | ----------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `send` | `severity` (required), `title` (required), `body`, `metadataJson`, `channels` | Send a notification to the user's inbox and registered channels |
| `list` | `unreadOnly`, `limit` (max 200, default 20)                                   | List recent notifications for context                           |

Automations (see [Automations](/docs/automations)) can call `manage-notifications` with `action=send` in their body — this is the canonical pattern for turning an external event into a user-visible alert.

## Event bus {#event-bus}

Every successful delivery emits `notification.sent` on the [event bus](/docs/automations#event-bus):

```json
{
  "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 {#internals}

- **Owner scoping** — every row has an `owner` column; 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 using [`useDbSync`](/docs/client) auto-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**](/docs/automations) — the most common caller of `notify()`
- [**Security**](/docs/security) — the `${keys.NAME}` substitution that powers the webhook channel
- [**Server plugins**](/docs/server) — where custom channels are registered at startup
