DEV Community

WonderLab
WonderLab

Posted on

OpenClaw Source Deep Dive (2): Channels & Routing — How Does a Message Find Its Agent?

Series goal: After reading the full series, you'll be able to do custom development on OpenClaw and build a similar system from scratch.

Core question in this article: When a WhatsApp message arrives, how does OpenClaw know which Agent's session should handle it?


Start With a Concrete Headache

Suppose you're using OpenClaw to manage:

  • WhatsApp Personal (+1-555-personal)
  • WhatsApp Business (+1-555-business)
  • Telegram Bot A (personal assistant)
  • Telegram Bot B (work assistant)
  • Discord server (with #general, #dev, and #ops channels)

You have two Agents:

  • personal: handles personal matters, has access to calendar and contacts
  • work: handles work matters, has access to code repos and servers

Requirements:

  • WhatsApp personal → personal Agent
  • WhatsApp business → work Agent
  • Telegram Bot A → personal Agent
  • Discord #ops → work Agent
  • Discord #general → personal Agent
  • Discord #dev messages from admin-role users → a dedicated devops Agent

Same Discord server, different channels, different user roles, different Agents.

This is a real routing problem. How did OpenClaw design its routing system to solve it?


First Problem: How Do You Unify Dozens of Different Platforms?

WhatsApp, Telegram, Discord, Slack, Signal, iMessage... each platform has a completely different API. Writing separate integration code for every platform would turn the core routing logic into a sprawling if platform == 'whatsapp': ... elif platform == 'telegram': ... nightmare.

OpenClaw's solution is the Channel plugin interface (ChannelPlugin) — a universal docking protocol. Each platform implements this protocol; the core routing code only talks to the protocol.

// src/channels/plugins/types.plugin.ts
export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknown> = {
  id: ChannelId;           // Platform ID: "whatsapp", "telegram", etc.
  meta: ChannelMeta;       // Display info: name, docs path, icon
  capabilities: ChannelCapabilities;  // Supported features: groups, polls, media, edit...

  config: ChannelConfigAdapter;    // Required: read/parse platform config
  security?: ChannelSecurityAdapter; // Optional: dmPolicy, allowFrom, etc.
  outbound?: ChannelOutboundAdapter; // Optional: send message implementation
  pairing?: ChannelPairingAdapter;   // Optional: QR code pairing flow
  groups?: ChannelGroupAdapter;      // Optional: group management
  gateway?: ChannelGatewayAdapter;   // Optional: register extra Gateway RPC methods
  agentTools?: ChannelAgentToolFactory; // Optional: tools for the Agent (e.g. whatsapp-login)
  // ... more optional adapters
};
Enter fullscreen mode Exit fullscreen mode

This is a layered optional protocol design: the core fields (id, meta, capabilities, config) are required; everything else is opt-in. A simple webhook channel can implement just outbound; a full-featured platform can implement all adapters.

This resembles Go's interface composition — each adapter is a small interface, and plugins compose them as needed.

How Are Plugins Registered?

// src/channels/plugins/index.ts
export function listChannelPlugins(): ChannelPlugin[] {
  const registry = requireActivePluginRegistry();
  return registry.channels
    .map((entry) => entry.plugin)
    .sort(/* by meta.order */);
}
Enter fullscreen mode Exit fullscreen mode

The plugin registry (PluginRegistry) is initialized at Gateway startup. Four source origins, from highest to lowest priority:

  • config: plugin paths specified in the config file
  • workspace: pnpm workspace (during development, extensions/*)
  • global: globally npm-installed plugins
  • bundled: plugins built into the core

When multiple sources provide the same channel ID, only the highest-priority version is kept, preventing conflicts.


Second Problem: How Do You Uniquely Identify a Session?

The core job of routing is: given "a message from WhatsApp Business, sent by contact Alice," find or create the corresponding AI session.

This "find or create" is done via a SessionKey — a string that uniquely identifies an AI conversation context.

Reading the test cases reveals the format:

agent:main:main                                    # simplest main session
agent:main:direct:+15551234567                     # DM session isolated by sender
agent:main:whatsapp:direct:+15551234567            # isolated by channel + sender
agent:main:telegram:tasks:direct:7550356539        # isolated by account + channel + sender
agent:main:discord:channel:1468834856187203680     # Discord channel session
agent:main:discord:group:987654321                 # Discord group session
agent:main:discord:channel:c1:thread:t1            # Discord thread session
agent:main:cron:job-1                              # scheduled task session
agent:main:subagent:worker                         # sub-agent session
Enter fullscreen mode Exit fullscreen mode

The pattern: agent:<agentId>:<rest>, where <rest> can be:

  • main: the main session
  • direct:<peerId>: DM, isolated by sender
  • <channel>:direct:<peerId>: DM, isolated by channel + sender
  • <channel>:<accountId>:direct:<peerId>: isolated by account + channel + sender
  • <channel>:<chatType>:<peerId>: group/channel session

The SessionKey is the coordinate system for AI conversations. The same SessionKey means the same conversation history and the same Agent instance.

dmScope: Controlling DM Session Isolation Granularity

By default, all direct messages (regardless of platform or sender) land in the same main session agent:main:main. This makes sense for the "single user, single assistant" case — you're the only one talking to it.

But if you want OpenClaw to serve multiple people (family members, team members), you need to isolate their conversation histories. That's what dmScope is for:

// src/config/types.base.ts
export type DmScope =
  | "main"                      // default: all DMs → same main session
  | "per-peer"                  // isolated by sender: agent:main:direct:<peerId>
  | "per-channel-peer"          // per channel+sender: agent:main:<channel>:direct:<peerId>
  | "per-account-channel-peer"  // most granular: per account+channel+sender
Enter fullscreen mode Exit fullscreen mode

Test cases show the effect:

// src/routing/resolve-route.test.ts
test("dmScope controls direct-message session key isolation", () => {
  // per-peer mode
  { dmScope: "per-peer", expected: "agent:main:direct:+15551234567" },
  // per-channel-peer mode
  { dmScope: "per-channel-peer", expected: "agent:main:whatsapp:direct:+15551234567" },
})
Enter fullscreen mode Exit fullscreen mode

Sidebar: Is the Same Person on Different Platforms the "Same Person"?

Here's a subtle problem: Alice's Telegram ID is 111111111, and her Discord ID is 222222222222222222.

With per-peer or per-channel-peer isolation, Alice's Telegram messages and Discord messages land in different sessions. But you know Telegram-111111111 and Discord-222222222222222222 are the same Alice.

Use identityLinks to solve this:

# openclaw.yml
session:
  dmScope: "per-peer"
  identityLinks:
    alice:                         # canonical name
      - "telegram:111111111"
      - "discord:222222222222222222"
Enter fullscreen mode Exit fullscreen mode

The result:

// src/routing/resolve-route.test.ts
test("identityLinks applies to direct-message scopes", () => {
  // Telegram message → alice's session
  { channel: "telegram", peerId: "111111111",
    expected: "agent:main:direct:alice" },
  // Discord message → same alice session
  { channel: "discord", peerId: "222222222222222222",
    expected: "agent:main:discord:direct:alice" },
})
Enter fullscreen mode Exit fullscreen mode

What identityLinks does: before generating a SessionKey, it replaces platform-native IDs with the canonical identity name. Messages from two different platforms, after identity linking, collapse to the same SessionKey — and thus the same conversation history.


The Core: Seven-Tier Routing Priority

dmScope and identityLinks only solve "how to isolate sessions within one Agent." They don't solve the original big problem: how do messages from WhatsApp Business get routed to the work Agent and not personal?

That's done through Bindings. A Binding is a condition-result pair:

// src/config/types.agents.ts
export type AgentBinding = {
  agentId: string;   // target Agent for this route
  match: {
    channel: string;          // required: which channel
    accountId?: string;       // optional: which account ("*" = any account)
    peer?: { kind: ChatType; id: string }; // optional: specific contact/group/channel
    guildId?: string;         // optional: Discord server ID
    teamId?: string;          // optional: Slack workspace ID
    roles?: string[];         // optional: Discord role IDs
  };
};
Enter fullscreen mode Exit fullscreen mode

Multiple Bindings form a list (the bindings: [...] in your config). When a message arrives, resolveAgentRoute tries them in seven-tier priority order, from most specific to most general:

// src/routing/resolve-route.ts  — the tiers array
const tiers = [
  { matchedBy: "binding.peer",        /* 1. Exact contact/group/channel match */ },
  { matchedBy: "binding.peer.parent", /* 2. Thread parent channel inheritance */ },
  { matchedBy: "binding.guild+roles", /* 3. Discord server + role */ },
  { matchedBy: "binding.guild",       /* 4. Discord server-wide */ },
  { matchedBy: "binding.team",        /* 5. Slack workspace-wide */ },
  { matchedBy: "binding.account",     /* 6. Account-level match */ },
  { matchedBy: "binding.channel",     /* 7. Channel-wide wildcard (accountId="*") */ },
];
// None of the seven matched → "default": use the default Agent
Enter fullscreen mode Exit fullscreen mode

