Chapter 10

Message Routing: Bindings Rule Engine and Eight-Level Priority Matching

Chapter 10: Message Routing: The Bindings Rule Engine and Eight-Level Priority Matching Algorithm

10.1 Why Deterministic Routing Is Necessary

10.1.1 The Chaos Problem in Multi-Agent Multi-Channel Scenarios

Imagine a typical enterprise OpenClaw deployment:

Channels:
  Discord server "Engineering"
    ├── #general (general discussion)
    ├── #code-review (code review requests)
    ├── #ops-alerts (operations alerts)
    └── Direct Messages (DM)

Agents:
  agent_code_reviewer  - Specialized in code review
  agent_ops_assistant  - Specialized in operations issues
  agent_general        - General-purpose conversational assistant
  agent_vip            - VIP user exclusive assistant (better model)

Without deterministic routing, any message might be handled by any Agent:

The more serious problem: if routing rules are ambiguous, the same message might be processed by multiple Agents simultaneously, causing duplicate responses and state conflicts.

10.1.2 Three Guarantees of Deterministic Routing

OpenClaw's Bindings routing engine provides three core guarantees:

  1. Uniqueness: Each message routes to exactly one Agent (even if multiple rules match, only the highest-priority one is selected)
  2. Determinism: The same message with the same configuration always routes to the same Agent
  3. Predictability: The routing result for any message can be predicted by examining the configuration, without observing runtime behavior

10.2 The Eight-Level Priority Matching Algorithm

Bindings priorities are arranged from high to low. Evaluation starts at Level 1 and descends; matching stops as soon as a match is found:

Priority levels (highest to lowest):

Level 1: Peer match (exact DM / group ID match)
Level 2: Parent peer match (thread inherits parent message routing)
Level 3: Guild ID + role (role-based match within a Discord server)
Level 4: Guild ID (Discord server-level match)
Level 5: Team ID (team-level match)
Level 6: Account ID (account-level match)
Level 7: Channel level (channel type match)
Level 8: Default Agent fallback (catch-all routing)

10.2.1 Level 1: Peer Match (Exact ID Match)

Matching logic: The message's source party (peer) exactly matches the peer ID configured in the Binding.

Definition of peer:

Configuration example:

bindings:
  - name: "VIP Alice - Claude Opus"
    match:
      peer: "user:alice_discord_id_123"   # Exact match of Alice's Discord ID
    agent: agent_vip
    priority: 1  # Explicitly annotated, ensures it comes first

  - name: "Code Review Channel"
    match:
      peer: "channel:code_review_channel_id"  # Exact match of the code review channel
    agent: agent_code_reviewer

Use cases:

10.2.2 Level 2: Parent Peer Match (Thread Inheritance Mechanism)

Matching logic: The message is a thread reply to an already-routed message, and inherits the parent message's routing result.

Why is thread inheritance needed?

Consider the following scenario:

Discord conversation:

[Original message] Alice → #general: "Help me review this code"
                    └─ Routed to agent_code_reviewer

[Thread reply] Bob → #general (replying to Alice's message): "I'd also like it to look at mine"
                    └─ Where should this route?

Without thread inheritance: Bob's message goes through general matching and might route to agent_general, rather than the agent_code_reviewer already handling this code review conversation.

With thread inheritance: Bob's message recognizes that it is a reply to Alice's message, inherits Alice's message's routing result, and also routes to agent_code_reviewer. This preserves conversational context consistency within the same thread.

Implementation mechanism:

function resolveParentPeerMatch(message: IncomingMessage): Binding | null {
  // Find the parent message ID
  const parentMessageId = message.replyToId || message.threadParentId;
  if (!parentMessageId) return null;
  
  // Look up the parent message's routing result
  const parentRouting = routingCache.get(parentMessageId);
  if (!parentRouting) return null;
  
  // Inherit the parent message's Binding
  return parentRouting.binding;
}

