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 →
personalAgent - WhatsApp business →
workAgent - Telegram Bot A →
personalAgent - Discord #ops →
workAgent - Discord #general →
personalAgent - Discord #dev messages from admin-role users → a dedicated
devopsAgent
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
};
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 */);
}
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
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
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" },
})
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"
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" },
})
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
};
};
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
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"
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");
})
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[]>;
};
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
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)