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

Starting from a concrete routing problem, we derive OpenClaw's Channel plugin interface, SessionKey format, seven-tier routing priority, four dmScope isolation modes, and the identityLinks cross-platform identity merging mechanism. Every design is motivated by a specific problem.

·10 min read·Source Analysis

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
};

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" },
})

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 sourceMatched tierRouted to
WhatsApp personal, any contactTier 6: binding.accountpersonal
WhatsApp business, any contactTier 6: binding.accountwork
Discord #ops channelTier 1: binding.peerwork
Discord #general, admin userTier 3: binding.guild+rolesdevops
Discord #general, regular memberTier 4: binding.guildpersonal

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

ProblemSolutionKey code
Unifying different platformsChannelPlugin docking protocolsrc/channels/plugins/types.plugin.ts
Uniquely identifying an AI sessionSessionKey format systemsrc/routing/session-key.ts
Isolating multi-user DMs within one AgentdmScope four modessrc/config/types.base.ts:DmScope
Same person on different platforms shares one sessionidentityLinks identity linkingsrc/routing/session-key.ts:resolveLinkedPeerId
Routing messages to the correct AgentSeven-tier priority bindingssrc/routing/resolve-route.ts:tiers
High-frequency routing efficiencyWeakMap + channel-account two-layer cachesrc/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