Skip to content
mcp-data-platform composable mcp data platform
v1.x part of txn2 ↗

Notifications

The platform tells people when something needs their attention. Most of what follows is email; the last surface, notification channels, is the same pipeline delivering to a Mattermost channel, an incoming webhook, or an operator-named mailing list.

The platform emails users when something needs their attention: a teammate shares an asset, collection, or prompt with them, comments on something they own or that is shared with them, or names them in a comment with an @-mention. It also alerts operators when the knowledge review queue goes unworked, and tells whoever authorized a connection when its credential is discarded. Delivery is durable (a database-backed queue with retries), never blocks the originating request, and respects per-user preferences including a daily digest mode.

Email is one of three ways the platform tells someone that something needs them. The portal activity feed shows it to anyone who opens the portal, and session-start notices put it in front of a person working through an agent, who may open neither.

Email notifications require a database-backed deployment running the HTTP transport: the queue, the send worker, and SMTP are owned by the HTTP server, which is the long-lived process they belong in. With no database, or under stdio, the feature is absent and everything else works unchanged - including feedback replies written through manage_feedback, which are stored either way.

How it works

graph LR
    subgraph Triggers
        Share[Share created]
        Comment[Thread comment / feedback]
        Mention["@-mention in a comment"]
        Review[Review queue over threshold]
    end
    subgraph Queue
        Prefs[(user preferences)]
        Rows[(notifications queue)]
    end
    subgraph Delivery
        Worker[Send worker]
        SMTP[SMTP server]
    end
    Share --> Prefs
    Comment --> Prefs
    Mention --> Prefs
    Review --> Prefs
    Prefs -->|off| Drop[Dropped]
    Prefs -->|immediate or daily| Rows
    Rows --> Worker --> SMTP
  1. When a direct share is created or a feedback thread event is written, the platform consults the recipient's preferences and queues a notification row. The queue insert is cheap and failures are logged, never surfaced: a share or comment always succeeds regardless of notification state. A thread event reaches the target's owner, the thread's author, and the people it is shared with. Anyone the comment @-mentioned is notified in the mention category instead, so one comment never sends the same person two emails, and mentions are queued first: enqueueing is rate-limited per author, so on a widely-shared item the people addressed by name are the ones that get through. The person who wrote the event is never a recipient of it: the actor is excluded at the enqueue seam every trigger passes through, comparing normalized addresses so an owner or grantee recorded as Display Name <addr> is still recognized as the author. A thread event whose author cannot be resolved queues no general fan-out at all -- it cannot be shown not to be a self-notification -- though addresses the body named explicitly still get their mention. One recipient is dropped for a reason that is not a preference: an API key that configured no email authenticates as a synthetic [email protected] address. That is an identity, not a mailbox, and it becomes the owner of every asset an agent saves under that key, so without the check each comment on an agent-produced asset would queue a message no mail server can accept. The drop happens at the same enqueue seam as the others, so it holds for every category. An API key configured with a real address is a real mailbox and receives mail normally.
  2. A background send worker claims due rows (immediately via Postgres LISTEN/NOTIFY, or on a poll interval), renders a branded HTML email with a plaintext alternative, and delivers it over SMTP. Failed sends retry with exponential backoff before being marked failed; queued rows survive pod restarts and expired delivery leases are reclaimed automatically.
  3. Daily-digest users get one email per day summarizing that window's events instead of one email per event.
  4. One trigger has no human behind it: a scheduled check compares the knowledge review queue against the operator's staleness threshold and queues an alert when it crosses. See Review queue alerts below.

Admin SMTP settings

Admins configure the mail server in the portal under Admin, then Settings, or via the REST API. Settings are stored in the database (platform_settings), so no config file edit or restart is needed. The SMTP password is encrypted at rest with the platform's ENCRYPTION_KEY (the same field encryption used for connection credentials) and is write-only: no API response ever includes it.

Field Description
enabled Master switch for outbound email.
host, port SMTP server address. Port 587 for STARTTLS, 465 for implicit TLS.
username, password SMTP AUTH credentials. The auth mechanism is negotiated automatically from what the server advertises (SCRAM-SHA-1/256, LOGIN, PLAIN, CRAM-MD5, and others). Leave username empty for unauthenticated relays. An empty password on update keeps the stored one.
from, from_name Sender address and optional display name.

Admin Settings: the SMTP card and the review-queue alert cardAdmin Settings: the SMTP card and the review-queue alert card

The Settings page carries both cards: the mail server above, and the review queue alert below it. | tls_mode | starttls (default), implicit, or none (closed-network relays only). |

The read and update responses carry a warnings array describing accepted but hazardous combinations in the stored configuration; the admin UI shows them as a banner above the form. A save is never blocked by one. The only warning today fires when tls_mode: none is stored alongside a username or password: SMTP AUTH then runs over an unencrypted connection and the credential crosses the network in the clear. It is evaluated against the stored settings rather than the request body, so it still fires when the write-only password field was left empty and the previously stored credential was kept.

