Channels
How Fermix handles messages from Telegram, WhatsApp, Slack, Discord, Signal, ACP clients, and the local CLI through one common path to the agent, and the slash commands those channels accept.
A channel is a place you message Fermix from. Fermix ships seven channel adapters (the connectors that talk to each service). Each adapter converts incoming messages into one common format and passes them to the main agent. The agent sees the same standardized data and a way to reply, and never knows which channel the message came from.
Channel adapters
| Channel | Transport | Inbound path | Outbound limits |
|---|---|---|---|
| Telegram | Bot API long-poll | Telegram.Poller (:polling mode; webhook transport is intentionally unsupported) |
image 10 MB, document 50 MB, audio/video 50 MB, voice 1 MB; text auto-split at 4096 chars; inbound media cap 20 MB |
| Cloud API webhook | POST /webhook/whatsapp (HMAC-SHA256 X-Hub-Signature-256) + GET /webhook/whatsapp verify-token challenge |
image 5 MB, audio/video/voice 16 MB, document 100 MB; text cap 4096 chars; inbound media cap 25 MB | |
| Slack | Events API webhook | POST /webhook/slack (X-Slack-Signature + X-Slack-Request-Timestamp, max 300 s age) |
media 100 MB; text cap 40,000 chars |
| Discord | WebSocket Gateway | Discord.Gateway (:gateway mode); REST replies |
media 10 MB; text cap 2,000 chars |
| Signal | Subprocess signal-cli |
Signal.Listener (:subprocess mode) polling signal-cli receive |
media 100 MB |
| ACP | Local Unix socket (a local channel between processes on the same machine) | An ACP client launches fermix acp, which bridges its standard input and output to the running daemon at ~/.fermix/acp.sock |
replies stream to the client as the model produces them; attachments cannot be transferred — a media reply arrives as one text line naming the file |
| CLI | Local stdin/stdout | fermix ask / fermix chat, same gateway and agent |
n/a |
The ACP surface is on by default. Fermix treats whoever reaches it as the operator: only the user account that runs the daemon can open that socket, and there is no network path to it, so the surface takes no owner id and no allowlist. It has no slash commands — a message starting with / reaches the model as ordinary text.
Telegram, Discord, and Signal keep an open connection to the service and do not expose any HTTP routes. WhatsApp and Slack receive messages through webhooks (URLs the service calls when something happens) under /webhook/whatsapp and /webhook/slack respectively. See webhooks for full endpoint details.
The Slack and Discord channels here (where you message Fermix and it replies) are separate from the Slack and Discord plugins in the plugin catalog, which give the agent read access to workspaces you connect. They are configured independently, with separate credentials; you can use either or both. See plugins.
Inbound multimodal
Images on media-capable channels are downloaded at the gateway and passed to the model as image content blocks. The agent sees the picture, not a placeholder. An image routed to a model that does not support vision fails loud rather than silently dropping the attachment.
Audio attachments are transcribed before reaching the agent on every media-capable channel — WhatsApp voice notes and audio messages, any audio/* attachment on Discord, Slack, and Signal, and on Telegram voice notes, audio files, audio-MIME documents, and round video notes (video notes are transcribed straight from their MP4 container). An audio attachment is transcribed whether or not the message also carries a caption: the gateway routes it through FermixChannels.Gateway.Transcription, the speech-to-text engine is FermixCore.Transcription, and the transcript becomes the message content. When a caption accompanies the audio, the caption is kept first and the transcript is appended below it under a [voice note transcript] delimiter, so the agent sees both.
Speech-to-text is a pluggable backend selected with [fermix_core.transcription] backend: openai (the default; model gpt-4o-mini-transcribe), xai (SpaceXAI’s native speech-to-text, which is modelless and requires an API key — a Grok subscription or OAuth login does not authorize it), or deepgram (model nova-3, which needs its own key). Pick it in setup’s Transcription step, or non-interactively with fermix setup --transcription-backend <name>, --transcription-model <id>, and --transcription-api-key <key>; fermix doctor’s transcription row reports the configured backend and whether its credential is present — an offline check, so it catches a missing key before a voice note ever arrives, but not one the service later rejects.
A failed transcription replies to the sender with a specific, actionable message — no transcription backend configured points at fermix setup, an over-size file names the cap, and other failures ask to try again — and no agent turn is scheduled. Audio larger than [fermix_core.transcription] max_file_mb (default 20 MB) is refused, checked against the declared size before download when the channel provides one.
Multi-image messages are combined into one turn so the agent sees every image together. Telegram albums (separate updates sharing a media_group_id) and WhatsApp per-image webhook messages are held for a brief moment and merged. Discord, Slack, and Signal already deliver all attachments in one message.
Voice transcription for the local Realtime companion is a separate subsystem; see realtime voice.
Gateway pipeline
Every incoming message goes through the same steps before it reaches the agent. FermixChannels.Gateway.ingest/2 is the single inbound entry point. FermixChannels.Dispatcher is a thin compatibility alias for the same function.
- Convert the input into Fermix’s common message format.
- Authorize the sender. Denied senders are dropped before transcription runs.
- Download inbound images and pass them as image content to the model (vision-capable providers); transcribe audio attachments; coalesce multi-image messages (Telegram albums, WhatsApp per-image webhooks) into one turn.
- Build the reply path that sends text and media back to the right channel.
- Parse and dispatch slash commands. Unrecognized slash commands pass through to the agent as ordinary text.
- Queue the turn so each conversation processes one turn at a time, in order (FIFO, first in, first out). A conversation is identified by its channel, chat, and thread; Slack’s
thread_tsis the thread identifier when present. - Deliver to
MainAgent. If the agent is mid-restart, the gateway sends a “restart in progress” reply instead.
Empty and unauthorized messages short-circuit this pipeline. An unauthorized sender is dropped at step 2 with no reply at all. A message with no text and no media (a sticker, a poll, or a blank message; a captionless image still counts as actionable) gets a brief “looks empty” reply at the gateway and never schedules a turn, so no model is called. If the model ever returns an empty completion, the turn replies with an honest “try again” instead of a blank message, and that empty reply is never committed to history.
See ingress and trust for the authorization model, owner configuration, and allowlist rules.
Streaming
Streaming is on by default with a mode derived from what the channel can do: a configured channel that can edit its own messages (Telegram today) streams in "draft" mode when its streaming key is unset, and every other configured channel streams in "block" mode. The key accepts "off", "draft", or "block"; any other value stops config load with an error naming the three valid values. Set it per channel to opt out or to override the derived mode:
[fermix_channels.slack]
streaming = "off"
[fermix_channels.telegram]
streaming = "block"
| Mode | Behavior |
|---|---|
"off" |
No streaming: the reply arrives as the final message(s) only. This is the per-channel opt-out. |
"draft" |
The reply streams into a live message edited in place (roughly once per second, opens after at least 30 chars). When the live text grows past one section card (about 1,400 rendered characters), the message is sealed at a natural boundary — a section heading, else a paragraph — and the rest keeps streaming into a fresh message, so a long answer lands as a few readable cards instead of one frozen wall. Sealed cards are finished messages: /stop deletes only the live unsealed one, a short final remainder merges into the live card instead of sending one more message, and a final response that does not extend what was already sealed is delivered in full instead. Reasoning summaries roll into a single “💭” status message edited in place and deleted once the answer lands. This is the default on draft-capable channels; on a channel that cannot edit messages an explicit "draft" is ignored at runtime — the turn simply does not stream, and it does not fall back to "block" — and fermix doctor’s channel streaming check is what surfaces the mismatch. |
"block" |
Each completed model output item is sent as its own ordinary message, and pre-tool commentary lands as its own message. Text that overflows one message is cut on the same boundary ladder as any long reply (never mid-sentence), 800-1200 chars per message with a 1-second idle flush. Reasoning summaries arrive as silent “💭” messages and are deleted once the answer lands; a channel that cannot delete messages gets no “💭” stream at all. This is the default on channels without message editing; works on every channel. |
Both streaming modes require a streaming-capable provider (Codex today; other providers deliver normally). Only real channel turns stream: background jobs, CLI runs, and cron runs never stream. Stream telemetry is emitted as [:fermix, :channel, :stream] with a phase of open, edit, block, rotate, seal, or discard.
Message presentation
Long replies are split on a boundary ladder — a section heading first, then a paragraph, a line, a sentence end, and whitespace, with a hard cut only as a last resort — measured the way each platform counts message length, and never inside a fenced code block. A long answer therefore arrives as a few section-shaped messages rather than one wall cut mid-sentence at the platform’s length cap. Only the first message of a reply rings a notification; the rest are delivered silently, and link previews are disabled on every outbound message. A fenced code block too large to fit one message is sent as a text-file attachment after the reply instead of being split into two broken halves.
Each channel renders the agent’s Markdown in its own dialect, so formatting never leaks as raw syntax:
| Channel | Rendering |
|---|---|
| Telegram | Native HTML formatting — bold, italic, links, code, block quotes (long ones collapse until tapped); tables become monospace blocks. |
| Discord | Markdown passes through — Discord renders it natively. |
| Slack | Converted to Slack mrkdwn — single-asterisk bold, <url|label> links, monospace tables. |
Converted to WhatsApp styles — *bold*, _italic_, monospace blocks; links appear as “label: url” since WhatsApp auto-links bare URLs. |
|
| Signal | Clean plain text — formatting markers removed, links as “label: url”, code kept verbatim and indented. |
If a platform rejects a formatted message, that one message is resent once as plain text so the reply is never lost, and the rendering fault is logged as an error.
Outbound media
The send_attachment tool sends a local file through the active channel reply port as {:media, %{kind, path, filename, caption, mime_type}}. Each adapter translates that into its native upload format. A short-term cache prevents duplicate sends within a 60-second window (so retrying the same send does not post the file twice), keyed on channel + chat + kind + caption + filename + MIME type + file SHA-256.
Reaction acknowledgements
When a message only needs acknowledging — “ok”, “thanks”, a thumbs-up — Fermix answers with an emoji reaction on your message instead of a text reply, the way a person taps 👍 rather than typing “you’re welcome”. The agent decides when a reaction is the right response; anything that needs a real answer still gets one, so this only replaces filler bubbles.
Reactions travel through the same reply port as any other output, via a built-in react capability that is offered to the model only on turns where the channel supports reactions. Telegram and Slack accept only a fixed set — Telegram a fixed emoji set, Slack its built-in shortcode names — so on those channels the model is given the allowed set; Discord, WhatsApp, and Signal accept any single emoji. The local command line has no reactions, so a bare acknowledgement there comes back as a short line of text instead.
Slash commands
All commands work in every channel through the shared dispatcher, and locally through fermix ask / fermix chat. The one exception is the ACP surface, which has no commands at all. An unrecognized /command is not a hard error; it passes through to the agent as ordinary text.
| Command | Description | Role gate |
|---|---|---|
/help |
List available commands | any authorized |
/whoami |
Show the stable channel user id used for command authorization | any authorized |
/new |
Clear the current conversation window (long-term memory, resource revisions, and scheduled jobs are preserved) | operator |
/clear |
Alias for /new |
operator |
/compact |
Summarize the current conversation window now and keep the summary in history | operator |
/background (alias /bg) |
Run the request as detached background work; Fermix posts the result to the same conversation when it finishes. At most 8 run at once daemon-wide; past that the command is refused with a message naming the limit, not queued | operator |
/tasks |
List running and recent background work started from this conversation | operator |
/stop |
Stop everything now: cancel active turns, clear queued messages, and stop background work. Runs immediately, ahead of the queue. Scheduled jobs and realtime voice are not affected; the interrupted request stays in history as context only and is not retried later | operator |
/pause |
Hand the cursor and keyboard back to you. The computer-use session stays alive and refuses further actions until you /resume — unlike /stop, which tears the session down |
operator |
/resume |
Resume a paused computer-use session. It does not pick the task back up on its own; tell Fermix what to do next | operator |
/ultra <prompt> |
Run that request as a normal foreground turn in exhaustive multi-agent (ultra) mode | operator |
/sandbox (aliases /grant, /revoke, /confirm) |
Inspect and adjust the workspace sandbox (env allow/deny/set, command presets, path grants); risky changes require a /confirm TOKEN step |
operator |
/soul |
Review, apply, revert, or reset the agent’s persona (SOUL.md) |
operator |
/skills |
Review, approve, or deny skill-curation proposals; list, archive, unpark, or restore skills | operator |
When the agent asks for access to a directory outside the sandbox, the owner gets a confirmation prompt. On Telegram and Discord the prompt also carries a one-tap Approve button; it sends the identical confirmation and rides the same single-use, time-limited, origin-bound, owner-only path. Every other surface — Slack, WhatsApp, Signal, and the local command line — shows the same prompt as text with a /confirm TOKEN line. In a shared chat on a button channel the code itself is deliberately withheld — approve from the button, a direct message, or the CLI. Only the agent’s own access request gets a button; proposals you start yourself with /sandbox still use the typed confirmation.
Commands that change state (role gate: operator, meaning only the owner may run them) require a configured owner. Fermix can infer the command owner from a single configured ingress allowlist entry (the list of who is allowed to send messages in), but multiple allowed users require an explicit owner_user_id or command_allowlist. Run /whoami from the target account to discover the id, then set it in ~/.fermix/config.toml or via fermix setup --reconfigure.
[fermix_channels.telegram]
owner_user_id = "123456789"
command_allowlist = ["987654321"]
Full trust-model details are at ingress and trust.
/soul subcommands
/soul manages the agent’s SOUL.md persona file. Mutations go through a propose-then-confirm flow: the command generates a token, and the owner must send /soul apply <token> to commit. Tokens expire in 5 minutes.
| Subcommand | Effect |
|---|---|
/soul |
Show current revision and usage |
/soul review [instruction] [--with-context] |
Draft a SOUL.md edit (subtle voice-preserving review, or instruction-guided); --with-context includes a bounded window of recent owner messages as evidence |
/soul diff TOKEN |
Re-display a pending proposal |
/soul history |
List all SOUL.md revisions |
/soul revert N |
Propose reverting to revision N |
/soul reset |
Propose resetting to the shipped default |
/soul apply TOKEN |
Commit a pending proposal, revert, or reset |
Automatic compaction
Compaction keeps long conversations from outgrowing the model’s limited context by summarizing older messages. After each turn the agent estimates how much of the conversation is in use. When usage reaches threshold * model_context_window, older messages are summarized and replaced with the summary plus recent turns. /new only clears the conversation window; it does not affect long-term memory.
[fermix_core.compaction]
enabled = true
threshold = 0.85
See prompt and compaction for full details.
Configuration
Enable a channel during setup (fermix setup) or by editing ~/.fermix/config.toml. Telegram and the ACP surface are enabled by default; WhatsApp, Slack, Discord, and Signal are off by default. Channel enablement affects both readiness reporting and which long-running client processes are supervised.
An enabled chat channel also needs to know who may talk to it: without an owner_user_id or an allowlist, Fermix logs a refusal at startup and will not serve that channel (the connecting adapter is not started; for webhook channels the route stays up but every sender is denied). The ACP surface is exempt — it authorizes by the socket it binds rather than by a sender id, so it has neither an owner id nor an allowlist to miss. See ingress and trust.
Besides credentials, each channel block accepts a few common keys: streaming (see above) and — on Telegram and WhatsApp — album_debounce_ms (how long multi-image messages are held for merging; default 3000 ms).
Relevant environment variables:
| Variable | Channel |
|---|---|
WHATSAPP_ACCESS_TOKEN |
|
WHATSAPP_PHONE_NUMBER_ID |
|
WHATSAPP_VERIFY_TOKEN |
|
WHATSAPP_APP_SECRET |
|
DISCORD_BOT_TOKEN |
Discord |
DISCORD_BOT_USER_ID |
Discord |
SLACK_BOT_TOKEN |
Slack |
SLACK_SIGNING_SECRET |
Slack |
SIGNAL_ACCOUNT |
Signal |
SIGNAL_CLI_PATH |
Signal (optional; overrides signal-cli executable path) |
Credentials set via fermix setup are stored in the OS keychain and do not require environment variables in a configured install. Environment variables remain the final override layer for headless or container deployments. Allowlists can also be set from the environment (TELEGRAM_ALLOWED_USER_IDS, DISCORD_ALLOWED_USER_IDS, SLACK_ALLOWED_USER_IDS, WHATSAPP_ALLOWED_SENDER_IDS, SIGNAL_ALLOWED_SENDER_IDS). One deliberate exception: TELEGRAM_BOT_TOKEN is never read from the environment — the Telegram bot token comes only from setup/config, so a stray shell variable cannot point two installs at the same bot.
The /health/ready endpoint reports per-channel status. See traces and telemetry for channel telemetry events.
Telemetry events
| Event | Description |
|---|---|
[:fermix, :channel, :message] |
Per-channel inbound and outbound message counts (direction: :inbound | :outbound) |
[:fermix, :channel, :reply] |
Each outbound reply send (text or media), with duration |
[:fermix, :channel, :media_send_error] |
Failed media reply attempts |
[:fermix, :command, :received] |
Slash command received |
[:fermix, :command, :unauthorized] |
Slash command rejected for insufficient role |
[:fermix, :command, :dispatch] |
Slash command dispatched (includes duration) |
[:fermix, :transcription, :message] |
Inbound audio transcription attempts, with duration, channel, and success/failure status |
[:fermix, :dispatcher, :agent_unavailable] |
MainAgent was mid-restart when a turn arrived |
[:fermix, :channel, :stream] |
Streaming phase events (phase: open, edit, block, seal, discard) with session_id |
Related pages
- ingress and trust: sender authorization, owner configuration, trust levels
- webhooks: HTTP routes, signature verification, and Phoenix controller details
- realtime voice: local voice companion mode (separate from channel voice transcription)
- capabilities and tools:
send_attachmentand other built-in tools - prompt and compaction: automatic and manual compaction behavior
- scheduled jobs: job delivery targets and per-channel delivery configuration