By Sagar Shankaran, Founder of CallSphere
Build a unified AI agent backend that serves conversations across web chat, Slack, and WhatsApp using a channel abstraction layer, shared agent logic, and centralized conversation storage.
Key takeaways
Most organizations interact with customers across multiple channels simultaneously. A user might start a conversation on your website, follow up via WhatsApp, and your team manages internal queries through Slack. Building a separate AI agent for each channel creates maintenance nightmares, inconsistent responses, and fragmented conversation histories.
This capstone builds a unified platform where a single agent backend serves all channels. The key architectural insight is the channel adapter pattern: each channel has a thin adapter that translates channel-specific message formats into a canonical internal format, passes it to the shared agent, and translates the response back.
Define a universal message format that all channel adapters produce and consume.
Hear it before you finish reading
Talk to a live CallSphere AI voice agent in your browser — 60 seconds, no signup.
flowchart LR
INPUT(["User intent"])
PARSE["Parse plus<br/>classify"]
PLAN["Plan and tool<br/>selection"]
AGENT["Agent loop<br/>LLM plus tools"]
GUARD{"Guardrails<br/>and policy"}
EXEC["Execute and<br/>verify result"]
OBS[("Trace and metrics")]
OUT(["Outcome plus<br/>next action"])
INPUT --> PARSE --> PLAN --> AGENT --> GUARD
GUARD -->|Pass| EXEC --> OUT
GUARD -->|Fail| AGENT
AGENT --> OBS
style AGENT fill:#4f46e5,stroke:#4338ca,color:#fff
style GUARD fill:#f59e0b,stroke:#d97706,color:#1f2937
style OBS fill:#ede9fe,stroke:#7c3aed,color:#1e1b4b
style OUT fill:#059669,stroke:#047857,color:#fff
# core/models.py
from pydantic import BaseModel
from typing import Optional
from enum import Enum
class Channel(str, Enum):
WEB = "web"
SLACK = "slack"
WHATSAPP = "whatsapp"
class InboundMessage(BaseModel):
channel: Channel
channel_user_id: str # channel-specific user identifier
channel_thread_id: str # channel-specific thread/conversation ID
text: str
attachments: list[str] = [] # URLs to any attached files
class OutboundMessage(BaseModel):
text: str
channel: Channel
channel_thread_id: str
metadata: dict = {} # channel-specific formatting hints
Each adapter implements two methods: parse_inbound to convert a channel-specific webhook payload into an InboundMessage, and send_outbound to deliver an OutboundMessage back through the channel.
# adapters/base.py
from abc import ABC, abstractmethod
class ChannelAdapter(ABC):
@abstractmethod
async def parse_inbound(self, raw_payload: dict) -> InboundMessage:
"""Convert channel-specific payload to canonical format."""
...
@abstractmethod
async def send_outbound(self, message: OutboundMessage) -> None:
"""Send response back through the channel."""
...
# adapters/slack_adapter.py
from slack_sdk.web.async_client import AsyncWebClient
class SlackAdapter(ChannelAdapter):
def __init__(self):
self.client = AsyncWebClient(token=os.environ["SLACK_BOT_TOKEN"])
async def parse_inbound(self, raw_payload: dict) -> InboundMessage:
event = raw_payload["event"]
return InboundMessage(
channel=Channel.SLACK,
channel_user_id=event["user"],
channel_thread_id=event.get("thread_ts", event["ts"]),
text=event["text"],
)
async def send_outbound(self, message: OutboundMessage) -> None:
await self.client.chat_postMessage(
channel=os.environ["SLACK_CHANNEL_ID"],
text=message.text,
thread_ts=message.channel_thread_id,
)
# adapters/whatsapp_adapter.py
from twilio.rest import Client
class WhatsAppAdapter(ChannelAdapter):
def __init__(self):
self.client = Client(
os.environ["TWILIO_ACCOUNT_SID"],
os.environ["TWILIO_AUTH_TOKEN"],
)
async def parse_inbound(self, raw_payload: dict) -> InboundMessage:
return InboundMessage(
channel=Channel.WHATSAPP,
channel_user_id=raw_payload["From"],
channel_thread_id=raw_payload["From"], # WhatsApp uses phone as thread
text=raw_payload["Body"],
)
async def send_outbound(self, message: OutboundMessage) -> None:
self.client.messages.create(
body=message.text,
from_=f"whatsapp:{os.environ['TWILIO_WHATSAPP_NUMBER']}",
to=message.channel_thread_id,
)
The core pipeline receives a canonical InboundMessage, loads conversation history from the database, runs the agent, stores the response, and returns an OutboundMessage.
# core/pipeline.py
from agents import Agent, Runner
support_agent = Agent(
name="Support Agent",
instructions="You are a helpful support assistant. Be concise.",
tools=[search_kb, create_ticket, check_order],
)
async def process_message(msg: InboundMessage, db) -> OutboundMessage:
# Load or create conversation
conv = await get_or_create_conversation(
db, msg.channel, msg.channel_user_id, msg.channel_thread_id
)
# Build message history
history = await get_message_history(db, conv.id, limit=20)
# Store inbound message
await store_message(db, conv.id, "user", msg.text)
# Run agent
result = await Runner.run(support_agent, msg.text, context={"history": history})
# Store agent response
await store_message(db, conv.id, "assistant", result.final_output)
return OutboundMessage(
text=result.final_output,
channel=msg.channel,
channel_thread_id=msg.channel_thread_id,
)
Each channel has a dedicated webhook endpoint. All endpoints converge on the same process_message pipeline.
# routes/webhooks.py
from fastapi import APIRouter, Request
router = APIRouter()
adapters = {
Channel.SLACK: SlackAdapter(),
Channel.WHATSAPP: WhatsAppAdapter(),
Channel.WEB: WebAdapter(),
}
@router.post("/webhooks/slack")
async def slack_webhook(request: Request, db=Depends(get_db)):
payload = await request.json()
if payload.get("type") == "url_verification":
return {"challenge": payload["challenge"]}
adapter = adapters[Channel.SLACK]
inbound = await adapter.parse_inbound(payload)
outbound = await process_message(inbound, db)
await adapter.send_outbound(outbound)
return {"ok": True}
@router.post("/webhooks/whatsapp")
async def whatsapp_webhook(request: Request, db=Depends(get_db)):
form = await request.form()
adapter = adapters[Channel.WHATSAPP]
inbound = await adapter.parse_inbound(dict(form))
outbound = await process_message(inbound, db)
await adapter.send_outbound(outbound)
return {"ok": True}
Test each adapter independently by mocking the channel SDK and verifying the canonical format conversion. Test the pipeline with synthetic InboundMessage objects to verify agent behavior is identical regardless of channel.
Still reading? Stop comparing — try CallSphere live.
CallSphere ships complete AI voice agents per industry — 14 tools for healthcare, 10 agents for real estate, 4 specialists for salons. See how it actually handles a call before you book a demo.
# tests/test_slack_adapter.py
import pytest
from adapters.slack_adapter import SlackAdapter
@pytest.mark.asyncio
async def test_parse_slack_message():
adapter = SlackAdapter()
payload = {"event": {"user": "U123", "text": "hello", "ts": "111.222"}}
msg = await adapter.parse_inbound(payload)
assert msg.channel == Channel.SLACK
assert msg.text == "hello"
assert msg.channel_thread_id == "111.222"
Store formatting hints in the OutboundMessage.metadata field. The Slack adapter can convert markdown to Slack blocks, WhatsApp can use WhatsApp-specific formatting, and web can render full HTML. The agent always outputs plain text or markdown, and the adapter transforms it.
Implement a user resolution layer that maps channel-specific user IDs to a unified user record. When a user verifies their email via the web widget and also uses WhatsApp, link both channel IDs to the same user record. This allows conversation history to persist across channels.
Implement per-adapter rate limiters. Slack has a 1 message per second limit per channel, WhatsApp has a 24-hour messaging window, and web has no external limits. Each adapter should queue messages and respect the channel rate limits independently.
#CapstoneProject #MultiChannel #Slack #WhatsApp #ChatAgent #FullStackAI #AgenticAI #LearnAI #AIEngineering

