Notification Policy

Decide whether and what to notify per recipient — categories, quiet hours, detail levels

Notification policy lets you decide, per recipient, what the gateway may notify you about — filter by category, honour quiet hours in the recipient’s own timezone, and redact content before it leaves the gateway. Set it once in gateway.yaml and every scheduled agent result is gated for you.

Diagram

Notification Policy

send=false

send=true

📩 Event
category + content

🧠 evaluate

⚙️ Preference
categories + quiet + detail

🔕 Skip

✂️ Redact

📱 Deliver

Quick Start

  1. Turn it on with one YAML block

    Add gateway.notifications to your gateway.yaml. Scheduled agent results are now gated automatically.

    yaml
    agents:
      ops-agent:
        instructions: "Run the overnight sales report."
    
    gateway:
      notifications:
        defaults:
          detail: identified
          categories: [approval_requested]

    With these defaults a scheduled ops-agent result (agent_finished) is not opted in, so it is suppressed.

  2. Set a per-recipient preference

    Key a recipient by platform:channel_id and override any default.

    yaml
    gateway:
      notifications:
        defaults:
          detail: identified
          categories: [approval_requested]
        recipients:
          "telegram:12345":
            categories: [approval_requested, agent_finished]
            quiet_hours: "22:00-07:30"
            timezone: "Europe/London"
            detail: detailed

    telegram:12345 now receives full scheduled results, except between 22:00 and 07:30 London time.

  3. Validate and hot-reload

    A bad entry fails at startup, not at send time:

    yaml
    gateway:
      notifications:
        recipients:
          "telegram:12345":
            timezone: "Europe/Londn"   # ValueError: unknown notification timezone: 'Europe/Londn'

    Edit gateway.yaml and reload. The new policy applies without a restart:

    bash
    kill -HUP $(pgrep -f "praisonai gateway")
    # or
    systemctl reload praisonai-gateway

How Categorised Sends Flow Through the Gateway

Every proactive send that carries a category passes through the gate before the idempotency check and the channel.

Diagram
ChannelNotificationGateDeliveryRouterSchedulerChannelNotificationGateDeliveryRouterScheduleralt[send=false (category not opted in, or quiet hours)][send=true]deliver(target, text, category=”agent_finished”, identity=session_id)admit(“telegram:12345”, “agent_finished”, text, identity)evaluate(category, prefs, now=UTC, content, identity)NotificationDecision(send=false, reason)True (handled — no retry)NotificationDecision(send=true, redacted_content)send(redacted_content)
  • Suppressed sends count as handled — deliver() returns True, so the scheduler and outbox do not retry, and no idempotency key is claimed.
  • Uncategorised sends are never gated — direct replies, hook replies and restart notices pass straight through.
  • Quiet hours use the recipient’s timezone — the gate passes an aware UTC now, so the window is checked in the recipient’s timezone, not the gateway host’s clock.
  • No notifications block, no gating — behaviour is unchanged when the block is absent.

What Gets Categorised Automatically

Send pathCategoryIdentity
Scheduled agent resultsagent_finishedsession id
Direct repliesnot categorised — not gated—
Hook repliesnot categorised — not gated—
Restart / recovery noticesnot categorised — not gated—
send_message toolnot categorised — not gated—

Where Recipients Come From

A recipient key is the resolved platform:channel_id of the delivery target — the same pair the gateway logs on each proactive send (Delivered scheduled result to telegram:12345, or notification suppressed for telegram:12345 when gated). A recipient with no entry falls back to defaults, and anything missing from defaults falls back to DEFAULT_PREFERENCE.


The Three Concerns

Categories

A recipient opts into each event class independently; an un-opted category is suppressed with reason category_not_subscribed.

yaml
gateway:
  notifications:
    recipients:
      "telegram:12345":
        categories: [approval_requested, agent_finished]
Diagram

yes

no

Event category

allows?

send

skip: category_not_subscribed

YAML valuePython enumMeaning
approval_requestedAPPROVAL_REQUESTEDAgent awaits your approval to proceed
agent_finishedAGENT_FINISHEDAgent completed its run
agent_questionAGENT_QUESTIONAgent needs an answer to continue
human_mentionedHUMAN_MENTIONEDYou were mentioned in a session
scheduled_task_failedSCHEDULED_TASK_FAILEDA scheduled task failed
background_task_failedBACKGROUND_TASK_FAILEDA background job failed

Quiet Hours

A per-recipient local window suppresses non-urgent categories; urgent events override when urgent_overrides_quiet is true.

yaml
gateway:
  notifications:
    recipients:
      "telegram:12345":
        quiet_hours: "22:00-07:30"
        timezone: "Europe/London"
Diagram

no

yes

yes

no

now, timezone

in quiet window?

send

urgent + override?

skip: quiet_hours

The window is half-open [start, end). When start == end the window is empty (never quiet). When start > end it wraps past midnight — (22:00, 07:30) means 22:00–24:00 ∪ 00:00–07:30. A naive now is treated as already local; an aware now is converted to prefs.timezone via stdlib zoneinfo. When calling evaluate() directly, an unknown timezone name is ignored silently and now is left unconverted (so an aware UTC now is checked in UTC); the gateway rejects it at config load instead.

