# Channels

> How Fermix handles messages from Telegram, WhatsApp, Slack, Discord, Signal, and the local CLI through one common path to the agent, and the slash commands available in every channel.

A channel is a place you message Fermix from. Fermix ships six 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 |
| WhatsApp | 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 |
| CLI | Local stdin/stdout | `fermix ask` / `fermix chat`, same gateway and agent | n/a |

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](/docs/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](/docs/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](/docs/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.

1. Convert the input into Fermix's common message format.
2. Authorize the sender. Denied senders are dropped before transcription runs.
3. 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.
4. Build the reply path that sends text and media back to the right channel.
5. Parse and dispatch slash commands. Unrecognized slash commands pass through to the agent as ordinary text.
6. 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_ts` is the thread identifier when present.
7. 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](/docs/ingress-and-trust) for the authorization model, owner configuration, and allowlist rules.

## Streaming

Streaming is off by default. Enable it per channel:

```toml
[fermix_channels.telegram]
streaming = "draft"
```

| Mode | Behavior |
|------|----------|
| `"draft"` | Reply appears as a single draft message edited in place (roughly once per second, opens after at least 30 chars), then sealed to the final authoritative text. `/stop` deletes the draft. Requires a draft-capable channel (Telegram today; `fermix doctor` warns for other channels). |
| `"block"` | Each completed model output item is sent as its own ordinary message. The model's reasoning summaries arrive as separate one-line "💭" messages. Long-text fallback uses 800-1200-char paragraph chunking with a 1-second idle flush. Pre-tool commentary lands as its own message. Works on every channel. |

Both 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`, `seal`, or `discard`.

## 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`. 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 | 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 |

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

```toml
[fermix_channels.telegram]
owner_user_id = "123456789"
command_allowlist = ["987654321"]
```

Full trust-model details are at [ingress and trust](/docs/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.

```toml
[fermix_core.compaction]
enabled = true
threshold = 0.85
```

See [prompt and compaction](/docs/prompt-and-compaction) for full details.

## Configuration

Enable a channel during setup (`fermix setup`) or by editing `~/.fermix/config.toml`. Telegram is 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 remote 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). See [ingress and trust](/docs/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 |
| `WHATSAPP_PHONE_NUMBER_ID` | WhatsApp |
| `WHATSAPP_VERIFY_TOKEN` | WhatsApp |
| `WHATSAPP_APP_SECRET` | WhatsApp |
| `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](/docs/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](/docs/ingress-and-trust): sender authorization, owner configuration, trust levels
- [webhooks](/docs/webhooks): HTTP routes, signature verification, and Phoenix controller details
- [realtime voice](/docs/realtime-voice): local voice companion mode (separate from channel voice transcription)
- [capabilities and tools](/docs/capabilities-and-tools): `send_attachment` and other built-in tools
- [prompt and compaction](/docs/prompt-and-compaction): automatic and manual compaction behavior
- [scheduled jobs](/docs/scheduled-jobs): job delivery targets and per-channel delivery configuration