Written by
Sagar Shankaran· Founder, CallSphere
LinkedInSagar Shankaran is the founder of CallSphere, where he builds production AI voice and chat agents deployed across healthcare, hospitality, real estate, and home services. He writes about agentic AI, LLM engineering, and shipping voice agents that handle real calls in production.
See how AI voice agents work for your industry. Live demo available -- no signup required.
A step-by-step how-to for Lima and Arequipa restaurants to use CallSphere AI voice and chat agents to capture reservations 24/7 on WhatsApp and by phone, in Spanish and English.
A market-data guide to how Peruvian businesses in Lima, Cusco, and Arequipa use CallSphere AI voice and chat agents to book tours and stays 24/7 for travelers in Spanish, English, and beyond.
A 2026 guide for Ecuadorian small businesses in Quito, Guayaquil and Cuenca using CallSphere AI voice and chat agents in Spanish, Kichwa-aware and English, priced in US dollars.
A 2026 guide for Bolivian small businesses in Santa Cruz, La Paz and Cochabamba using CallSphere AI voice and chat agents in Spanish, Quechua-aware and English, over WhatsApp.
Brazilian real estate brokers lose deals to slow replies. This market-data look shows the cost of slow speed-to-lead and how a CallSphere AI voice and chat agent qualifies buyers 24/7 on WhatsApp.
A step-by-step guide for Chilean retail and online stores to use CallSphere AI voice and chat agents to answer product questions, recover carts, and close sales 24/7 on WhatsApp.
© 2026 CallSphere Inc. All rights reserved.
Made within San Francisco
Watch how CallSphere handles real customer calls, schedules appointments, and processes payments — live.
Try Live DemoBook a DemoCalculate Your ROI