Polpo can notify you about anything that happens — task failures, SLA warnings, mission completions. It subscribes to internal events, matches them against configurable rules, and dispatches notifications to external channels.
Architecture
The notification system has three layers:
- Channels — adapters that deliver messages to external services (Slack, Telegram, Email, Webhook)
- Rules — event pattern + optional condition + target channels
- Router — glue that listens to orchestrator events, evaluates rules, and dispatches
Configuration
Notifications are configured under settings.notifications in your Polpo config:
{
"settings": {
"notifications": {
"channels": {
"slack-alerts": {
"type": "slack",
"webhookUrl": "https://hooks.slack.com/services/T00/B00/xxx"
},
"ops-email": {
"type": "email",
"provider": "smtp",
"host": "smtp.example.com",
"port": 587,
"from": "polpo@example.com",
"to": ["ops@example.com"]
},
"telegram-oncall": {
"type": "telegram",
"botToken": "${TELEGRAM_BOT_TOKEN}",
"chatId": "-1001234567890"
},
"webhook-pagerduty": {
"type": "webhook",
"url": "https://events.pagerduty.com/v2/enqueue",
"headers": {
"Authorization": "Token token=${PD_TOKEN}"
}
}
},
"rules": [
{
"id": "failed-tasks",
"name": "Notify on task failure",
"events": ["task:transition"],
"condition": { "field": "to", "op": "==", "value": "failed" },
"channels": ["slack-alerts"],
"severity": "critical"
},
{
"id": "sla-warnings",
"name": "SLA approaching deadline",
"events": ["sla:warning", "sla:violated"],
"channels": ["ops-email", "slack-alerts"],
"severity": "warning",
"cooldownMs": 300000
}
]
}
}
}
Channel Adapters
Polpo supports four notification channels. Each has its own configuration page with setup instructions, payload formats, and attachment behavior.
| Channel | Required Fields | Description |
|---|
slack | webhookUrl | Posts to a Slack incoming webhook |
telegram | botToken, chatId | Sends via Telegram Bot API |
whatsapp | (none required) | Personal WhatsApp via Baileys (unofficial) |
email | provider, to, host/port or apiKey | Email delivery via Resend, SendGrid, or SMTP |
webhook | url | Generic HTTP POST with JSON payload |
API keys, tokens, and URLs support ${ENV_VAR} syntax for environment variable references.
Event Pattern Matching
Rules use glob-style patterns to match events:
| Pattern | Matches |
|---|
task:created | Exactly task:created |
task:* | task:created, task:transition, task:timeout, etc. |
sla:** | All SLA events at any depth |
* | Every event |
The router resolves glob patterns against all known event names at startup and subscribes to each matching concrete event.
Condition DSL
Rules can include an optional JSON condition that filters on the event payload. Conditions are pure data — no eval(), no string parsing.
Simple comparison
{ "field": "to", "op": "==", "value": "failed" }
Supported operators
| Operator | Description |
|---|
== | Loose equality |
!= | Loose inequality |
>, >=, <, <= | Numeric comparison |
includes | String contains or array includes |
not_includes | Negation of includes |
exists | Field is not null/undefined |
not_exists | Field is null/undefined |
Logical combinators
{
"and": [
{ "field": "to", "op": "==", "value": "failed" },
{ "field": "task.retries", "op": ">=", "value": 3 }
]
}
{
"or": [
{ "field": "severity", "op": "==", "value": "critical" },
{ "field": "level", "op": ">=", "value": 3 }
]
}
{
"not": { "field": "status", "op": "==", "value": "done" }
}
Fields use dot-path resolution on the event data (e.g. task.status, result.assessment.globalScore).
Cooldown / Throttling
Each rule can set cooldownMs to prevent notification storms. If a rule fires and its cooldown has not elapsed since the last dispatch, the notification is silently dropped.
{
"id": "task-failure-alert",
"name": "Task failures",
"events": ["task:transition"],
"condition": { "field": "to", "op": "==", "value": "failed" },
"channels": ["slack-alerts"],
"cooldownMs": 60000
}
Template System
The router generates default titles and bodies for each event type using built-in templates with severity emoji prefixes. You can override this per-rule with a template field using {{variable}} interpolation:
{
"id": "custom-alert",
"name": "Custom task alert",
"events": ["task:transition"],
"channels": ["slack-alerts"],
"template": "Task {{taskId}} moved to {{to}} (agent: {{agentName}})"
}
Template variables are resolved from the event payload using dot-path access.
Dynamic Rule Registration
Rules can be added programmatically at runtime via addRule(). This is used internally by:
- SLAMonitor — registers rules for
warningChannels and violationChannels
- QualityController — registers rules for per-gate
notifyChannels
When a rule is added after the router has started, the router automatically subscribes to any new event patterns.
Events
The notification system emits its own events:
| Event | Payload | Description |
|---|
notification:sent | { ruleId, channel, event } | Notification delivered successfully |
notification:failed | { ruleId, channel, error } | Channel delivery failed |
Use testChannels() on the NotificationRouter to verify all configured channels are reachable before starting Polpo.
Scoped Notification Rules
Global rules (configured in settings.notifications.rules) apply to every event by default. You can override or extend these rules at the mission and task level using scoped notification rules.
Scope hierarchy
The most specific scope wins: if a task has scoped rules, those are used instead of global rules when processing events for that task. Similarly, mission-level rules override global rules for all events within that mission’s execution.
Replacement vs. inheritance
By default, scoped rules replace the global rules for matching events. If you set inherit: true, the scoped rules are added on top of the parent scope instead.
{
"notifications": {
"rules": [
{
"id": "task-fail-slack",
"name": "Notify Slack on task failure",
"events": ["task:transition"],
"condition": { "field": "to", "op": "==", "value": "failed" },
"channels": ["slack-alerts"],
"severity": "critical"
}
],
"inherit": false
}
}
With inherit: false (the default), only the rules in this scope are used — global rules are ignored for events matching this scope.
{
"notifications": {
"rules": [
{
"id": "extra-telegram",
"name": "Also ping Telegram",
"events": ["task:transition"],
"channels": ["telegram-oncall"],
"severity": "warning"
}
],
"inherit": true
}
}
With inherit: true, the scoped rules are added on top of global rules. Both the global Slack notification and this Telegram notification would fire.
Setting scoped rules via API
When creating tasks or missions via the REST API, include a notifications field:
# Task with scoped notification rules
curl -X POST http://localhost:3000/api/v1/projects/my-project/tasks \
-H "Content-Type: application/json" \
-d '{
"title": "Critical migration",
"description": "Run database migration",
"assignTo": "db-agent",
"notifications": {
"rules": [{
"id": "migration-alert",
"name": "Migration status",
"events": ["task:transition"],
"channels": ["telegram-oncall", "slack-alerts"],
"severity": "critical"
}],
"inherit": false
}
}'
# Mission with scoped notification rules
curl -X POST http://localhost:3000/api/v1/projects/my-project/missions \
-H "Content-Type: application/json" \
-d '{
"name": "Release v2.0",
"data": "...",
"notifications": {
"rules": [{
"id": "release-updates",
"name": "Release progress",
"events": ["task:*", "mission:*"],
"channels": ["slack-releases"],
"severity": "info"
}],
"inherit": true
}
}'
Scope resolution logic
When an event fires, the router resolves which rules to use:
- If the event is associated with a task that has
notifications, use those rules (respecting inherit).
- Otherwise, if the event is associated with a mission that has
notifications, use those rules (respecting inherit).
- Otherwise, use global rules from
settings.notifications.rules.
Send Direct API
The POST /notifications/send endpoint lets you send a one-off notification to any configured channel, bypassing rules entirely. This is useful for manual alerts, testing, or custom integrations.
Request
curl -X POST http://localhost:3000/api/v1/projects/my-project/notifications/send \
-H "Content-Type: application/json" \
-d '{
"channel": "telegram-oncall",
"title": "Deployment Complete",
"body": "Version 2.0.1 deployed to production",
"severity": "info",
"delayMs": 5000
}'
| Field | Type | Required | Description |
|---|
channel | string | Yes | Channel ID from your configuration |
title | string | Yes | Notification title |
body | string | Yes | Notification body (HTML supported for Telegram) |
severity | string | No | info, warning, or critical (default: info) |
delayMs | number | No | Delay in milliseconds before sending (in-memory timer) |
Response
{
"ok": true,
"data": {
"id": "notif_abc123",
"scheduledAt": "2025-01-15T10:30:00.000Z",
"firesAt": "2025-01-15T10:30:05.000Z"
}
}
When delayMs is set, the notification is scheduled with an in-memory setTimeout. The firesAt field indicates when the notification will actually be dispatched. Note that delayed notifications are not persisted — if the server restarts, scheduled notifications are lost.
Delayed notifications use in-memory timers and are not persisted. They will be lost if the server restarts before they fire.
REST API Endpoints
| Method | Path | Description |
|---|
GET | /notifications | List notification history (supports ?limit, ?status, ?channel, ?rule query params) |
GET | /notifications/stats | Get aggregate stats (total, sent, failed) |
POST | /notifications/send | Send a direct notification to a channel |
React SDK
The @polpo-ai/react provides the useNotifications hook for accessing notification data in the UI:
import { useNotifications } from "@polpo-ai/react";
function NotificationsPanel() {
const { notifications, stats, sendNotification, refetch, loading } = useNotifications({
limit: 100,
status: "sent",
});
const handleSend = async () => {
await sendNotification({
channel: "telegram-oncall",
title: "Manual Alert",
body: "Something needs attention",
severity: "warning",
});
};
return (
<div>
<p>Total: {stats?.total} | Sent: {stats?.sent} | Failed: {stats?.failed}</p>
{notifications.map(n => (
<div key={n.id}>{n.title} — {n.status}</div>
))}
<button onClick={handleSend}>Send Alert</button>
</div>
);
}