Vapora/docs/features/notification-channels.md
Jesús Pérez 27a290b369
feat(kg,channels): hybrid search + agent-inactive notifications
- KG: HNSW + BM25 + RRF(k=60) hybrid search via SurrealDB 3 native indexes
  - Fix schema bug: kg_executions missing agent_role/provider/cost_cents (silent empty reads)
  - channels: on_agent_inactive hook (AgentStatus::Inactive → Message::error)
  - migration 012: adds missing fields + HNSW + BM25 indexes
  - docs: ADR-0036, update ADR-0035 + notification-channels feature doc
2026-02-26 15:32:44 +00:00

7.8 KiB

Notification Channels

Real-time outbound alerts to Slack, Discord, and Telegram via webhook delivery.

Overview

vapora-channels provides a trait-based webhook notification layer. When VAPORA events occur (task completion, proposal decisions, workflow lifecycle), configured channels receive a message immediately — no polling required.

Key properties:

  • No vendor SDKs — plain HTTP POST to webhook URLs
  • Secret tokens resolved from environment variables at startup; a raw ${VAR} placeholder never reaches the HTTP layer
  • Fire-and-forget delivery: channel failures never surface as API errors

Configuration

All channel configuration lives in vapora.toml.

Declaring channels

[channels.team-slack]
type = "slack"
webhook_url = "${SLACK_WEBHOOK_URL}"

[channels.ops-discord]
type = "discord"
webhook_url = "${DISCORD_WEBHOOK_URL}"

[channels.alerts-telegram]
type = "telegram"
bot_token = "${TELEGRAM_BOT_TOKEN}"
chat_id   = "${TELEGRAM_CHAT_ID}"

Channel names (team-slack, ops-discord, alerts-telegram) are arbitrary identifiers used in event routing below.

Routing events to channels

[notifications]
on_task_done         = ["team-slack"]
on_proposal_approved = ["team-slack", "ops-discord"]
on_proposal_rejected = ["ops-discord"]
on_agent_inactive    = ["ops-telegram"]

Each key is an event name; the value is a list of channel names declared in [channels.*]. An empty list or absent key means no notification for that event.

Workflow lifecycle notifications

Per-workflow notification targets are set in the workflow template:

[[workflows]]
name = "nightly_analysis"
trigger = "schedule"

[workflows.nightly_analysis.notifications]
on_stage_complete = ["team-slack"]
on_stage_failed   = ["team-slack", "ops-discord"]
on_completed      = ["team-slack"]
on_cancelled      = ["ops-discord"]

Secret Resolution

Token values in [channels.*] blocks are interpolated from the environment before any network call is made. Two syntaxes are supported:

Syntax Behaviour
"${VAR}" Replaced with $VAR; startup fails if the variable is unset
"${VAR:-default}" Replaced with $VAR if set, otherwise default

Resolution happens inside ChannelRegistry::from_config — the single mandatory call site. There is no way to construct a registry with an unresolved placeholder.

Example:

export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/T.../..."
export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/..."
export TELEGRAM_BOT_TOKEN="123456:ABC..."
export TELEGRAM_CHAT_ID="-1001234567890"

If a required variable is absent and no default is provided, VAPORA exits at startup with:

Error: Secret reference '${SLACK_WEBHOOK_URL}' not resolved: env var not set and no default provided

Supported Channel Types

Slack

Uses the Incoming Webhooks API. The webhook URL is obtained from Slack's app configuration.

[channels.my-slack]
type        = "slack"
webhook_url = "${SLACK_WEBHOOK_URL}"

Payload format: { "text": "**Title**\nBody" }. No SDK dependency.

Discord

Uses the Discord Webhook endpoint. The webhook URL includes the token — obtain it from the channel's Integrations settings.

[channels.my-discord]
type        = "discord"
webhook_url = "${DISCORD_WEBHOOK_URL}"

Payload format: { "embeds": [{ "title": "...", "description": "...", "color": <level-color> }] }.

Telegram

Uses the Bot API sendMessage endpoint. Requires a bot token from @BotFather and the numeric chat ID of the target group or channel.

[channels.my-telegram]
type      = "telegram"
bot_token = "${TELEGRAM_BOT_TOKEN}"
chat_id   = "${TELEGRAM_CHAT_ID}"

Payload format: { "chat_id": "...", "text": "**Title**\nBody", "parse_mode": "Markdown" }.

Message Levels

Every notification carries a level that controls colour and emoji in the rendered message:

Level Constructor Use case
Info Message::info(title, body) General status updates
Success Message::success(title, body) Task done, workflow completed
Warning Message::warning(title, body) Proposal rejected, stage failed
Error Message::error(title, body) Unrecoverable failure

REST API

Two endpoints are available under /api/v1/channels:

List channels

GET /api/v1/channels

Returns the names of all registered channels (sorted alphabetically). Returns an empty list when no channels are configured.

Response:

{
  "channels": ["ops-discord", "team-slack"]
}

Test a channel

POST /api/v1/channels/:name/test

Sends a connectivity test message to the named channel and returns synchronously.

Status Meaning
200 OK Message delivered successfully
404 Not Found Channel name unknown or no channels configured
502 Bad Gateway Delivery attempt failed at the remote platform

Example:

curl -X POST http://localhost:8001/api/v1/channels/team-slack/test

Expected Slack message: Test notification — Connectivity test from VAPORA backend for channel 'team-slack'

Delivery Semantics

Delivery is fire-and-forget: AppState::notify spawns a background Tokio task and returns immediately. The API response does not wait for webhook delivery to complete.

Behaviour on failure:

  • Unknown channel name: warn! log, delivery to other targets continues
  • HTTP error from the remote platform: warn! log, delivery to other targets continues
  • No channels configured (channel_registry = None): silent no-op

There is no built-in retry. A channel that is consistently unreachable produces warn! log lines but no escalation. Use the /test endpoint to confirm connectivity after configuration changes.

Events Reference

Event key Trigger Default level
on_task_done Task moved to Done status Success
on_proposal_approved Proposal approved via API Success
on_proposal_rejected Proposal rejected via API Warning
on_agent_inactive Agent status transitions to Inactive Error
on_stage_complete Workflow stage finished Info
on_stage_failed Workflow stage failed Warning
on_completed Workflow reached terminal Completed state Success
on_cancelled Workflow cancelled Warning

Troubleshooting

Channel not receiving messages

  1. Verify the channel name in [notifications] matches the name in [channels.*] exactly (case-sensitive).
  2. Confirm the env variable is set: echo $SLACK_WEBHOOK_URL.
  3. Send a test message: POST /api/v1/channels/<name>/test.
  4. Check backend logs for warn entries with channel = "<name>".

Startup fails with SecretNotFound

The env variable referenced in webhook_url or bot_token/chat_id is not set. Either export the variable or add a default value:

webhook_url = "${SLACK_WEBHOOK_URL:-https://hooks.slack.com/...}"

Discord returns 400

The webhook URL must end with /slack for Slack-compatible mode, or be the raw Discord webhook URL. Ensure the URL copied from Discord's channel settings is used without modification.

Telegram chat_id not found

The bot must be a member of the target group or channel. For groups, prefix the numeric ID with - (e.g. -1001234567890). Use @userinfobot in Telegram to retrieve your chat ID.