# Channels

A **channel** is the surface where an end user (or an external system, or another agent) meets a Polyant instance. Channels are interchangeable transports: the supervisor and the pipeline never care whether a turn arrived from WhatsApp or from the OpenAI-compatible HTTP API. Each channel is implemented as a `ChannelAdapter` that normalises traffic into an `IncomingMessage` going in and an `OutgoingMessage` going out.

This page covers the full list of message sources, how channel credentials are scoped, how messy fragmented bursts are collapsed before they hit the LLM, how voice notes are transcribed, and how the in-process `agent` channel powers agent-to-agent calls.

## The eleven message sources

Polyant distinguishes two related but distinct concepts:

- **`ChannelType`** - the narrow set of channels with stored, admin-API-configurable rows in `instance_channels`. The tuple is `["telegram", "slack", "whatsapp", "agent", "web", "http", "email", "hubspot", "voice"]` (see `packages/engine/src/instances/channels.store.ts`). The `agent` entry is API-configurable because per-instance agent-to-agent settings live in `instance_channels` just like the network transports - but its "credentials" are routing metadata, not external tokens. The `web` entry has no adapter and no credentials either: its row's `enabled` flag decides whether the agent's public HTTP endpoints answer, and a missing row means off. `http` (the [synchronous REST channel](https://docs.polyant.ai/admin-panel/channels#http-synchronous-rest)), [`email`](https://docs.polyant.ai/admin-panel/channels#email) [`hubspot`](https://docs.polyant.ai/admin-panel/channels#hubspot) and [`voice`](https://docs.polyant.ai/admin-panel/channels#voice-phone-calls) are available only with managed services.
- **`MessageChannelType`** - the wide superset, covering *every* possible source of an inbound pipeline message. It adds `scheduled` (cron-style task) and `room` (event-driven cycles) to the `ChannelType` entries.

Putting them together, the eleven message sources are:

| `channelType`  | Direction | Configurable | Transport / Trigger |
|----------------|-----------|--------------|---------------------|
| `telegram`     | Inbound + outbound | Yes (per-instance) | grammY webhook |
| `slack`        | Inbound + outbound | Yes (per-instance) | `@slack/bolt` Events API webhook |
| `whatsapp`     | Inbound + outbound | Yes (per-instance) | Twilio Programmable Messaging webhook |
| `web`          | Inbound + outbound | Yes (per-instance), off for a new agent; switch, key and context fields on the [Web/API](https://docs.polyant.ai/admin-panel/channels#webapi) card | `/v1/chat/completions`, `/api/instances/{slug}/chat/stream`; the Playground's own route, which works with Web off |
| `scheduled`    | Inbound (system)   | No                 | Scheduler ticks |
| `room`         | Inbound (system)   | No                 | Room scheduler (event-driven, 30s tick) |
| `agent`        | Inbound + outbound | Yes (per-instance) | In-process agent-to-agent invocation |
| `http`         | Inbound + outbound | Yes (per-instance) | Synchronous REST call (managed services) |
| `email`        | Inbound + outbound | Yes (per-instance) | Provider webhook in, SMTP out (managed services) |
| `hubspot`      | Inbound + outbound | Yes (per-instance) | HubSpot Conversations webhook in, REST out (managed services) |
| `voice`        | Inbound phone calls | Yes (per-instance) | Twilio voice webhook and Media Streams WebSocket (managed services) |

The supervisor handles all eleven the same way. The difference shows up only at the edges: which adapter received the message, and which adapter (if any) sends the reply.

### Inbound from outside: the A2A server

There is one more way a turn can start, and it is not a `channelType`: an agent can be **published to external Agent-to-Agent clients**. With A2A enabled, clients discover it with GET at `/a2a/{slug}/.well-known/agent-card.json` and run a turn with POST at `/a2a/{slug}/jsonrpc`.

It is worth keeping distinct from the `agent` source above, because the names invite confusion:

| | `agent` channel | A2A server |
|---|---|---|
| Who calls | your own agents, in-process | any external A2A client, over HTTP |
| Boundary | the deployment | the **organization** - a task belongs to the agent it was created on |
| Auth | internal routing | agent card is public; JSON-RPC follows the agent's API-key setting like `/v1/chat/completions` |

See [A2A configuration](https://docs.polyant.ai/admin-panel/channels#a2a---the-agent-as-a-server) and the [route reference](https://docs.polyant.ai/reference/rest-api#a2a).

## Per-instance credentials, encrypted at rest

There are **no global channel credentials** in `.env`. Every channel config lives in `instance_channels`, keyed by `(instance_id, channel_type)`, with the JSON payload AES-256-GCM encrypted via the engine's `ENCRYPTION_KEY`. The admin panel's Channels tab is the canonical write surface; the same data is exposed via `PUT /api/instances/:slug/channels/:type` and `DELETE /api/instances/:slug/channels/:type`.

The `ChannelManager` reads enabled configs from the database, instantiates the right adapter (Telegram, Slack, WhatsApp), and starts it. Adapters can be started, stopped, and reconfigured at runtime - no engine restart required. Channel boot is fire-and-forget: a provider failure cannot block the rest of the engine.

Two failure modes are recorded rather than swallowed, because both used to be indistinguishable from a channel nobody had configured:

- **An adapter that fails to initialise disables its own channel**, storing the adapter's error text and the time. Without that, a bad credential meant a crash loop on every restart. Re-enabling the channel clears both fields.
- **A payload that will not decrypt is marked unreadable** instead of being returned as an empty object. A rotated `ENCRYPTION_KEY` and a never-configured channel look identical otherwise, and they call for opposite repairs.

Both surface on the agent's [Status](https://docs.polyant.ai/admin-panel/instances#status) section, which is the only place that reports them.

## Inbound message coordinator

Two of the human-facing channels - WhatsApp and Telegram - have a habit of arriving in fragments. A user types one thought across three quick bubbles, and three near-simultaneous webhooks land at the engine. Running the supervisor three times for what is conceptually one message wastes tokens and confuses the LLM.

The **`MessageCoordinator`** debounces fragmented bursts using a cancel-and-restart model:

1. The first fragment arms a soft-debounce timer and a typing-indicator timer. Both windows belong to the AGENT - a support agent and a booking agent legitimately want different ones - set in its [Behaviour overrides](https://docs.polyant.ai/admin-panel/settings#behaviour-overrides) (**Burst window** and **Typing indicator delay**) and falling back to the shipped defaults of 2 s and 1.5 s.

   They are resolved once per burst, when the first fragment arrives, and held for it: a burst belongs to one conversation and therefore one agent, so the window cannot change halfway through.
2. Additional fragments inside the soft-debounce window reset both timers.
3. When the timer fires, the pipeline runs with a fresh `AbortSignal`.
4. If a *new* fragment lands after the pipeline has started, the in-flight run is aborted, the buffered fragments are restored to the head of the buffer, and the soft-debounce re-arms - up to the agent's **Max restarts** (default 3) consecutive cancel cycles.
5. After the cap, the next fragments accumulate and flush in a follow-up run.

`DEBOUNCED_CHANNELS = {"whatsapp", "telegram"}` - every other source skips the coordinator and runs the pipeline immediately. Aborted runs leave **zero** DB trace: no conversation row, no message row, no memory extraction. The Coordinator also schedules a typing indicator on WhatsApp (Twilio `/v2/Indicators/Typing.json`) for slow turns; on Telegram it uses native `sendChatAction`.

## STT pipeline

Voice notes are first-class. Each agent selects `openai` (Whisper), `deepgram`, `aws` (Amazon Transcribe), or `disabled` with its `sttProvider` setting. When WhatsApp or Telegram receives audio, the engine routes the downloaded bytes to the selected provider before the supervisor sees the transcript; missing credentials and explicit disablement remain different outcomes.

On success, the adapter forwards the transcript plus metadata such as kind, duration, provider, language and latency. The downloaded audio bytes are not attached to the `IncomingMessage`, and no replayable raw audio is retained.

## The agent channel

The `agent` channel is a virtual, in-process transport with no network hop. When agent A invokes agent B via the supervisor's delegation path, the call lands on agent B's `AgentChannelAdapter`, runs through B's full pipeline (history, summary, supervisor, tools), and the reply is returned synchronously as a tool result on A's side.

Each agent-to-agent call carries:

- `callerSlug` - which instance initiated the call.
- `callerConversationId` - the parent conversation that spawned this turn.
- `depth` - recursion counter, capped to prevent infinite loops.
- `parentTraceId` - used to stitch LangSmith traces into a single tree.

This is how compositions like "concierge agent delegates billing questions to billing agent" work without any external Slack/Telegram round-trip.

## How it works

```
                 inbound side                              outbound side
+-----------------------------------+         +-------------------------------+
| Telegram   (grammY webhook)       |         | Telegram.sendMessage()         |
| Slack      (Bolt Events API)      |         | Slack.chat.postMessage()       |
| WhatsApp   (Twilio webhook)       |         | WhatsApp / Twilio Programmable |
| Web        (POST /v1/chat...)     |         | SSE chunk stream               |
| Room       (Room scheduler tick)  |         | (configured outbound channel)  |
| Scheduled  (cron tick)            |         | (configured outbound channel)  |
| Agent      (in-process call)      |         | (returns to caller)            |
+-----------------+-----------------+         +---------------+----------------+
                  |                                           ^
                  v                                           |
        +-----------------------+                +-------------------------+
        | MessageCoordinator    |                | OutgoingMessage emit    |
        |  (whatsapp+telegram   |                |                         |
        |   only - debounce)    |                +-------------------------+
        +-----------+-----------+                            ^
                    |                                        |
                    v                                        |
        +--------------------------------+                   |
        | Pipeline (handleMessage)       |                   |
        |  - resolve instance config     |                   |
        |  - load history + summary      |                   |
        |  - STT if audio attachment     |                   |
        |  - supervisor.run()            +-------------------+
        +--------------------------------+
```

## Code reference

- `packages/engine/src/channels/types.ts` - `ChannelAdapter`, `MessageChannelType`, `IncomingMessage`, `OutgoingMessage`, `AgentCallMetadata`.
- `packages/engine/src/channels/channel-manager.ts` - Adapter orchestrator, `DEBOUNCED_CHANNELS` set.
- `packages/engine/src/channels/message-coordinator.ts` - Fragment debouncer with cancel-and-restart.
- `packages/engine/src/channels/adapters/telegram/` - Telegram adapter (grammY).
- `packages/engine/src/channels/adapters/slack/` - Slack adapter (Bolt Events API webhook).
- `packages/engine/src/channels/adapters/whatsapp/` - Twilio Programmable Messaging adapter.
- `packages/engine/src/channels/adapters/agent.adapter.ts` - In-process agent-to-agent channel.
- `packages/engine/src/channels/audio-transcription.ts` - STT routing.
- `packages/engine/src/stt-gateway/providers/openai-whisper.ts` - Default Whisper provider.
- `packages/engine/src/instances/channels.store.ts` - Encrypted `instance_channels` CRUD and the narrow `ChannelType` union.

## See also

- [Agents](https://docs.polyant.ai/concepts/agents)
- [Architecture](https://docs.polyant.ai/concepts/architecture)
- [Connect a channel](https://docs.polyant.ai/getting-started/connect-a-channel) - Telegram, Slack and WhatsApp, step by step
- [Channels (admin panel)](https://docs.polyant.ai/admin-panel/channels) - the field reference for each channel section
