Channel Bridge

Deep Dive

Bridging channel-specific plugins with MCP tool capabilities and conversation binding

The channel bridge connects channel adapters (Discord, Slack, WhatsApp, and 20+ more) with the MCP tool system. It resolves which plugin owns a given channel, binds the plugin’s identity to the gateway request scope, and ensures that channel-scoped tools are available to agents processing messages from that channel.

Conversation Binding Channel Scoping Plugin Identity Gateway Integration

📚 What is this & why does it matter?

When a message arrives from Discord or Slack, the agent needs to know which plugin owns that channel and what tools that plugin provides. The channel bridge is the routing layer — it maps each incoming message to a plugin identity and ensures the agent has the right tools for the job.

💡 Real-world analogy

Think of a large hotel (the platform) with multiple concierge desks (channel plugins) — one for business guests (Slack), one for leisure guests (Discord), one for international guests (WhatsApp). When a guest (message) walks in, the lobby receptionist (channel bridge) identifies which concierge desk handles their booking, puts on the right name badge (plugin identity binding), and gives them the right set of room keys (channel-scoped tools). This way, the concierge can only access rooms and services relevant to their guests.

Contents

Overview

Primary Role

Connect channel plugins (Discord, Slack, WhatsApp, etc.) with the MCP tool system via conversation binding.

Main Inputs

Channel plugin registrations, conversation context, gateway request scope.

Key Decisions

How to bind plugin identity to gateway requests, when to allow gateway subagent binding, how channel plugins expose tools.

Outputs

Channel-scoped tools available to agents processing messages from specific channels.

flowchart TD
  subgraph Channels["Channel Plugins"]
    Discord["Discord Plugin"]
    Slack["Slack Plugin"]
    WhatsApp["WhatsApp Plugin"]
    More["20+ more"]
  end
  subgraph Binding["Conversation Binding"]
    ConvBind["conversation-binding.ts"]
    ChannelIDs["channel-plugin-ids.ts"]
    Scope["gateway-request-scope.ts"]
  end
  subgraph Bridge["Tool Bridge"]
    PluginRT["Plugin Runtime"]
    ToolFactory["Tool Factory"]
    AgentTools["Agent Tool Set"]
  end
  Discord --> ConvBind
  Slack --> ConvBind
  WhatsApp --> ConvBind
  More --> ConvBind
  ConvBind --> ChannelIDs
  ChannelIDs --> Scope
  Scope --> PluginRT
  PluginRT --> ToolFactory
  ToolFactory --> AgentTools

Each channel plugin registers itself during loading. When a message arrives, the conversation binding layer identifies the owning plugin, binds its identity to the gateway request scope, and passes context through the tool factory pipeline so the agent receives the correct channel-scoped tools.

How It Works

Channel bridging operates in three stages: registration, identity resolution, and scope binding.

Stage 1: Channel Registration

Channel plugins register via api.registerChannel(channelPlugin) during plugin loading. This stores a mapping from channel type identifiers to the plugin that handles them. Each channel plugin declares its supported channel types and any tools it provides.

// channel-plugin-ids.ts — registration mapping
const channelPluginMap = new Map<string, string>();

export function registerChannelPlugin(
  channelType: string,
  pluginId: string,
): void {
  if (channelPluginMap.has(channelType)) {
    throw new Error(
      `Channel type "${channelType}" already claimed by plugin "${channelPluginMap.get(channelType)}"`,
    );
  }
  channelPluginMap.set(channelType, pluginId);
}

export function getPluginIdForChannel(channelType: string): string | undefined {
  return channelPluginMap.get(channelType);
}
One owner per channel: Each channel type can only be claimed by a single plugin. If two plugins try to register the same channel type, the second registration throws an error. This prevents ambiguous routing.

Stage 2: Identity Resolution

When a message arrives, conversation-binding.ts resolves which channel plugin handles it by looking up the channel type in the channelPluginMap. The resolved pluginId is then passed to the scope binding layer.

// conversation-binding.ts — identity resolution
export async function resolveConversationBinding(
  message: IncomingMessage,
): Promise<ConversationBinding> {
  const channelType = message.channelType;
  const pluginId = getPluginIdForChannel(channelType);

  if (!pluginId) {
    throw new Error(`No plugin registered for channel type: "${channelType}"`);
  }

  return {
    pluginId,
    channelType,
    conversationId: message.conversationId,
    metadata: message.channelMetadata,
  };
}

