Agent-first notification service. One Rust binary, Postgres only. Email, SMS, WhatsApp, push, in-app inbox, MCP server built in.
notifyd has no admin UI on purpose. Everything an operator needs is an API call, and the same calls are exposed as MCP tools so an AI agent (Claude Code, Claude Desktop, Cursor, any MCP client) can run the service: read a digest, investigate, fix, prove.
Endpoint: POST https://<your-notifyd>/mcp, Streamable HTTP, stateless.
Authentication: the instance ADMIN_API_KEY as a bearer token.
The server speaks the current MCP revision (2026-07-28: server/discover,
_meta on every request, Mcp-Method/Mcp-Name headers, resultType,
cache hints) and the legacy initialize handshake (2024-11-05 → 2025-11-25)
on the same endpoint, so both new and old clients work. Every tool carries
annotations (readOnlyHint, destructiveHint, idempotentHint,
openWorldHint) and an outputSchema; clients use the read-only hint to
skip confirmations on digest, list_jobs, get_job, list_projects,
list_suppressions. retry_job and send_test are flagged as sending real
messages. Tool calls are rate limited (600/min) and written to the audit log
(tool name, argument keys, outcome, latency; never argument values).
Claude Code (.mcp.json in the project, or ~/.claude.json):
{
"mcpServers": {
"notifyd-philoe": {
"type": "http",
"url": "https://api-os.philoeparis.com/notifyd/mcp",
"headers": { "Authorization": "Bearer ${NOTIFYD_ADMIN_API_KEY}" }
}
}
}
One entry per company instance (notifyd-craie, notifyd-sqare…). The key
is read from the environment, never written in the file.
ADMIN_API_KEY does everything. READONLY_API_KEY (optional) sees the
digest, the listings, the metrics and only the read-only MCP tools: hand it
to a support agent or a dashboard. With it, mutating tools answer
isError: true with a sentence saying the admin key is needed. tools/list
is filtered accordingly (cacheScope: private).
npx skills add rmzlb/notifyd installs three Agent Skills:
notifyd-operate (run an instance: digest → investigate → fix → prove),
notifyd-integrate (send from an application correctly: idempotency,
priority, tags, send windows) and notifyd-deploy (stand up an instance).
| Tool | Use it when |
|---|---|
digest |
“How are notifications doing?” Findings first, ranked critical → warning → info, each with the action to take. Then queue, outcomes per channel/provider, failure reasons, retries waiting, latency p50/p95, deliverability, projects. window 1h…30d, format markdown or json. |
template_metrics |
Delivery funnel per template over time (sent, failed, delivered, bounced, complained, opened, clicked), bucket 1h or 1d. Which template drives the bounces. |
list_jobs |
Investigate: by status, channel, project, recipient, since. Recipients are masked. |
get_job |
One job: attempts, provider, provider message id, delivery events, error. |
retry_job |
After fixing a cause. Re-queues a failed/cancelled job with a fresh attempt budget. |
cancel_job |
Stop a pending or retrying job. |
list_projects, update_project |
Sender identity (from_email, from_name), channels, inbound rate limit, daily send_window for bulk email in the recipients’ timezone. Keys are never touched. |
list_suppressions, add_suppression, release_suppression |
The do-not-send list: bounces, complaints and commercial unsubscribes land there automatically; block (scope all or marketing) or release an address by hand. |
send_test |
Prove a channel end to end: enqueues a high-priority category=test message and returns the job id. |
The same operations exist as REST endpoints under /v1/admin/* (see
docs/API.md): GET /v1/admin/digest?window=24h&format=markdown,
GET /v1/admin/jobs, POST /v1/admin/jobs/:id/retry,
PATCH /v1/admin/projects/:id, GET|POST /v1/admin/suppressions,
DELETE /v1/admin/suppressions/:id. Project keys get POST /v1/jobs/:id/retry
and POST /v1/suppressions for their own scope.
| Finding | Threshold | Why |
|---|---|---|
No email provider / provider log |
always | nothing leaves the instance |
| Lane paused | any | a provider answered 429 |
| Oldest waiting job | > 5 min (urgent, normal), > 60 min (bulk) | the worker is stuck or paced out |
| Failed jobs | ≥ 2 % warning, ≥ 10 % critical, of terminal jobs in the window | permanent errors need a fix at the caller |
| Bounce rate | ≥ 2 % warning, ≥ 5 % critical | above 5 % providers throttle or suspend senders |
| Complaints | any | content or frequency problem |
Project without from_email |
always | emails leave with the instance default identity |
When none applies the digest says so (“All quiet: N sent, 0 failed”).
digest → read the findings.list_jobs(status="failed"), then get_job on the
sample id. The error says whether it is permanent (fix the address, the
sender, the template) or was transient (already retried by the worker).update_project for a sender, release_suppression for a
wrongly blocked address), then retry_job.send_test on the channel to confirm, get_job until sent.digest again: the finding should be gone.isError: true and a sentence the
model can act on; JSON-RPC errors are reserved for malformed requests.get_job shows the masked
recipient too; the raw address is only in the database.send_test enqueues a real message: use an address you own.