Skip to content

Server-side notifications

openagent.email v0.3 includes a private ntfy service inside the Docker stack. It is an interrupt channel, not another mailbox: agents still read mail through the normal protected API, while a notification only says that action may be needed.

The default is a server-only loop. It supports notify_verify, manual agent/user notifications, and mail-arrival alerts without opening ntfy to the internet. Phone delivery is optional and uses a separate public HTTPS hostname. Webhooks are not part of this feature.

.env.example includes the required values:

NTFY_ENABLED=true
NOTIFY_PUBLIC_URL=http://127.0.0.1:2586
NTFY_UPSTREAM=true
NTFY_ADMIN_PASSWORD=replace-with-openssl-rand-hex-24

NTFY_ADMIN_PASSWORD stays between Docker and the server. It is not returned by the API and must never go in an agent’s MCP configuration. Protect the whole file before starting the stack:

Terminal window
chmod 600 .env
docker compose up -d
./deploy/doctor.sh

The doctor calls POST /v1/notify/verify: it publishes a harmless check and polls it back from ntfy’s cache. The ntfy port is bound to 127.0.0.1:2586 by default. Keep it that way in v0.3.

Setting NTFY_ENABLED=false turns off notification API operations and the mail watcher, but the ntfy container still runs. This is intentional for v0.3: the fixed Compose graph is simpler and costs about 20 MB RAM. Set NTFY_UPSTREAM=false if you do not want ntfy to forward unknown topics to ntfy.sh.

The server creates random-suffixed physical topics, for example:

Logical routeExample physical topicWho can use it
user-alertsuser-alerts-x7k2server-side human alerts
user-lowuser-low-x7k2server-side low-priority human alerts
agent:<localpart>agent-qa-bot-x7k2the matching agent route

The suffix and all ntfy tokens are stored only in the server’s private JSON state. MCP clients use notify_user and notify_agent and never receive a topic name or ntfy credential. ntfy is deny-by-default: the server publisher is write-only, while reserved reader accounts are read-only. The optional phone setup creates one separate read-only account for the two human topics; it has no access to an agent route. Creating an identity in a running stack creates its reader account and route before the API returns the new identity, and also records it for the next ntfy restart.

Phone setup prints a password and the two random-suffixed human topic names. Treat them as credentials: do not paste them into a ticket, chat transcript, image, or shell history. The CLI path still prints those values and does not generate a QR code; the dashboard Add device flow does — see Phone notifications.

notify_user is deliberately stronger than normal mail operations. An admin key may use it. An identity token needs that identity to be created with canNotifyUser: true, and it has its own NOTIFY_RATE_LIMIT rolling-hour budget (default 10). It does not share the email send limit.

An identity may read notification history only for its own route:

identity qa-bot@example.com -> GET /v1/notify/messages?topic=self

The server maps self to agent:qa-bot. Trying to read agent:other-agent, user-alerts, or user-low returns 403, even if the caller guesses their logical names.

PUSH_POLICY=otp is the default: a new message causes a notification only when the server finds an OTP code or verification link. Set all for every new message, or none to disable the watcher.

How much of the mail appears in that human push is per-identity push content tier (default 1):

TierWhat the push contains
1Interrupt only — e.g. qa-bot@example.com received new email (contains OTP or verification link). No sender, subject, preview, or code.
2Tier 1 plus masked From / Subject
3Interrupt line plus unmasked From / Subject, body preview, and extracted OTP codes/links. That content leaves this server for the ntfy channel.

Admins set the tier with PUT /v1/identities/:address/push-tier. Tier 3 requires "confirm_risk": true or the API returns confirm_risk_required. Identity tokens may read their own tier (GET …/push-tier) but cannot change it. The Dashboard overview also exposes the same control for admin sessions.

Mail from outside your own managed identities can alert the user, but it can never wake an agent:<localpart> route. The IMAP watcher only publishes to human topics; an agent wake-up is emitted only after the API has accepted a server-authenticated send to another managed identity. That boundary does not depend on email text or attacker-controlled headers.

The REST shapes are in the API reference. The MCP package exposes four matching operations:

  • notify_user(title, message, level?, tags?)
  • notify_agent(name, title, message, level?, tags?)
  • notify_check(since?)
  • notify_verify()

Levels map to ntfy priority conservatively: urgent → priority 5, normal → 3, and low → 1. For user alerts, urgent and normal use the normal human-alert route; low uses the separate low-priority route.