Claw Mart
← Back to Blog
March 21, 20268 min readClaw Mart Team

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

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:

  1. Ask the user to link accounts via a simple /link email@example.com command.
  2. Match by email if the platform provides it (Slack gives you email via API if you have the users:read.email scope).
  3. 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:

  1. Load conversation history by conversation_id.
  2. Load user preferences/context by user_id.
  3. Process the message with its full skill set.
  4. 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:

  1. Define your canonical message schema. Steal the one from this post and modify it for your needs.
  2. Get your OpenClaw agent working as a standalone HTTP endpoint. Test it with curl before you add any channels.
  3. Build your first adapter — whichever channel your users are on most. Get it solid.
  4. Add the unified user table and conversation mapping. Even if it's basic.
  5. Add your second and third channels. This should be fast because the hard parts are done.
  6. Add output formatters that actually use each platform's features. Slack blocks, Discord embeds, HTML emails.
  7. 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.

Claw Mart Daily

Get one AI agent tip every morning

Free daily tips to make your OpenClaw agent smarter. No spam, unsubscribe anytime.

More From the Blog