Skip to content

AI Notifications

The assistant can write first. A platform event — a margin call, a verification step, a closed trade — becomes a short text addressed to the client or to the manager.

This is the only place where the module produces a message nobody asked for, so the path is deliberately narrow.

The path of one event

flowchart LR
    e["Platform event"] --> b["Bindings<br/>event + brand"]
    b --> p["Rules:<br/>filters, dedupe, cooldown, daily cap, quiet hours"]
    p --> v["Re-read the state<br/>(margin events)"]
    v --> g["Generate the text"]
    g --> q{"Recipient"}
    q -->|client| a["Approval queue"]
    q -->|manager| d["Delivery"]
    a -->|manager approves| d

Two decisions from that picture matter to the interface:

Client messages wait for a person. A generated text in a brokerage product turns into investment advice too easily, so by default it is parked in awaiting_approval. A manager reads it, edits it if needed, and only then it is delivered. Manager-addressed messages go out directly.

A stale event is not sent. Before a margin message reaches a client, the module re-reads the account. If the level recovered, the notification is suppressed rather than delivered — the event queue on the platform side can lose transitions, so the payload alone is not trusted.

The approval screen

One screen is enough: the awaiting_approval queue with the text, the recipient and the reason. Everything else is a journal.

MngGetAINotificationsByFilter returns rows plus the current state of the delivery channels:

{
  "rows": [
    {
      "id": "ntf_7c1…",
      "eventName": "account.margin_state",
      "recipientType": "account",
      "recipientId": 2000067,
      "state": "awaiting_approval",
      "content": "The margin level on your account is low…",
      "suppressReason": null,
      "variables": { "login": 2000067, "margin_level": 95.4 },
      "delivery": null,
      "approvedBy": null,
      "approvedAt": null,
      "sentAt": null,
      "createdAt": "2026-09-17 10:12:04"
    }
  ],
  "count": 1,
  "channels": [
    { "channel": "notify", "implemented": true, "requires": "MngAddNotify", "available": true },
    { "channel": "email", "implemented": false, "requires": null, "available": false },
    { "channel": "sms", "implemented": false, "requires": null, "available": false }
  ]
}
State Meaning
pending generated, not yet delivered — usually a channel is unavailable
awaiting_approval waiting for a manager
approved a manager approved it; delivery has not succeeded yet
sent delivered to at least one channel
suppressed a rule stopped it, or the state changed; suppressReason says which
failed generation failed
rejected a manager rejected it

Editing the text is the main scenario, not an extra. MngApproveAINotification takes an optional content that replaces the generated text. The assistant proposes wording; the person who approves it owns it.

Rejections keep the text. It shows what the assistant wanted to write and why it was refused — the same material that improves prompts. Make reason a required field in the interface even though the method allows it to be empty.

Why a notification never appeared

Suppressed events are not written to the journal — only counted. A high-volume event like trade.add would otherwise flood the table with "not sent, filter" rows and bury the real ones.

To explain a specific case, use the dry run MngTestAIWorkflowBinding: it takes a sample payload and shows, for every binding, whether it would fire, the reason if not, the recipient, whether approval is required, and the prompt that would go to the model. It sends nothing and writes nothing.

Reasons a binding stays silent:

reason Meaning
filter a binding filter did not match the payload
duplicate the same event within the deduplication window
cooldown this recipient was messaged by this binding recently
recipient_limit the daily cap for this recipient is reached, across all bindings
quiet_hours the configured quiet hours (service time, UTC)
no_recipient the payload has no valid recipient id

Delivery channels

The module writes the text; existing services deliver it:

Channel Delivered by Note
notify platform MngAddNotify in-terminal and backoffice bell
email Mailer module not wired yet
sms SMS module not wired yet
manager a task for the responsible manager instead of messaging the client

A channel whose command is not published on the bus does not break the scenario: the notification stays in the journal marked command_unavailable, and MngRetryAINotification delivers it once the command appears. Nothing is regenerated.

When the model is unavailable

A binding can carry settings.fallbackText — a ready sentence with the same event placeholders. If the provider is down, the notification is sent with that text instead of being lost. The journal marks it, so the wording is not mistaken for the model's.

Without a fallback text the notification is recorded as failed: there is nothing to send, but the reason is visible.