The Send test action delivers a test email through the stored settings so the configuration can be verified end to end before users depend on it. It requires an enabled, saved configuration; a disabled or unconfigured setup gets a 409 rather than sending around the master switch. A test send deliberately bypasses per-user preference gating (a delivery test should deliver); when the target address has opted out of notification emails, the admin UI shows an informational notice next to the send action so "receives test mail but never notifications" is self-explaining rather than a troubleshooting mystery.

A failed send answers 503 with fixed text that does not vary with the failure mode. The host and port are admin-chosen and deliberately unrestricted, so a reflected dial error would distinguish refused from timed out from TLS handshake failure for any address the server can reach. The underlying error is written to the server log instead, together with the host and port that produced it:

level=ERROR msg="notification: test send failed" [email protected] smtp_host=smtp.example.com smtp_port=587 error="..."
GET  /api/v1/admin/settings/smtp                     read settings (password_set only, never the password)
PUT  /api/v1/admin/settings/smtp                     update settings
POST /api/v1/admin/settings/smtp/test                send a test email  {"to": "[email protected]"}
GET  /api/v1/admin/settings/smtp/recipient-status    opt-out state of an address  [email protected]

Like other admin configuration, writes require database config mode; in file mode the endpoints respond 405.

Review queue alerts

An apply_knowledge review queue that nobody works erodes the knowledge flywheel silently: captures stop becoming shared knowledge and agents keep re-deriving facts nobody promoted. The pending count and its age are already visible to anyone who looks (bulk_review, platform_info, and the portal's Insights tab all report them). This is the push signal for everyone who does not look.

A scheduled check reads the pending queue once an hour through the same lightweight rollup platform_info uses -- one aggregate query, never on a request path -- and queues an alert when the queue crosses the operator's threshold. The alert is a normal notification: it goes through the same preference gate, queue, worker, and branded renderer as every other email, and a daily-mode recipient reads it as one line of their digest.

Admins configure it in the portal under Admin > Settings > Knowledge review (/portal/admin/settings/knowledge-review), or via the REST API:

Field Description
enabled Master switch for the scheduled check.
pending_threshold Alert once this many insights are awaiting review. 0 turns this condition off.
oldest_pending_days Alert once the oldest pending insight reaches this age in days. 0 turns this condition off. Defaults to 30, the same age at which the portal badges an insight stale.
cooldown_hours Minimum gap between two alerts while the queue stays over threshold (1-720, default 24).
recipients The addresses the digest is delivered to (at most 20).
channels The notification channels the alert is also posted to, by name (at most 10). The queue is the post's key, so a queue that crosses again inside a channel's repeat window is counted rather than posted.

Either threshold alone is enough to cross; an empty queue never crosses. The recipient list is explicit rather than derived from roles: role membership arrives with a request from the identity provider, so there is no set of admins the platform can enumerate at check time.

Like the SMTP section, the read and update responses carry a warnings array for a configuration that saves cleanly and delivers nothing -- an enabled alert with no recipients and no channels, or with both thresholds cleared -- and the admin UI shows them as a banner above the form. Neither blocks a save.

GET /api/v1/admin/settings/review-queue-alert    read the threshold, cooldown, recipients and channels
PUT /api/v1/admin/settings/review-queue-alert    update them

channels on a PUT follows one rule on every alert route: absent keeps the stored list, so a client that predates channels does not clear them by saving recipients; [] clears it; a name no channel is stored under is refused with 400. A channel that exists but is disabled is accepted, so enabling it later does not mean naming it again.

The check runs hourly. MCP_PLATFORM_REVIEW_ALERT_INTERVAL (a duration such as 30s) overrides that, for a local stack proved against in seconds; the first check runs one interval after startup.

Re-alert policy. A queue that stays over threshold produces one alert per cooldown window, not one per check. A queue worked back under the threshold clears the marker, so the next crossing alerts immediately rather than serving out a cooldown that belongs to a queue which has since been dealt with. The claim is a single conditional write against a one-row table, so it also makes the alert a cluster-wide singleton: on a multi-replica deployment exactly one replica's check wins a given window.

What the email says. The pending count, the age of the oldest pending insight, how many are past the staleness threshold, and a deep link to the review queue itself (<portal>/knowledge#review) -- the queue, not the tab it lives behind. The figures are the queue as the check saw it, so a digest delivered hours later still reports what actually tripped the threshold. The link opens the review queue for anyone holding apply_knowledge; a recipient without that capability lands on the Insights tab's own-captures view, since the review queue is not a surface they can act on.

Preferences. This category has no per-user toggle: the operator chose the recipient list, so removing an address there is how you stop sending it. A recipient still opts out for themselves with delivery mode off, including through the unsubscribe link the email carries like any other.

Connection revocation alerts

A connection's OAuth credential is authorized once, in a browser, by one person. Everyone else reaches the upstream through it without ever seeing that sign-in. When the upstream rejects the refresh, the platform discards the credential and every call through that connection fails needing reauthorization -- and until this existed, nobody was told. The revocation was written to the connection's auth-event history and shown on its OAuth status card, both of which answer an operator who already knows to ask.

This is the one auth surface where the person holding the credential is deliberately not the person using the connection day to day, which is exactly why that person is not watching the status card. On a scheduled script there is no person at all: the run comes back with an empty or partial answer that reads like data rather than like an outage.

Who is told, and when. The moment the credential is discarded, the platform emails the identity that authorized the connection. The email names the connection and the upstream, says when the refresh failed and what the upstream returned, and links to the Connections page, where reconnecting it restores access.

One email per revocation, not one per rejected call: the revocation is recorded as an open row, and a connection that already has one is not announced again. Authorizing the connection again deletes that row, so a later revocation is news.

Escalation. The premise of the first email is that its recipient may be unreachable -- the reason proactive refresh exists at all. A connection is a tenant asset, so a revocation nobody has acted on after the operator's window is raised with the addresses the operator named. That email says whose authorization lapsed and how long it has been, so the reader knows why it reached them rather than the person who signed in.

Admin Settings: the connection revocation alert cardAdmin Settings: the connection revocation alert card

Admins configure it in the portal under Admin > Settings > Connection revocations (/portal/admin/settings/connection-revocations), or via the REST API:

Field Description
enabled Master switch for both emails. Off, the revocation is still recorded in the auth-event history and shown on the status card, and nobody is told.
escalate_after_hours How long a connection stays revoked before the escalation is raised (1-720, default 24).
recipients The addresses the escalation is delivered to (at most 20). Empty is the default and means no escalation: the person who authorized the connection is still told, and nobody else is.
channels The notification channels a revocation is posted to, by name (at most 10): at once, when the credential is discarded or an assertion is refused, and again when it escalates. The connection is the post's key. With channels named and no recipients, the escalation still goes to the channels.

The recipient list is explicit rather than derived from roles, for the same reason the review-queue list is: role membership arrives with a request from the identity provider, so there is no set of admins the platform can enumerate when the sweep runs.

GET /api/v1/admin/settings/connection-alert    read the window, recipients and channels
PUT /api/v1/admin/settings/connection-alert    update them

The three ways a credential is discarded. The email says which one it was, because they ask the reader for different things. The upstream rejected the refresh (the email carries the RFC 6749 code it returned, such as invalid_grant for a revoked refresh token or invalid_client for a client secret that no longer works); the connection held no refresh token to renew with; or the refresh deadline the upstream had disclosed passed before the renewal. Only the first is a rejection, and the email never reports one that did not happen.

Escalation policy. The sweep stamps each revocation as it escalates it, in the same statement that selects it, so exactly one replica raises a given escalation and it is raised once. It also re-checks the credential table before mailing anyone: a connection that holds a credential again is never escalated, whatever the bookkeeping row says.

A refused jwt_bearer assertion. A connection on oauth_grant: jwt_bearer holds no stored credential: it signs an assertion with a key the upstream registered and exchanges it at the token endpoint. When the endpoint refuses it (invalid_grant, invalid_client or unauthorized_client), every call through the connection fails until the upstream is fixed, which is the same incident, and it is told through the same row and settings with three differences. Nobody signed in to authorize the connection, so the alert goes to the configured recipients at once rather than after the window, and is never escalated; with no recipients configured nothing is recorded, so the first refusal after an operator names recipients is announced. The email says the upstream refused the signed assertion, quotes the upstream's error_description, and names the causes an operator fixes at the upstream (an unapproved key or integration user, or clock skew) instead of asking anyone to reconnect. And the row is cleared by the first exchange the upstream next accepts, on whichever replica makes it, because there is no callback to clear it. See OAuth JWT bearer grant.

Preferences. Like the review-queue alert, this category has no per-user toggle. Its first recipient is addressed by responsibility rather than by interest, and its second was named by the operator. A recipient still opts out for themselves with delivery mode off, including through the unsubscribe link the email carries like any other.

Scheduled run failures

A managed script's scheduled run that fails emails the script's owner: they are accountable for the automation and can fix it. The email names the script, why the run failed, the head of its error and the tail of its log, and links to the run's page. A run somebody started with run_script reports its failure in the answer they are already reading and sends nothing.

A failing automation is one incident, not one email per run. The failed scheduled run that starts it is the one emailed; the scheduled runs that fail while it continues are counted and send nothing, so a script that fails every five minutes overnight sends one email, not dozens. The next run of the script that succeeds, scheduled or started by hand, ends the incident and sends one recovered email: the run that succeeded, when the failing began and how long it lasted, how many runs failed, and the cause, the error line naming the failure and the link of the run that began it. A single failed run followed by a success therefore sends exactly two emails. A canceled run neither starts nor ends an incident; a run failed because its worker was lost counts as a failure.

"Recovered" says the latest run succeeded. It does not say that what the failed runs left undone was redone: that happens only if the script redoes it, and the email says so. The incident is kept in the database (script_failure_incidents), so it survives a restart and a failure on one replica pairs with the recovery on another. It agrees with the session-start notice about which automations are failing (#1934): both end a failure at the next succeeded run.

An operations team that watches a chat channel rather than each owner's inbox names it here, once for every script:

Field Description
channels The notification channels a failing automation is posted to, by name (at most 10), beside the owner's email: the failed run that starts an incident, and the run that recovers it. The script is the failure post's key, and the recovery is posted under its own key, so a failure's repeat window never holds back the recovery that ends it.
GET /api/v1/admin/settings/script-run-alert    read the channels
PUT /api/v1/admin/settings/script-run-alert    set them

In the portal it is Admin > Settings > Run failures (/portal/admin/settings/run-failures). The post is authorized as the platform, as the email is: naming the channel here is the authorization, and no persona is consulted.

Leveled alerts: webhooks and storage

Two alerts judge a condition at a warning and a critical level and post to channels only: the webhook receiver's conditions (#2149, Admin > Settings > Webhooks, /api/v1/admin/settings/webhook-alert, see Inbound Webhooks) and storage filling up (#2148, Admin > Settings > Storage, /api/v1/admin/settings/storage, see Storage Capacity). A condition is posted every evaluation under a key carrying its level and when that level began, <condition key>:<warning|critical>:<unix time>, so the channel's repeat window holds back the repeats of one episode, while a move to critical, or a condition that clears and comes back, posts at once. A condition that was alerting and has cleared is posted once more under <condition key>:resolved:<unix time>. A store or replica a pass could not read keeps the condition it had rather than reading as resolved. What was alerting is kept in the database (alert_conditions), so a restart, or the next evaluation running on another replica, still sends the resolution.

Notification channels

Admin Settings: the notification channels sectionAdmin Settings: the notification channels section

A channel is an operator-configured destination that is not one person: a Slack channel, a Mattermost channel, an incoming webhook, or a named list of email addresses. A managed script posts what it found to one, a person in a session asks the agent to send to one, and the portal offers the same action on an asset.

Channels are configured in the portal under Admin > Settings > Channels (/portal/admin/settings/channels), or through the REST routes below. Nothing about a channel lives in YAML.

Kinds

Kind Delivers through Needs Carries
slack chat.postMessage, with files through files.getUploadURLExternal and files.completeUploadExternal an api connection with base_url: https://slack.com/api, auth_mode: bearer and the bot token as its credential; the target channel id; the bot invited to that channel a title, a markdown body Slack renders, up to 5 files in the message's thread, and a link
mattermost POST /api/v4/files, then POST /api/v4/posts an api connection holding the bot token, and the target channel id a title, a markdown body, up to 5 files, and a link
webhook one POST to the connection's base URL: {"text": ...} (format: text, the default), or a JSON envelope (format: json) an api connection whose base URL is the webhook URL, with any secret part as its path_secret text: a title and a text body, each attachment and the link appended as a line. json: every field, the body whole, the attachments by link, and the sender's data
email the deployment's mail server one to 20 recipient addresses a subject, the body rendered from markdown, up to 5 files as attachments, and a link button, in the branded template

A channel holds no credential

The two HTTP kinds name an api connection rather than storing a token. That is the whole security model of a channel:

  • The bot token is the connection's credential, encrypted at rest by the platform's field encryptor like every other upstream credential, and rotated in one place.
  • The outbound client is the connection's, with its TLS material, its timeouts and the platform's User-Agent.
  • Who may send to a channel is whoever may reach its connection. A persona denied the connection is denied the channel: it is not listed to them and cannot be sent to. Restricting a channel to fewer people means giving it its own connection. No new persona dimension was added.

An email channel names no connection, so there is no upstream authorization to inherit, and it is reachable by anyone who can send at all - as the built-in portal destination is reachable by every script.

What is sent

A document: a title, a markdown body, an optional link back to whatever produced it, and up to five attachments. Each kind shows as much of it as it can and falls back to an excerpt plus the link past its own cap, so a long report always arrives as a summary and a pointer rather than as a wall or a truncation.

Kind The body Files Cap
email rendered to HTML (headings, tables, lists, code, links, strikethrough) in the branded template; the plain-text part carries the markdown as written attached as MIME parts the queue's 256 KiB
slack a header block with the title, a markdown block Slack renders into its own headings, tables, lists and code, and a context block with the link uploaded, then shared into the message's thread 12,000 bytes of markdown; a 150-character header
mattermost the title as a heading, then the markdown, which Mattermost renders uploaded to the channel and attached to the post 16,000 bytes
webhook (text) the title and the markdown as text each named on its own line with its link 12,000 bytes
webhook (json) the body whole in the envelope named with their links in attachments the queue's 256 KiB

A cut ends on a paragraph boundary where one is near, and the attachments named by link and the document's own link always survive it: they are added after the cut, not before.

The email body is rendered by goldmark with its default renderer, which writes no raw HTML from the source (an HTML tag in a sender's markdown arrives as an omission comment) and drops a javascript: link, so nothing a sender writes becomes markup of its own.

Attachments

An attachment names a portal asset or a managed resource. A queue row carries the reference and never the bytes; the worker reads the file when it delivers:

  • An asset is sent at the version it had when the message was queued, so editing the asset afterwards does not change what a queued message sends.
  • A resource is sent as it is when the message is delivered.
  • A preview is an asset's light tile image. notify action=publish attaches it on its own when the asset has one, so a chat reader sees what the report looks like before opening it.

Who may attach a file is decided when the message is queued, against the sender: an asset they can read, a resource they can read. The worker acts for nobody and checks nothing, which is why the check cannot wait for it. A file over 8 MiB, or one that can no longer be read (deleted since it was queued), is sent as its link instead; it does not fail the message.

Repeating alerts: key

A monitor whose condition stays true would post on every schedule tick, which is what gets a channel muted. A send that names a key is held back when the same key was posted to the same channel inside the channel's repeat_after window (default 1 hour): it is counted on the key and not queued, and the sender is told so (suppressed: true). The next send with the key after the window goes through, opening with a line that says how many repeats were not posted and since when:

4 repeats of this alert since 2026-10-10 09:00 UTC were not posted.

A send may pass repeat_after to lengthen the window for its key ("6h"); a value shorter than the channel's own is ignored, so a sender can make an alert quieter, never louder. The key, the count and the window are decided by the database in the transaction that writes the message, under a per-channel lock, so two replicas sending the same alert at once post it once. A key's record is purged with the queue's retention, once its window has long ended.

A daily channel's posts are not keyed: a day's posts are already one bulletin.

Hourly cap

Every channel has a max_per_hour (default 60), counted in sends across every replica: an email channel's send is one send however many recipients it fans out to. A send past the cap is refused, and the refusal names the cap; nothing is queued and the history records nothing. A send held back by its key does not count.

Daily channels

A channel with mode: daily delivers once a day, at the deployment's notifications.digest_hour_utc. A chat or text webhook channel posts the day's documents as one bulletin: each document's title, its first paragraph and its link, titled <channel>: <n> updates today. Attachments are not carried in a bulletin; each item's link is where its files are. A json webhook still receives each document as its own envelope, because a system wants each event. An email channel's recipients get the day's documents in the branded digest, each as its first paragraph and link.

Every send is an ordinary row in the same queue every email uses: claimed by the same worker, retried on the same backoff, purged on the same retention, and visible in the same delivery history. There is no synchronous send path, so an upstream outage is a retry rather than a failed script.

An email channel fans out at enqueue to one row per recipient, so a person on an operator's list keeps everything a person has: their own delivery mode, their own digest window, and the unsubscribe link in the footer. One person opting out silences nothing for anybody else.

A failure that retrying cannot fix - a deleted channel, a revoked bot token, a chat channel the bot was never invited to - fails the row at once rather than five times, and the upstream's own words are recorded on it.

A webhook for a system: format: json

A webhook channel with format: json posts one envelope per notification, for a system that routes on fields rather than a person reading a chat client:

{
  "id": "ntf_48213",
  "type": "script.finding",
  "occurred_at": "2026-09-30T14:02:11Z",
  "deployment": "ACME Data Platform",
  "channel": "ops-events",
  "title": "Restock delayed for 12 stores at reorder point",
  "body": "markdown body as sent",
  "link": "https://platform.example.com/portal/automations/.../runs/...",
  "source": {"kind": "script", "script": "mcp:script:...", "run_id": "..."},
  "data": {"stores": [3, 7, 12]}
}
  • id is the queue row's, the same on every retry of that row, so a receiver deduplicates on it. A test send has an id of its own each time.
  • type is what produced it: script.finding (a script's platform.notify), script.published (platform.publish), notify.sent and notify.published (a person's notify call), or test (Send test). A receiver routes on it.
  • source is a script's reference and run, or {"kind": "user"}.
  • data is the sender's structured payload, verbatim: notify action=send data=... or platform.notify(..., data=...), JSON of at most 64 KiB. A text channel, a Mattermost channel and an email channel ignore it.
  • deployment is the portal's brand name.

Signing comes from the connection. The channel holds no secret: with auth_mode: hmac on its connection, every delivery is signed, and with hmac_id_header set the envelope's id is sent in that header too, covered by the signature. Send test delivers a test envelope through the same path, so a receiver's signature check is proved from the editor.

A receiver verifies the signature over the raw body, drops an id it has seen, and routes on type. For a connection with hmac_preset: platform:

import hashlib, hmac, json, time

def receive(headers, raw_body, secret, seen):
    ts = headers["X-Timestamp"]
    if abs(time.time() - int(ts)) > 300:
        return 401
    want = "sha256=" + hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(want, headers["X-Signature"]):
        return 401
    event = json.loads(raw_body)
    if event["id"] in seen:
        return 200
    seen.add(event["id"])
    if event["type"] == "script.finding":
        open_ticket(event["title"], event["data"])
    return 202

Another deployment's inbound webhook source receives the envelope as a table when it is configured with event_id_path: $.id and event_type_path: $.type: a retried delivery is one event after compaction.

What the upstream answers. Any 2xx is delivered. A 429 or a 503 is retried, after the Retry-After it sends when it sends one, up to the queue's longest wait (32 minutes). A 410 Gone fails the row at once, recording that the receiver asked for no more deliveries. Any other 4xx fails at once with an excerpt of the upstream's answer.

Sending

Surface How
A person in a session the notify tool: action=list, then action=send (with optional data, attachments, key and repeat_after) or action=publish (with optional key and repeat_after)
A managed script platform.notify(channel, title, body="", link="", data=None, key="", repeat_after="", attachments=None) and platform.publish(channel, name, message="", key="", repeat_after="")
A person in the portal Post to a channel on an asset's page: the channel, an optional message, and the asset posted as notify action=publish would post it
An administrator Send test on the channel's editor, which delivers immediately and reports what the upstream said
The platform the review-queue, connection revocation and scheduled run failure alerts, to the channels each names

attachments takes a list whose entries are an asset id, an asset's portal URL, a resource id or mcp:resource: reference, or an object naming asset_id or resource_id with an optional name; the two forms may be mixed. A string that could be either an asset or a resource id is tried as an asset first.

notify is an ordinary tool, so persona allow lists, the per-session rate limit and audit apply to it unchanged. A script's send is the run's principal, holding the roles its version author held, and is refused in a draft run without allow_writes - a message in somebody else's chat client is not something a rehearsal may leave behind. A script's post links back to the run that produced it unless the script names its own link, and the run's page lists every post the run made, with whether it was delivered.

Asset page: Post to a channelAsset page: Post to a channel

The portal's Post to a channel is the same tool call: the portal opens an in-process session as the signed-in person and calls notify, so the channels offered, the persona rules, the rate limit and the audit row are the tool's.

REST

Route Does
GET /api/v1/admin/notification-channels list every channel, and the kinds this deployment delivers to
GET /api/v1/admin/notification-channels/{name} read one
PUT /api/v1/admin/notification-channels/{name} create or replace one
DELETE /api/v1/admin/notification-channels/{name} remove one
POST /api/v1/admin/notification-channels/{name}/test deliver a test through the channel's own transport
GET /api/v1/admin/notification-channels/{name}/status what the channel has done recently: when it last delivered, the last failure still recorded against it, its sends in the last hour against its cap, what is waiting, and the repeats its keys are holding back

Every route is admin-only and audited. The write routes answer 405 in file config mode, as every other admin configuration surface does. The settings page shows each channel's status under its name.

The portal routes are the signed-in person's, and each is a notify call made as them:

Route Does
GET /api/v1/portal/channels the channels the person can post to
POST /api/v1/portal/channels/{name}/publish post an asset ({"asset_id": "...", "message": "..."})
POST /api/v1/portal/channels/{name}/send post a message ({"title": "...", "body": "...", "link": "..."})
GET /api/v1/portal/scripts/{id}/runs/{runID}/posts the posts one run made, each with its channel, title and status (sent, failed, queued); readable by the script's owner, an administrator and whoever requested the run

A refusal from the tool answers 400 in the tool's own words, and its per-session rate limit answers 429.

Slack

The bot needs chat:write, and files:write for attachments, and must be invited to the channel (/invite @<bot>). Slack answers a refusal with HTTP 200 and ok: false, so the platform reads that envelope: ratelimited, internal_error, service_unavailable, fatal_error and request_timeout retry, and every other code fails the message at once with the next step an operator takes (not_in_channel says to invite the bot, missing_scope names the scope, a revoked token says to reinstall the app). An answer that is not a Web API envelope means the connection's base_url is not https://slack.com/api.

A message's files are uploaded before it is posted and shared into its thread after, so they sit with the message they belong to. The bytes go only to an upload address on the connection's own domain (files.slack.com for slack.com). A share that fails after the message was posted fails the delivery saying so, rather than retrying and posting the message twice.

Local development

make dev runs both upstreams a channel can be exercised against:

  • mailpit on http://localhost:8025 - every email the dev stack sends lands there, and the stack points SMTP at it automatically.
  • mattermost on http://localhost:8065 ([email protected] / Dev-Password-1) - seeded with a team, an ops-alerts channel and a token, and registered as the mattermost-dev api connection with an ops-alerts channel over it. An ops-email channel is registered too.
  • Slack, when dev/.slack-env (gitignored) holds SLACK_TEST_BOT_TOKEN and SLACK_TEST_CHANNEL_ID for a workspace of your own: the stack registers a slack-dev connection and channel over it. No Slack server runs locally, so without the file there is no Slack channel.

User notification preferences

Each user manages their own preferences in the portal under Settings (user section, not admin), or via the self-scoped REST API. A user can only ever read or write their own preferences.

  • Delivery mode: immediate (one email per event, the default), daily (one digest email per day), or off.
  • Category toggles: shares, comments/feedback, and mentions, each individually switchable. Review queue alerts have no toggle here; see Review queue alerts.

Users with no stored preferences get the defaults: immediate delivery with all categories enabled. Turning notifications off drops events at enqueue time; nothing is queued.

Settings: delivery mode, category toggles, and recent notificationsSettings: delivery mode, category toggles, and recent notifications

The same screen carries the delivery history described below, so a user can see what the platform actually sent them next to the switches that decided it.

GET /api/v1/portal/notification-prefs
PUT /api/v1/portal/notification-prefs   {"mode": "daily", "shares_enabled": true, "comments_enabled": false}

Both responses carry delivery_available, a read-only boolean derived from the stored SMTP settings: false when SMTP has never been configured, when it is disabled, or when its host is empty. It exposes no SMTP detail, so it is safe for a non-admin caller, and it is the signal the Settings page uses to render the section inert rather than offering live controls over a preference nothing can act on. Stored preferences are untouched while delivery is unavailable; they take effect as soon as an admin configures SMTP. Admins see the same note with a link into Admin > Settings > Email, and that section itself states the consequence of leaving delivery off: triggers keep queueing rows, and those rows expire undelivered after 7 days.

Preferences are keyed by bare email address, so they also apply to share recipients who have no platform account. Because such a recipient cannot reach the Settings page, every notification email carries an unsubscribe footer link that works without signing in: it opens GET /portal/notifications/unsubscribe?tok=..., which verifies an HMAC token bound to the recipient address and renders a confirmation page with a single Unsubscribe button. The GET itself records nothing: corporate mail security layers (Safe Links, Proofpoint, and similar) prefetch URLs in message bodies, and since the token is a bearer credential a mutating GET would let a recipient's own mail infrastructure silently opt them out. Confirming submits a form POST to the same URL, which records delivery mode off for the address. The token is minted with a key derived from the browser-session signing key, so only a holder of the emailed link can opt an address out. Opting out stops notification emails only; one-time view links requested from a share's landing page are transactional (the recipient asks for each one) and still send.

The same token URL is emitted as an RFC 8058 one-click unsubscribe header pair (List-Unsubscribe and List-Unsubscribe-Post) on every message that carries the footer link, which Gmail and Yahoo require of bulk senders. A mail provider acting on the header sends POST to the same endpoint with body List-Unsubscribe=One-Click; the server records the opt-out and returns a bare status with no page. Providers fire this only on a real user action in their own UI, so it is not exposed to scanner prefetch. Transactional sends (one-time guest links, admin SMTP tests) carry neither the footer nor the headers.

An opted-out share recipient keeps a way back in: when the recipient of an email share has delivery mode off, the share's landing page shows a notice with a Resume notification emails action. The opt-back-in is a deliberate POST /portal/view/{token}/resubscribe (same no-mutation-on-GET rule as the unsubscribe endpoint it reverses), answers uniformly for every share state, is rate limited alongside the other public share routes, and restores the immediate-delivery default for the share's stored recipient address.

Sharer control over the share email

A share addressed to a person notifies its recipient by default. The sharer can change two things about that email at the moment they share, from the share dialog or the API:

  • notify (*bool, omitted means notify): false shares quietly. No row is queued and no email is sent; the share itself is created exactly as it would be otherwise. The recipient's own preferences still apply when notification is on, so this only removes the sharer's ability to force one.
  • message (optional, 500 characters): a plain-text note from the sharer, rendered in the email as a quoted block attributed to them. It is never persisted: it travels with the one notification the share produces and is stored nowhere, so a share created with notify: false carries no note anywhere.

The note is plain text and is checked as such at validation time: markup and links are rejected with a 400 rather than escaped and delivered. Escaping alone would stop a note from rendering as markup, but a plausible-looking link inside a trusted platform email is a phishing vector however it is encoded. Rendering escapes as well, so the two defenses are independent.

POST /api/v1/portal/assets/{id}/shares
{"shared_with_email": "[email protected]", "notify": false}

POST /api/v1/portal/assets/{id}/shares
{"shared_with_email": "[email protected]",
 "message": "Here's the Q3 revenue breakdown you asked about"}

Recipient addresses are accepted in both the bare form and the Example User <[email protected]> form mail clients put on the clipboard; only the bare address is stored, lowercased. A value that names no single routable address is refused with a 400 instead of being stored raw, which previously produced a share matching no signed-in user and a notification addressed to a string no mail server would route. The share dialog applies the same rule as the field loses focus, so what the sharer sees is what will be stored and mailed.

A share addressed to a person carries no expiration: it grants that person access until the owner revokes it. expires_in belongs to a public link alone -- there the URL is the credential, and a bounded life limits what a forwarded copy is worth -- so it is required for access_mode: public and refused everywhere else, rather than silently resolved either way.

Delivery history

Both the admin monitoring tab and each user's own notification screen read the queue's delivery history. Both are bounded by the retention pass below: they show recent history, not an archive, and both state the effective window.

Admin (Dashboard > Notifications) lists every queue row with its recipient, category, subject, status, attempt count, and -- on drill-in -- the error the mail server returned. Counts by status sit above the list as an at-a-glance health read, and each count doubles as a filter. The routes sit behind the admin persona gate:

GET /api/v1/admin/notifications?status=failed&[email protected]
GET /api/v1/admin/notifications?channel=ops-alerts
GET /api/v1/admin/notifications/stats

A row sent to a notification channel names its channel in the list, and its drill-in says how many repeats its key held back before it went through.

Dashboard: the Notifications tabDashboard: the Notifications tab

Users (Settings > Recent notifications) see the notifications addressed to them, alongside the preferences that govern them, because the two answer one question together: what should I be told, and what was I actually told. The endpoint is self-scoped server-side -- the authenticated caller's address is the only recipient it queries, and there is no parameter to widen it:

GET /api/v1/portal/notifications

The user view deliberately omits the delivery error text the admin view carries. A failed send fails for reasons belonging to the platform's mail infrastructure (host names, credentials, relay refusals), which the recipient can act on none of; the status alone tells them whether to expect an email.

Branded emails

Emails are responsive, table-based HTML (broad email-client compatibility) with a plaintext alternative part. They carry the deployment's brand name linked to the portal, the implementor footer when configured, deep links to the shared or discussed item (portal.public_base_url must be set for links to render), and a link to the recipient's notification preferences. When portal.terms_url or portal.privacy_url is set, the footer also renders the corresponding legal link; useful when the portal runs on a different domain than the mail From address, since it gives recipients and content filters body links that associate with the sending identity. When portal.about_text or portal.support_contact is set, a small help/about block renders below the legal links in both the HTML and plain text parts of every mail type (an email support contact links as mailto:, an http(s) URL links directly): it gives first-contact recipients context about the sender and lifts short image-bearing messages out of the low-text band content filters penalize. When portal.reply_to is set, every outgoing message carries it as the Reply-To header so recipient replies reach a monitored mailbox instead of bouncing off a no-reply From address. Like the rest of the email branding these are YAML config, owned by the implementor rather than the runtime admin settings, so fully managed deployments keep them out of admin hands. Each message's Message-ID domain is taken from the configured From address rather than the server hostname, so IDs resolve to the sending domain in containerized deployments. Share links for assets and collections use the token viewer; prompt shares link to the in-app prompt page. The emailed link is not a bearer credential: a share addressed to a person is restricted to that person, so the viewer resolves it only once the recipient is signed in (or opens a one-time guest link requested from the share's landing page), and forwarding the message grants nothing.

One-time guest link emails are a separate, transactional send: rendered through the same branding but delivered directly rather than queued, so they are never deferred into a daily digest and are not gated on notification preferences. They exist only because the recipient pressed the request button on the share page.

Configuration

Notifications are enabled by default whenever a database is configured. The YAML section controls only the enqueue/delivery machinery; the SMTP connection itself is admin-configured at runtime as described above.

notifications:
  enabled: false        # opt out of email notifications entirely
  digest_hour_utc: 13   # UTC hour (0-23) daily digests are sent (default: 13)

Delivery semantics

  • Immediate rows are picked up as soon as they are queued (LISTEN/NOTIFY) or within the worker's 30 second poll fallback.
  • A delivery attempt is bounded by a 2 minute lease; if a worker dies mid-send, the row returns to claimable state and another replica picks it up.
  • Failed sends retry up to 5 times with exponential backoff (30s doubling to a 32 minute cap), then are marked failed with the error recorded on the row.
  • When SMTP is unconfigured or disabled, queued rows simply wait without burning retry attempts; configuring SMTP later delivers the recent backlog.
  • Retention bounds the queue table: delivered and failed rows are purged after 30 days, and undelivered rows older than 7 days are dropped as stale (so enabling SMTP months into a deployment does not deliver an ancient backlog).
  • A per-actor rate limit (burst of 30, 6 per minute sustained, per replica) bounds how much outbound email one account can generate; excess events are dropped with a log line, never a request failure. An alert the platform raises is charged to no actor: its channel's hourly cap and repeat window bound it, so the repeats a leveled alert sends every pass can never use up a budget and drop the resolution that follows them.