# Channel Configuration

Configuration for all 45 channel adapters, per-channel behavior overrides, and sidecar channel integration.

---

### `[channels]`

All in-process channel adapters are configured under `[channels.<name>]`. Each channel is `Option<T>` -- omitting the section disables the adapter entirely. Including the section header (even empty) enables it with default values.

**Universal channel fields:** Every channel adapter supports the following common fields in addition to its own specific fields:

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `default_agent` | string or null | `null` | Agent name to route messages to by default. |
| `account_id` | string or null | `null` | Unique identifier for this bot instance. Used for multi-bot routing via `[[bindings]]` match rules. |
| `overrides` | object | (defaults) | Per-channel behavior overrides. See [Channel Overrides](#channel-overrides). |

### Webhook security

Channels that receive inbound traffic over HTTP enforce a uniform contract:

| Outcome | Status |
|---|---|
| Required header missing or malformed (`X-Line-Signature`, `Authorization: HMAC …`, DingTalk `timestamp`/`sign`) | **400 Bad Request** |
| Header present but signature mismatched, replayed, or expired | **401 Unauthorized** |

HMAC verification covers the **raw wire bytes** (not bytes round-tripped through `serde_json::Value`), so the signing key set in the platform portal must match the env var configured below. Comparison runs in constant time.

Channels with a per-secret env var (Teams `security_token_env`) fall back to "skip verification + log a startup warning" when the env var is unset — this preserves backwards compatibility but should never be left unset in production.

Outbound webhook URLs (`callback_url` on `[channels.webhook]`) are run through an SSRF guard at adapter construction: any private (`10/8`, `172.16/12`, `192.168/16`), CGN (`100.64/10`), loopback (`127/8`, `::1`), link-local (`169.254/16`, `fe80::/10`), unique-local (`fc00::/7`), multicast, or cloud-metadata (`169.254.169.254`, `metadata.google.internal`, `metadata.azure.com`) target — including IPv6-bracket forms like `[::]`, `[::ffff:127.0.0.1]`, NAT64 `[64:ff9b::7f00:1]`, and trailing-dot FQDNs — is rejected with a startup error.

#### Upgrading from earlier versions

If you are upgrading from a release before this contract was introduced:

1. **Operating Teams?** Copy the outgoing-webhook **security token** (base64) from the Teams portal and export it as `TEAMS_SECURITY_TOKEN` (or whatever you set `security_token_env` to). Without it, inbound webhooks still work but are unauthenticated — a warning is logged once at startup.
2. **Operating LINE / DingTalk?** No new env var to set, but probes/health-checks that hit the webhook path without the platform's signature header now return `400`/`401`. Real platform traffic always carries a signature, so genuine inbound is unaffected; only direct probes break.
3. **Using `[channels.webhook]` with `callback_url` pointing at a private address?** The most common case is a local dev setup with `callback_url = "http://127.0.0.1/..."`. The adapter now refuses to start with that. Switch to a public tunnel (e.g. ngrok, cloudflared) or omit `callback_url` entirely if you don't need outbound delivery.

If you operate **none** of the channels above, no action is required — your existing config keeps working unchanged.

#### Telegram

Telegram is provided by the Python sidecar adapter (`librefang.sidecar.adapters.telegram`). The in-process `[channels.telegram]` config block was removed in #5241. Declare Telegram as a `[[sidecar_channels]]` entry instead:

```toml
[[sidecar_channels]]
name = "telegram"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.telegram"]
channel_type = "telegram"
[sidecar_channels.env]
TELEGRAM_BOT_TOKEN = "..."
# ALLOWED_USERS = "111,@alice"
```

See [`[[sidecar_channels]]`](#sidecar_channels) below for all fields. The sidecar preserves the same runtime semantics: `TELEGRAM_BOT_TOKEN` env var, optional `ALLOWED_USERS` allowlist, `channel_type = "telegram"`, and RBAC mapping via `channel_role_mapping.telegram`.

#### Discord (sidecar)

Discord migrated to an out-of-process sidecar in v2026.5; declare it as a
`[[sidecar_channels]]` block. The sidecar preserves runtime semantics
of the removed in-process adapter: `DISCORD_BOT_TOKEN` env var, gateway
intents, `allowed_guilds` / `allowed_users` filters, and RBAC mapping
via `channel_role_mapping.discord`.

```toml
[[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_ALLOWED_USERS = "789"
# DISCORD_INTENTS = "37376"
# DISCORD_IGNORE_BOTS = "true"
# DISCORD_MENTION_PATTERNS = "hey bot,!ask"
# DISCORD_ACCOUNT_ID = "guild-42"
```

See [`[[sidecar_channels]]`](#sidecar_channels) below for all
supervisor fields (restart, backoff, ready timeout, message buffer).

#### Slack (sidecar)

Slack migrated to an out-of-process sidecar in v2026.5; declare it as a
`[[sidecar_channels]]` block. The sidecar preserves the runtime semantics
of the removed in-process adapter: Socket Mode WebSocket + Web API,
`SLACK_ALLOWED_CHANNELS` filter (DMs exempt), thread-reply context,
Block Kit interactive callbacks, eyes / check reactions, and multi-bot
routing via `SLACK_ACCOUNT_ID`.

```toml
[[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_ALLOWED_CHANNELS = "C0123,C0456"
# SLACK_UNFURL_LINKS = "false"
# SLACK_FORCE_FLAT_REPLIES = "false"
# SLACK_REACTIONS = "true"
# SLACK_ACCOUNT_ID = "workspace-prod"
```

See [`[[sidecar_channels]]`](#sidecar_channels) below for all
supervisor fields (restart, backoff, ready timeout, message buffer).

#### 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. The sidecar covers **both** modes — Meta Cloud API and the legacy Web/QR (Baileys) gateway.

**Cloud API mode** (recommended — production):

```toml
[[sidecar_channels]]
name = "whatsapp"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.whatsapp"]
channel_type = "whatsapp"
[sidecar_channels.env]
WHATSAPP_PHONE_NUMBER_ID = "123456789012345"
WHATSAPP_WEBHOOK_PORT = "8460"
# WHATSAPP_WEBHOOK_PATH = "/webhook"
# WHATSAPP_ALLOWED_USERS = "+15551111,+15552222"
# WHATSAPP_ACCOUNT_ID = "production"
# WHATSAPP_DM_POLICY = "respond"          # respond / allowed_only / ignore
# WHATSAPP_GROUP_POLICY = "all"           # all / mention_only / commands_only / ignore
```

`WHATSAPP_ACCESS_TOKEN` (Cloud API access token), `WHATSAPP_VERIFY_TOKEN` (for Meta's webhook subscription handshake), and `WHATSAPP_APP_SECRET` (HMAC-SHA256 key for inbound `X-Hub-Signature-256`) belong in `~/.librefang/secrets.env`. The sidecar runs its own webhook server on `WHATSAPP_WEBHOOK_PORT` (default 8460) — Meta's Webhook subscription URL becomes `https://<host>:<WHATSAPP_WEBHOOK_PORT><WHATSAPP_WEBHOOK_PATH>`.

The in-process Rust adapter's `start()` (whatsapp.rs:454-483) was a TODO stub that logged "webhook ready" but never parsed inbound activities — the sidecar implements the real handler, so Cloud API inbound now works end-to-end without a separate forwarder.

**Web/QR mode** (legacy — personal accounts, no Meta dev account needed):

```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"
# WHATSAPP_ALLOWED_USERS = "+15551111,+15552222"
# WHATSAPP_ACCOUNT_ID = "personal"
```

The Baileys gateway (`@librefang/whatsapp-gateway`, Node.js, runs at `WHATSAPP_GATEWAY_URL`) must be **started separately** — the kernel no longer embeds or auto-spawns it. Inbound messages flow directly from the gateway to LibreFang's REST API (`POST /api/agents/{id}/message`), bypassing the sidecar; outbound replies route through the sidecar to `{gateway_url}/message/send`.

#### Signal (sidecar)

Signal migrated from an in-process Rust adapter to an out-of-process Python sidecar (`librefang.sidecar.adapters.signal`). The sidecar talks to a separately-run `signal-cli-rest-api` container. An existing `[channels.signal]` block is no longer recognised — re-declare as `[[sidecar_channels]]`:

```toml
[[sidecar_channels]]
name = "signal"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.signal"]
channel_type = "signal"
[sidecar_channels.env]
SIGNAL_API_URL = "https://signal-cli.example.com"
SIGNAL_NUMBER  = "+15555550100"
# SIGNAL_ALLOWED_USERS = "+15555550199,+15555550200"  # optional
# SIGNAL_ACCOUNT_ID = "prod-bot"                       # optional
# SIGNAL_POLL_INTERVAL_SECS = "2"                      # optional
# SIGNAL_ALLOW_LOCAL = "1"                             # opt-in SSRF bypass for localhost
```

The sidecar enforces the same SSRF guard as the Rust adapter — `SIGNAL_API_URL` must resolve to a public address unless `SIGNAL_ALLOW_LOCAL=1` is set. Optional `SIGNAL_API_KEY` belongs in `~/.librefang/secrets.env`.

#### Matrix (sidecar)

Matrix migrated from an in-process Rust adapter to an out-of-process Python sidecar (`librefang.sidecar.adapters.matrix`). The sidecar polls `/sync` on the configured homeserver via the Client-Server API. An existing `[channels.matrix]` block is no longer recognised — re-declare as `[[sidecar_channels]]`:

```toml
[[sidecar_channels]]
name = "matrix"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.matrix"]
channel_type = "matrix"
[sidecar_channels.env]
MATRIX_HOMESERVER_URL = "https://matrix.org"
MATRIX_USER_ID = "@librefang: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
```

Secret via `~/.librefang/secrets.env`: `MATRIX_ACCESS_TOKEN` (the bot's access token from the homeserver — same shape Element uses).

#### Email (IMAP + SMTP) (sidecar)

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

```toml
[[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 = "bot@example.com"
# EMAIL_IMAP_PORT = "993"
# EMAIL_SMTP_PORT = "587"
# EMAIL_POLL_INTERVAL_SECS = "30"      # IMAP polling interval
# EMAIL_FOLDERS = "INBOX"              # comma-separated
# EMAIL_ALLOWED_SENDERS = ""           # exact addr or @domain, csv
# EMAIL_ACCOUNT_ID = "prod"            # multi-bot routing key
# EMAIL_TLS_ROOT_CA_PATH = ""          # additional CA bundle (PEM)
# EMAIL_TLS_ACCEPT_INVALID_CERTS = ""  # last-resort dev escape hatch
```

`EMAIL_PASSWORD` (and the optional per-protocol overrides `EMAIL_IMAP_PASSWORD` / `EMAIL_SMTP_PASSWORD`) go into `~/.librefang/secrets.env` — the dashboard's Channels page writes them there when you fill the form. The sidecar polls IMAP with `UID SEARCH UNSEEN UNKEYWORD Librefang-Quarantine` (falls back to plain `UNSEEN` when the server rejects the keyword), retries `LOGIN` → `AUTHENTICATE PLAIN` for servers like Lark that only advertise SASL PLAIN, and threads outbound replies via `In-Reply-To` + `References` from a per-sender cache.

#### Microsoft Teams (sidecar)

Teams migrated from the in-process Rust adapter to an out-of-process Python sidecar (`librefang.sidecar.adapters.teams`). The in-process `[channels.teams]` config block is no longer recognised — re-declare as `[[sidecar_channels]]`:

```toml
[[sidecar_channels]]
name = "teams"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.teams"]
channel_type = "teams"
[sidecar_channels.env]
TEAMS_APP_ID = "00000000-0000-0000-0000-000000000000"
TEAMS_WEBHOOK_PORT = "8459"
# TEAMS_WEBHOOK_PATH = "/webhook"
# TEAMS_ALLOWED_TENANTS = "tenant-a,tenant-b"     # empty = all
# TEAMS_ACCOUNT_ID = "production"
```

`TEAMS_APP_PASSWORD` (Bot Framework client secret) and `TEAMS_SECURITY_TOKEN` (base64 outgoing-webhook token from the Teams portal — gates HMAC-SHA256 verification of every inbound) go into `~/.librefang/secrets.env`. The sidecar runs its own webhook server on `TEAMS_WEBHOOK_PORT` (default 8459) — the public URL operators register in the Azure Bot Channel configuration changes from `https://<host>/channels/teams/webhook` to `https://<host>:<TEAMS_WEBHOOK_PORT><TEAMS_WEBHOOK_PATH>`.

#### Mattermost (sidecar)

Mattermost migrated from an in-process Rust adapter to an out-of-process Python sidecar (`librefang.sidecar.adapters.mattermost`). An existing `[channels.mattermost]` block is no longer recognised — re-declare as `[[sidecar_channels]]`:

```toml
[[sidecar_channels]]
name = "mattermost"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.mattermost"]
channel_type = "mattermost"
[sidecar_channels.env]
MATTERMOST_SERVER_URL = "https://mattermost.example.com"
# MATTERMOST_ALLOWED_CHANNELS = "ch-id-1,ch-id-2"   # optional
# MATTERMOST_ACCOUNT_ID = "team-prod"               # optional
```

`MATTERMOST_TOKEN` belongs in `~/.librefang/secrets.env`.

#### Google Chat

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

```toml
[[sidecar_channels]]
name = "google_chat"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.google_chat"]
channel_type = "google_chat"
[sidecar_channels.env]
GOOGLE_CHAT_WEBHOOK_PORT = "8090"
# GOOGLE_CHAT_SPACE_IDS = "spaces/AAAA,spaces/BBBB"   # optional, empty = all spaces
# GOOGLE_CHAT_ACCOUNT_ID = "prod"                     # optional, multi-bot routing
# GOOGLE_CHAT_API_BASE = "https://chat.googleapis.com/v1"  # testing override
```

`GOOGLE_CHAT_SERVICE_ACCOUNT_JSON` (the full JSON blob — **not** a path) belongs in `~/.librefang/secrets.env`. The Google Cloud Console Bot configuration messaging endpoint should point at `https://<host>:<GOOGLE_CHAT_WEBHOOK_PORT>/webhook`.

#### Twitch

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

```toml
[[sidecar_channels]]
name = "twitch"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.twitch"]
channel_type = "twitch"
[sidecar_channels.env]
TWITCH_NICK = "librefang-bot"
TWITCH_CHANNELS = "channel1,channel2"      # comma-separated, no '#'
# TWITCH_ACCOUNT_ID = "prod"                # optional, multi-bot routing
# TWITCH_RATE_LIMIT_MSGS = "20"             # 20/30s for unmodded; 100 if bot is mod
# TWITCH_RATE_LIMIT_SECS = "30"
```

`TWITCH_OAUTH_TOKEN` belongs in `~/.librefang/secrets.env`. The sidecar defaults to TLS on `irc.chat.twitch.tv:6697`; plaintext is reachable only via `TWITCH_PLAINTEXT=1` for local mock listeners.

#### Rocket.Chat

Rocket.Chat is provided by the Python sidecar adapter (`librefang.sidecar.adapters.rocketchat`). The in-process `[channels.rocketchat]` block was removed in the sidecar migration. Declare Rocket.Chat as a `[[sidecar_channels]]` entry instead:

```bash
[[sidecar_channels]]
name = "rocketchat"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.rocketchat"]
channel_type = "rocketchat"
[sidecar_channels.env]
ROCKETCHAT_SERVER_URL = "https://chat.example.com"
ROCKETCHAT_USER_ID = "abc123"
# ROCKETCHAT_CHANNELS = "GENERAL,room2"        # optional; empty = all joined
# ROCKETCHAT_ACCOUNT_ID = "prod"                # optional, multi-bot routing
```

`ROCKETCHAT_TOKEN` belongs in `~/.librefang/secrets.env` — the dashboard's Channels page writes it there when you fill the Rocket.Chat form.

#### Zulip

Zulip is provided by the Python sidecar adapter (`librefang.sidecar.adapters.zulip`). The in-process `[channels.zulip]` config block was removed in the sidecar migration. Declare Zulip as a `[[sidecar_channels]]` entry instead — see the configuration reference's "Zulip" section above for the full block.

#### LINE

LINE is provided by the Python sidecar adapter (`librefang.sidecar.adapters.line`). The in-process `[channels.line]` config block was removed in the sidecar migration. Declare LINE as a `[[sidecar_channels]]` entry instead — the sidecar runs its **own** HTTP webhook server (no longer mounted on the LibreFang API port), so the URL you register at the LINE Developers Console is now `https://<your-sidecar-host>:<LINE_WEBHOOK_PORT><LINE_WEBHOOK_PATH>`:

```toml
[[sidecar_channels]]
name = "line"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.line"]
channel_type = "line"
[sidecar_channels.env]
LINE_WEBHOOK_PORT = "9090"
# LINE_WEBHOOK_PATH = "/webhook"   # override the default path
# LINE_ACCOUNT_ID = "production"   # optional, multi-bot routing key
```

Set both `LINE_CHANNEL_SECRET` (used to verify the inbound `X-Line-Signature` HMAC-SHA256 over the raw request body) and `LINE_CHANNEL_ACCESS_TOKEN` (Bearer auth on outbound `POST /v2/bot/message/push`) in `~/.librefang/secrets.env`.

#### Reddit

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

```toml
[[sidecar_channels]]
name = "reddit"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.reddit"]
channel_type = "reddit"
[sidecar_channels.env]
REDDIT_CLIENT_ID = "abc123"
REDDIT_USERNAME = "librefang-bot"
REDDIT_SUBREDDITS = "rust,programming"   # comma-separated
# REDDIT_ACCOUNT_ID = "prod"              # optional, multi-bot routing key
# REDDIT_USER_AGENT = "myorg-bot/1.0 (by /u/me)"   # override default UA
```

`REDDIT_CLIENT_SECRET` and `REDDIT_PASSWORD` belong in `~/.librefang/secrets.env` — the dashboard's Channels page writes them there when you fill the Reddit form.

#### Mastodon

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

```toml
[[sidecar_channels]]
name = "mastodon"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.mastodon"]
channel_type = "mastodon"
[sidecar_channels.env]
MASTODON_INSTANCE_URL = "https://mastodon.social"
# MASTODON_VISIBILITY = "unlisted"          # public | unlisted | private | direct
# MASTODON_MAX_MESSAGE_LEN = "500"          # raise for higher-limit instances
# MASTODON_ACCOUNT_ID = "prod"              # optional, multi-bot routing key
```

`MASTODON_ACCESS_TOKEN` (OAuth bearer) goes into `~/.librefang/secrets.env` — the dashboard's Channels page writes it there when you fill the Mastodon form. The sidecar inherits SSE-with-polling-fallback semantics from the removed in-process adapter.

#### Bluesky

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

```toml
[[sidecar_channels]]
name = "bluesky"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.bluesky"]
channel_type = "bluesky"
[sidecar_channels.env]
BLUESKY_IDENTIFIER = "mybot.bsky.social"
# BLUESKY_SERVICE_URL = "https://bsky.social"   # custom PDS
# BLUESKY_ACCOUNT_ID = "prod"                   # optional, multi-bot routing key
```

`BLUESKY_APP_PASSWORD` goes into `~/.librefang/secrets.env` — the dashboard's Channels page writes it there when you fill the Bluesky form. The sidecar polls `app.bsky.notification.listNotifications` every 5 s and threads outbound replies back to the originating notification via an in-memory LRU cache (capacity 200).

#### Feishu / Lark (sidecar)

Feishu / Lark migrated to an out-of-process sidecar; declare it as a `[[sidecar_channels]]` block instead of `[channels.feishu]`.

```bash
[[sidecar_channels]]
name = "feishu"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.feishu"]
channel_type = "feishu"
[sidecar_channels.env]
FEISHU_APP_ID = "cli_abc123"
# FEISHU_REGION = "cn"                       # cn (default) or intl (Lark)
# FEISHU_RECEIVE_MODE = "websocket"          # websocket (default) or webhook
# FEISHU_WEBHOOK_PORT = "8453"               # webhook mode only
# FEISHU_VERIFICATION_TOKEN = "..."          # webhook mode only
# FEISHU_ENCRYPT_KEY = "..."                 # webhook mode only (AES-256-CBC payloads)
# FEISHU_ACCOUNT_ID = "prod"                 # optional, multi-bot routing key
```

`FEISHU_APP_SECRET` goes into `~/.librefang/secrets.env` — the dashboard's Channels page writes it there when you fill the Feishu form. The default `websocket` mode opens a long-lived connection to Feishu's event gateway (no public IP required). `webhook` mode listens on `FEISHU_WEBHOOK_PORT` and supports encrypted payloads (AES-256-CBC + PKCS7) when `FEISHU_ENCRYPT_KEY` is configured.

#### Nextcloud Talk

Nextcloud Talk is provided by the Python sidecar adapter (`librefang.sidecar.adapters.nextcloud`). The in-process `[channels.nextcloud]` block was removed in the sidecar migration. Declare Nextcloud as a `[[sidecar_channels]]` entry instead:

```bash
[[sidecar_channels]]
name = "nextcloud"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.nextcloud"]
channel_type = "nextcloud"
[sidecar_channels.env]
NEXTCLOUD_SERVER_URL = "https://cloud.example.com"
# NEXTCLOUD_ROOMS = "abc123,def456"           # optional; empty = all joined
# NEXTCLOUD_ACCOUNT_ID = "prod"               # optional, multi-bot routing
```

`NEXTCLOUD_TOKEN` belongs in `~/.librefang/secrets.env` — the dashboard's Channels page writes it there when you fill the Nextcloud form. Outbound replies thread via Talk's `replyTo` parameter (improvement over the former Rust adapter, which never sent `replyTo`).

#### Webex (sidecar)

Webex migrated to an out-of-process sidecar in v2026.5; declare it as a `[[sidecar_channels]]` block instead of `[channels.webex]`.

```bash
[[sidecar_channels]]
name = "webex"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.webex"]
channel_type = "webex"

[sidecar_channels.env]
# WEBEX_ALLOWED_ROOMS = "Y2lz...A,Y2lz...B"  # optional, empty = all rooms
# WEBEX_ACCOUNT_ID = "org-prod"              # optional, multi-bot routing
```

`WEBEX_BOT_TOKEN` belongs in `~/.librefang/secrets.env` — the dashboard's Channels page writes it there when you fill the Webex form. Outbound replies thread via Webex's `parentId` parameter (improvement over the former Rust adapter, which never sent `parentId`).

#### DingTalk (sidecar)

DingTalk is provided by the Python sidecar adapter (`librefang.sidecar.adapters.dingtalk`), **stream mode only**. The in-process `[channels.dingtalk]` config block was removed in the sidecar migration. Declare DingTalk as a `[[sidecar_channels]]` entry instead. The legacy in-process `webhook` mode (HTTP callback + HMAC-SHA256) is NOT ported — operators who relied on webhook mode must re-create the robot with stream subscription enabled in the DingTalk admin console.

```toml
[[sidecar_channels]]
name = "dingtalk"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.dingtalk"]
channel_type = "dingtalk"
[sidecar_channels.env]
DINGTALK_APP_KEY = "dingxxx..."
# DINGTALK_ACCOUNT_ID = "prod-bot"     # optional, multi-bot routing key
# DINGTALK_ROBOT_CODE = ""             # optional, defaults to app_key
```

`DINGTALK_APP_SECRET` goes into `~/.librefang/secrets.env` — the dashboard's Channels page writes it there when you fill the DingTalk form. The sidecar opens a WebSocket to the DingTalk Stream Gateway via two-step endpoint discovery (`POST /v1.0/gateway/connections/open`), ACKs every CALLBACK frame inline, and posts replies to the per-message `sessionWebhook` URL delivered with each inbound event.

#### ntfy

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

```toml
[[sidecar_channels]]
name = "ntfy"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.ntfy"]
channel_type = "ntfy"
[sidecar_channels.env]
NTFY_SERVER_URL = "https://ntfy.sh"
NTFY_TOPIC = "my-agent-topic"
# NTFY_ACCOUNT_ID = "prod"                  # optional, multi-bot routing
```

`NTFY_TOKEN` (auth, optional for public topics) belongs in `~/.librefang/secrets.env`.

#### Gotify

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

```toml
[[sidecar_channels]]
name = "gotify"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.gotify"]
channel_type = "gotify"
[sidecar_channels.env]
GOTIFY_SERVER_URL = "https://gotify.example.com"
# GOTIFY_ACCOUNT_ID = "prod"    # optional, multi-bot routing key
```

`GOTIFY_APP_TOKEN` (used for outbound publish) and `GOTIFY_CLIENT_TOKEN` (used for inbound WebSocket subscribe) go into `~/.librefang/secrets.env` — the dashboard's Channels page writes them there automatically when you fill the Gotify form.

#### Webhook (sidecar)

Webhook migrated from the in-process Rust adapter to a Python sidecar (`librefang.sidecar.adapters.webhook`, stdlib-only). The in-process `[channels.webhook]` block is no longer recognised. Same HMAC-SHA256 + timestamp + SSRF-guard semantics, plus inbound dedupe and 429-retry that the Rust adapter didn't have.

```toml
[[sidecar_channels]]
name = "webhook"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.webhook"]
channel_type = "webhook"
[sidecar_channels.env]
WEBHOOK_LISTEN_PORT = "8461"
# WEBHOOK_LISTEN_PATH = "/webhook"
# WEBHOOK_CALLBACK_URL = "https://example.com/incoming"
# WEBHOOK_DELIVER_ONLY = "1"
# WEBHOOK_DELIVER = "telegram"
```

`WEBHOOK_SECRET` (the HMAC-SHA256 signing secret) belongs in `~/.librefang/secrets.env`. Sidecar binds its own listener on `WEBHOOK_LISTEN_PORT` (default 8461) at `WEBHOOK_LISTEN_PATH` (default `/webhook`). The same SSRF guard from the Rust adapter (private / loopback / link-local / multicast / cloud-metadata / 100.64-CGN / IPv4-mapped-IPv6 all rejected, plus reserved hostnames like `localhost.`, `kubernetes.default.svc.cluster.local`) runs at sidecar startup AND on every outbound POST (defence-in-depth — a config reload that swaps the URL to a private host doesn't leak the secret to localhost).

When `WEBHOOK_DELIVER_ONLY=1` the sidecar tags inbound with `__deliver_only__` / `__deliver_target__` metadata; the kernel's bridge layer reads those to forward the message body straight to the named channel without invoking an LLM. `WEBHOOK_DELIVER` is required when `WEBHOOK_DELIVER_ONLY=1` — fail-closed at sidecar startup if missing (the Rust validator only warned at runtime).

#### QQ Bot (sidecar)

QQ migrated from an in-process Rust adapter to an out-of-process Python sidecar (`librefang.sidecar.adapters.qq`). The sidecar talks to the QQ Open Platform via WebSocket (gateway) + REST (token + outbound). An existing `[channels.qq]` block is no longer recognised — re-declare as `[[sidecar_channels]]`:

```toml
[[sidecar_channels]]
name = "qq"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.qq"]
channel_type = "qq"
[sidecar_channels.env]
QQ_APP_ID = "your-app-id"
# QQ_ALLOWED_USERS = "openid-1,openid-2"   # optional
# QQ_ACCOUNT_ID = "prod-bot"               # optional
# QQ_INTENTS = "1073746435"                # optional bitmask override
```

Secret via `~/.librefang/secrets.env`: `QQ_APP_SECRET` (the bot's `clientSecret` from the QQ Open Platform console).

#### WeCom (sidecar)

WeCom (formerly WeChat Work / Enterprise WeChat) migrated from an in-process Rust adapter to an out-of-process Python sidecar (`librefang.sidecar.adapters.wecom`). The sidecar connects via WebSocket to `wss://openws.work.weixin.qq.com` using the intelligent-bot Bot ID and Secret. The legacy **callback mode** (HTTP webhook + AES-CBC-256 inbound decryption) is no longer supported — Python's stdlib has no AES, and the sidecar SDK is stdlib-only by policy. Operators who relied on callback mode must switch the bot to WebSocket mode in the WeCom admin console.

An existing `[channels.wecom]` block is no longer recognised — re-declare as `[[sidecar_channels]]`:

```toml
[[sidecar_channels]]
name = "wecom"
command = "python3"
args = ["-m", "librefang.sidecar.adapters.wecom"]
channel_type = "wecom"

[sidecar_channels.env]
WECOM_BOT_ID = "aibxxxxxxx"
# WECOM_ALLOWED_USERS = "alice,bob"   # optional CSV; empty = all
# WECOM_ACCOUNT_ID    = "prod-bot"    # optional multi-bot routing
```

Secret via `~/.librefang/secrets.env`: `WECOM_BOT_SECRET` (the bot secret from the WeCom admin console).

#### WeChat (sidecar)

WeChat (personal account via iLink) 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:

```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
# WECHAT_ACCOUNT_ID = "prod"       # optional, multi-bot routing
```

On first start the sidecar runs the QR login flow against `ilinkai.weixin.qq.com` and logs the QR code string at INFO; scan it from the WeChat app to confirm. The returned bot token can be pasted into `WECHAT_BOT_TOKEN` to skip the QR step on subsequent restarts (the dashboard's Channels page writes it to `~/.librefang/secrets.env` for you).

---


### Channel Overrides (legacy — removed)

The `[channels.<name>.overrides]` sub-table is **no longer recognised**. It lived on the in-process `ChannelsConfig` which was emptied in the sidecar migration (`ChannelsConfig` now only carries `file_download_*` / `file_upload_max_bytes` global file-transfer fields). The section below documents the **pre-migration** behaviour for historical context and migration mapping.

**Migration mapping — equivalent knobs in the sidecar model:**

| Old override | Sidecar equivalent |
|---|---|
| `model`, `system_prompt` | Set on the agent (`agent.toml`) rather than per-channel — sidecars route every inbound to the same agent. |
| `dm_policy`, `group_policy` | Pass as `*_DM_POLICY` / `*_GROUP_POLICY` env vars on the `[sidecar_channels.env]` block (Telegram / Discord / Slack / WhatsApp / Teams sidecars all read these). |
| `rate_limit_per_minute`, `rate_limit_per_user` | Sidecars rate-limit themselves to platform limits; for tighter caps, wrap the sidecar in a reverse proxy. |
| `output_format` | Not exposed on `[[sidecar_channels]]`. Agents that need a specific output format should flip it on the agent side. |
| `disable_commands`, `allowed_commands`, `blocked_commands` | Set on the agent's manifest (`agent.toml`) under the command-policy section — applies uniformly across channels. |
| `threading`, `usage_footer`, `typing_mode` | Sidecar defaults (threading where the platform supports it; no usage footer; instant typing). Not configurable per-channel today. |

The remainder of this section describes the **deleted** override shape — keep for reference; do **not** copy-paste into a current `config.toml`.

### Channel Overrides (historical reference)

Every channel adapter (pre-migration) supported an `[channels.<name>.overrides]` sub-table that customized agent behavior per-channel.

```toml
[channels.discord.overrides]
model = "claude-haiku-4-5-20251001"
system_prompt = "You are a concise Discord assistant."
dm_policy = "respond"
group_policy = "mention_only"
rate_limit_per_minute = 0
rate_limit_per_user = 10
threading = true
output_format = "markdown"
usage_footer = "tokens"
typing_mode = "instant"
disable_commands = false
allowed_commands = []
blocked_commands = []
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `model` | string or null | `null` | Model override for this channel. Uses the agent's default model when null. |
| `system_prompt` | string or null | `null` | System prompt override for this channel. |
| `dm_policy` | string | `"respond"` | How the bot handles direct messages. See below. |
| `group_policy` | string | `"mention_only"` | How the bot handles group messages. See below. |
| `rate_limit_per_minute` | u32 | `0` | Global rate limit for this channel (messages per minute). `0` = unlimited. |
| `rate_limit_per_user` | u32 | `0` | Maximum messages per user per minute. `0` = unlimited. |
| `threading` | bool | `false` | Enable thread replies (where supported by the platform). |
| `output_format` | string or null | `null` | Override output formatting. See below. |
| `usage_footer` | string or null | `null` | Override usage footer mode for this channel. Values: `off`, `tokens`, `cost`, `full`. |
| `typing_mode` | string or null | `null` | Typing indicator behavior. See below. Defaults to `instant`. |
| `disable_commands` | bool | `false` | Disable all built-in slash commands. See [Command Policy](#command-policy). |
| `allowed_commands` | list of strings | `[]` | Whitelist of command names (no leading `/`). When non-empty, only these commands work. |
| `blocked_commands` | list of strings | `[]` | Blacklist of command names (no leading `/`). Applied when `allowed_commands` is empty. |

**`dm_policy` values:**

| Value | Description |
|-------|-------------|
| `respond` | Respond to all direct messages (default). |
| `allowed_only` | Only respond to DMs from users in the allowed list. |
| `ignore` | Ignore all direct messages. |

**`group_policy` values:**

| Value | Description |
|-------|-------------|
| `all` | Respond to all messages in group chats. |
| `mention_only` | Only respond when the bot is @mentioned (default). |
| `commands_only` | Only respond to slash commands. |
| `ignore` | Ignore all group messages. |

**`output_format` values:**

| Value | Description |
|-------|-------------|
| `markdown` | Standard Markdown (default). |
| `telegram_html` | Telegram HTML subset (`<b>`, `<i>`, `<code>`, etc.). |
| `slack_mrkdwn` | Slack mrkdwn format (`*bold*`, `_italic_`, `` `code` ``). |
| `plain_text` | No formatting markup. |

**`typing_mode` values:**

| Value | Description |
|-------|-------------|
| `instant` | Send typing indicator immediately on message receipt (default). |
| `message` | Send typing indicator only when the first text delta arrives from the LLM. |
| `thinking` | Send typing indicator only during LLM reasoning/thinking phase. |
| `never` | Never send typing indicators. |

#### Command Policy

Every channel ships with a set of built-in slash commands (`/agent`, `/new`, `/reboot`, `/model`, `/usage`, etc.) that let users switch agents, fork into a fresh session, inspect usage, and trigger workflows. On **public-facing bots** (customer support, community assistants), exposing these to arbitrary users is a security hole — anyone can type `/agent admin` to switch to an internal agent, `/new` to fork off into a new session (the prior conversation stays resumable on the channel), or `/model` to change the model.

The three command-policy fields gate the built-in commands per channel. **Precedence:** `disable_commands` > `allowed_commands` (whitelist) > `blocked_commands` (blacklist).

**Blocked commands are forwarded to the agent as plain text** — they are *not* rejected with an error. So a public bot with `disable_commands = true` will respond conversationally when a user types `/agent admin`, and the user never learns that any command system exists.

**Recipe: locked-down public bot (no commands at all)**

```toml
[channels.discord.overrides]
disable_commands = true
```

**Recipe: public bot that keeps platform UX commands**

Keep only the safe built-in commands:

```toml
[channels.discord.overrides]
allowed_commands = ["start", "help"]
```

**Recipe: admin bot that just blocks the dangerous ones**

For an operator-facing bot (with an `allowed_users` gate), you may still want to block commands that change costly state:

```toml
[channels.discord.overrides]
blocked_commands = ["agent", "new", "reboot", "model", "stop"]
```

Command names are the bare tokens used internally — either `"agent"` or `"/agent"` in TOML works, the leading slash is stripped on match. The full list of built-in commands is in `/help`.

---


### `[[sidecar_channels]]`

Sidecar channel adapters allow external processes (written in any language) to act as channel adapters. Communication uses newline-delimited JSON over stdin/stdout.

```toml
[[sidecar_channels]]
name = "my-custom-channel"
command = "python3"
args = ["adapters/my_adapter.py"]
channel_type = "custom_platform"
[sidecar_channels.env]
MY_API_TOKEN = "secret"
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `name` | string | *required* | Display name for this adapter. |
| `command` | string | *required* | Executable to run (e.g., `"python3"`, `"/usr/local/bin/my-adapter"`). |
| `args` | list of strings | `[]` | Arguments to pass to the command. |
| `env` | map of string to string | `{}` | Extra environment variables to pass to the subprocess. |
| `channel_type` | string or null | `null` | Channel type identifier. Defaults to `Custom(<name>)` if null. |

---