Detail Levels

Three levels control how much content leaves the gateway.

yaml
gateway:
  notifications:
    recipients:
      "telegram:12345":
        detail: private
Diagram

detailed

identified

private

content + identity

detail?

content verbatim

Activity in {identity}

You have new activity

LevelOutput
detailedcontent verbatim (or "You have new activity" when content is None)
identified"Activity in {identity}" (or "You have new activity" when identity is None)
privateAlways "You have new activity"; content and identity ignored

Which Detail Level Should I Pick?

Match the level to how trusted the destination surface is.

Diagram

Where does
this land?

📱 Mobile lock-screen

💬 Shared team channel

🖥️ Trusted desktop

detail: private

detail: identified

detail: detailed


YAML Reference

All keys live under gateway.notifications.

yaml
gateway:
  notifications:
    defaults:
      detail: identified
      categories: [approval_requested]
    recipients:
      "telegram:12345":
        categories: [approval_requested, agent_finished]
        quiet_hours: "22:00-07:30"
        timezone: "Europe/London"
        detail: detailed
KeyTypeDefaultDescription
defaultsobjectDEFAULT_PREFERENCEPreference applied to any recipient without its own entry
recipientsmap{}Per-recipient preferences keyed by platform:channel_id (e.g. "telegram:12345", "slack:C01ABC")
<entry>.categorieslist of stringsinheritedOpted-in categories: approval_requested, agent_finished, agent_question, human_mentioned, scheduled_task_failed, background_task_failed. Unknown name → startup error
<entry>.quiet_hours"HH:MM-HH:MM" or [start, end]inheritedQuiet window; wraps past midnight when start > end. null clears an inherited window
<entry>.timezonestring (IANA)inheritede.g. "Europe/London". Unknown zone → startup error (no silent UTC fallback)
<entry>.detailstringinheritedprivate, identified or detailed
<entry>.urgent_overrides_quietboolinherited (true)Let urgent events through quiet hours. Gateway sends are not marked urgent today

<entry> is defaults or any recipients entry. A key missing from a recipient entry inherits from defaults; a key missing from defaults inherits from DEFAULT_PREFERENCE.


Validation and Hot-Reload

  • Fails at startup — loading gateway.yaml builds the gate, so an unknown category or detail, a malformed quiet_hours, or an unknown timezone raises before the gateway serves traffic.
  • Published schema — praisonai gateway schema prints the JSON schema, including the notifications block, for editor validation.
  • Hot-reload — on file change or SIGHUP, the gateway applies the new notifications block to the live delivery router. Removing the block clears gating. See Gateway Hot Reload.

Under the Hood: evaluate()

The gate wraps one pure function, which you can also call from your own notifier before delivery; the decision carries the send flag and the already-redacted content.

Diagram
DeliveryevaluateNotifierDeliveryevaluateNotifierskip (reason)alt[send == true][send == false]evaluate(category, prefs, now, is_urgent, content, identity)NotificationDecision(send, reason, redacted_content)deliver(redacted_content)

evaluate() is pure and side-effect-free, so any process can call it at delivery-decision time without I/O. Pass prefs=None to fall back to DEFAULT_PREFERENCE; pass now=None to use the host’s naive local clock (prefs.timezone is not applied). To honour the recipient’s timezone, pass an aware now such as datetime.now(timezone.utc), as the gateway does.


Python Reference

NotificationPreference

OptionTypeDefaultDescription
categoriesSet[NotificationCategory]set()Opted-in categories; a category not present is suppressed
quiet_hoursOptional[Tuple[time, time]]NoneLocal (start, end) window suppressing non-urgent categories; None disables. Wraps past midnight when start > end
timezonestr"UTC"IANA name used to localise now for the quiet-hours test
detailDetailLevelDetailLevel.IDENTIFIEDRedaction level applied to notification content
urgent_overrides_quietboolTrueWhen True, an urgent event is delivered even inside quiet hours

Also exposes .allows(category) → bool, whether this recipient opted into category.

NotificationDecision

OptionTypeDefaultDescription
sendbool—Whether the notification should be delivered
reasonstr—One of "ok", "urgent_override", "quiet_hours", "category_not_subscribed"
redacted_contentOptional[str]NoneContent to push per prefs.detail; None when send is False

evaluate(...)

ArgumentTypeDefaultDescription
categoryNotificationCategory—The event class being considered
prefsOptional[NotificationPreference]NoneRecipient preference; DEFAULT_PREFERENCE when None
nowOptional[datetime]NoneEvaluation moment. Aware → converted to prefs.timezone; naive → treated as already local; None → naive datetime.now() (host clock)
is_urgentboolFalseWhether this event may override quiet hours
contentOptional[str]NoneFull message content, redacted per prefs.detail
identityOptional[str]NoneAgent/session label used at the identified level

DEFAULT_PREFERENCE

Applied when a recipient has set nothing: the two interaction-blocking categories are on, at the identified (no-content) detail level, with no quiet hours.