Configuration: Thread inheritance is the default behavior, no explicit configuration needed. To disable it:

bindings:
  - name: "General Channel"
    match:
      peer: "channel:general_id"
    agent: agent_general
    threadInheritance: false  # Disable thread inheritance

10.2.3 Level 3: Guild ID + Role

Matching logic: The message comes from a specific Discord server (Guild) and the sender holds a specific role.

Configuration example:

bindings:
  - name: "Engineering Team - Code Agent"
    match:
      guildId: "guild:my_company_discord"
      roles:
        - "role:engineer"
        - "role:senior_engineer"
        - "role:tech_lead"
    agent: agent_code_reviewer
    
  - name: "Management - Executive Agent"
    match:
      guildId: "guild:my_company_discord"
      roles:
        - "role:manager"
        - "role:director"
        - "role:vp"
    agent: agent_executive_assistant

Role matching strategy:

rolesMatchMode: "any"   # Match if the sender has any one of the listed roles (default)
rolesMatchMode: "all"   # Match only if the sender has all listed roles

Discord use cases: This is the most important routing mechanism for OpenClaw in the Discord ecosystem. An enterprise Discord server can achieve fine-grained Agent segregation through roles:

Discord server "My Company"
  role: engineer → agent_code
  role: sales → agent_crm
  role: support → agent_helpdesk
  role: @everyone → agent_general (fallback via Level 4)

10.2.4 Level 4: Guild ID (Server-Level)

Matching logic: The message comes from a specific Discord server, without role restrictions.

bindings:
  - name: "Partner Company Discord"
    match:
      guildId: "guild:partner_company_discord"
    agent: agent_partner_support
    
  - name: "Internal Company Discord"
    match:
      guildId: "guild:my_company_discord"
    agent: agent_internal_general

Typical uses:

10.2.5 Level 5: Team ID (Team-Level)

Matching logic: The message source belongs to a specific Team (an organizational unit in OpenClaw that spans channels).

bindings:
  - name: "Frontend Team"
    match:
      teamId: "team:frontend"
    agent: agent_frontend
    
  - name: "Backend Team"
    match:
      teamId: "team:backend"
    agent: agent_backend

The concept of Team: A Team in OpenClaw is a cross-channel organizational unit. A user can belong to multiple Teams, and Team members can come from different channels (Discord, Slack, API).

# config/teams.yaml
teams:
  - id: team:frontend
    name: "Frontend Engineering"
    members:
      - { type: "discord-user", id: "user:alice" }
      - { type: "slack-user", id: "U01234567" }
      - { type: "email", id: "[email protected]" }

10.2.6 Level 6: Account ID (Account-Level)

Matching logic: The message source belongs to a specific account (a unified account after cross-channel identity linking).

bindings:
  - name: "Enterprise Customer Alice"
    match:
      accountId: "account:enterprise_alice"
    agent: agent_enterprise
    model: "claude-opus-4-5"  # Better model
    
  - name: "Free Tier Users"
    match:
      accountId: "account:free_*"  # Wildcards supported
    agent: agent_free
    model: "claude-3-5-haiku"  # Economy model

Relationship between Account and channel identity:

Unified Account
    ├── Discord: user:123
    ├── Slack: U04567
    └── Email: [email protected]

A user may contact OpenClaw through multiple channels; the Account ID unifies these identities to enable consistent cross-channel routing policies.

10.2.7 Level 7: Channel Level (Channel Type Match)

Matching logic: Match based on the message's channel type (not a specific ID).

bindings:
  - name: "All Discord DMs"
    match:
      channel:
        type: "discord-dm"
    agent: agent_dm_assistant
    
  - name: "All Slack DMs"
    match:
      channel:
        type: "slack-dm"
    agent: agent_slack_assistant
    
  - name: "All API Calls"
    match:
      channel:
        type: "api"
    agent: agent_api

Common channel types:

