Skip to main content
Glama

notify

Self-hosted Web Push notifications with three interfaces over one Bun process:

  • HTTP API for the PWA, CLI, and automation.

  • CLI for registration, sending, and administration.

  • MCP for AI agents that need to notify a human when work finishes.

One Bun process, one SQLite database, one Docker image. No Redis, no broker, no worker container.

Browser/PWA ── HTTP ─┐
CLI ──────── HTTP ───┤
MCP clients ─ HTTP ───┼── Bun.serve ── core services ── SQLite
                      │                            └── Web Push
                      └── /mcp

Quick start (local)

bun install

cp .env.example .env
# edit .env

From a checkout, either prefix commands with bun run src/index.ts, or link the CLI once so the notify examples below work:

bun link
notify --help

The example keeps HOST=127.0.0.1 and stores the database at ./data/notify.sqlite. That is the safe default for self-hosting. Put a reverse proxy on the same host if you need a public hostname.

Generate VAPID keys once:

bun run src/index.ts vapid generate

Put the three printed lines in .env. Use a real contact URI for VAPID_SUBJECT (https://your-domain or mailto:you@your-domain); Apple rejects localhost and example values.

Uncomment NOTIFY_BOOTSTRAP_TOKEN in .env and set it to at least 32 random characters:

openssl rand -base64 32

Start the server:

bun run src/index.ts serve --dev

On iPhone, Web Push only works after adding the page to the Home Screen (Share → Add to Home Screen). Safari tabs cannot subscribe.

In another terminal, use the bootstrap token:

export NOTIFY_URL=http://127.0.0.1:8787
export NOTIFY_TOKEN='<your bootstrap token>'

bun run src/index.ts channel list
bun run src/index.ts register --channel alice --label "MacBook Chrome"

Open the printed enrollment URL on the device, grant notification permission, and send a test:

bun run src/index.ts send "Hello from notify" --channel alice

Create a real token and retire bootstrap

bun run src/index.ts token create --name admin --scope admin
# save the printed token, then
bun run src/index.ts token revoke bootstrap

Use a separate send-only token for agents:

bun run src/index.ts token create --name agent --scope notify:send

Related MCP server: BotBell MCP Server

Docker

A public image is published to GHCR by .github/workflows/publish.yml when you publish a GitHub release (and on manual dispatch):

docker pull ghcr.io/moureau-dev/notify:latest
docker pull ghcr.io/moureau-dev/notify:0.1.0

The workflow builds linux/amd64 and linux/arm64, emits an SBOM, and tags the release (0.1.0, 0.1, 0, v0.1.0, latest, and sha-...). If you fork the repo, the image is ghcr.io/<your-owner>/notify; if GHCR leaves the package private, set Packages → notify → Package settings → Change visibility → Public.

This is the local Docker path. For a public hostname, see Deployment.

cp .env.example .env
# set VAPID_* and NOTIFY_BOOTSTRAP_TOKEN
docker compose up -d --build

Compose publishes 127.0.0.1:8787:8787, not every interface. Change the port mapping only if you understand the exposure, or put a TLS reverse proxy in front.

The database lives in the notify-data volume at /data/notify.sqlite. Compose overrides HOST=0.0.0.0 and DATABASE_PATH=/data/notify.sqlite for the container; .env stays written for bare-metal self-hosting. The container runs as a non-root user with a read-only root filesystem (except /data and /tmp). For a public hostname, put the container behind a TLS reverse proxy and set PUBLIC_URL to the https:// URL.

Tailscale helper (optional)

For local or phone testing without certificates, serve can create a tailnet-only HTTPS proxy:

PORT=8890 notify serve --tailscale

It:

  • detects the Tailscale CLI and MagicDNS name,

  • sets PUBLIC_URL to https://<magicdns>:8443,

  • binds the Bun server to 127.0.0.1,

  • runs tailscale serve --bg --https=8443 http://127.0.0.1:<port>,

  • removes the proxy on Ctrl-C (unless --tailscale-keep).

Options:

--tailscale-port 8443   # HTTPS port on the tailnet
--tailscale-force       # replace an existing handler on that port
--tailscale-keep        # leave the proxy after the server exits

It is tailnet-only (tailscale serve), never public Funnel. If 8443 is busy, pick another port. If Tailscale is not installed or HTTPS/MagicDNS is disabled, it fails with an explanation instead of starting a half-configured server.

Deployment

docker compose up -d --build is the reference deployment. It sets HOST=0.0.0.0 and DATABASE_PATH=/data/notify.sqlite inside the container, publishes only 127.0.0.1:8787, and persists /data.

For a public hostname:

  1. Set NODE_ENV=production and PUBLIC_URL=https://notify.example.com.

  2. Terminate TLS in Caddy, nginx, Traefik, or Cloudflare Tunnel.

  3. Set TRUST_PROXY=true only when that proxy is trusted.

  4. Proxy to 127.0.0.1:8787. The proxy must run on the host (or share a Docker network with the container); 127.0.0.1 is not reachable from another container.

  5. Supply VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT, and NOTIFY_BOOTSTRAP_TOKEN from your secret manager.

  6. Back up /data; it contains the SQLite database, token hashes, and push subscriptions.

Need tailnet-only access instead of a public hostname? Use notify serve --tailscale and skip the public proxy.

Web Push requires the public HTTPS origin to match PUBLIC_URL.

CLI

notify serve [--dev] [--tailscale] [--tailscale-port 8443] [--tailscale-force] [--tailscale-keep]
notify vapid generate [--json]
notify login --url <url> --token <token>

notify register [--channel <slug>] [--label <label>] [--ttl 15m] [--no-create] [--json]
notify send <message> [--title <title>] [--channel <slug>] [--link <url>] [--tag <tag>] [--json]

notify channel list|create|delete|set-default
notify device list
notify subscription list|remove
notify notifications list|show
notify setup
notify token list|create|revoke
notify doctor

register --channel alice uses get-or-create when authenticated with a global token. A channel-scoped token can only enroll into its own channel and cannot create channels. --ttl accepts 15m, 24h, or 7d (minimum 1m, maximum 24h); the HTTP API takes the same value as ttlMs.

subscription list shows the subscription IDs used by subscription remove. notifications show <id> prints the stored notification and every delivery attempt.

notify setup verifies the server/token and prints a portable contract: MCP URL, auth shape, OpenCode config, Claude Code command, and the live tool list. Use --json for agents.

Environment: NOTIFY_URL, NOTIFY_TOKEN. Config: ~/.config/notify/config.json (or NOTIFY_CONFIG).

MCP

The endpoint is POST /mcp on the same host. It uses Bearer authentication and per-tool scopes.

Agents do not need the commands hardcoded: after connecting, they call tools/list and receive tool names, descriptions, and JSON input schemas. You still configure the URL and Authorization header per client. Any client that supports remote Streamable HTTP MCP with custom headers works; stdio-only clients need a bridge, and OAuth-only clients without custom headers cannot use static Bearer tokens. Autocompletion, when a client offers it, is driven by those schemas.

notify setup --json prints a portable contract, and the running server serves a plain-text agent contract at /llms.txt. Repo-level guidance for coding agents is in AGENTS.md and llms.txt.

Tools:

Tool

Scope

Admin gate

notify

notify:send

no

list_channels

channels:read

no

list_devices

devices:read

no

list_notifications

notify:read

no

create_channel

channels:write

MCP_ADMIN_ENABLED=1

register_device (ttlMs optional, returns URL + QR)

devices:write

MCP_ADMIN_ENABLED=1

manage_subscription

devices:write

MCP_ADMIN_ENABLED=1

Admin tools are not registered unless MCP_ADMIN_ENABLED=1, and even then they require the right scope. Give agents a notify:send token, not the admin token. list_devices includes subscription IDs so an agent can call manage_subscription.

OpenCode example:

{
  "mcp": {
    "notify": {
      "type": "remote",
      "url": "https://notify.example.com/mcp",
      "enabled": true,
      "headers": {
        "Authorization": "Bearer ntk_..."
      }
    }
  }
}

Claude Code example:

claude mcp add --transport http notify https://notify.example.com/mcp \
  --header "Authorization: Bearer ntk_..."

The MCP server is stateless and uses the Streamable HTTP transport. A stdio-only client needs a bridge; none is included in v1.

HTTP API

Method

Path

Auth

GET

/healthz

public

GET

/api/config

public

GET

/enroll?token=...

public page (token in URL)

POST

/api/enroll

one-time enrollment token

GET

/api/subscriptions/me

device cookie

DELETE

/api/subscriptions/me/:id

device cookie

POST

/api/subscriptions/me/test

device cookie

POST

/api/notify

notify:send

GET

/api/notifications

notify:read

GET

/api/notifications/:id

notify:read

GET

/llms.txt

public

GET/POST

/api/channels

channels:read / channels:write

DELETE

/api/channels/:slug

channels:write

POST

/api/channels/:slug/default

channels:write

POST

/api/enrollments

devices:write

GET

/api/devices

devices:read

GET

/api/subscriptions

devices:read

DELETE

/api/subscriptions/:id

devices:write

GET/POST

/api/tokens

admin

DELETE

/api/tokens/:id

admin

POST

/mcp

Bearer + per-tool scope

Scopes: notify:send, notify:read, channels:read, channels:write, devices:read, devices:write, admin. Tokens are stored as hashes and shown once.

Creating or deleting channels requires a global token; channel-scoped tokens are read/enroll/send-only within their channel. POST /api/enrollments accepts ttlMs from 60000 to 86400000.

Data and migrations

SQLite uses WAL, foreign keys, and a busy timeout. Migrations are pure SQL files:

src/db/migrations/
├── 0001_initial_schema.up.sql
└── 0002_example.up.sql

The runner creates _migrations, reads *.up.sql files in alphabetical order, skips already-applied files, executes each in a transaction, and records it. Never edit a migration that has run in production; add a new one.

Tables: channels, devices, subscriptions, enrollment_tokens, api_tokens, notifications, deliveries, audit_events.

Every send creates a notifications row, even when the channel has no subscriptions. Each subscription attempt creates a deliveries row with status (sent, failed, expired), HTTP status code, and error. RETENTION_DAYS (default 90) prunes old notifications, deliveries, and audit rows.

A device can have subscriptions in multiple channels. A channel-scoped token is restricted to one channel; a global token can act across channels.

Security

  • No default credentials. The first boot fails closed without NOTIFY_BOOTSTRAP_TOKEN.

  • API and MCP requests require Bearer tokens. /mcp requires a valid token before tool discovery.

  • Admin MCP tools are opt-in and scope-gated.

  • Device management uses an HttpOnly, SameSite=Strict cookie; no device token in localStorage.

  • Enrollment tokens are high-entropy, single-use, short-lived, and stripped from the URL.

  • Push endpoints are HTTPS-only, host-allowlisted, and validated for key sizes (SSRF protection).

  • Notification URLs are scheme-validated; the service worker never injects HTML.

  • CSP, HSTS (on HTTPS), no CORS, no directory listing, body limits, and auth/send rate limits.

  • Tokens and push endpoints are redacted from logs. Sensitive actions are audited.

  • Production refuses to bind a public host over plaintext HTTP.

  • The image runs as a non-root user; the compose file adds a read-only root filesystem.

See SECURITY.md for the threat model and disclosure process.

Development

bun install
bun test
bunx tsc --noEmit
bun run build

bun run src/index.ts serve --dev generates a persistent development VAPID keypair at data/dev-vapid.json if real keys are not configured. Do not use those keys in production.

See CONTRIBUTING.md for migration and test conventions.

Changelog

See CHANGELOG.md. Release notes are written from the matching version section.

License

MIT. See LICENSE.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      B
      quality
      D
      maintenance
      Enables AI assistants to send push notifications through the kweenkl service. Allows users to receive contextual notifications from their AI when tasks are complete or important events occur.
      1
      9 npm
      MIT
    • A
      license
      A
      quality
      D
      maintenance
      Enables AI assistants to send push notifications and interactive alerts to iPhone and Mac devices via the BotBell app. It allows AI to receive user replies and manage notification bots for tasks like alerts, reminders, and remote approvals.
      2
      18 npm
      1
      MIT
    • A
      license
      Not graded
      quality
      D
      maintenance
      Enables sending real-time and webhook notifications when AI agents complete tasks, with support for Discord, Slack, Teams, Feishu, Ntfy, and custom webhooks.
      6
      MIT