Channel registry
Responsibilities
The channel registry is the layer that unifies "22 chat channels + any third-party plugin channels" into a queryable dictionary in OpenClaw. It exposes only four things: list registered channel ids, normalize (normalize) user-input aliases (alias) into canonical ids, find a channel plugin (channel plugin) entry by id or alias, and produce a short introductory line for the setup / status UI.
The registry itself doesn't load a channel's runtime implementation — it only holds metadata (metadata) and lookup indexes. The actual startAccount / sendMessage actions are done by the Channel adapters; the registry only handles "tell me which plugin entry the id telegram corresponds to, and what aliases it has".
Design motivation
Why have a separate registry layer instead of just import { telegramPlugin } from "..."? Three real reasons:
First, the channel count is fixed but they have aliases. telegram might appear in user config as tg, Telegram, TELEGRAM; the registry must normalize all of these to the same canonical id, otherwise every id-indexed map downstream has to repeat the case and alias handling.
Second, channels have bundled and loaded layers. Bundled is metadata shipped with the generated code; loaded is what's loaded at runtime from a plugin npm package. Loaded takes priority over bundled, so installing the telegram plugin package can pin or override the bundled version, but the bundled fallback works when not installed. The registry has to merge these two layers into a single query view.
Third, plugin hot registration: channels registered after the startup snapshot must still be queryable (this was the fix for issues like #94127). The registry can't just build once at startup, but every query can't recompute either — so a snapshot version number (version) is used for cache invalidation.
Key files
channels/registry.ts:L22-L77— public facade;normalizeChannelId/normalizeAnyChannelId/listRegisteredChannelPluginIds/formatChannelPrimerLine.registry-lookup.ts:L42-L100— versioned-cached lookup view;buildRegisteredChannelPluginLookupis the hot path.channels/ids.ts:L21-L93— source of the 22 bundled channel ids and aliases, derived from generated metadata.channels/plugins/registry.ts:L20-L65— runtime facade, stacks bundled fallback on top.registry-loaded.ts— real state of loaded channel plugins, called byregistry.ts.registry-loader.ts:L17-L43— generic lazy value loader; resolves any plugin surface from a channel id.plugins/bundled.ts:L796-L900— bundled channel loader; on failure only warns, doesn't throw.plugins/catalog.ts:L463-L532— UI catalog builder, merging bundled / official / external catalogs.bundled-channel-config-metadata.generated.ts— auto-generated metadata for 22 channels, including schema, aliases, order.
Data flow
The 22 bundled channel ids come from generated metadata. ids.ts filters and sorts them at startup (ids.ts:L21):
function listBundledChatChannelEntries(): BundledChatChannelEntry[] {
return GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA.filter((entry) => entry.configurable !== false)
.map((entry) => ({
id: normalizeOptionalLowercaseString(entry.channelId) ?? entry.channelId,
aliases: entry.aliases ?? [],
order: entry.order ?? Number.MAX_SAFE_INTEGER,
}))
.toSorted(
(left, right) =>
left.order - right.order || left.id.localeCompare(right.id, "en", { sensitivity: "base" }),
);
}The actual 22 ids are: clickclack / discord / feishu / googlechat / imessage / irc / line / matrix / mattermost / msteams / nostr / qqbot / raft / signal / slack / sms / telegram / tlon / twitch / whatsapp / zalo / zalouser.
Normalization falls back through two levels: alias table → runtime catalog (ids.ts:L84):
export function normalizeChatChannelId(raw?: string | null): ChatChannelId | null {
const normalized = normalizeOptionalLowercaseString(raw);
if (!normalized) {
return null;
}
const resolved = CHAT_CHANNEL_ALIASES[normalized] ?? normalized;
return CHAT_CHANNEL_ID_SET.has(resolved)
? resolved
: normalizeRuntimeBundledChatChannelId(normalized);
}Here the compile-time-generated CHAT_CHANNEL_ALIASES handles common aliases first; on miss it falls back to listRuntimeBundledChatChannelEntries — which reads bundled-channel-catalog-read.ts, covering bundled metadata that isn't generated at compile time but is dynamically registered at runtime.
Runtime lookup uses snapshot + version caching in registry-lookup.ts (registry-lookup.ts:L42):
function buildRegisteredChannelPluginLookup(): RegisteredChannelPluginLookup {
const { registry, version } = getActivePluginChannelRegistrySnapshotFromState();
const channels = Array.isArray(registry?.channels) ? registry.channels : undefined;
const channelCount = channels?.length ?? 0;
const cached = registeredChannelPluginLookup;
if (
cached &&
cached.registry === registry &&
cached.channels === channels &&
cached.channelCount === channelCount &&
cached.version === version
) {
return cached;
}
// ... rebuild byKey / byId indexes
}Note the rebuild condition uses four reference equalities plus a version. version is an integer bumped by the plugin registry; every time a new plugin is hot-registered, the version bumps and the cache invalidates — avoiding recomputing the byKey Map on every registry query.
Index population is first writer wins (registry-lookup.ts:L31):
function setLookupEntry(
map: Map<string, RegisteredChannelPluginEntry>,
key: string | undefined,
entry: RegisteredChannelPluginEntry,
): void {
// First writer wins so canonical ids keep priority over later aliases.
if (key && !map.has(key)) {
map.set(key, entry);
}
}This rule guarantees the canonical id always points to the first registered entry; later aliases with the same name can't preempt it — this matters for hot-plug scenarios, preventing a later-loaded plugin from accidentally clobbering a bundled channel.
Finding the channel plugin body goes through getChannelPlugin, which checks loaded first then falls back to bundled (plugins/registry.ts:L49):
export function getChannelPlugin(id: ChannelId): ChannelPlugin | undefined {
const resolvedId = normalizeOptionalString(id) ?? "";
if (!resolvedId) {
return undefined;
}
// Loaded plugins win over bundled fallbacks so installed plugin state can pin
// or override a bundled channel during runtime.
return getLoadedChannelPlugin(resolvedId) ?? getBundledChannelPlugin(resolvedId);
}Bundled load failure only warns, doesn't throw (plugins/bundled.ts:L679): log.warn(\[channels] failed to load bundled channel ${id}: ${detail}`), then caches null. The next query for the same id directly returns undefined without retriggering the load — this is the fault-tolerance room for "missing runtime deps", and describeBundledChannelLoadErroralso suggests runningopenclaw doctor --fix`.
The whole lookup chain:
Boundaries and failures
- snapshot cache invalidation by version:
buildRegisteredChannelPluginLookupgetsversionthroughgetActivePluginChannelRegistrySnapshotFromState. If the plugin registry bumps the version but forgets to notify channel state, hot-registered channels won't be queryable. - first writer wins: aliases are first-come-first-served. If two plugins register the same id, only the first takes effect; the later one is silently ignored — so channel id naming collisions don't throw, just "looks installed but actually not in effect".
- bundled load failure only warns: a missing-module error suggests running
openclaw doctor --fix(bundled.ts:L318) but doesn't block startup. This is so one channel failing doesn't affect the other 21. - bundled fallback only applies to the 22 built-in channels: third-party plugin channels aren't in generated metadata; if loaded isn't installed,
getBundledChannelPlugindirectly returns undefined — no fallback. - runtime catalog fallback:
listRuntimeBundledChatChannelEntriesuses??=to cache once (ids.ts:L61); the first call reads the catalog file, after that it's in-memory. If the catalog file is replaced at runtime, the process must restart to pick it up. - generation timing:
GENERATED_BUNDLED_CHANNEL_CONFIG_METADATAis generated byscripts/generate-bundled-channel-config-metadata.tsat build time; adding a new channel requires re-running the generation script, otherwiseCHAT_CHANNEL_ID_SETwon't contain the new id.
Summary
The registry only does four things — "list, normalize, find, introduce" — and doesn't touch channel runtime. It covers the 22 bundled channels plus any third-party plugin channels through a two-level fallback: generated metadata + runtime catalog. It avoids recomputing indexes on every query via snapshot version caching, and protects existing canonical ids from being overwritten by hot registration via first-writer-wins.
Where channel capabilities are actually called is Channel adapters; how users configure these channels in openclaw.json is Channel config; the overall plugin system mechanism is Plugins. How a channel feeds messages into the Gateway core to drive the agent main loop, then sends back through the adapter, is the topic of the next page.