Skip to content

Channel config

源码版本v2026.6.11

Responsibilities

Channel config is the parse-and-match layer for the channels.* block in openclaw.json. It does three things: parses the user-written JSON into structured types, matches an account config by channel id, and upgrades sensitive fields like token / botToken / webhookSecret from hardcoded strings into secret references (env / file / exec).

It doesn't decide "which channel should start" — that's the Channel registry combined with config-presence.ts. It only handles "what the user wrote, whether it's well-formed, and by which key it matches to which account config".

Design motivation

Four real reasons.

First, the 22 channels each have different fields. Telegram has botToken + webhookUrl + pollingStallThresholdMs; Slack has botToken + appToken + socketMode; WhatsApp has authDir; Discord has guilds + channels. Stuffing them into one ChannelsConfig type would turn into a thousands-line union type, and editing one channel would touch the core type.

Second, multi-account. Telegram can mount multiple bots; Slack can mount multiple workspaces; each account has independent config. The unified pattern of accounts: Record<string, AccountConfig> + defaultAccount: string is needed, with per-channel fields pushed down to the account level.

Third, secrets can't be hardcoded. botToken: "123456:ABC..." written in openclaw.json leaks the token the moment the file leaks. SecretInputSchema = z.union([z.string(), SecretRefSchema]) lets users write either the legacy inline string or a structured reference like {source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN"}.

Fourth, config matching must support direct / parent / wildcard. The same channel can have multiple configs: one for a specific account id, one for the parent channel, one wildcard. Parsing falls back by priority and records matchSource so downstream knows which level hit.

Key files

Data flow

The top-level channels field in openclaw.json goes through ChannelsSchema (zod-schema.channels-config.ts:L52):

typescript
export const ChannelsSchema: z.ZodType<ChannelsConfig | undefined> = z
  .object({
    defaults: z
      .object({
        groupPolicy: GroupPolicySchema.optional(),
        contextVisibility: ContextVisibilityModeSchema.optional(),
        heartbeat: ChannelHeartbeatVisibilitySchema,
        botLoopProtection: ChannelBotLoopProtectionSchema.optional(),
      })
      .strict()
      .optional(),
    modelByChannel: ChannelModelByChannelSchema,
  })
  .passthrough() // Allow extension channel configs (nostr, matrix, zalo, etc.)
  .superRefine((value, ctx) => {
    addLegacyChannelAcpBindingIssues(value, ctx);
  })
  .optional() as z.ZodType<ChannelsConfig | undefined>;

The top level has only defaults and modelByChannel as strict fields; everything else passes through — channels.telegram / channels.slack / channels.nostr / channels.zalo aren't field-level validated at the top schema. Field validation is delegated to each channel's own TelegramConfigSchema / SlackConfigSchema / WhatsAppConfigSchema. .superRefine only scans the legacy bindings.acp field (deprecated) and issues an error to tell users to migrate to the top-level bindings[].

Why passthrough? Because plugin channels are open-world — the ChannelsConfig interface in types.channels.ts (types.channels.ts:L127) writes 10 core channels (discord / googlechat / imessage / irc / msteams / signal / slack / telegram / whatsapp etc.) as typed fields; the rest go through [key: string]: OpenWorldChannelConfig, where OpenWorldChannelConfig is ReturnType<typeof JSON.parse>, fully open.

Secret references are the config layer's core abstraction (zod-schema.core.ts:L83):

typescript
/** Config-level secret reference schema shared by model/provider/plugin credential fields. */
export const SecretRefSchema = z.discriminatedUnion("source", [
  EnvSecretRefSchema,
  FileSecretRefSchema,
  ExecSecretRefSchema,
]);

/** Accepts either legacy inline secret strings or structured secret references. */
export const SecretInputSchema = z.union([z.string(), SecretRefSchema]);

SecretRefSchema uses discriminatedUnion("source", ...) with three sources: env reads from environment variables, id matching /^[A-Z][A-Z0-9_]{0,127}$/ like TELEGRAM_BOT_TOKEN; file reads from a file, id is a JSON pointer like /providers/openai/apiKey or value in single-value mode; exec calls an external command to fetch the secret.

SecretInputSchema is z.union([z.string(), SecretRefSchema]) — legacy inline strings still work, but .register(sensitive) marks the field as sensitive (zod-schema.providers-core.ts:L269):

typescript
botToken: SecretInputSchema.optional().register(sensitive),

So openclaw doctor / log output redacts the literal botToken value.

Each channel's own schema combines common fields + channel-specific fields. Telegram is the canonical multi-account example (zod-schema.providers-core.ts:L405):