Priority: highest first. The first tier that matches is the answer.

Validating With Real Config

Returning to the scenario at the top, the corresponding bindings look like this:

bindings:
  # Tier 6 - account: route WhatsApp accounts to different agents
  - agentId: personal
    match:
      channel: whatsapp
      accountId: "+1-555-personal"

  - agentId: work
    match:
      channel: whatsapp
      accountId: "+1-555-business"

  # Tier 1 - peer: Discord #ops channel specifically
  - agentId: work
    match:
      channel: discord
      peer: { kind: channel, id: "1111111" }   # #ops channel ID

  # Tier 3 - guild+roles: admin role → devops agent
  - agentId: devops
    match:
      channel: discord
      guildId: "999999"
      roles: ["admin-role-id"]

  # Tier 4 - guild: everything else in this Discord → personal
  - agentId: personal
    match:
      channel: discord
      guildId: "999999"
Enter fullscreen mode Exit fullscreen mode

Results (matching the test-verified priority order):

Message source Matched tier Routed to
WhatsApp personal, any contact Tier 6: binding.account personal
WhatsApp business, any contact Tier 6: binding.account work
Discord #ops channel Tier 1: binding.peer work
Discord #general, admin user Tier 3: binding.guild+roles devops
Discord #general, regular member Tier 4: binding.guild personal