discord-dm          - Discord direct messages
discord-channel     - Discord channel messages
discord-thread      - Discord thread messages
slack-dm            - Slack direct messages
slack-channel       - Slack channel messages
slack-thread        - Slack thread messages
api                 - REST/WebSocket API calls
webhook             - External Webhook triggers

10.2.8 Level 8: Default Agent Fallback

Matching logic: The catch-all routing used when none of the first 7 levels match.

bindings:
  - name: "Default Fallback"
    match: "*"          # Matches everything
    agent: agent_general
    isDefault: true     # Mark as default fallback

If no default Agent is configured:

{
  "type": "res",
  "ok": false,
  "error": {
    "code": "NO_ROUTE_FOUND",
    "message": "No binding matched the incoming message and no default agent is configured",
    "details": {
      "messageId": "msg_001",
      "source": { "channelType": "discord-dm", "userId": "user:unknown" }
    }
  }
}

Best practice: Always configure a default fallback to avoid routing failures that show error messages to users.


10.3 Complete Routing Match Flow

Incoming message received
          │
          ▼
Extract message attributes:
  peer, parentId, guildId, roles[], teamId, accountId, channelType
          │
          ▼
Level 1: Peer Match
  │
  ├── Matching Binding found ─────────────────→ Route to Agent, done
  │
  └── Not found
            │
            ▼
Level 2: Parent Peer Match
  │
  ├── Has parent msg & parent has routing record ──→ Inherit routing, done
  │
  └── No parent msg or no routing record
            │
            ▼
Level 3: Guild ID + Roles
  │
  ├── Match found (guildId AND role match) ──────→ Route to Agent, done
  │
  └── Not found
            │
            ▼
Level 4: Guild ID Only
  │
  ├── Match found (guildId match only) ──────────→ Route to Agent, done
  │
  └── Not found
            │
            ▼
Level 5: Team ID
  │
  ├── Match found ────────────────────────────→ Route to Agent, done
  │
  └── Not found
            │
            ▼
Level 6: Account ID
  │
  ├── Match found ────────────────────────────→ Route to Agent, done
  │
  └── Not found
            │
            ▼
Level 7: Channel Type
  │
  ├── Match found ────────────────────────────→ Route to Agent, done
  │
  └── Not found
            │
            ▼
Level 8: Default Fallback
  │
  ├── Default Agent configured ─────────────→ Route to default Agent, done
  │
  └── No default Agent ──────────────────────→ Return NO_ROUTE_FOUND error

10.4 Bindings Configuration Examples

10.4.1 Channel-Based Traffic Segregation

# config/bindings.yaml

bindings:
  # Discord code review channel → Code review Agent
  - name: "discord-code-review"
    match:
      peer: "channel:${DISCORD_CODE_REVIEW_CHANNEL_ID}"
    agent: agent_code_reviewer
    responseMode: "thread"   # Reply in threads to keep the channel clean

  # Discord ops alert channel → Ops Agent (quiet mode, only replies when necessary)
  - name: "discord-ops-alerts"
    match:
      peer: "channel:${DISCORD_OPS_CHANNEL_ID}"
    agent: agent_ops
    responseMode: "reaction-only"  # Acknowledge with emoji reactions only

  # All Discord DMs → General assistant
  - name: "discord-dm-general"
    match:
      channel:
        type: "discord-dm"
    agent: agent_general

  # API calls → Stateless API Agent
  - name: "api-requests"
    match:
      channel:
        type: "api"
    agent: agent_api
    sessionMode: "ephemeral"  # Do not persist session

10.4.2 Account-Based Traffic Segregation (Tiered Service)

