docs/channel-adapters.md
OpenFang connects to messaging platforms through 40 channel adapters, allowing users to interact with their agents across every major communication platform. Adapters span consumer messaging, enterprise collaboration, social media, community platforms, privacy-focused protocols, and generic webhooks.
All adapters share a common foundation: graceful shutdown via watch::channel, exponential backoff on connection failures, Zeroizing<String> for secrets, automatic message splitting for platform limits, per-channel model/prompt overrides, DM/group policy enforcement, per-user rate limiting, and output formatting (Markdown, TelegramHTML, SlackMrkdwn, PlainText).
| Channel | Protocol | Env Vars | ChannelType Variant |
|---|---|---|---|
| Telegram | Bot API long-polling | TELEGRAM_BOT_TOKEN | Telegram |
| Discord | Gateway WebSocket v10 | DISCORD_BOT_TOKEN | Discord |
| Slack | Socket Mode WebSocket | SLACK_BOT_TOKEN, SLACK_APP_TOKEN | Slack |
| Cloud API webhook | WA_ACCESS_TOKEN, WA_PHONE_ID, WA_VERIFY_TOKEN | WhatsApp | |
| Signal | signal-cli REST/JSON-RPC | (system service) | Signal |
| Matrix | Client-Server API /sync | MATRIX_TOKEN | Matrix |
| IMAP + SMTP | EMAIL_PASSWORD | Email |
| Channel | Protocol | Env Vars | ChannelType Variant |
|---|---|---|---|
| Microsoft Teams | Bot Framework v3 webhook + OAuth2 | TEAMS_APP_ID, TEAMS_APP_SECRET | Teams |
| Mattermost | WebSocket + REST v4 | MATTERMOST_TOKEN, MATTERMOST_URL | Mattermost |
| Google Chat | Service account webhook | GOOGLE_CHAT_SA_KEY, GOOGLE_CHAT_SPACE | Custom("google_chat") |
| Webex | Bot SDK WebSocket | WEBEX_BOT_TOKEN | Custom("webex") |
| Feishu / Lark | Open Platform Webhook / WebSocket | FEISHU_APP_ID, FEISHU_APP_SECRET | Custom("feishu") |
| Rocket.Chat | REST polling | ROCKETCHAT_TOKEN, ROCKETCHAT_URL | Custom("rocketchat") |
| Zulip | Event queue long-polling | ZULIP_EMAIL, ZULIP_API_KEY, ZULIP_URL | Custom("zulip") |
| XMPP | XMPP protocol (stub) | XMPP_JID, XMPP_PASSWORD, XMPP_SERVER | Custom("xmpp") |
| Channel | Protocol | Env Vars | ChannelType Variant |
|---|---|---|---|
| LINE | Messaging API webhook | LINE_CHANNEL_SECRET, LINE_CHANNEL_TOKEN | Custom("line") |
| Viber | Bot API webhook | VIBER_AUTH_TOKEN | Custom("viber") |
| Facebook Messenger | Platform API webhook | MESSENGER_PAGE_TOKEN, MESSENGER_VERIFY_TOKEN | Custom("messenger") |
| Mastodon | Streaming API WebSocket | MASTODON_TOKEN, MASTODON_INSTANCE | Custom("mastodon") |
| Bluesky | AT Protocol WebSocket | BLUESKY_HANDLE, BLUESKY_APP_PASSWORD | Custom("bluesky") |
| OAuth2 polling | REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET, REDDIT_USERNAME, REDDIT_PASSWORD | Custom("reddit") | |
| Messaging API polling | LINKEDIN_ACCESS_TOKEN | Custom("linkedin") | |
| Twitch | IRC gateway | TWITCH_TOKEN, TWITCH_CHANNEL | Custom("twitch") |
| Channel | Protocol | Env Vars | ChannelType Variant |
|---|---|---|---|
| IRC | Raw TCP PRIVMSG | IRC_SERVER, IRC_NICK, IRC_PASSWORD | Custom("irc") |
| Guilded | WebSocket | GUILDED_BOT_TOKEN | Custom("guilded") |
| Revolt | WebSocket | REVOLT_BOT_TOKEN | Custom("revolt") |
| Keybase | Bot API polling | KEYBASE_USERNAME, KEYBASE_PAPERKEY | Custom("keybase") |
| Discourse | REST polling | DISCOURSE_API_KEY, DISCOURSE_URL | Custom("discourse") |
| Gitter | Streaming API | GITTER_TOKEN | Custom("gitter") |
| Channel | Protocol | Env Vars | ChannelType Variant |
|---|---|---|---|
| Nextcloud Talk | REST polling | NEXTCLOUD_TOKEN, NEXTCLOUD_URL | Custom("nextcloud") |
| Channel | Protocol | Env Vars | ChannelType Variant |
|---|---|---|---|
| Threema | Gateway API webhook | THREEMA_ID, THREEMA_SECRET | Custom("threema") |
| Nostr | NIP-01 relay WebSocket | NOSTR_PRIVATE_KEY, NOSTR_RELAY | Custom("nostr") |
| Mumble | TCP text protocol | MUMBLE_SERVER, MUMBLE_USERNAME, MUMBLE_PASSWORD | Custom("mumble") |
| Channel | Protocol | Env Vars | ChannelType Variant |
|---|---|---|---|
| Pumble | Webhook | PUMBLE_WEBHOOK_URL, PUMBLE_TOKEN | Custom("pumble") |
| Flock | Webhook | FLOCK_TOKEN | Custom("flock") |
| Twist | API v3 polling | TWIST_TOKEN | Custom("twist") |
| DingTalk | Robot API webhook | DINGTALK_TOKEN, DINGTALK_SECRET | Custom("dingtalk") |
| Channel | Protocol | Env Vars | ChannelType Variant |
|---|---|---|---|
| ntfy | SSE pub/sub | NTFY_TOPIC, NTFY_SERVER | Custom("ntfy") |
| Gotify | WebSocket | GOTIFY_TOKEN, GOTIFY_URL | Custom("gotify") |
| Channel | Protocol | Env Vars | ChannelType Variant |
|---|---|---|---|
| Webhook | Generic HTTP with HMAC-SHA256 | WEBHOOK_URL, WEBHOOK_SECRET | Custom("webhook") |
All channel configurations live in ~/.openfang/config.toml under the [channels] section. Each channel is a subsection:
[channels.telegram]
bot_token_env = "TELEGRAM_BOT_TOKEN"
default_agent = "assistant"
allowed_users = ["123456789"]
[channels.discord]
bot_token_env = "DISCORD_BOT_TOKEN"
default_agent = "coder"
[channels.slack]
bot_token_env = "SLACK_BOT_TOKEN"
app_token_env = "SLACK_APP_TOKEN"
default_agent = "ops"
# Enterprise example
[channels.teams]
app_id_env = "TEAMS_APP_ID"
app_secret_env = "TEAMS_APP_SECRET"
default_agent = "ops"
# Social example
[channels.mastodon]
token_env = "MASTODON_TOKEN"
instance = "https://mastodon.social"
default_agent = "social-media"
bot_token_env / token_env -- The environment variable holding the bot/access token. OpenFang reads the token from this env var at startup. All secrets are stored as Zeroizing<String> and wiped from memory on drop.default_agent -- The agent name (or ID) that receives messages when no specific routing applies.allowed_users -- Optional list of platform user IDs allowed to interact. Empty means allow all.overrides -- Optional per-channel behavior overrides (see Channel Overrides below).| Channel | Required Env Vars |
|---|---|
| Telegram | TELEGRAM_BOT_TOKEN |
| Discord | DISCORD_BOT_TOKEN |
| Slack | SLACK_BOT_TOKEN, SLACK_APP_TOKEN |
WA_ACCESS_TOKEN, WA_PHONE_ID, WA_VERIFY_TOKEN | |
| Matrix | MATRIX_TOKEN |
EMAIL_PASSWORD |
Env vars for all other channels are listed in the All 40 Channels tables above.
Every channel adapter supports ChannelOverrides, which let you customize behavior per channel without modifying the agent manifest. Add an [channels.<name>.overrides] section in config.toml:
[channels.telegram.overrides]
model = "gemini-2.5-flash"
system_prompt = "You are a concise Telegram assistant. Keep replies under 200 words."
dm_policy = "respond"
group_policy = "mention_only"
rate_limit_per_user = 10
threading = true
output_format = "telegram_html"
usage_footer = "compact"
| Field | Type | Default | Description |
|---|---|---|---|
model | Option<String> | Agent default | Override the LLM model for this channel. |
system_prompt | Option<String> | Agent default | Override the system prompt for this channel. |
dm_policy | DmPolicy | Respond | How to handle direct messages. |
group_policy | GroupPolicy | MentionOnly | How to handle group/channel messages. |
rate_limit_per_user | u32 | 0 (unlimited) | Max messages per minute per user. |
threading | bool | false | Send replies as thread responses (platforms that support it). |
output_format | Option<OutputFormat> | Markdown | Output format for this channel. |
usage_footer | Option<UsageFooterMode> | None | Whether to append token usage to responses. |
The formatter module (openfang-channels/src/formatter.rs) converts Markdown output from the LLM into platform-native formats:
| OutputFormat | Target | Notes |
|---|---|---|
Markdown | Standard Markdown | Default; passed through as-is. |
TelegramHtml | Telegram HTML subset | Converts **bold** to <b>, `code` to <code>, etc. |
SlackMrkdwn | Slack mrkdwn | Converts **bold** to *bold*, links to <url|text>, etc. |
PlainText | Plain text | Strips all formatting. |
The ChannelRateLimiter (openfang-channels/src/rate_limiter.rs) uses a DashMap to track per-user message counts. When rate_limit_per_user is set on a channel's overrides, the limiter enforces a sliding-window cap of N messages per minute. Excess messages receive a polite rejection.
Controls how the adapter handles direct messages:
| DmPolicy | Behavior |
|---|---|
Respond | Respond to all DMs (default). |
AllowedOnly | Only respond to DMs from users in allowed_users. |
Ignore | Silently drop all DMs. |
Controls how the adapter handles messages in group chats, channels, and rooms:
| GroupPolicy | Behavior |
|---|---|
All | Respond to every message in the group. |
MentionOnly | Only respond when the bot is @mentioned (default). |
CommandsOnly | Only respond to /command messages. |
Ignore | Silently ignore all group messages. |
Policy enforcement happens in dispatch_message() before the message reaches the agent loop. This means ignored messages consume zero LLM tokens.
@BotFather./newbot and follow the prompts to create a new bot.export TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
[channels.telegram]
bot_token_env = "TELEGRAM_BOT_TOKEN"
default_agent = "assistant"
# Optional: restrict to specific Telegram user IDs
# allowed_users = ["123456789"]
[channels.telegram.overrides]
# Optional: Telegram-native HTML formatting
# output_format = "telegram_html"
# group_policy = "mention_only"
openfang start
The Telegram adapter uses long-polling via the getUpdates API. It polls every few seconds with a 30-second long-poll timeout. On API failures, it applies exponential backoff (starting at 1 second, up to 60 seconds). Shutdown is coordinated via a watch::channel.
Messages from authorized users are converted to ChannelMessage events and routed to the configured agent. Responses are sent back via the sendMessage API. Long responses are automatically split into multiple messages to respect Telegram's 4096-character limit using the shared split_message() utility.
openfang channel setup telegram
This walks you through the setup interactively.
botSend Messages, Read Message Historyexport DISCORD_BOT_TOKEN=MTIzNDU2Nzg5.ABCDEF.ghijklmnop
[channels.discord]
bot_token_env = "DISCORD_BOT_TOKEN"
default_agent = "coder"
The Discord adapter connects to the Discord Gateway via WebSocket (v10). It listens for MESSAGE_CREATE events and routes messages to the configured agent. Responses are sent via the REST API's channels/{id}/messages endpoint.
The adapter handles Gateway reconnection, heartbeating, and session resumption automatically.
connections:write.xapp-...).chat:writeapp_mentions:readim:historyim:readim:writexoxb-...).export SLACK_APP_TOKEN=xapp-1-...
export SLACK_BOT_TOKEN=xoxb-...
[channels.slack]
bot_token_env = "SLACK_BOT_TOKEN"
app_token_env = "SLACK_APP_TOKEN"
default_agent = "ops"
[channels.slack.overrides]
# Optional: Slack-native mrkdwn formatting
# output_format = "slack_mrkdwn"
# threading = true
The Slack adapter uses Socket Mode, which establishes a WebSocket connection to Slack's servers. This avoids the need for a public webhook URL. The adapter receives events (app mentions, direct messages) and routes them to the configured agent. Responses are posted via the chat.postMessage Web API. When threading = true, replies are sent to the message's thread via thread_ts.
export WA_PHONE_ID=123456789012345
export WA_ACCESS_TOKEN=EAABs...
export WA_VERIFY_TOKEN=my-secret-verify-token
[channels.whatsapp]
mode = "cloud_api"
phone_number_id_env = "WA_PHONE_ID"
access_token_env = "WA_ACCESS_TOKEN"
verify_token_env = "WA_VERIFY_TOKEN"
webhook_port = 8443
default_agent = "assistant"
Set up a webhook in the Meta dashboard pointing to your server's public URL:
https://your-domain.com:8443/webhook/whatsappmessagesRestart the daemon.
The WhatsApp adapter runs an HTTP server (on the configured webhook_port) that receives incoming webhooks from the WhatsApp Cloud API. It handles webhook verification (GET) and message reception (POST). Responses are sent via the Cloud API's messages endpoint.
export FEISHU_APP_SECRET=cli_xxx_secret
websocket mode):[channels.feishu]
app_id = "cli_xxx"
app_secret_env = "FEISHU_APP_SECRET"
mode = "websocket"
default_agent = "assistant"
If you need the legacy callback flow, switch to webhook and expose a public callback URL:
[channels.feishu]
app_id = "cli_xxx"
app_secret_env = "FEISHU_APP_SECRET"
mode = "webhook"
webhook_port = 8453
default_agent = "assistant"
Then configure Feishu event callback to:
https://<your-domain>:8453/feishu/webhook
im/v1/messages.[channels.signal]
signal_cli_path = "/usr/local/bin/signal-cli"
phone_number = "+1234567890"
default_agent = "assistant"
The Signal adapter spawns signal-cli as a subprocess in daemon mode and communicates via JSON-RPC. Incoming messages are read from the signal-cli output stream and routed to the configured agent.
export MATRIX_TOKEN=syt_...
[channels.matrix]
homeserver_url = "https://matrix.org"
access_token_env = "MATRIX_TOKEN"
user_id = "@openfang-bot:matrix.org"
default_agent = "assistant"
The Matrix adapter uses the Matrix Client-Server API. It syncs with the homeserver using long-polling (/sync with a timeout) and processes new messages from joined rooms. Responses are sent via the /rooms/{roomId}/send endpoint.
export EMAIL_PASSWORD=abcd-efgh-ijkl-mnop
[channels.email]
imap_host = "imap.gmail.com"
imap_port = 993
smtp_host = "smtp.gmail.com"
smtp_port = 587
username = "[email protected]"
password_env = "EMAIL_PASSWORD"
poll_interval = 30
default_agent = "email-assistant"
The email adapter polls the IMAP inbox at the configured interval. New emails are parsed (subject + body) and routed to the configured agent. Responses are sent as reply emails via SMTP, preserving the subject line threading.
The WebChat UI is embedded in the daemon and requires no configuration. When the daemon is running:
http://127.0.0.1:4200/
Features:
The AgentRouter determines which agent receives an incoming message. The routing logic is:
default_agent field. Messages from that channel go to that agent./agent coder in the chat. Subsequent messages will be routed to the "coder" agent.To add support for a new messaging platform, implement the ChannelAdapter trait. The trait is defined in crates/openfang-channels/src/types.rs.
pub trait ChannelAdapter: Send + Sync {
/// Human-readable name of this adapter.
fn name(&self) -> &str;
/// The channel type this adapter handles.
fn channel_type(&self) -> ChannelType;
/// Start receiving messages. Returns a stream of incoming messages.
async fn start(
&self,
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>>;
/// Send a response back to a user on this channel.
async fn send(
&self,
user: &ChannelUser,
content: ChannelContent,
) -> Result<(), Box<dyn std::error::Error>>;
/// Send a typing indicator (optional -- default no-op).
async fn send_typing(&self, _user: &ChannelUser) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
/// Stop the adapter and clean up resources.
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>>;
/// Get the current health status of this adapter (optional -- default returns disconnected).
fn status(&self) -> ChannelStatus {
ChannelStatus::default()
}
/// Send a response as a thread reply (optional -- default falls back to `send()`).
async fn send_in_thread(
&self,
user: &ChannelUser,
content: ChannelContent,
_thread_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
self.send(user, content).await
}
}
Create crates/openfang-channels/src/myplatform.rs:
use crate::types::{
ChannelAdapter, ChannelContent, ChannelMessage, ChannelStatus, ChannelType, ChannelUser,
};
use futures::stream::{self, Stream};
use std::pin::Pin;
use tokio::sync::watch;
use zeroize::Zeroizing;
pub struct MyPlatformAdapter {
token: Zeroizing<String>,
client: reqwest::Client,
shutdown: watch::Receiver<bool>,
}
impl MyPlatformAdapter {
pub fn new(token: String, shutdown: watch::Receiver<bool>) -> Self {
Self {
token: Zeroizing::new(token),
client: reqwest::Client::new(),
shutdown,
}
}
}
impl ChannelAdapter for MyPlatformAdapter {
fn name(&self) -> &str {
"MyPlatform"
}
fn channel_type(&self) -> ChannelType {
ChannelType::Custom("myplatform".to_string())
}
async fn start(
&self,
) -> Result<Pin<Box<dyn Stream<Item = ChannelMessage> + Send>>, Box<dyn std::error::Error>> {
// Return a stream that yields ChannelMessage items.
// Use self.shutdown to detect when the daemon is stopping.
// Apply exponential backoff on connection failures.
let stream = stream::empty(); // Replace with your polling/WebSocket logic
Ok(Box::pin(stream))
}
async fn send(
&self,
user: &ChannelUser,
content: ChannelContent,
) -> Result<(), Box<dyn std::error::Error>> {
// Send the response back to the platform.
// Use split_message() if the platform has message length limits.
// Use self.client and self.token to call the platform's API.
Ok(())
}
async fn stop(&self) -> Result<(), Box<dyn std::error::Error>> {
// Clean shutdown: close connections, stop polling.
Ok(())
}
fn status(&self) -> ChannelStatus {
ChannelStatus::default()
}
}
Key points for new adapters:
ChannelType::Custom("myplatform".to_string()) for the channel type. Only the 9 most common channels have named ChannelType variants (Telegram, WhatsApp, Slack, Discord, Signal, Matrix, Email, Teams, Mattermost). All others use Custom(String).Zeroizing<String> so they are wiped from memory on drop.watch::Receiver<bool> for coordinated shutdown with the daemon.split_message(text, max_len) utility for platforms with message length limits.In crates/openfang-channels/src/lib.rs:
pub mod myplatform;
In crates/openfang-api/src/channel_bridge.rs, add initialization logic for your adapter alongside the existing adapters.
In openfang-types, add a config struct:
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MyPlatformConfig {
pub token_env: String,
pub default_agent: Option<String>,
#[serde(default)]
pub overrides: ChannelOverrides,
}
Add it to the ChannelsConfig struct and config.toml parsing. The overrides field gives your channel automatic support for model/prompt overrides, DM/group policies, rate limiting, threading, and output format selection.
In crates/openfang-cli/src/main.rs, add a case to cmd_channel_setup with step-by-step instructions for your platform.
Write integration tests. Use the ChannelMessage type to simulate incoming messages without connecting to the real platform.