Channel Adapters

LibreFang connects to messaging platforms through 45 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, generic webhooks, and extensible sidecar processes.

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).

Table of Contents

  • Core Messaging — Telegram, Discord, Slack, WhatsApp, WeChat, Signal, Matrix, Email, WebChat
  • Enterprise — Teams, Mattermost, Google Chat, Webex, Feishu, Rocket.Chat, Zulip, Flock, Twist, Pumble, Guilded
  • Social & Community — Reddit, Mastodon, Bluesky, LinkedIn, Twitch, Discourse, Gitter, Revolt, Keybase, Nostr
  • Integrations & Protocols — LINE, Viber, Messenger, DingTalk, QQ, WeCom, Threema, IRC, XMPP, MQTT, Ntfy, Gotify, Nextcloud, Mumble, Webhook, Voice
  • Custom Adapters — Writing your own adapter

Channel Configuration

All channel configurations live in ~/.librefang/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"

# IoT/MQTT example (requires all-channels feature flag)
[channels.mqtt]
host = "broker.example.com"
port = 1883
client_id = "librefang-bot"
subscribe_topics = ["home/agents/input", "home/agents/commands"]
response_topic = "home/agents/output"
qos = "at_least_once"                # at_most_once | at_least_once | exactly_once
# username_env = "MQTT_USERNAME"     # optional
# password_env = "MQTT_PASSWORD"     # optional
keep_alive_secs = 30
default_agent = "home-assistant"

Common Fields

  • bot_token_env / token_env -- The environment variable holding the bot/access token. LibreFang 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. For Telegram specifically, entries can be numeric user IDs ("123456789"), usernames ("alice"), or usernames prefixed with @ ("@alice"); matching is case-insensitive.
  • overrides -- Optional per-channel behavior overrides (see Channel Overrides below).

Environment Variables Reference (Core Channels)

ChannelRequired Env Vars
TelegramTELEGRAM_BOT_TOKEN
DiscordDISCORD_BOT_TOKEN
SlackSLACK_BOT_TOKEN, SLACK_APP_TOKEN
WhatsAppWA_ACCESS_TOKEN, WA_PHONE_ID, WA_VERIFY_TOKEN
MatrixMATRIX_TOKEN
EmailEMAIL_PASSWORD

Env vars for each channel are listed in their respective setup sections below.


Channel Overrides

Every channel adapter supports ChannelOverrides, which let you customize per-channel behaviour at two levels:

  • Channel-level in config.toml — applies to every agent that uses that channel. Add a [channels.<name>.overrides] section.
  • Agent-level in agent.toml — applies only to that specific agent on every channel. Add a top-level [channel_overrides] section.

Resolution order: agent-level → channel-level → built-in default. Whichever sets the field wins; missing fields fall through to the next layer (same pattern as exec_policy).

# agent.toml — per-agent override (e.g. "this agent answers every DM")
[channel_overrides]
dm_policy = "always"
group_policy = "trigger_only"
group_trigger_patterns = ["(?i)\\bmy-bot\\b"]
# config.toml — channel-level default for every agent on that channel
[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"

Override Fields

FieldTypeDefaultDescription
modelOption<String>Agent defaultOverride the LLM model for this channel.
system_promptOption<String>Agent defaultOverride the system prompt for this channel.
dm_policyDmPolicyRespondHow to handle direct messages.
group_policyGroupPolicyMentionOnlyHow to handle group/channel messages.
rate_limit_per_useru320 (unlimited)Max messages per minute per user.
threadingboolfalseSend replies as thread responses (platforms that support it).
output_formatOption<OutputFormat>MarkdownOutput format for this channel.
usage_footerOption<UsageFooterMode>NoneWhether to append token usage to responses.

Formatter, Rate Limiter, and Policies

Output Formatter

The formatter module (librefang-channels/src/formatter.rs) converts Markdown output from the LLM into platform-native formats:

OutputFormatTargetNotes
MarkdownStandard MarkdownDefault; passed through as-is.
TelegramHtmlTelegram HTML subsetConverts **bold** to <b>, `code` to <code>, etc.
SlackMrkdwnSlack mrkdwnConverts **bold** to *bold*, links to <url|text>, etc.
PlainTextPlain textStrips all formatting.

Per-User Rate Limiter

The ChannelRateLimiter (librefang-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.

DM Policy

Controls how the adapter handles direct messages:

DmPolicyBehavior
RespondRespond to all DMs (default).
AllowedOnlyOnly respond to DMs from users in allowed_users.
IgnoreSilently drop all DMs.

Group Policy

Controls how the adapter handles messages in group chats, channels, and rooms:

GroupPolicyBehavior
AllRespond to every message in the group.
MentionOnlyOnly respond when the bot is @mentioned (default).
CommandsOnlyOnly respond to /command messages.
IgnoreSilently 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.


Agent Routing

The AgentRouter determines which agent receives an incoming message. The routing logic is:

  1. Per-channel default: Each channel config has a default_agent field. Messages from that channel go to that agent.
  2. User-agent binding: If a user has previously been associated with a specific agent (via commands or configuration), messages from that user route to that agent.
  3. Command prefix: Users can switch agents by sending a command like /agent coder in the chat. Subsequent messages will be routed to the "coder" agent.
  4. Fallback: If no routing applies, messages go to the first available agent.

When a channel has default_agent configured, messages from that channel bypass semantic and keyword routing and go directly to the specified agent. Users can still switch agents manually via the /agent command.


Outbound Tagging

Set prefix_agent_name in ChannelOverrides to prepend the agent's name to every outbound message. Useful when several agents share one channel — readers can tell at a glance which agent replied.

StyleWraps reply as
Off (default)unchanged — byte-identical to the agent's text
Bracket[agent-name] reply text
BoldBracket**[agent-name]** reply text (bold rendering depends on the channel's output format)
[channels.telegram.overrides]
prefix_agent_name = "BoldBracket"

The wrap is applied once on every non-streaming success path (auto_reply, kernel-streaming-with-status accumulated, streaming-fallback buffered_text, non-streaming fallback, retry after re-resolution, dispatch_with_blocks). Streaming tee (where each chunk is forwarded as it arrives) does not wrap — the prefix would interleave with chunk boundaries.

Signal Plain-Text Default

The Signal adapter defaults to OutputFormat::PlainText. signal-cli renders Markdown asterisks and underscores literally, so leaving them in produced visible noise. To re-enable Markdown, override the format on the agent or channel:

[channels.signal.overrides]
output_format = "markdown_v2"

Other channels keep their previous formatter defaults — only Signal flipped.

Reactions and Processing State

Several adapters can show "I'm working on it" feedback by attaching a reaction to the user's message and removing it once the reply is sent. This is per-channel:

  • Slackreactions_enabled toggle (env var SLACK_REACTIONS, default true). Adds 👀 on receive, replaces with ✅ on reply. already_reacted / no_reaction errors are silently ignored (fail-open).
  • Feishu — adds a Typing reaction on receive, removes it on reply send. Both calls are fire-and-forget; API failures warn! but never block message processing.

Add the same on a custom adapter by spawning the reaction call as a tokio::spawn task and storing (reaction_id, message_id) in a per-chat map keyed off the chat id.

Feishu @Mention Preservation

Feishu used to silently drop @_user_N placeholders from incoming text. The Feishu adapter now substitutes each placeholder with @<display-name> resolved from the mention payload (falling back to open_id when name is absent), and rewrites @_all to @all. Agents see the original conversational tone — "@alice can you check this?" — instead of bare punctuation.

Signal Media Attachments

The Signal adapter now delivers Image, Voice, Video, Audio, Animation, File, FileData, and MediaGroup content blocks. Media URLs are downloaded and base64-encoded into base64_attachments on /v2/send. MediaGroup recurses into one send() per item. Unsupported types (Poll, Sticker, …) warn! and degrade gracefully — text-only fallback or skip — rather than fail the whole reply.

WhatsApp Voice + DM/Group Policies

The WhatsApp adapter exposes:

  • send_voice(to, audio, mime_type) — Cloud API mode uploads via /{phone_number_id}/media then sends an audio message with the returned media_id; gateway mode base64-encodes and POSTs to /message/send-voice.
  • dm_policy: DmPolicy (default Respond) and group_policy: GroupPolicy (default MentionOnly) on the adapter — the same shape as Telegram. Builder methods: with_dm_policy(), with_group_policy(), with_bot_phone(), with_bot_name().
  • WhatsAppAdapter::should_handle_message(is_group, text) -> bool for bridge / webhook entry-point filtering.

MentionOnly checks whether the message text contains the bot phone or bot name; TriggerOnly checks group_trigger_patterns; Always and Never short-circuit.

Webhook deliver_only Mode

The webhook channel can run as a pass-through: incoming payloads bypass the LLM entirely (no sanitizer, no rate limiter, no agent lookup) and forward straight to a target channel. Useful for push notifications and out-of-band alerts where you only need the message to land in Telegram / Slack / etc.

[channels.webhook]
enabled = true
deliver_only = true
deliver = "telegram:123456789"     # required when deliver_only is true

When deliver_only = true but deliver is unset, librefang emits a startup warn and the webhook stays inactive. The fan-out is signalled internally via __deliver_only__ / __deliver_target__ metadata so adapter trait signatures stay unchanged.

Channel File Downloads

Files attached on Telegram (and other channels) come through as temporary authenticated URLs that the LLM cannot access directly. The bridge now downloads them to a configurable directory and rewrites the message into local-path content blocks — non-image files become a ContentBlock::Text with the saved path (the agent calls file_read), images become ContentBlock::ImageFile.

[channels]
file_download_dir = "/var/lib/librefang/channel-files"   # default: ~/.librefang/data/channel-files
file_download_max_bytes = 33554432                       # default: 32 MiB

A startup sweep removes stale files older than 24 h; an additional probabilistic 1-in-256 sweep runs during downloads so a long-running daemon doesn't accumulate orphaned attachments.