Multi-Channel Setup: Run One Agent on Discord, Slack & Email
Multi-Channel Setup: Run One Agent on Discord, Slack & Email

Look, I'll save you the frustration I went through. You've got an OpenClaw agent that works beautifully in one channel — say, Discord — and now you want it to also handle Slack messages and respond to emails. Sounds simple enough, right? One agent, three channels, same brain.
Then you actually try to set it up and suddenly you're drowning in webhook URLs, duplicated logic, three different authentication flows, and an agent that somehow has amnesia every time a user switches from Slack to email. Your .env file looks like a classified document. Your routing code is held together with duct tape and prayer.
I've been there. And after wiring up OpenClaw agents across Discord, Slack, and email for multiple projects, I can tell you there's a clean way to do this that doesn't involve losing your mind. This post is the guide I wish I'd had on day one.
Why Multi-Channel Is Harder Than It Looks
Before we get into the how, let's be honest about the why — why does this trip everyone up?
The core issue is that Discord, Slack, and email are fundamentally different communication protocols pretending to do the same thing. They each have:
- Different message formats. Slack uses Block Kit and mrkdwn. Discord uses standard Markdown with embeds. Email uses HTML or plain text. Your agent doesn't natively know the difference.
- Different auth models. Slack wants OAuth with scopes and bot tokens. Discord uses a bot token with gateway intents. Email needs IMAP/SMTP credentials or an API key for something like SendGrid or Postmark.
- Different interaction patterns. Slack has threads. Discord has channels and threads. Email has reply chains with quoted text. The concept of "a conversation" is different on each one.
- Different rate limits and policies. Slack throttles you differently than Discord. Email has deliverability concerns. Each platform can and will reject your messages for different reasons.
If you try to handle all of this inside your agent's core logic, you'll end up with spaghetti code that's impossible to debug. The agent shouldn't care what channel it's talking through. That's the principle we're building around.
The Architecture That Actually Works
Here's the mental model. Think of your OpenClaw setup as three layers:
[Channel Adapters] → [Canonical Message Bus] → [OpenClaw Agent Core]
Channel Adapters are thin, dumb translators. One per platform. Their only job is to receive a platform-specific message, convert it to a standard internal format, send it to the agent, get the response, and convert it back to the platform-specific format.
The Canonical Message Bus is your internal standard. Every message, regardless of source, gets normalized to the same shape before it ever touches your agent.
The OpenClaw Agent Core is your actual agent — skills, memory, personality, tools. It never sees a Slack block or a Discord embed. It just sees a clean message and responds with clean text.
This separation is everything. Without it, you're toast.
Step 1: Define Your Canonical Message Format
This is where you start. Before touching any platform SDK, decide what a message looks like internally. Here's a solid starting schema:
{
"message_id": "uuid-v4",
"user_id": "unified-user-id",
"platform": "slack | discord | email",
"platform_user_id": "U04ABCD1234",
"channel_ref": "platform-specific-channel-or-thread-id",
"content": "Plain text message content from the user",
"attachments": [],
"timestamp": "2026-01-15T10:30:00Z",
"conversation_id": "uuid-linking-cross-platform-threads"
}
The critical field here is conversation_id. This is what lets your agent maintain context when a user starts a conversation in Slack and follows up over email. More on that in a minute.
And the outbound response from your agent should look like:
{
"response_id": "uuid-v4",
"conversation_id": "same-conversation-id",
"content": "Plain text response from the agent",
"metadata": {
"suggested_actions": [],
"confidence": 0.95
}
}
Notice: no Slack formatting, no Discord embeds, no HTML. Just clean text and metadata. The channel adapter handles the pretty-printing.
Step 2: Build Your Channel Adapters
Each adapter is a small, self-contained service (or module, depending on your deployment). Let's walk through all three.
Discord Adapter
import discord
import httpx
from config import OPENCLAW_AGENT_ENDPOINT, DISCORD_BOT_TOKEN
client = discord.Client(intents=discord.Intents.default())
@client.event
async def on_message(message):
if message.author == client.user:
return
# Normalize to canonical format
canonical = {
"message_id": str(message.id),
"user_id": resolve_unified_user(platform="discord", pid=str(message.author.id)),
"platform": "discord",
"platform_user_id": str(message.author.id),
"channel_ref": str(message.channel.id),
"content": message.content,
"attachments": [a.url for a in message.attachments],
"conversation_id": resolve_conversation(platform="discord", channel=str(message.channel.id))
}
# Send to OpenClaw agent
async with httpx.AsyncClient() as http:
resp = await http.post(OPENCLAW_AGENT_ENDPOINT, json=canonical)
agent_response = resp.json()
# Format response for Discord
formatted = format_for_discord(agent_response["content"])
await message.channel.send(formatted)
def format_for_discord(text):
# Add Discord-specific formatting
# Convert any markdown conventions, add embeds if needed
if len(text) > 2000:
return text[:1997] + "..."
return text
client.run(DISCORD_BOT_TOKEN)
Slack Adapter
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler
import httpx
from config import OPENCLAW_AGENT_ENDPOINT, SLACK_BOT_TOKEN, SLACK_APP_TOKEN
app = App(token=SLACK_BOT_TOKEN)
@app.message("")
def handle_message(message, say):
canonical = {
"message_id": message.get("ts"),
"user_id": resolve_unified_user(platform="slack", pid=message.get("user")),
"platform": "slack",
"platform_user_id": message.get("user"),
"channel_ref": message.get("channel"),
"content": message.get("text", ""),
"attachments": extract_slack_attachments(message),
"conversation_id": resolve_conversation(
platform="slack",
channel=message.get("channel"),
thread_ts=message.get("thread_ts")
)
}
resp = httpx.post(OPENCLAW_AGENT_ENDPOINT, json=canonical)
agent_response = resp.json()
formatted = format_for_slack(agent_response["content"])
# Reply in thread if the original message was in a thread
thread_ts = message.get("thread_ts", message.get("ts"))
say(text=formatted, thread_ts=thread_ts)
def format_for_slack(text):
# Convert standard markdown to Slack's mrkdwn
text = text.replace("**", "*") # Bold
text = text.replace("```", "```") # Code blocks stay the same, mostly
return text
handler = SocketModeHandler(app, SLACK_APP_TOKEN)
handler.start()
Email Adapter
Email is the weirdest one because it's not real-time. You'll either poll an inbox or use a webhook from a service like SendGrid Inbound Parse, Postmark, or Mailgun.
from fastapi import FastAPI, Request
import httpx
from config import OPENCLAW_AGENT_ENDPOINT
from email_utils import send_email, parse_inbound_email
app = FastAPI()
@app.post("/inbound-email")
async def handle_inbound_email(request: Request):
form_data = await request.form()
parsed = parse_inbound_email(form_data)
canonical = {
"message_id": parsed["message_id"],
"user_id": resolve_unified_user(platform="email", pid=parsed["from_address"]),
"platform": "email",
"platform_user_id": parsed["from_address"],
"channel_ref": parsed["subject_hash"],
"content": parsed["stripped_text"], # Remove quoted reply text
"attachments": parsed.get("attachments", []),
"conversation_id": resolve_conversation(
platform="email",
thread_id=parsed.get("in_reply_to") or parsed["subject_hash"]
)
}
async with httpx.AsyncClient() as http:
resp = await http.post(OPENCLAW_AGENT_ENDPOINT, json=canonical)
agent_response = resp.json()
formatted = format_for_email(agent_response["content"])
send_email(
to=parsed["from_address"],
subject=f"Re: {parsed['subject']}",
body_html=formatted,
in_reply_to=parsed["message_id"]
)
return {"status": "ok"}
def format_for_email(text):
# Convert markdown to simple HTML
import markdown
return markdown.markdown(text)
Notice the pattern? Each adapter is roughly 40–60 lines. They're dead simple. They normalize in, format out. That's it.
Step 3: The Unified User Identity Problem
This is the part people skip and then regret. If the same human talks to your agent on Discord and then emails you the next day, your agent needs to know it's the same person.
You need a users table:
CREATE TABLE unified_users (
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
display_name TEXT,
email TEXT UNIQUE,
discord_id TEXT UNIQUE,
slack_id TEXT UNIQUE,
created_at TIMESTAMP DEFAULT now()
);
Your resolve_unified_user() function looks up or creates a user based on whatever platform identifier comes in. The tricky part is linking accounts — if someone messages on Discord and you don't know their email, you can't auto-link. Common solutions:
- Ask the user to link accounts via a simple
/link email@example.comcommand. - Match by email if the platform provides it (Slack gives you email via API if you have the
users:read.emailscope). - Let it be separate until linked. This is fine for most use cases. Don't over-engineer this on day one.
The resolve_conversation() function works similarly — it maps platform-specific thread/channel IDs to a unified conversation ID so your OpenClaw agent can pull the right memory context.
Step 4: Configure OpenClaw's Memory to Be Channel-Agnostic
This is where OpenClaw shines compared to trying to duct-tape this together with raw LangChain or CrewAI. Your agent's memory and skills should key off conversation_id and user_id, never off platform-specific identifiers.
When your OpenClaw agent receives the canonical message, it should:
- Load conversation history by
conversation_id. - Load user preferences/context by
user_id. - Process the message with its full skill set.
- Return a platform-agnostic response.
The agent literally does not know or care whether it's talking to someone on Discord or email. All the channel-specific complexity is handled before and after the agent runs.
This is one of those things that sounds obvious when you read it, but in practice, people constantly let platform details leak into their agent logic. They'll have skills that say "if Slack, do X" or memory keys that include the platform name. Don't do that. Keep it clean.
Step 5: Output Formatting That Doesn't Suck
Your agent returns plain text (maybe with standard markdown). Each adapter needs to make that look good on its platform. Here's a more complete formatting layer:
# formatters.py
def format_for_discord(content: str, metadata: dict = None) -> dict:
"""Returns a dict compatible with discord.py's send()."""
# Discord supports standard markdown natively
# But truncate at 2000 chars
if len(content) > 2000:
chunks = [content[i:i+2000] for i in range(0, len(content), 2000)]
return {"chunks": chunks}
return {"content": content}
def format_for_slack(content: str, metadata: dict = None) -> dict:
"""Returns Block Kit-compatible payload."""
# Convert bold: **text** → *text*
import re
content = re.sub(r'\*\*(.+?)\*\*', r'*\1*', content)
blocks = [
{
"type": "section",
"text": {"type": "mrkdwn", "text": content}
}
]
# Add action buttons if the agent suggested them
if metadata and metadata.get("suggested_actions"):
blocks.append({
"type": "actions",
"elements": [
{
"type": "button",
"text": {"type": "plain_text", "text": action},
"action_id": f"action_{i}"
}
for i, action in enumerate(metadata["suggested_actions"])
]
})
return {"blocks": blocks, "text": content} # text is fallback
def format_for_email(content: str, metadata: dict = None) -> str:
"""Returns HTML string."""
import markdown
html = markdown.markdown(content, extensions=['fenced_code', 'tables'])
# Wrap in a basic email template
return f"""
<div style="font-family: -apple-system, sans-serif; max-width: 600px; line-height: 1.6;">
{html}
</div>
"""
This is where a lot of the polish comes from. An agent that sends perfectly formatted Slack blocks, clean Discord messages, and readable HTML emails feels professional. An agent that dumps raw markdown into an email looks broken.
The Conversation ID Strategy in Practice
Let me drill into this because it's the linchpin of the whole setup. Here's what resolve_conversation() actually looks like:
import hashlib
from db import get_db
def resolve_conversation(platform: str, channel: str = None,
thread_ts: str = None, thread_id: str = None) -> str:
db = get_db()
# Build a platform-specific reference
if platform == "slack":
platform_ref = f"slack:{channel}:{thread_ts or 'main'}"
elif platform == "discord":
platform_ref = f"discord:{channel}"
elif platform == "email":
platform_ref = f"email:{thread_id}"
else:
platform_ref = f"{platform}:{channel}"
# Check if we already have a conversation mapped
existing = db.execute(
"SELECT conversation_id FROM conversation_map WHERE platform_ref = ?",
(platform_ref,)
).fetchone()
if existing:
return existing["conversation_id"]
# Create new conversation
import uuid
conv_id = str(uuid.uuid4())
db.execute(
"INSERT INTO conversation_map (platform_ref, conversation_id, platform) VALUES (?, ?, ?)",
(platform_ref, conv_id, platform)
)
db.commit()
return conv_id
And if you want cross-platform conversation linking (user starts in Slack, continues in email), you'd add an endpoint or command:
def link_conversations(conv_id_1: str, conv_id_2: str):
"""Merge two conversations into one, keeping all history under conv_id_1."""
db = get_db()
db.execute(
"UPDATE conversation_map SET conversation_id = ? WHERE conversation_id = ?",
(conv_id_1, conv_id_2)
)
db.execute(
"UPDATE message_history SET conversation_id = ? WHERE conversation_id = ?",
(conv_id_1, conv_id_2)
)
db.commit()
Common Mistakes I See People Make
Mistake 1: Building the adapter logic into the agent. Your OpenClaw agent should be a single endpoint that accepts canonical messages and returns canonical responses. Period. The moment you put if platform == "slack" inside your agent logic, you've lost.
Mistake 2: Skipping the unified user table. "I'll add that later." No you won't. And then you'll have three months of conversation history with no way to link users across platforms. Build the table on day one even if it only has one platform ID per user initially.
Mistake 3: Trying to support every platform simultaneously from the start. Get one channel working perfectly. Then add the second. The canonical message format makes this trivial — you're just adding a new adapter, not rewriting your agent.
Mistake 4: Ignoring platform-specific features. Email needs subject lines. Slack has threads and reactions. Discord has embeds and reactions. Your formatters should take advantage of these, not just dump plain text everywhere.
Mistake 5: Not handling errors per-channel. When your Discord adapter fails, it shouldn't take down your Slack adapter. Run them as separate processes or at minimum isolate their error handling completely.
The Shortcut: Felix's OpenClaw Starter Pack
Now, everything above? You can absolutely build it from scratch. I did the first time, and I learned a lot. But I also spent about two weeks debugging edge cases with Slack thread resolution and email reply chain parsing.
If you don't want to wire all of this up manually, Felix's OpenClaw Starter Pack on Claw Mart includes pre-built channel adapters, the canonical message layer, unified user resolution, and output formatters for Discord, Slack, and email — all pre-configured as OpenClaw skills. It's $29 and honestly saves you the most painful parts of this setup. The adapter code is clean and well-commented, so you can customize it easily. I'd especially recommend it if you want the email parsing and conversation threading handled for you, because those are the two gnarliest pieces to get right.
It's not magic — it's essentially a polished, tested version of everything I described above. But having a working reference implementation that you can deploy and then modify is way faster than building from zero.
Deployment: How to Run All Three
In production, you want each adapter running as its own process. A simple docker-compose.yml:
version: "3.8"
services:
openclaw-agent:
build: ./agent
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://...
- OPENCLAW_API_KEY=${OPENCLAW_API_KEY}
discord-adapter:
build: ./adapters/discord
environment:
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN}
- OPENCLAW_AGENT_ENDPOINT=http://openclaw-agent:8000/message
depends_on:
- openclaw-agent
slack-adapter:
build: ./adapters/slack
environment:
- SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN}
- SLACK_APP_TOKEN=${SLACK_APP_TOKEN}
- OPENCLAW_AGENT_ENDPOINT=http://openclaw-agent:8000/message
depends_on:
- openclaw-agent
email-adapter:
build: ./adapters/email
ports:
- "8001:8001"
environment:
- SMTP_HOST=${SMTP_HOST}
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- OPENCLAW_AGENT_ENDPOINT=http://openclaw-agent:8000/message
depends_on:
- openclaw-agent
postgres:
image: postgres:16
environment:
- POSTGRES_DB=openclaw
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Each adapter is isolated. If Discord goes down, Slack and email keep running. Your OpenClaw agent is a single service that all adapters talk to. Clean, simple, debuggable.
Observability: Don't Fly Blind
Once you're running three channels, you need to be able to trace a conversation end-to-end. Add a simple logging middleware to your agent endpoint:
import logging
import json
from datetime import datetime
logger = logging.getLogger("openclaw-multi-channel")
def log_interaction(canonical_message: dict, agent_response: dict, duration_ms: float):
logger.info(json.dumps({
"timestamp": datetime.utcnow().isoformat(),
"conversation_id": canonical_message["conversation_id"],
"user_id": canonical_message["user_id"],
"platform": canonical_message["platform"],
"input_length": len(canonical_message["content"]),
"output_length": len(agent_response["content"]),
"duration_ms": duration_ms
}))
Ship these logs to wherever you monitor things. The key is that conversation_id and user_id are present on every log line, so you can filter and trace across platforms easily.
What To Do Next
Here's the order I'd recommend:
- Define your canonical message schema. Steal the one from this post and modify it for your needs.
- Get your OpenClaw agent working as a standalone HTTP endpoint. Test it with curl before you add any channels.
- Build your first adapter — whichever channel your users are on most. Get it solid.
- Add the unified user table and conversation mapping. Even if it's basic.
- Add your second and third channels. This should be fast because the hard parts are done.
- Add output formatters that actually use each platform's features. Slack blocks, Discord embeds, HTML emails.
- Set up logging and monitoring so you can debug cross-channel conversations.
Or grab Felix's OpenClaw Starter Pack and skip to step 5. Either way, the architecture is what matters. One agent, canonical messages, thin adapters. Get that right and adding new channels becomes a one-afternoon project instead of a one-month nightmare.