python
DEFAULT_PREFERENCE = NotificationPreference(
    categories={
        NotificationCategory.APPROVAL_REQUESTED,
        NotificationCategory.AGENT_QUESTION,
    },
    detail=DetailLevel.IDENTIFIED,
)

Common Patterns

Mute scheduled results overnight, keep approvals — agent_finished is suppressed inside the window.

yaml
gateway:
  notifications:
    recipients:
      "slack:C01ABC":
        categories: [approval_requested, agent_finished]
        quiet_hours: "22:00-07:00"
        timezone: "America/New_York"

Privacy-first mobile recipient — detail: private so the lock-screen never shows content.

yaml
gateway:
  notifications:
    recipients:
      "telegram:12345":
        categories: [agent_finished]
        detail: private      # → "You have new activity"

The patterns below call evaluate() directly from your own notifier.

Gate a scheduler completion notification — the recipient opts into AGENT_FINISHED, and it is not urgent.

python
from praisonaiagents.push import (
    NotificationCategory, NotificationPreference, evaluate,
)

prefs = NotificationPreference(categories={NotificationCategory.AGENT_FINISHED})

decision = evaluate(
    NotificationCategory.AGENT_FINISHED,
    prefs=prefs,
    content="Nightly ETL finished in 12m",
    identity="etl-agent",
)
if decision.send:
    print(decision.redacted_content)   # → "Activity in etl-agent"

Approval request during quiet hours — the recipient is inside their window, but the urgent flag overrides it.

python
from datetime import datetime, time
from praisonaiagents.push import (
    NotificationCategory, NotificationPreference, evaluate,
)

prefs = NotificationPreference(
    categories={NotificationCategory.APPROVAL_REQUESTED},
    quiet_hours=(time(22, 0), time(7, 30)),
    timezone="Europe/London",
)

decision = evaluate(
    NotificationCategory.APPROVAL_REQUESTED,
    prefs=prefs,
    now=datetime(2026, 1, 1, 2, 15),   # 02:15 → inside quiet window
    is_urgent=True,
    identity="ops-agent",
)
print(decision.send, decision.reason)  # → True urgent_override

Privacy-first mobile push — DetailLevel.PRIVATE so the lock-screen never shows content.

python
from praisonaiagents.push import (
    NotificationCategory, DetailLevel, NotificationPreference, evaluate,
)

prefs = NotificationPreference(
    categories={NotificationCategory.HUMAN_MENTIONED},
    detail=DetailLevel.PRIVATE,
)

decision = evaluate(
    NotificationCategory.HUMAN_MENTIONED,
    prefs=prefs,
    content="Secret: launch is Tuesday",
    identity="strategy-session",
)
print(decision.redacted_content)       # → "You have new activity"

Best Practices

Key recipients by platform:channel_id, not by display name

The gateway looks up each send by the resolved platform:channel_id (e.g. telegram:12345). A key that does not match — a username, a channel name — silently falls back to defaults. Copy the pair from the gateway’s delivery log line.

Reload instead of restart

Edit gateway.yaml and save, or send SIGHUP / systemctl reload praisonai-gateway. The new policy is applied to the live delivery router without restarting the gateway.

Pick a sensible default per product surface

Match the default detail to where notifications land. Use PRIVATE for mobile lock-screens where anyone nearby can read the preview, IDENTIFIED for shared team channels that route by agent, and DETAILED only for trusted single-user desktops. DEFAULT_PREFERENCE uses IDENTIFIED as a safe middle ground.

Reserve urgency for interaction-blocking events

Treat APPROVAL_REQUESTED and AGENT_QUESTION as urgent by convention — they block an agent from continuing until you answer. Pass is_urgent=True for these so quiet hours never strand a paused agent. Keep failure alerts (SCHEDULED_TASK_FAILED, BACKGROUND_TASK_FAILED) non-urgent unless your operations demand overnight paging.

Get quiet-hours edge cases right

The window is half-open [start, end). A same-day window needs start < end (e.g. (09:00, 17:00)); an overnight window needs start > end (e.g. (22:00, 07:30)), which wraps across midnight. Setting start == end produces an empty window — never quiet. Always set timezone to the recipient’s IANA zone and pass an aware now so it localises correctly; a naive (or None) now is assumed already local, and an unknown zone leaves now unconverted.

Balance lock-screen privacy against actionability

Lower detail protects content but forces the recipient to open the app to act. If a notification only exists to prompt an approval, IDENTIFIED is usually enough — the recipient knows which agent to open. Reserve DETAILED for events where the content itself is the action (e.g. a failure stack trace) and the destination is trusted.


User Interaction Flow

An operator sets a preference once in gateway.yaml; later scheduled results are gated automatically.

Diagram
DeviceSchedulerGatewayOperatorDeviceSchedulerGatewayOperator23:00 local — scheduled task finishesnothing delivered, no retry07:31 local — next scheduled run finishesedit gateway.yaml → gateway.notifications(agent_finished, quiet 22:00–07:30)SIGHUP / reloadNotificationGate rebuiltdeliver(…, category=”agent_finished”)admit → send=false (quiet_hours)deliver(…, category=”agent_finished”)admit → send=true (ok)redacted result

Related