Skip to content
Learn Agentic AI13 min read0 views

Multi-Channel Chat Agents: One Agent for Web, WhatsApp, Slack, and SMS

Architect a single chat agent that operates across web widgets, WhatsApp, Slack, and SMS by building a channel abstraction layer, message normalization pipeline, and channel-specific rendering adapters.

The Multi-Channel Problem

Your customers are on WhatsApp, Slack, your website, and SMS. Building a separate chat agent for each channel means quadrupling your development and maintenance effort. The solution is a single agent brain with a channel abstraction layer that normalizes incoming messages and adapts outgoing responses to each channel's capabilities.

The architecture has three layers: channel adapters that handle platform-specific APIs, a normalization layer that converts all messages into a common format, and a rendering layer that converts the agent's structured output back into channel-specific formats.

The Common Message Format

Define a canonical message format that every channel converts to and from:

from pydantic import BaseModel
from enum import Enum
from datetime import datetime

class Channel(str, Enum):
    WEB = "web"
    WHATSAPP = "whatsapp"
    SLACK = "slack"
    SMS = "sms"

class Attachment(BaseModel):
    type: str  # "image", "file", "audio"
    url: str
    filename: str | None = None
    mime_type: str | None = None

class InboundMessage(BaseModel):
    channel: Channel
    channel_user_id: str
    conversation_id: str
    text: str
    attachments: list[Attachment] = []
    metadata: dict = {}
    timestamp: datetime

class OutboundMessage(BaseModel):
    text: str
    rich_elements: list[dict] = []  # Cards, buttons, etc.
    attachments: list[Attachment] = []
    quick_replies: list[dict] = []
    metadata: dict = {}

Channel Adapters

Each channel adapter translates between the platform's native format and your common format. Here is the WhatsApp adapter using the Cloud API:

from fastapi import APIRouter, Request
import httpx

router = APIRouter(prefix="/webhooks")

class WhatsAppAdapter:
    def __init__(self, phone_number_id: str, access_token: str):
        self.phone_number_id = phone_number_id
        self.access_token = access_token
        self.api_base = f"https://graph.facebook.com/v18.0/{phone_number_id}"

    def normalize(self, webhook_data: dict) -> InboundMessage | None:
        changes = webhook_data.get("entry", [{}])[0].get("changes", [{}])[0]
        value = changes.get("value", {})
        messages = value.get("messages", [])

        if not messages:
            return None

        msg = messages[0]
        return InboundMessage(
            channel=Channel.WHATSAPP,
            channel_user_id=msg["from"],
            conversation_id=msg["from"],  # WhatsApp uses phone as conversation ID
            text=msg.get("text", {}).get("body", ""),
            attachments=self._extract_attachments(msg),
            timestamp=datetime.utcnow(),
        )

    async def send(self, to: str, message: OutboundMessage):
        headers = {"Authorization": f"Bearer {self.access_token}"}

        # WhatsApp supports interactive messages with buttons
        if message.quick_replies:
            payload = {
                "messaging_product": "whatsapp",
                "to": to,
                "type": "interactive",
                "interactive": {
                    "type": "button",
                    "body": {"text": message.text},
                    "action": {
                        "buttons": [
                            {"type": "reply", "reply": {"id": r["value"], "title": r["label"][:20]}}
                            for r in message.quick_replies[:3]  # WhatsApp limit: 3 buttons
                        ],
                    },
                },
            }
        else:
            payload = {
                "messaging_product": "whatsapp",
                "to": to,
                "type": "text",
                "text": {"body": message.text},
            }

        async with httpx.AsyncClient() as client:
            await client.post(f"{self.api_base}/messages", json=payload, headers=headers)

    def _extract_attachments(self, msg: dict) -> list[Attachment]:
        attachments = []
        for media_type in ("image", "document", "audio", "video"):
            if media_type in msg:
                attachments.append(Attachment(
                    type=media_type,
                    url=msg[media_type].get("id", ""),
                    mime_type=msg[media_type].get("mime_type"),
                ))
        return attachments

And the Slack adapter:

See AI Voice Agents Handle Real Calls

Book a free demo or calculate how much you can save with AI voice automation.