Note the Discord #ops case: even if the sender is an admin, the exact channel match at Tier 1 wins before the role check at Tier 3. More specific rules always win.

Thread Inheritance (Tier 2)

Discord threads are a special case. Suppose #parent-channel has a binding pointing to agent-A, but the thread itself has no separate binding:

// src/routing/resolve-route.test.ts
test("thread inherits binding from parent channel when no direct match", () => {
  const route = resolveAgentRoute({
    cfg: { bindings: [{ agentId: "adecco", match: { channel: "discord",
           peer: { kind: "channel", id: "parent-channel-123" } } }] },
    channel: "discord",
    peer: { kind: "channel", id: "thread-456" },     // the thread
    parentPeer: { kind: "channel", id: "parent-channel-123" }, // parent channel
  });
  expect(route.matchedBy).toBe("binding.peer.parent");
  expect(route.agentId).toBe("adecco");
})
Enter fullscreen mode Exit fullscreen mode

Threads inherit their parent channel's binding — this is intuitive: a thread you open under #ops should still be handled by the work Agent.


Under the Hood: Cache + Lazy Evaluation

resolveAgentRoute is called on every incoming message. If it traversed all bindings every time, that could become a bottleneck under load (many groups active simultaneously).

OpenClaw uses two-layer caching:

// src/routing/resolve-route.ts

// WeakMap: keyed by Config object — cache stays valid as long as config is unchanged
const evaluatedBindingsCacheByCfg = new WeakMap<OpenClawConfig, EvaluatedBindingsCache>();

// Inner cache: keyed by "channel\taccountId" — stores the pre-filtered binding list
type EvaluatedBindingsCache = {
  bindingsRef: OpenClawConfig["bindings"];
  byChannelAccount: Map<string, EvaluatedBinding[]>;
};
Enter fullscreen mode Exit fullscreen mode

On the first routing call for a given channel+account pair, getEvaluatedBindingsForChannelAccount traverses all bindings, filters to the relevant subset, and caches it. Subsequent messages on the same channel+account hit the cache directly.

When config changes (the Config object reference changes), the WeakMap key becomes invalid and the cache is automatically garbage collected — no explicit invalidation needed. This is a clean use of WeakMap's GC semantics to solve cache invalidation elegantly.


Putting It All Together

From an incoming WhatsApp message to the AI starting to think:

1. WhatsApp channel receives message
         ↓
2. Channel calls resolveAgentRoute(cfg, channel="whatsapp", accountId="+1-555-business", peer=...)
         ↓
3. Seven-tier matching: agentId="work", matchedBy="binding.account"
         ↓
4. buildAgentSessionKey(...) generates the SessionKey
   (with dmScope=per-peer → "agent:work:direct:+15551234567")
         ↓
5. Gateway finds (or creates) the session identified by that SessionKey
         ↓
6. Message is delivered to the Agent; AI begins executing
         ↓
7. AI reply is sent back through the same channel to WhatsApp Business
Enter fullscreen mode Exit fullscreen mode

Each step maps to a concrete file:

  • Steps 2–3: src/routing/resolve-route.ts:resolveAgentRoute
  • Step 4: src/routing/session-key.ts:buildAgentPeerSessionKey
  • Step 5: Gateway's Session Manager (the subject of the next article)

Summary

Problem Solution Key code
Unifying different platforms ChannelPlugin docking protocol src/channels/plugins/types.plugin.ts
Uniquely identifying an AI session SessionKey format system src/routing/session-key.ts
Isolating multi-user DMs within one Agent dmScope four modes src/config/types.base.ts:DmScope
Same person on different platforms shares one session identityLinks identity linking src/routing/session-key.ts:resolveLinkedPeerId
Routing messages to the correct Agent Seven-tier priority bindings src/routing/resolve-route.ts:tiers
High-frequency routing efficiency WeakMap + channel-account two-layer cache src/routing/resolve-route.ts:evaluatedBindingsCacheByCfg

Next article dives into the Agent execution engine:

After the Agent receives a message, how does the AI "think"? Tool calls, sandbox execution, streaming output — how does pi-embedded-runner's execution loop actually work?


Source paths: src/routing/ | src/channels/plugins/ | Key files: resolve-route.ts, session-key.ts, types.plugin.ts

Top comments (0)