bindings:
  # Enterprise accounts → Premium Agent + best model
  - name: "enterprise-tier"
    match:
      accountId: "account:enterprise_*"
    agent: agent_enterprise
    model: "claude-opus-4-5"
    rateLimit:
      requestsPerHour: 500
      
  # Professional accounts → Standard Agent
  - name: "pro-tier"
    match:
      accountId: "account:pro_*"
    agent: agent_pro
    model: "claude-3-5-sonnet"
    rateLimit:
      requestsPerHour: 100
      
  # Free accounts → Lightweight Agent + speed-optimized model
  - name: "free-tier"
    match:
      accountId: "account:free_*"
    agent: agent_free
    model: "claude-3-5-haiku"
    rateLimit:
      requestsPerHour: 20
      
  # Default fallback
  - name: "default"
    match: "*"
    agent: agent_general
    model: "claude-3-5-haiku"

10.4.3 Multi-Agent Discord Scenario

bindings:
  # CTO exclusive (highest priority)
  - name: "cto-personal"
    match:
      peer: "user:${CTO_DISCORD_ID}"
    agent: agent_executive
    model: "claude-opus-4-5"
    
  # Engineering role (Level 3: guildId + role)
  - name: "engineers-code-agent"
    match:
      guildId: "guild:${COMPANY_DISCORD}"
      roles: ["role:engineer", "role:senior-engineer"]
    agent: agent_code
    
  # Product role
  - name: "product-team"
    match:
      guildId: "guild:${COMPANY_DISCORD}"
      roles: ["role:product-manager", "role:designer"]
    agent: agent_product
    
  # All company Discord members (Level 4: guildId only)
  - name: "company-general"
    match:
      guildId: "guild:${COMPANY_DISCORD}"
    agent: agent_company_general
    
  # Public Discord server
  - name: "public-discord"
    match:
      guildId: "guild:${PUBLIC_COMMUNITY_DISCORD}"
    agent: agent_community
    
  # Default fallback
  - name: "default"
    match: "*"
    agent: agent_general

10.5 Session Freshness Evaluation

After routing to an Agent, the system must decide whether to use an existing Session or create a new one. This is determined by Session Freshness evaluation.

10.5.1 Freshness Evaluation Criteria

function isSessionFresh(session: Session, config: FreshnessConfig): boolean {
  const now = Date.now();
  
  // Criterion 1: Idle timeout
  const idleMs = now - session.lastActiveAt;
  if (idleMs > config.idleTimeoutMs) {
    return false;  // Session has timed out
  }
  
  // Criterion 2: Daily reset (default at 4 AM)
  const sessionDate = new Date(session.lastActiveAt);
  const resetHour = config.dailyResetHour ?? 4;  // Default 4 AM
  
  // Determine if the reset time point has passed since the last activity
  const sessionDateResetTime = new Date(sessionDate);
  sessionDateResetTime.setHours(resetHour, 0, 0, 0);
  if (sessionDateResetTime < sessionDate) {
    // Reset time was yesterday, advance by one day
    sessionDateResetTime.setDate(sessionDateResetTime.getDate() + 1);
  }
  
  if (now > sessionDateResetTime.getTime()) {
    return false;  // The daily reset time has already passed
  }
  
  return true;  // Session is still fresh
}

10.5.2 Freshness Configuration

# config/sessions.yaml
sessionFreshness:
  idleTimeoutMs: 3600000    # 1 hour idle timeout (milliseconds)
  dailyResetHour: 4         # Reset at 4 AM every day
  
  # Per-Agent overrides
  agentOverrides:
    agent_code:
      idleTimeoutMs: 7200000  # Code tasks allow 2 hours of idle time
    agent_general:
      idleTimeoutMs: 1800000  # General conversation: 30-minute idle timeout

10.5.3 Handling After Freshness Expires

Session Freshness evaluation:

Does an existing Session exist?
       │
      Yes ──→ Is the Session fresh?
      │             │
      │            Yes ──→ Reuse existing Session (with historical context)
      │             │
      │            No ──→ Archive old Session, create new Session
      │
      No ──→ Create new Session

Archived sessions are retained in data/transcripts/, and users can browse historical sessions through the TUI.


10.6 Handling Routing Failures

10.6.1 No Matching Rule

