Channels
On this page
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 ininstance_channels. The tuple is["telegram", "slack", "whatsapp", "agent", "web", "http", "email", "hubspot", "voice"](seepackages/engine/src/instances/channels.store.ts). Theagententry is API-configurable because per-instance agent-to-agent settings live ininstance_channelsjust like the network transports - but its “credentials” are routing metadata, not external tokens. Thewebentry has no adapter and no credentials either: its row’senabledflag decides whether the agent’s public HTTP endpoints answer, and a missing row means off.http(the synchronous REST channel),emailhubspotandvoiceare available only with managed services.MessageChannelType- the wide superset, covering every possible source of an inbound pipeline message. It addsscheduled(cron-style task) androom(event-driven cycles) to theChannelTypeentries.
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 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 and the route reference.
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_KEYand a never-configured channel look identical otherwise, and they call for opposite repairs.
Both surface on the agent’s 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:
-
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 (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.
-
Additional fragments inside the soft-debounce window reset both timers.
-
When the timer fires, the pipeline runs with a fresh
AbortSignal. -
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.
-
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_CHANNELSset.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- Encryptedinstance_channelsCRUD and the narrowChannelTypeunion.
See also
- Agents
- Architecture
- Connect a channel - Telegram, Slack and WhatsApp, step by step
- Channels (admin panel) - the field reference for each channel section