← Writing

OpenClaw

How Does OpenClaw Decide Which Session a Message Belongs To?

Trace a Telegram message through OpenClaw agent selection and session-key construction. Explore dmScope, identity links, routing precedence, and isolation boundaries, with 15 checks against a pinned historical revision.

How Does OpenClaw Decide Which Session a Message Belongs To?

Start with a conversation that seems to cross wires

Imagine an assistant connected to both Telegram and Discord. I discuss work with it in a Telegram direct message, then switch to Discord and ask, “Where were we?” Should it remember the earlier conversation? And if a colleague can also message the assistant, which context should their message enter?

Both questions sound like memory problems. But before the model reads any history, the system has already made a more fundamental decision: which session owns this message. Even a capable model cannot reliably untangle history that the application has combined incorrectly.

OpenClaw's routing layer is interesting because it does not use one user ID to stand for the sender, the agent, and the session. It treats “who handles this?” and “which conversations share context?” as separate decisions. This article follows that routing segment only. It does not attempt to explain model calls, vector memory, or sandbox implementation. The goal is to explain why a particular message enters a particular session, beyond simply knowing that a setting called dmScope exists.

Separate four concepts first

channel identifies the integration, such as Telegram. accountId identifies a configured account instance within that channel, such as a work bot and a personal bot. agentId identifies the selected agent. sessionKey is the key that downstream code uses to locate conversation context and coordinate execution.

An account is not the person sending the message. Two bots can receive a message from user 42: the sender is the same, while the receiving account differs. An agent is not a conversation either. One agent can handle multiple sessions without creating another agent for every conversation.

The routing result type also includes matchedBy, mainSessionKey, and lastRoutePolicy. The result therefore carries an explanation and downstream routing hints, rather than just a destination string.

flowchart TD
    A[Telegram message processor] --> B[Build message context]
    B --> C[Telegram conversation routing]
    C --> D[Select agent using generic rules]
    D --> E[Build initial sessionKey]
    E --> F[Apply channel overrides and bindings]
    F --> G[Apply account fallback and thread handling]
    G --> H[Context with final route]
    H --> I[Downstream message dispatch]

The generic rules deliberately sit in the middle of this diagram. A channel can still transform their result. Finding a shared routing function is not the same as understanding the entire inbound path.

Follow actual call sites

Our entry into this subsystem is Telegram's message processor, not a filename that merely looks like an entry point. createTelegramMessageProcessor returns the asynchronous function that processes a message. It calls buildTelegramMessageContext first. If no valid context is returned, processing stops; otherwise it proceeds to dispatchTelegramMessage.

Context construction reads fresh configuration and calls resolveTelegramConversationRoute. That function converts platform facts into generic routing inputs: channel, account, a direct or group peer, and a parent peer for topics. It calls the core resolveAgentRoute through the plugin SDK, keeping Telegram's message object out of the core interface.

resolveAgentRoute matches configuration rules, selects an agent, and calls buildAgentSessionKey. That delegates key construction to buildAgentPeerSessionKey. The agent, session key, and match reason then return to the channel layer.

There is more work after that return. Telegram handles topic-agent overrides, configured bindings, and runtime conversation bindings. Context construction subsequently calculates the base session key, adds a direct-message thread suffix when appropriate, recalculates lastRoutePolicy, and passes the final route into inbound context assembly. The verified chain in this article ends at the handoff to downstream dispatch. It does not claim to have executed a complete live model response.

Design 1: Agent routing has deterministic priorities

A minimal implementation might scan a rule list and take the first match. But then a broad rule near the top could swallow a more specific rule farther down. OpenClaw first separates rules into semantic priority tiers, then searches the candidates within the applicable tier.

In this version, tiers orders matches as follows: exact peer, parent peer, peer-kind wildcard, guild plus roles, guild, team, account, and channel. If none match, it chooses the default agent. A peer match does not bypass other constraints: guild, team, and role requirements still go through their checks. Within the configured role list, matching at least one role is sufficient; the sender does not need every listed role.

A thread without its own binding can inherit its parent channel's agent. A more specific thread binding can override that choice. The upstream tests cover both cases. What is inherited here is the handler selection, not a requirement for parent and child conversations to share all their history.

Another subtlety: omitting accountId in a binding matches only the default account in this version's generic matching layer. It does not mean every account. Cross-account matching requires *. An explicit test captures this behavior, so an absent field should not be interpreted intuitively as an unrestricted field.

This is a familiar rule-engine tradeoff. Explicit priorities make decisions explainable, while increasing the amount of semantics an operator must understand. Returning matchedBy is valuable: investigating a misrouted response starts with knowing which tier actually won.

Design 2: Session keys express context-sharing scope

After selecting an agent, the system must decide which bucket holds the conversation. buildAgentPeerSessionKey supports four scopes for direct messages. Suppose the agent is main, the channel is telegram, the receiving account is work, and the sender ID is 42:

dmScope Generated sessionKey
main agent:main:main
per-peer agent:main:direct:42
per-channel-peer agent:main:telegram:direct:42
per-account-channel-peer agent:main:telegram:work:direct:42