{
  "type": "event",
  "event": "session.error",
  "payload": {
    "code": "NO_ROUTE_FOUND",
    "message": "No binding matched the incoming message",
    "suggestions": [
      "Check if a default binding is configured",
      "Verify the channel ID is correct in your bindings config"
    ]
  }
}

10.6.2 Agent Unavailable

{
  "type": "event",
  "event": "session.error",
  "payload": {
    "code": "AGENT_UNAVAILABLE",
    "message": "The matched agent 'agent_code' is not running",
    "fallbackAgent": "agent_general",
    "usedFallback": true
  }
}

10.6.3 Routing Debugging Tools

# Simulate routing decisions (without actually sending messages)
openclaw route simulate \
  --peer "user:alice_discord_123" \
  --guild "guild:company_discord" \
  --roles "engineer,senior-engineer" \
  --channel-type "discord-dm"

# Output:
# Level 1 (Peer Match):    agent_vip  ← Matched! Stop evaluation
# Final Route: agent_vip
# Session: sess_01HXYZ (fresh, last active 5 minutes ago)

# View match statistics for all Bindings
openclaw bindings stats --period 24h

10.7 Advanced Features of Bindings

10.7.1 Conditional Routing (Context-based Routing)

bindings:
  - name: "Code Review - Working Hours"
    match:
      peer: "channel:code_review_id"
    conditions:
      # Route to professional Agent only during working hours (9 AM - 6 PM)
      - timeRange:
          start: "09:00"
          end: "18:00"
          timezone: "Asia/Shanghai"
    agent: agent_code_reviewer
    
  - name: "Code Review - After Hours"
    match:
      peer: "channel:code_review_id"
    # After hours, route to lightweight model
    agent: agent_code_reviewer_lite
    model: "claude-3-5-haiku"

10.7.2 Load-Balanced Routing

bindings:
  - name: "Load Balanced Agents"
    match:
      channel:
        type: "api"
    agents:
      - agent: agent_api_1
        weight: 50
      - agent: agent_api_2
        weight: 50
    loadBalancing: "weighted-random"

10.7.3 Numeric Priority Within Levels

When multiple Bindings at the same Level match simultaneously, the priority numeric value determines the final selection:

bindings:
  - name: "Alice VIP"
    match:
      peer: "user:alice"
    agent: agent_vip
    priority: 100    # Higher value means higher priority (within the same Level)
    
  - name: "Alice Normal"
    match:
      peer: "user:alice"
    agent: agent_general
    priority: 10     # This rule will never be selected (Alice matches priority:100 first)

Chapter Summary

The Bindings rule engine is the foundation of OpenClaw's multi-Agent orchestration capability:

  1. Deterministic routing guarantees each message routes to exactly one Agent, eliminating multi-Agent competition and duplicate responses
  2. Eight-level priority algorithm descends from exact to fuzzy matching, covering all possible routing scenarios
  3. Level 1 (Peer Match) handles precise routing for VIP users and specific channels
  4. Level 2 (Parent Peer Match) preserves conversational context consistency through thread inheritance
  5. Level 3 (Guild ID + Role) is the most important routing dimension in Discord deployments
  6. Levels 4-6 (Guild/Team/Account) provide layered routing from server to team to individual
  7. Level 7 (Channel type) segregates traffic by channel form (DM/channel/API)
  8. Level 8 (Default fallback) catches all unmatched messages to prevent routing failures
  9. Session Freshness determines whether to reuse an existing session or create a new one, via idle timeout and daily reset
  10. Routing failure handling includes no-match errors, Agent unavailability fallback, and debugging tool support

With this, Chapters 6 through 10 of the Complete Guide to OpenClaw have fully presented the complete technical chain spanning the Gateway control plane, Command Queue, Pi framework, execution loop, and message routing. These five chapters form the core knowledge backbone for understanding OpenClaw's architecture.

Rate this chapter
4.5  / 5  (38 ratings)

💬 Comments