Channels overview
Ongrid talks to humans through channels. A channel is either:
- a notification channel — Ongrid pushes alerts to a chat surface; the human reads, but the channel itself never carries a reply back to the agent. This is
notify_channelsin the DB and theinternal/pkg/notify/webhook.goSenders. - an IM channel — Ongrid sits on a workspace's chat surface as a bot, reads inbound messages, and runs the same agent reasoning that the web UI runs, streaming the answer back as edits to a placeholder message. This is
im_appsin the DB andinternal/manager/biz/imbridge/provider/*.
The two are independent. Wiring Telegram alerts (sendMessage out) does not let users chat with the agent on Telegram, and vice versa.
Different table, different credential, same UI
Both surfaces are configured in Settings → Channels in the web UI. Notification channels go to the Notify tab; IM channels go to the IM bridge tab.
Which one do I want?
| Goal | Use | File |
|---|---|---|
| Push fired alerts to a Slack channel | Slack incoming webhook (Notify) | internal/pkg/notify/webhook.go NewSlackSender |
| Push fired alerts to Feishu/Lark group | Feishu custom bot (Notify) | NewFeishuSender |
| Push fired alerts to DingTalk group | DingTalk custom bot (Notify) | NewDingTalkSender |
| Push fired alerts to WeCom group | WeCom group bot (Notify) | NewWeComSender |
| Generic JSON POST to your own service | Webhook (Notify) | NewGenericWebhookSender |
| Telegram alerts (single direction) | Telegram bot sendMessage (Notify) | NewTelegramSender |
| Talk to the agent from Slack as a bot | Slack Socket Mode app (IM bridge) | imbridge/provider/slack/ |
| Talk to the agent from Telegram | Telegram bot getUpdates (IM bridge) | imbridge/provider/telegram/ |
| Talk to the agent from Feishu/Larksuite | Feishu long-connection (IM bridge) | imbridge/provider/feishu/ |
A workspace can run both: e.g. a Slack incoming webhook for alerts plus a separate Slack Socket Mode app for conversation. They share no state.
Notification channels
A notification channel is a stateless Send(ctx, Message) Sender. It is called by the alert pipeline whenever an alert fires (or recovers, or the dampening window expires). All channel types render the same canonical notify.Message shape; only the payload format and the signing protocol differ per provider.
Common fields a Sender receives
type Message struct {
Severity Severity // critical | warning | info
Subject string // alert rule name + target
Body string // human-readable detail
Source string // "alert" | "test" | ...
Labels map[string]string // rule, incident_id, device_id, ...
DedupeKey string // pipeline:rule:label-set
OccurredAt time.Time
}The Slack sender renders this into the attachments format with a severity-tinted color rail, structured fields (Severity, Source, Rule, Incident, Device, Dedupe), and a ts footer. The Feishu / DingTalk / WeCom senders flatten it to a [SEV] subject\nbody\nsource:…\ndedupe:… text payload because their bot APIs only ship plain text in v1.
Signing models at a glance
| Provider | How it authenticates |
|---|---|
| Slack | The webhook URL itself is the secret. No extra signing. |
| Feishu | HMAC-SHA256(ts\nsecret), placed in JSON body as sign. |
| DingTalk | HMAC-SHA256(ts\nsecret), placed in URL query as sign. |
| WeCom | Bot key in URL query. No extra signing. |
| Telegram | Bot token in path (/bot<TOKEN>/sendMessage). |
| Webhook | Optional X-Ongrid-Signature: sha256=<HMAC> over body. |
The exact implementations live in internal/pkg/notify/webhook.go: signFeishu, signDingTalkURL, signGenericWebhook.
Slack drops the secret field silently
The Slack incoming webhook URL is itself the credential. If you fill the Secret field for a Slack channel, the channel builder drops it before constructing the Sender. This is intentional — Slack's protocol has no separate signing surface for incoming webhooks.
IM channels
An IM channel is a long-running goroutine that:
- Connects outbound to the provider (websocket for Feishu/Slack, long poll for Telegram). No inbound ports are opened on the manager.
- Receives inbound user messages, filters them through
allow_from, and hands the text tobizbridge.Bridge.HandleInbound. HandleInboundposts a placeholder reply, runs the full agent graph (same one the web UI uses), and streams edits back to the placeholder message id.
The same agent kernel, skills, and persona registry power both the web UI and the IM channels. There is no "IM-specific agent" — the coordinator is the same one you see on /chat.
Inbound is provider-specific, outbound is uniform
Provider differences (Slack envelope_id ack, Telegram update offset, Feishu encrypt_key) live in imbridge/provider/*/stream.go. Once a message is inside bizbridge.HandleInbound everything downstream is provider-agnostic.
default_locale
Both IM and Notify rows carry an optional default_locale. The validator accepts the empty string (auto), en, or zh only — en-US / zh-CN are folded to their primary subtag, and EN-us / a typo'd locale is rejected up front in AppInput.validate.
| Value | Behavior |
|---|---|
"" (auto) | No directive. The LLM mirrors the user's language. Legacy default. |
en | Add a Respond in English directive to the system prompt. |
zh | Add a 「请用中文回复」 directive to the system prompt. |
This is independent from the UI locale (ONGRID_DEFAULT_LOCALE env var or browser language). An IM channel always wins for messages received through it. See the AI output locale feedback for the auto-trigger fallback rule on manager-initiated outputs.
allow_from
The sender allowlist. Required for Telegram and Slack; optional for Feishu/DingTalk. It is parsed exactly once by ParseAllowFrom, shared between the validator and every provider's poll/stream loop so the parse rule has a single definition.
Syntax. Comma, space, newline, tab, semicolon — any combination separates tokens. Order is preserved, duplicates are dropped. The telegram: and tg: prefixes are stripped silently (OpenClaw compatibility).
Per-provider format.
- Telegram. Numeric user IDs only. Non-numeric tokens and negative values (group chat IDs) are dropped at validate time. At least one ID is required — the bot is publicly discoverable by username, so an empty allowlist would let any Telegram user command a tool-equipped agent. See ADR-031.
- Slack. User IDs starting with
U(orWfor Enterprise guests). At least one is required. Find your own by clicking your workspace profile →⋯→ Copy member ID. - Feishu / DingTalk. Optional. These platforms are gated by enterprise-tenant membership; only org members can reach the bot.
Empty allowlist for Telegram or Slack is rejected
The validator returns telegram requires allow_from / slack requires allow_from when the parsed list is empty. There is no "deny by default but allow setup later" mode. The bot is publicly reachable; the operator must consciously open the door.
Failure mode is silent drop. A message from a non-allowlisted sender is logged at WARN with the sender's user_id, then dropped. No reply, no placeholder, no agent run. The bot does not even confirm it exists — the same shape as OpenClaw dmPolicy: allowFrom.
What a fired-alert delivery looks like
alert evaluator → produces notify.Message
↓
notify.Sender (per channel) ↓ buildBody(msg) → JSON payload
↓ signTarget(endpoint, secret, body) → headers / URL params
↓ POST endpoint
↓ resp.StatusCode in [200, 299] → success
↓
chat surfaceFor deeper detail see the per-channel pages: