Core Messaging Channels

These are the most commonly used channel adapters, covering the major consumer messaging platforms and built-in communication options.


Telegram

Prerequisites

Setup

  1. Open Telegram and message @BotFather.
  2. Send /newbot and follow the prompts to create a new bot.
  3. Copy the bot token.
  4. Set the environment variable:
export TELEGRAM_BOT_TOKEN=your-token          # Environment variable
librefang vault set TELEGRAM_BOT_TOKEN        # Encrypted vault (recommended)
librefang config set-key telegram             # .env file
# Or set via dashboard "Set API Key" button   # secrets.env
  1. Add to config:
[[sidecar_channels]]
name = "telegram"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.telegram"]
channel_type = "telegram"
[sidecar_channels.env]
TELEGRAM_BOT_TOKEN = "..."
# ALLOWED_USERS = "123456789,@alice"

The in-process [channels.telegram] block is no longer accepted. Declare Telegram as a [[sidecar_channels]] entry as shown above.

Rust alternative (since #5831). A first-party Rust Telegram sidecar binary at sdk/rust/librefang-sidecar-telegram/ is wire-equivalent to the Python adapter — same TELEGRAM_BOT_TOKEN / ALLOWED_USERS env, same Markdown → Telegram-HTML rendering, same allowed-update set. Pick it when you do not want a Python runtime on the host, when per-respawn startup latency matters, or when the ~3 MB stripped binary beats a Python image. Build with cargo build --release -p librefang-sidecar-telegram and swap the command line above for /abs/path/to/target/release/librefang-sidecar-telegram. Full reference: Rust Telegram sidecar adapter.

  1. Restart the daemon:
librefang start

How It Works

The Telegram sidecar adapter (librefang.sidecar.adapters.telegram, shipped in librefang-sdk) uses long-polling via the getUpdates API with a 30-second server timeout. On API failures it applies supervised exponential backoff. The daemon restarts the subprocess on crash.

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 to respect Telegram's 4096-character limit.

File Attachments

Files attached to Telegram messages arrive as temporary authenticated URLs that the LLM cannot access directly. The bridge 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 then calls file_read); images become ContentBlock::ImageFile. See Channel File Downloads on the overview for file_download_dir / file_download_max_bytes and the stale-file sweeper.

Interactive Setup

librefang channel setup telegram

This walks you through the setup interactively.


Discord

Prerequisites

Setup

  1. Go to Discord Developer Portal.
  2. Click "New Application" and name it.
  3. Go to the Bot section and click "Add Bot".
  4. Copy the bot token.
  5. Under Privileged Gateway Intents, enable:
    • Message Content Intent (required to read message content)
  6. Go to OAuth2 > URL Generator:
    • Select scopes: bot
    • Select permissions: Send Messages, Read Message History
    • Copy the generated URL and open it to invite the bot to your server.
  7. Set the environment variable:
export DISCORD_BOT_TOKEN=your-token           # Environment variable
librefang vault set DISCORD_BOT_TOKEN         # Encrypted vault (recommended)
librefang config set-key discord              # .env file
# Or set via dashboard "Set API Key" button   # secrets.env
  1. Add to config (Discord is an out-of-process sidecar adapter):
[[sidecar_channels]]
name = "discord"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.discord"]
channel_type = "discord"

[sidecar_channels.env]
DISCORD_BOT_TOKEN = "..."
# DISCORD_ALLOWED_GUILDS = "123,456"
# DISCORD_INTENTS = "37376"
  1. Restart the daemon.

How It Works

The Discord sidecar (librefang.sidecar.adapters.discord in the Python SDK) connects to the Discord Gateway via WebSocket (v10) in a supervised subprocess and speaks newline-delimited JSON-RPC over stdio with the daemon. It listens for MESSAGE_CREATE events and emits them as message events; agent replies are POSTed via the REST API's channels/{id}/messages endpoint.

The sidecar handles Gateway reconnection, periodic heartbeats (with RFC-required jitter on the first beat), session resumption, and gateway close-code handling automatically. Daemon-side supervision (restart, exponential backoff, circuit-break) is configured via the common [[sidecar_channels]] fields documented under Sidecar channels.


Slack

Prerequisites

  • A Slack app with Socket Mode enabled

Setup

  1. Go to Slack API and click "Create New App" > "From Scratch".
  2. Enable Socket Mode (Settings > Socket Mode):
    • Generate an App-Level Token with scope connections:write.
    • Copy the token (xapp-...).
  3. Go to OAuth & Permissions and add Bot Token Scopes:
    • chat:write
    • files:write (required for File / FileData attachments sent by agents)
    • files:read (required to forward user-uploaded files and images to the agent)
    • app_mentions:read
    • im:history
    • im:read
    • im:write
    • users:read (only if you enable display-name resolution, below)
  4. Install the app to your workspace.
  5. Copy the Bot User OAuth Token (xoxb-...).
  6. Set the environment variables:
export SLACK_APP_TOKEN=xapp-...               # Environment variable
export SLACK_BOT_TOKEN=xoxb-...
librefang vault set SLACK_APP_TOKEN           # Encrypted vault (recommended)
librefang vault set SLACK_BOT_TOKEN
librefang config set-key slack                # .env file
# Or set via dashboard "Set API Key" button   # secrets.env
  1. Add to config (Slack is an out-of-process sidecar adapter):
[[sidecar_channels]]
name = "slack"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.slack"]
channel_type = "slack"

[sidecar_channels.env]
SLACK_APP_TOKEN = "xapp-..."
SLACK_BOT_TOKEN = "xoxb-..."
# SLACK_FORCE_FLAT_REPLIES = "false"   # set "true" to post replies as top-level messages
# SLACK_REACTIONS = "true"             # set "false" to drop the 👀 / ✅ receipt
# SLACK_PROGRESS_CARD = "true"         # set "false" to drop the task-progress card
# SLACK_FILE_DOWNLOADS = "true"        # set "false" to stop forwarding user uploads to the agent
# SLACK_FILE_MAX_BYTES = "10485760"    # inbound attachment size cap
# SLACK_FILE_ALLOWED_EXTENSIONS = ""   # comma-separated allow-list; empty accepts every extension
# SLACK_FILE_DOWNLOAD_CHANNELS = ""    # comma-separated channel IDs; empty means every channel
# SLACK_FILE_DOWNLOAD_EXCLUDE_CHANNELS = ""   # channel IDs that never accept uploads
# SLACK_RESOLVE_DISPLAY_NAMES = "false"       # set "true" to show human names instead of raw U… ids
# SLACK_ENUMERATE_MEMBERS = "false"           # set "true" to list every channel member, not only those who have spoken
# SLACK_DISPLAY_NAME_TTL = "21600"            # how long a resolved name is cached, in seconds
  1. Restart the daemon.

How It Works

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. Text responses are posted via the chat.postMessage Web API. File and FileData responses use Slack's external upload flow and retain the same thread context. URL-backed files are downloaded only from public HTTP(S) targets and both URL-backed and inline files are capped at 10 MiB. When threading = true, replies are sent to the message's thread via thread_ts.

User Uploads

A file or image dropped into a channel or DM reaches the agent as the message content, so the agent can look at the picture, transcribe the clip or read the document instead of being handed a URL it cannot open. Slack's private file URLs require the bot token, so the adapter declares a per-host authorization rule for Slack's own file hosts and the daemon downloads the file with it; the token is never attached to any other host, which matters because a workspace member can register a remote file pointing anywhere. One attachment per message is forwarded (the wire protocol carries one content value per message) and the attachment takes precedence over the message text, which rides along as its caption. Link-preview images are not followed — those belong to a URL somebody pasted, not to an upload.

Four knobs gate it, all optional: SLACK_FILE_DOWNLOADS turns the whole feature off, SLACK_FILE_MAX_BYTES caps the size (10 MiB by default), SLACK_FILE_ALLOWED_EXTENSIONS restricts which extensions are accepted, and SLACK_FILE_DOWNLOAD_CHANNELS / SLACK_FILE_DOWNLOAD_EXCLUDE_CHANNELS decide which channels participate — the pair a busy channel needs to opt out without disabling uploads everywhere.

Sender Display Names

By default the adapter reports the sender's raw Slack id (U09…) as their display name, and that is what an agent sees and what the group roster stores. Set SLACK_RESOLVE_DISPLAY_NAMES = "true" to resolve it through Slack's users.info instead, which also fills in the @handle. This needs the users:read bot scope; without it every lookup fails and the adapter keeps reporting ids.

Lookups are cached per user id for SLACK_DISPLAY_NAME_TTL seconds (six hours by default), so a busy channel costs a handful of API calls a day rather than one per message — users.info sits in a per-method rate limit that an uncached lookup would exhaust. Absences are cached too: a deleted user, or a bot missing the scope, costs one request rather than one per message. A transient failure (a rate limit, a dropped connection) is only suppressed for a minute, so a workspace that recovers starts resolving names again promptly.

This is off by default because it changes what LibreFang stores, not only what it shows. The daemon persists one roster row per person who speaks in a group (channel, chat_id, user_id, display name, handle, first and last seen), and that row carries whatever this adapter reports. Leaving the knob off keeps the stored value an opaque platform id; turning it on writes real names into ~/.librefang/memory.db until the row is removed. Rows have no automatic expiry today, so treat enabling it as a decision about personal data retention, not a display preference. Operators who want human-readable names for a handful of people without any lookup or storage change can keep using [users] entries, which stay authoritative.

Channel Member Enumeration

By default the daemon knows about the people who have spoken in a channel and nobody else, so channel_members answers "who has talked here?" rather than "who is in here?". Set SLACK_ENUMERATE_MEMBERS = "true" to have every inbound group message carry the channel's full conversations.members list, which channel_members then reports. This needs the channels:read scope for public channels and groups:read for private ones; without them every sweep fails and the roster keeps listing only the speakers.

The list is cached per channel for SLACK_MEMBER_LIST_TTL seconds (an hour by default) and capped at SLACK_MEMBER_LIST_MAX members (500 by default), across all pages. The cap is a data decision, not only a rate-limit one: a company-wide channel lists everyone in the workspace, and each name becomes a stored row. Display names on this path come from the cache that SLACK_RESOLVE_DISPLAY_NAMES fills, and a sweep never issues its own users.info calls — resolving hundreds of people who have never spoken would exhaust that method's rate limit. In practice the people who have spoken carry names and the rest carry ids until they do.

Enumerated members can be listed, never privately messaged. The daemon records them with source = "enumerated", apart from the observed rows it writes for people who actually speak, and channel_dm authorizes only against the observed set. That separation is the point of the feature's shape: filling the existing roster from the platform's member list would have handed the agent a private channel to every member of every channel it sits in, including people who have never addressed it. Speaking promotes someone from enumerated to observed, and a later sweep never demotes them.

This is off by default because it changes how many people LibreFang stores, not just how well it names them. SLACK_RESOLVE_DISPLAY_NAMES improves the record kept about the handful of people who have addressed the agent; this one creates a record for everyone the workspace lists in the channel, most of whom have not interacted with it. The two are independent — enumeration on with name resolution off stores opaque ids, which is the least-data configuration that still answers "who is in this channel?". As with display names, rows have no automatic expiry today, so treat enabling it as a decision about personal data retention.

Processing-State Reactions

The Slack adapter shows live "I'm working on it" feedback by adding 👀 to the message that triggered a turn, then replacing it with ✅ when the turn succeeds or ❌ when it fails. The receipt is driven by the daemon's turn lifecycle, not by receiving the message, so a message the daemon declines to answer — one that misses mention-only group gating, a rate-limited sender, a slash command the bridge handles itself — gets no reaction at all rather than a 👀 that is never cleared. Toggle with the SLACK_REACTIONS environment variable (default true — set to false to disable). already_reacted and no_reaction errors are silently ignored, so reaction failures never block message processing. See Reactions and Processing State on the channels overview.

The multi-step task-progress card (the Block Kit step list a tool-using turn updates in place) is a separate indicator with its own switch, SLACK_PROGRESS_CARD. It defaults to whatever SLACK_REACTIONS is set to, so SLACK_REACTIONS=false still means total silence — but the two can now be set independently: SLACK_REACTIONS=false with SLACK_PROGRESS_CARD=true gives the card without the emoji, and the reverse gives the receipt without the card.


WhatsApp (sidecar)

WhatsApp migrated from the in-process Rust adapter to a Python sidecar (librefang.sidecar.adapters.whatsapp, stdlib-only). The in-process [channels.whatsapp] block is no longer recognised. Both modes are preserved: Cloud API (Meta's official Business API) and Web/QR mode (the Node.js @librefang/whatsapp-gateway Baileys process).

Prerequisites

  • For Cloud API: a Meta Business account with WhatsApp Cloud API access (phone number ID, access token, app secret)
  • For Web/QR mode: Node.js >= 20 on the daemon host and a personal WhatsApp account
  • python3 on the daemon host (no third-party Python packages required)

Cloud API mode setup

  1. Go to Meta for Developers and create a Business App with the WhatsApp product.
  2. Set up a phone number; note the Phone Number ID and Permanent Access Token. Pick any string for the Verify Token.
  3. In the app's Settings, copy the App Secret — that's the HMAC-SHA256 key for X-Hub-Signature-256.
  4. Add to config.toml:
[[sidecar_channels]]
name = "whatsapp"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.whatsapp"]
channel_type = "whatsapp"
[sidecar_channels.env]
WHATSAPP_PHONE_NUMBER_ID = "your-phone-id"
WHATSAPP_WEBHOOK_PORT = "8460"
# WHATSAPP_DM_POLICY = "respond"     # Cloud API webhook mode only
# WHATSAPP_GROUP_POLICY = "all"      # read but currently has no effect
  1. Put WHATSAPP_ACCESS_TOKEN, WHATSAPP_VERIFY_TOKEN, WHATSAPP_APP_SECRET in ~/.librefang/secrets.env.
  2. In the Meta dashboard, set the Webhook URL to https://your-domain.com:8460/webhook (or whatever WHATSAPP_WEBHOOK_PORT / WHATSAPP_WEBHOOK_PATH you chose). Subscribe to messages. Use the same Verify Token.
  3. Restart the daemon.

The sidecar runs its own webhook server, verifies X-Hub-Signature-256 against the App Secret on every inbound, and sends outbound via https://graph.facebook.com/v17.0/{phone_id}/messages. Unlike the legacy Rust adapter (whose start() was a stub that never parsed inbound), this sidecar implements the full Cloud API webhook handler.

Web/QR mode setup

  1. Install + run the Baileys gateway separately:
npx @librefang/whatsapp-gateway
# Listens on http://127.0.0.1:3009 by default
  1. Scan the QR code printed by the gateway with WhatsApp → Linked Devices.
  2. Add to config.toml:
[[sidecar_channels]]
name = "whatsapp"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.whatsapp"]
channel_type = "whatsapp"
[sidecar_channels.env]
WHATSAPP_GATEWAY_URL = "http://localhost:3009"
  1. Restart the daemon.

In Web/QR mode the gateway POSTs inbound directly to LibreFang's REST API (/api/agents/{id}/message), bypassing the sidecar; outbound replies route through the sidecar to {gateway_url}/message/send. The kernel no longer auto-spawns the gateway (the embedded whatsapp_gateway.rs module was removed in this migration) — you must run it as a separate process.

Group chat & DM policy

The sidecar reads the same DM × group policy env vars as the legacy Rust adapter, with two caveats. WHATSAPP_DM_POLICY is enforced in Cloud API webhook mode only — in Web/QR mode the gateway POSTs inbound directly to the REST API and bypasses this filter (see above). WHATSAPP_GROUP_POLICY is accepted but currently has no effect in either mode: the Cloud API webhook treats every inbound as a DM, so the group branch is never reached.

WHATSAPP_DM_POLICYBehaviour (Cloud API webhook mode)
respond (default)Respond to all DMs
allowed_onlyRespond only when sender's phone is in WHATSAPP_ALLOWED_USERS
ignoreDrop all DMs
WHATSAPP_GROUP_POLICYDocumented value (currently inert)
all (default)Respond to all group messages
mention_onlyRespond only when bot phone or WHATSAPP_BOT_NAME appears in the text
commands_onlyRespond only to /cmd messages
ignoreDrop all group messages

For mention_only set WHATSAPP_BOT_PHONE (with +, e.g. +15551234567) and / or WHATSAPP_BOT_NAME (case-insensitive substring).


WeChat (Personal) — sidecar

WeChat is provided by the Python sidecar adapter (librefang.sidecar.adapters.wechat). The in-process [channels.wechat] config block was removed in the sidecar migration. Declare WeChat as a [[sidecar_channels]] entry instead.

Prerequisites

  • A personal WeChat account (iOS 8.0.70+ recommended)
  • WeChat ClawBot plugin access (currently in gradual rollout)
  • python3 on the daemon host (stdlib-only sidecar — no third-party packages)

Setup

  1. Add to config.toml:
[[sidecar_channels]]
name = "wechat"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.wechat"]
channel_type = "wechat"
[sidecar_channels.env]
# WECHAT_BOT_TOKEN = ""        # leave blank to trigger QR login
# WECHAT_ALLOWED_USERS = ""    # comma-separated hash@im.wechat
  1. Start (or restart) the LibreFang daemon. The sidecar will log a QR code at INFO — scan it with your WeChat app to confirm. After the first QR login, paste the bot_token into WECHAT_BOT_TOKEN (in ~/.librefang/secrets.env) to skip the QR flow on subsequent restarts.

How It Works

The WeChat adapter uses Tencent's official iLink protocol (ilinkai.weixin.qq.com), the same protocol behind the WeChat ClawBot plugin. No third-party proxies or unofficial APIs are involved.

Connection flow:

  1. QR Login — calls GET /ilink/bot/get_bot_qrcode to generate a QR code, then polls GET /ilink/bot/get_qrcode_status until the user scans and confirms. Returns a bot_token for all subsequent requests.
  2. Long-Polling — calls POST /ilink/bot/getupdates with a cursor (get_updates_buf). The server holds the connection for up to 35 seconds until new messages arrive, then returns them along with an updated cursor.
  3. Sending — calls POST /ilink/bot/sendmessage with the context_token from the inbound message to associate the reply with the correct conversation.
  4. Typing — calls POST /ilink/bot/sendtyping with a typing_ticket (fetched from POST /ilink/bot/getconfig) to show a typing indicator.

Supported message types: text, image, voice, file, video (all 5 iLink item types).

Reconnection: If a bot_token is configured, the adapter skips QR login and resumes polling immediately. On network errors, exponential backoff is applied (2s → 60s max).

Limitations

  • Media upload is not yet supported (CDN flow with AES-128-ECB encryption). Incoming media is received; outgoing media falls back to text placeholders.
  • Group chat detection is not yet implemented.
  • Streaming responses are not yet supported (messages are sent as complete text).
  • Grayscale rollout — the iLink API may not be available to all WeChat accounts yet.

Signal (sidecar)

Signal migrated from an in-process Rust adapter to an out-of-process Python sidecar (librefang.sidecar.adapters.signal).

Prerequisites

  • A running signal-cli-rest-api instance (Docker image recommended), registered with a phone number.
  • Python 3 + the librefang SDK (pip install -e sdk/python).

Setup

  1. Stand up signal-cli-rest-api (typically as a container behind an HTTPS reverse proxy) and register the bot's phone number with Signal.
  2. Declare the sidecar in ~/.librefang/config.toml:
[[sidecar_channels]]
name = "signal"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.signal"]
channel_type = "signal"
default_agent = "assistant"
[sidecar_channels.env]
SIGNAL_API_URL = "https://signal-cli.example.com"
SIGNAL_NUMBER  = "+15555550100"
# SIGNAL_ALLOWED_USERS = "+15555550199,+15555550200"   # optional
# SIGNAL_POLL_INTERVAL_SECS = "2"                       # optional
# SIGNAL_ALLOW_LOCAL = "1"                              # only if API runs on localhost
  1. (Optional) If signal-cli-rest-api was started with --api-key, add SIGNAL_API_KEY=… to ~/.librefang/secrets.env.
  2. Restart the daemon.

How It Works

The sidecar polls GET /v1/receive/{phone} every SIGNAL_POLL_INTERVAL_SECS (default 2 s) and emits each new dataMessage.message to the kernel over stdio. Outbound messages use POST /v2/send. The SSRF guard rejects SIGNAL_API_URL values that resolve to private / loopback / CGNAT / link-local addresses unless SIGNAL_ALLOW_LOCAL=1 is set explicitly.

Plain-Text Output by Default

Signal output defaults to OutputFormat::PlainText because signal-cli renders Markdown asterisks and underscores literally. The mapping is keyed off the sidecar entry's channel_type = "signal", so the default still applies after the migration. Per-channel output_format overrides on [[sidecar_channels]] are not currently exposed — flip the format on the agent if you need Markdown — see Signal Plain-Text Default on the channels overview.

Media Attachments

The Rust adapter delivered Image, Voice, Video, Audio, Animation, File, FileData, and MediaGroup content blocks via base64_attachments on /v2/send. The sidecar currently routes anything other than text to the standard (Unsupported content type) placeholder; a follow-up will re-wire base64 attachments end-to-end through the sidecar runtime.


Matrix (sidecar)

Matrix migrated from an in-process Rust adapter to an out-of-process Python sidecar (librefang.sidecar.adapters.matrix).

Prerequisites

  • A Matrix homeserver account and access token (Element-issued token works).
  • Python 3 + the librefang SDK (pip install -e sdk/python).

Setup

  1. Create a bot account on your Matrix homeserver (or use an existing one).
  2. Generate an access token (Element settings → Help & About → Access Token, or POST /_matrix/client/v3/login).
  3. Put the token in ~/.librefang/secrets.env:
MATRIX_ACCESS_TOKEN=syt_...
  1. Declare the sidecar in ~/.librefang/config.toml:
[[sidecar_channels]]
name = "matrix"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.matrix"]
channel_type = "matrix"
default_agent = "assistant"
[sidecar_channels.env]
MATRIX_HOMESERVER_URL = "https://matrix.org"
MATRIX_USER_ID = "@librefang-bot:matrix.org"
# MATRIX_ALLOWED_ROOMS = "!abc:matrix.org,!def:matrix.org"  # optional
# MATRIX_ACCOUNT_ID = "prod-bot"                             # optional
# MATRIX_MAX_UPLOAD_BYTES = "52428800"                       # optional, default 50 MiB
  1. Invite the bot to the rooms you want it to monitor.
  2. Restart the daemon.

How It Works

The sidecar polls GET /_matrix/client/v3/sync with the bot's access token, 30 s server timeout, since cursor for incremental delivery. Inbound m.room.message events (m.text / m.notice / m.emote / m.image / m.file / m.audio / m.video) are emitted to the kernel; outbound replies use PUT /_matrix/client/v3/rooms/{room}/send/{type}/{txn}. Markdown is rendered to Matrix's HTML subset for formatted_body; m.thread and m.replace (edit) relations are wired through so threaded replies and streaming edits work out of the box.


Email

Prerequisites

  • An email account with IMAP and SMTP access

Setup

  1. For Gmail, create an App Password.
  2. Set the environment variable:
export EMAIL_PASSWORD=your-password           # Environment variable
librefang vault set EMAIL_PASSWORD            # Encrypted vault (recommended)
librefang config set-key email                # .env file
# Or set via dashboard "Set API Key" button   # secrets.env
  1. Add to config (email runs as an out-of-process sidecar adapter, librefang.sidecar.adapters.email):
[[sidecar_channels]]
name = "email"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.email"]
channel_type = "email"
[sidecar_channels.env]
EMAIL_IMAP_HOST = "imap.gmail.com"
EMAIL_SMTP_HOST = "smtp.gmail.com"
EMAIL_USERNAME = "you@gmail.com"
# EMAIL_IMAP_PORT = "993"
# EMAIL_SMTP_PORT = "587"
# EMAIL_POLL_INTERVAL_SECS = "30"

EMAIL_PASSWORD belongs in ~/.librefang/secrets.env.

  1. Hot-reload (curl -X POST http://127.0.0.1:4545/api/channels/reload) or restart the daemon.

How It Works

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.


WebChat (Built-in)

The WebChat UI is embedded in the daemon and requires no configuration. When the daemon is running:

http://127.0.0.1:4545/

Features:

  • Real-time chat via WebSocket
  • Streaming responses (text deltas as they arrive)
  • Agent selection (switch between running agents)
  • Token usage display
  • No authentication required on localhost (protected by CORS)

Message Truncation for Platform Limits

Source: librefang-channels/src/message_truncator.rs

Messaging platforms impose hard character limits on outbound messages, counted in UTF-16 code units rather than bytes or Unicode code points. LibreFang measures message length in UTF-16 units and handles oversized messages automatically.

Platform Limits

PlatformUTF-16 unit limitBehavior when exceeded
Telegram4 096Message is split into multiple sequential messages
Discord2 000Message is split into multiple sequential messages
Other channelsConfigurableTruncated by default unless split is enabled

How UTF-16 Length Differs from Byte Length

Most ASCII text has the same byte count and UTF-16 unit count. The difference matters for:

  • Emoji (😀): 2 UTF-16 units, but 4 bytes in UTF-8
  • Supplementary CJK characters: 2 UTF-16 units, 4 bytes in UTF-8
  • Basic CJK / Korean / Arabic: 1 UTF-16 unit, 3 bytes in UTF-8

A string that fits in 2 000 bytes may still exceed 2 000 UTF-16 units, or vice versa. Using byte length as a proxy silently produces truncated or rejected messages.

Truncation

truncate_to_utf16_limit(text, limit) uses binary search over the string's UTF-16 unit positions to find the longest prefix that fits, then cuts at a valid Unicode scalar boundary. The result is never split mid-codepoint or mid-surrogate-pair.

Splitting

split_to_utf16_chunks(text, limit) divides the message into sequential chunks, each within limit UTF-16 units. LibreFang sends the chunks as separate messages in order, preserving all content. The split boundary respects Unicode scalar boundaries — no chunk ends in the middle of a character.

Splitting and chunking are now sidecar-side

Every channel adapter runs out-of-process as a sidecar now, so each sidecar owns its own chunking at platform limits — the pre-migration split_long_messages knob on [channels.<name>] is gone with the rest of ChannelsConfig. Sidecars cannot be told to truncate instead of split from the daemon side; if you need that behaviour for a custom sidecar, implement it inside that sidecar.