Stage 3: Scope Binding

withPluginRuntimePluginIdScope() in gateway-request-scope.ts binds the plugin’s identity to the gateway request. This scoping ensures that all tool calls made during agent execution carry the correct plugin identity, so MCP servers declared by the channel plugin’s bundle are accessible through the bridge.

// gateway-request-scope.ts — scope binding
export function withPluginRuntimePluginIdScope<T>(
  pluginId: string,
  fn: () => T,
): T {
  const previousScope = currentPluginScope;
  currentPluginScope = pluginId;
  try {
    return fn();
  } finally {
    currentPluginScope = previousScope;
  }
}
Scoping is synchronous: The scope binding uses a simple try/finally pattern. Even if the callback throws, the previous scope is always restored, preventing plugin identity leaks across requests.

Message Flow Sequence

sequenceDiagram
  participant Chan as Channel Adapter
  participant Bind as conversation-binding.ts
  participant IDs as channel-plugin-ids.ts
  participant Scope as gateway-request-scope.ts
  participant RT as Plugin Runtime
  participant Agent as Agent
  Chan->>Bind: Incoming message
  Bind->>IDs: Resolve plugin ID for channel
  IDs-->>Bind: pluginId
  Bind->>Scope: withPluginRuntimePluginIdScope(pluginId)
  Scope-->>RT: Scoped runtime context
  RT->>Agent: Provide channel-scoped tools
  Agent->>Agent: Execute with tools

The sequence above traces a single incoming message from a channel adapter through the full binding pipeline. The plugin identity is resolved once per message, and the scope is held for the duration of agent execution.

Annotated Code Walkthrough

Channel ID to plugin ID mapping — the channelPluginMap is populated during plugin loading. Each channel type (e.g., "discord", "slack") maps to exactly one plugin ID. Lookups are O(1) via Map.get().

const pluginId = channelPluginMap.get(message.channelType);
if (!pluginId) {
  throw new Error(`No plugin registered for channel: "${message.channelType}"`);
}

Gateway request scope binding — withPluginRuntimePluginIdScope() sets the active plugin identity for the duration of the callback. This is a synchronous context manager pattern — the scope is restored in the finally block even if the callback throws.

const result = withPluginRuntimePluginIdScope(pluginId, () => {
  // All tool calls inside this scope carry pluginId
  return resolvePluginTools({ context, allowlist, existingToolNames });
});

Conversation binding resolution — the onConversationBindingResolved event fires after successful binding, allowing other subsystems (logging, analytics, rate limiting) to react to the resolved channel context.

const binding = await resolveConversationBinding(message);
eventBus.emit('onConversationBindingResolved', {
  pluginId: binding.pluginId,
  channelType: binding.channelType,
  conversationId: binding.conversationId,
});

Edge Cases & Error Handling

ScenarioBehaviorCode Location
Unknown channel IDError thrown: No plugin registered for channel type; message is rejected at the binding layerconversation-binding.ts
Plugin not enabled for channelChannel type is not in the map (plugin didn’t register); same error as unknown channel IDchannel-plugin-ids.ts
Gateway subagent mode without bindingFalls back to gatewaySubagentPluginId from context; scope is set directly without channel lookupgateway-request-scope.ts
Multiple plugins claim same channelSecond registration throws an error during plugin loading; the first plugin winsregisterChannelPlugin()

Directory Map

Source files for channel bridge
src/plugins/
├── conversation-binding.ts      ← Resolves incoming messages to plugin identity
├── channel-plugin-ids.ts        ← Channel type → plugin ID mapping registry
├── gateway-request-scope.ts     ← withPluginRuntimePluginIdScope() context manager
├── plugin-runtime.ts            ← Plugin runtime that invokes tool factories in scope
└── channels/
    ├── discord.ts               ← Discord channel adapter plugin
    ├── slack.ts                 ← Slack channel adapter plugin
    ├── whatsapp.ts              ← WhatsApp channel adapter plugin
    └── ...                      ← 20+ additional channel adapters