typescript
export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({
  accounts: z.record(z.string(), TelegramAccountSchema.optional()).optional(),
  defaultAccount: z.string().optional(),
}).superRefine((value, ctx) => {
  requireOpenAllowFrom({
    // ...
  });
});

TelegramAccountSchemaBase is the channel-level field set; TelegramConfigSchema adds the accounts multi-account container and defaultAccount on top. buildCatchallMultiAccountChannelSchema (plugins/config-schema.ts:L42) is the generic helper for this pattern:

typescript
export function buildCatchallMultiAccountChannelSchema<T extends ExtendableZodObject>(
  accountSchema: T,
): T {
  return accountSchema.extend({
    accounts: z.object({}).catchall(accountSchema).optional(),
    defaultAccount: z.string().optional(),
  }) as T;
}

catchall(accountSchema) makes any key under accounts validate against accountSchema — so adding new accounts doesn't require touching the schema.

Matching goes through resolveChannelEntryMatchWithFallback (channel-config.ts:L83); the core is priority-based fallback — a direct hit returns { matchKey, matchSource: "direct" } directly; no hit goes to normalized direct (using normalizeKey to normalize the key then matching), then parent, normalized parent, finally wildcard. Each step writes matchKey and matchSource into the result; downstream uses applyChannelMatchMeta to copy these two fields onto the final config object, making it easy to audit "which key matched this config".

normalizeKey is an optional normalization function. For example, normalizeChannelSlug (channel-config.ts:L47) normalizes #general, General, GENERAL ROOM all to general — users writing channel names in config aren't forced to match exactly.

hasMeaningfulChannelConfig distinguishes "has configured content" from "just enabled=false" (config-presence.ts:L47): it checks Object.keys(value).some((key) => key !== "enabled")enabled alone doesn't count as "has config", just operational intent. This way openclaw status doesn't misreport "explicitly disabled" channels as "configured but not started".

The whole config parse + match flow:

Boundaries and failures

  • passthrough tolerates plugin fields: ChannelsSchema.passthrough() (zod-schema.channels-config.ts:L65) lets plugin fields like channels.nostr / channels.matrix pass through without error at the top level, but also without validation. Validation is delegated to each channel plugin's own ChannelConfigSchema; channels without a registered schema are unvalidated.
  • legacy ACP bindings error: addLegacyChannelAcpBindingIssues (zod-schema.channels-config.ts:L20) recursively scans bindings.acp and issues ctx.addIssue when found, with the message "Legacy channel-local ACP bindings were removed; use top-level bindings[] entries". Old config upgrades will be blocked here.
  • strict secret ref validation: env-type id must match /^[A-Z][A-Z0-9_]{0,127}$/ (zod-schema.core.ts:L44); file-type id must be an absolute JSON pointer. Format errors throw directly, no silent fallback to the literal value.
  • allowlist requires allowFrom: requireOpenAllowFrom (zod-schema.providers-core.ts:L409) forces allowFrom to be non-empty when dmPolicy: "allowlist", otherwise safeParse fails. Prevents "thought I had a whitelist but anyone can DM".
  • accounts uses catchall not record: z.object({}).catchall(accountSchema) (plugins/config-schema.ts:L46) is one layer more than z.record() — it can distinguish "known fields" from "dynamic account keys"; future reserved fields like defaultAccount won't collide with account ids.
  • enabled=false is not configured: hasMeaningfulChannelConfig (config-presence.ts:L47) explicitly excludes enabled; the setup flow won't mistake "explicitly disabled" for "configured but not started".
  • WhatsApp uses preprocess: WhatsAppConfigSchema = z.preprocess(...) (zod-schema.providers-whatsapp.ts:L238) migrates old config before parsing rather than superRefine — mutate-before-validate matters for historical compatibility, but note when debugging that the data safeParse sees has already been migrated.

Summary

The channel config layer absorbs the field differences across 22 channels in a two-level scheme: ChannelsSchema.passthrough() + each channel's own ConfigSchema. The top level only handles common fields and legacy detection; field-level validation is delegated down. Multi-account uses the unified buildCatchallMultiAccountChannelSchema pattern; secrets use the three-source (env / file / exec) SecretInputSchema that also accepts literal values; matching goes through a five-level fallback (direct / normalized / parent / normalized parent / wildcard) and writes matchSource for downstream audit.

How the Channel registry gets a plugin then calls config.listAccountIds / config.resolveAccount to turn these configs into structured account objects, see Channel adapters. The overall load + validation flow of openclaw.json is in openclaw.json config; how all zod schemas are organized is in Zod schema system.