Each additional dimension partitions context more finely. The default, main, supports continuity around one owner. Once multiple people can reach the assistant, however, a shared default session does not provide direct-message isolation. per-peer removes the channel dimension, so equal raw IDs on different platforms can produce the same key. The application does not automatically prove that they represent the same human.

identityLinks provides explicit identity association. Mapping telegram:42 and discord:99 to alice gives the two inputs a common canonical identity before key construction. The order matters: this replaces the peer identity; it does not erase the channel or account dimensions required by the selected scope. With per-peer, both can resolve to agent:main:direct:alice. With per-channel-peer, their channel segments remain, and identity association alone does not merge the sessions.

The distinction is useful in support, ticketing, and collaboration products. Identity association answers “is this the same person?” Context scope answers “should these conversations share history?” Neither question can replace the other. An association configuration is also not authentication: the inbound integration still has to establish a trustworthy sender identity.

Group messages take a different branch. Their generic key contains the agent, channel, peer kind, and peer ID. It does not apply direct-message dmScope, and it does not automatically include the account. Meanwhile, buildGroupHistoryKey does include the account in its group-history key. These similarly named keys serve different purposes. A key containing “history” is not automatically interchangeable with the agent session key.

Design 3: Channels own their final layer of semantics

Reusable generic rules do not require every platform to behave identically. Telegram's resolveTelegramConversationBaseSessionKey contains a specific fallback: when a direct message to a non-default account reaches only the default-agent fallback, it rebuilds the session key using account, channel, and peer.

For group messages in the analogous situation, context construction returns no context and requires an explicit binding. The processor then stops before dispatch. Consequently, the generic function's default does not establish that every Telegram inbound message reaches the main session.

My interpretation is that keeping account and topic semantics in the channel layer avoids filling generic routing with platform-specific branches. The cost is equally clear: debugging has to inspect the final sessionKey, not only the intermediate generic result. As integrations grow, tracing the initial route, each override reason, and the final route becomes increasingly useful.

Session partitioning is not a complete tool-permission model either. This version's channel-routing documentation explains that external direct messages can share main-session history while tool and sandbox policies use a separately derived runtime key. This article does not follow that permission chain, so it does not equate an identical sessionKey with identical execution permissions.

Checking the claims beyond reading

For this investigation, I executed the session-key functions and their dependencies from the pinned commit using Node 24's TypeScript support. The only adaptation mapped .js import specifiers to their corresponding .ts source files; the function implementations were unchanged. All 15 assertions passed.

The checks cover the four direct-message scopes; distinct senders sharing main; equal raw IDs converging across channels under per-peer; explicit identity association; channel dimensions surviving association; account differences between group session and group-history keys; thread suffixes; and case preservation for opaque Signal group IDs. These checks support the key examples in this article. They are not the complete upstream test suite, and they do not simulate live Telegram traffic, call a model, or verify permissions end to end.

One reading habit also proved useful. The routing-priority list in the documentation at the same commit omits the peer-wildcard tier, although the implementation and upstream tests contain it. I therefore based the article's ordering on the code. This observation applies to the historical snapshot; it is not a claim that today's documentation still has the same omission.

An input boundary that is easy to miss

The experiment also includes a counterexample: call the low-level key builder directly, select the finest per-account-channel-peer scope, but leave the peer ID empty. The result still falls back to the agent's main session. The more specific branches require a usable peer ID as well as the appropriate configuration. A stricter setting does not automatically produce finer partitioning for every possible input.

This is not evidence of an externally exploitable isolation bypass. The higher-level buildAgentSessionKey performs additional normalization for a supplied peer without a valid ID, and the channel entry has its own input constraints. I did not construct a malicious message that travels through the entire ingress chain, so the evidence establishes only the low-level fallback behavior.

The lesson is to distinguish what a local function accepts, what its callers guarantee, and what an external user can actually control. Declaring a system safe or unsafe after reading only one layer can skip the code that determines the real outcome.

How I would start from scratch

My first version would expose a pure routing function: normalized message origin and configuration in; agent, session key, and match reason out. Platform account policies would live in adapters. A test matrix would cover one person across channels, multiple accounts on one channel, groups, and threads. Comprehensive layered caching could wait; explicit sharing and isolation semantics could not.

Repeated configuration scans become worth optimizing as deployments grow. This version already groups bindings by channel and account, builds indexes for peers and guilds, and maintains a bounded result cache. The result-cache limit is 4,000 entries. Exceeding it clears the cache and retains the current result, rather than performing per-entry LRU eviction. Identity-link configuration or verbose logging bypasses the result cache. Binding-index caches are separate, so this should not be described as disabling every cache.

I would watch two potential risks. First, cache validity depends on several configuration object references. Callers that mutate configuration in place need validation against that contract; this is a risk to investigate, not a failure reproduced here. Second, repeated channel-level overrides make a route harder to explain, suggesting a diagnostic view that shows the decision step by step. Neither cache optimization nor better diagnostics should change the rule priorities that users depend on.

The main lesson is to model three questions separately: who handles the message, which conversations share context, and what permissions this execution has. OpenClaw's routing and session keys address important parts of the first two. The third requires following tool policy and runtime boundaries. The next installment turns to Gateway requests and events: once routing is settled, how does a client observe an execution?

继续阅读