class SlackAdapter:
    def __init__(self, bot_token: str):
        self.bot_token = bot_token

    def normalize(self, event: dict) -> InboundMessage | None:
        if event.get("type") != "message" or "bot_id" in event:
            return None

        return InboundMessage(
            channel=Channel.SLACK,
            channel_user_id=event["user"],
            conversation_id=event["channel"],
            text=event.get("text", ""),
            timestamp=datetime.utcnow(),
        )

    async def send(self, channel: str, message: OutboundMessage):
        blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": message.text}}]

        # Slack supports rich blocks natively
        for element in message.rich_elements:
            if element.get("type") == "card":
                blocks.append({
                    "type": "section",
                    "text": {"type": "mrkdwn", "text": f"*{element['title']}*\n{element['body']}"},
                    "accessory": {
                        "type": "image",
                        "image_url": element.get("image_url", ""),
                        "alt_text": element["title"],
                    } if element.get("image_url") else None,
                })

        if message.quick_replies:
            blocks.append({
                "type": "actions",
                "elements": [
                    {"type": "button", "text": {"type": "plain_text", "text": r["label"]},
                     "action_id": r["value"], "value": r["value"]}
                    for r in message.quick_replies[:5]  # Slack limit per actions block
                ],
            })

        async with httpx.AsyncClient() as client:
            await client.post(
                "https://slack.com/api/chat.postMessage",
                json={"channel": channel, "blocks": blocks},
                headers={"Authorization": f"Bearer {self.bot_token}"},
            )

The Router

A central router dispatches normalized messages to the agent and routes responses back through the correct adapter:

class ChannelRouter:
    def __init__(self, agent_processor):
        self.adapters: dict[Channel, object] = {}
        self.agent = agent_processor

    def register(self, channel: Channel, adapter):
        self.adapters[channel] = adapter

    async def handle_inbound(self, channel: Channel, raw_data: dict):
        adapter = self.adapters[channel]
        message = adapter.normalize(raw_data)
        if not message:
            return

        # Process through the single agent brain
        response: OutboundMessage = await self.agent.process(message)

        # Send back through the same channel
        await adapter.send(message.channel_user_id, response)

router = ChannelRouter(agent_processor=my_agent)
router.register(Channel.WHATSAPP, whatsapp_adapter)
router.register(Channel.SLACK, slack_adapter)

Channel-Aware Response Adaptation

The agent's core logic is channel-agnostic, but some responses need adaptation. A TypeScript utility handles this:

function adaptForChannel(message: OutboundMessage, channel: Channel): OutboundMessage {
  const adapted = { ...message };

  switch (channel) {
    case "sms":
      // SMS: text only, no rich elements, max 160 chars per segment
      adapted.rich_elements = [];
      adapted.quick_replies = [];
      if (adapted.text.length > 300) {
        adapted.text = adapted.text.substring(0, 297) + "...";
      }
      break;

    case "whatsapp":
      // WhatsApp: max 3 buttons, 20 char button labels
      adapted.quick_replies = adapted.quick_replies.slice(0, 3).map((r) => ({
        ...r,
        label: r.label.substring(0, 20),
      }));
      break;

    case "slack":
      // Slack: convert markdown links, support up to 5 buttons
      adapted.text = adapted.text.replace(
        /[(.+?)]((.+?))/g, "<$2|$1>"
      );
      adapted.quick_replies = adapted.quick_replies.slice(0, 5);
      break;
  }

  return adapted;
}

FAQ

How do I handle channels that support images versus those that do not?

Include images as optional attachments in your OutboundMessage. Each channel adapter decides how to handle them. Web renders inline images. WhatsApp sends them as media messages. Slack uses image blocks. SMS sends a shortened URL to the image. The agent does not need to know which channel it is talking to — the adapter handles the translation.

How do I maintain conversation state across channels for the same user?

Map channel-specific user IDs to a unified user profile. When a known user messages on WhatsApp, look up their phone number in your user table to find their unified ID. Load conversation history from all channels for that unified ID. This lets a user start a conversation on the web widget and continue it on WhatsApp without losing context.

What about channel-specific features like Slack slash commands or WhatsApp templates?

Handle these at the adapter level before normalization. Slack slash commands get parsed by the Slack adapter and converted into regular InboundMessages with metadata indicating the command. WhatsApp template messages are handled by the send method with a separate code path. The agent brain sees a standard message either way.


#MultiChannel #WhatsApp #Slack #SMS #ChannelAbstraction #AgenticAI #LearnAI #AIEngineering

Share this article
C

CallSphere Team

Expert insights on AI voice agents and customer communication automation.

Try CallSphere AI Voice Agents

See how AI voice agents work for your industry. Live demo available -- no signup required.