Map multiple invocation keys (primary name + provider-specific aliases) to a single PluginCommandHandler without duplicating registrations.
Command Aliases
Provider-specific native names and alias resolution for plugin commands
When a plugin registers a command like /summarize, it can simultaneously declare platform-specific aliases via the nativeNames property — for example /tldr on Telegram and /recap on Discord. The alias resolution layer ensures that every invocation key — primary name, default alias, and provider-specific aliases — maps back to the same handler without duplication or ambiguity.
📚 What is this & why does it matter?
Command aliases allow plugin authors to register a single command handler that responds to multiple slash-command names across different chat platforms. This is critical in a multi-provider architecture where Telegram users expect short commands like /tl while Discord communities prefer descriptive names like /translate. Without aliases, plugin authors would need to register separate commands for each provider — leading to duplicated handlers, inconsistent validation, and a maintenance nightmare.
💡 Real-world analogy
Think of an international airport arrivals board. A single flight — say BA117 — might be listed as "British Airways 117" on the departures screen, "BAW117" on the ATC radar, and "Speedbird 117" on the radio. All three names refer to the same aircraft and crew. The alias system works the same way: one handler, multiple invocation keys, each tuned for a different surface.
Contents
Overview
The nativeNames property on OpenClawPluginCommandDefinition, keyed by provider name (default, telegram, discord).
Which invocation key wins when a user types a command? Is the alias valid? Does it collide with another registered command?
A deduplicated string[] of slash-prefixed invocation keys returned by listPluginInvocationKeys. Used by the matcher and help system.
src/commands/models/aliases.ts. Model aliases are user-defined shortcuts for LLM model identifiers (e.g. fast → gpt-4o-mini), while command aliases are developer-defined alternative invocation names for slash commands.
Core Types
The alias system is built on the nativeNames property of OpenClawPluginCommandDefinition. This is an optional partial record keyed by provider name, with a special default key for a cross-platform alias.
// src/plugins/types.ts — OpenClawPluginCommandDefinition
export type OpenClawPluginCommandDefinition = {
name: string;
nativeNames?: Partial<Record<string, string>> & { default?: string };
description: string;
acceptsArgs?: boolean;
requireAuth?: boolean;
handler: PluginCommandHandler;
};
| Property | Type | Purpose |
|---|---|---|
name | string | Primary invocation key — always registered as /name |
nativeNames.default | string | undefined | Cross-platform alias available on all providers |
nativeNames.telegram | string | undefined | Telegram-specific alias, used when the bot runs on Telegram |
nativeNames.discord | string | undefined | Discord-specific alias, used when the bot runs in Discord guilds |
handler | PluginCommandHandler | The single handler function invoked regardless of which alias was used |
Decision Points
| Decision | What controls it | Why it matters |
|---|---|---|
| Include primary name? | push(command.name) always called first | Guarantees the canonical name is always a valid invocation key |
| Include default alias? | push(command.nativeNames?.default) | Provides a cross-platform shortcut without per-provider config |
| Include provider aliases? | push(command.nativeNames?.telegram) and push(command.nativeNames?.discord) | Allows platform-idiomatic naming conventions |
| Deduplication | Set<string> collects all keys | Prevents duplicate entries when default equals name |
| Normalization | value?.trim().toLowerCase() | Ensures case-insensitive matching regardless of definition casing |
| Validation of aliases | validateCommandName applied to each alias entry | Rejects invalid characters, empty strings, and reserved names at registration time |
How It Works
Alias resolution operates in three distinct phases spread across registration time and runtime lookup. At registration, aliases are validated and stored. At lookup time, the matcher iterates all registered commands and checks both primary keys and alias keys.
Phase 1 — Registration & Validation
When a plugin is loaded, validatePluginCommandDefinition iterates over nativeNames entries and applies the same validateCommandName rules used for the primary name. This prevents invalid aliases from entering the registry.
// src/plugins/command-registration.ts — alias validation loop
for (const [label, alias] of Object.entries(command.nativeNames ?? {})) {
if (typeof alias !== "string") continue;
const aliasError = validateCommandName(alias.trim());
if (aliasError) {
return `Native command alias "${label}" invalid: ${aliasError}`;
}
}
pluginCommands Map. If any single alias fails validation, the entire command registration is rejected — not just the offending alias.
Phase 2 — Invocation Key Collection
The listPluginInvocationKeys function collects all valid invocation keys for a command. It uses a Set to automatically deduplicate keys that resolve to the same normalized string.
// src/plugins/command-registration.ts — listPluginInvocationKeys
export function listPluginInvocationKeys(
command: OpenClawPluginCommandDefinition,
): string[] {
const keys = new Set<string>();
const push = (value: string | undefined) => {
const normalized = value?.trim().toLowerCase();
if (!normalized) return;
keys.add(`/${normalized}`);
};
push(command.name);
push(command.nativeNames?.default);
push(command.nativeNames?.telegram);
push(command.nativeNames?.discord);
return [...keys];
}
A Set<string> is created to hold unique invocation keys. The push helper normalizes each value by trimming whitespace and lowercasing, then adds it with a / prefix.
The primary command.name is pushed first, ensuring it is always present in the result set.
The default alias is pushed next. If it matches the primary name after normalization, the Set silently deduplicates it.
Provider-specific aliases (telegram, discord) are pushed last. Again, the Set handles any overlaps.
The Set is spread into an array and returned. Callers receive a flat list of slash-prefixed strings like ["/summarize", "/tldr", "/recap"].
Phase 3 — Runtime Alias Resolution
At runtime, when getPluginCommandSpecs is called (e.g. for generating help text or autocomplete lists), it uses resolvePluginNativeName to pick the best name for the current provider context.
// src/plugins/command-registry-state.ts — getPluginCommandSpecs
export function getPluginCommandSpecs(
provider?: string,
): Array<{ name: string; description: string; acceptsArgs: boolean }> {
const providerName = provider?.trim().toLowerCase();
if (providerName && providerName !== "telegram" && providerName !== "discord")
return [];
return Array.from(pluginCommands.values()).map((cmd) => ({
name: resolvePluginNativeName(cmd, provider),
description: cmd.description,
acceptsArgs: cmd.acceptsArgs ?? false,
}));
}
resolvePluginNativeName returns the telegram alias if defined, falling back to default, then to the primary name. This means /setbotcommands on Telegram automatically shows platform-idiomatic names.
Alias Resolution Flow
The following flowchart shows how the system resolves an incoming slash command against the registered aliases. The matcher first checks the primary key in the pluginCommands Map, then falls back to scanning all registered commands via listPluginInvocationKeys.
flowchart TD
INPUT["Incoming command: /tldr some text"]
EXTRACT["Extract command key: /tldr"]
DIRECT{"pluginCommands.get(/tldr)?"}
SCAN["Scan all commands via listPluginInvocationKeys"]
FOUND{"Alias match found?"}
ACCEPT_ARGS{"command.acceptsArgs?"}
DISPATCH["Dispatch to command.handler"]
FALLTHROUGH["Fall through to built-in / agent"]
REJECT["Reject: args not accepted"]
INPUT --> EXTRACT
EXTRACT --> DIRECT
DIRECT -->|"Hit"| ACCEPT_ARGS
DIRECT -->|"Miss"| SCAN
SCAN --> FOUND
FOUND -->|"Yes"| ACCEPT_ARGS
FOUND -->|"No"| FALLTHROUGH
ACCEPT_ARGS -->|"Yes or no args"| DISPATCH
ACCEPT_ARGS -->|"No but args present"| REJECT
REJECT --> FALLTHROUGH
The direct Map lookup is O(1) for commands registered under their primary name. The fallback scan iterates all registered commands and checks each one's invocation keys — this is O(n) where n is the number of registered plugin commands. In practice, n is small (typically < 50), so the linear scan has negligible cost.
Plugin Registration with Native Names
The following sequence diagram shows the lifecycle of a plugin command with nativeNames, from definition through registration to runtime invocation via an alias.
sequenceDiagram
participant Plugin as Plugin Module
participant Validator as validatePluginCommandDefinition
participant Registry as pluginCommands Map
participant KeyBuilder as listPluginInvocationKeys
participant Matcher as matchPluginCommand
participant User as End User
Plugin->>Validator: Register command with nativeNames
Validator->>Validator: Validate primary name
Validator->>Validator: Validate each alias in nativeNames
alt Validation passes
Validator->>Registry: Store command under /primaryName
Registry-->>Plugin: Registration success
else Validation fails
Validator-->>Plugin: Error: alias invalid
end
Note over User,Matcher: Later at runtime...
User->>Matcher: /tldr summarize this
Matcher->>Registry: pluginCommands.get("/tldr")
Registry-->>Matcher: null (not primary key)
Matcher->>KeyBuilder: Scan all commands
KeyBuilder-->>Matcher: ["/summarize", "/tldr", "/recap"]
Matcher->>Matcher: "/tldr" found in keys
Matcher-->>User: Dispatch to summarize handler
Note that the command is stored in the Map under its primary name (/summarize), not under each alias. Aliases are resolved at match time by calling listPluginInvocationKeys on each candidate command. This design keeps the Map simple and avoids stale entries when aliases are updated.
Edge Cases
| Scenario | Input / Config | Result | Rationale |
|---|---|---|---|
| Alias matches primary name | name: "help", nativeNames: { default: "help" } | Single key: ["/help"] | The Set deduplicates identical normalized keys automatically |
| Case mismatch in alias | nativeNames: { telegram: "TL" } | Key: /tl | All values are lowercased via trim().toLowerCase() before insertion |
| Empty alias string | nativeNames: { discord: "" } | Alias is silently skipped | The push helper returns early if !normalized |
| Undefined alias | nativeNames: { telegram: undefined } | Alias is silently skipped | Optional chaining value?.trim() produces undefined, caught by the guard |
| Non-string alias value | nativeNames: { telegram: 42 } | Skipped during validation | typeof alias !== "string" guard in the validation loop |
| Alias collides with another command | Two plugins both alias /quick | First registered wins | Array.from(...).find() returns the first match in iteration order |
| Alias with whitespace | nativeNames: { default: " my cmd " } | Fails validateCommandName | Command names cannot contain spaces; trimming removes leading/trailing but not inner spaces |
| Unknown provider key | nativeNames: { slack: "shortcut" } | Stored but never resolved | listPluginInvocationKeys only reads default, telegram, discord |
Configuration
| Setting | Location | Default | Description |
|---|---|---|---|
nativeNames | OpenClawPluginCommandDefinition | undefined | Optional record of provider-specific aliases for the command |
nativeNames.default | Same as above | undefined | Cross-platform alias — used on all providers as a secondary invocation key |
nativeNames.telegram | Same as above | undefined | Telegram-specific alias — shown in /setbotcommands and used for matching |
nativeNames.discord | Same as above | undefined | Discord-specific alias — shown in guild command registration |
provider param | getPluginCommandSpecs(provider?) | undefined | When provided, resolvePluginNativeName picks the best alias for that provider |
Model Aliases (Distinct System)
OpenClaw also has a separate alias system for LLM model identifiers, managed in src/commands/models/aliases.ts. These are user-defined shortcuts, not developer-defined command names. The two systems are entirely independent.
| Aspect | Command Aliases (nativeNames) | Model Aliases (models/aliases.ts) |
|---|---|---|
| Defined by | Plugin developers at registration time | End users via /models aliases add |
| Scope | Slash command invocation keys | LLM model identifier shortcuts |
| Storage | In-memory pluginCommands Map | Persistent runtime environment |
| Management | Automatic via plugin lifecycle | Manual via modelsAliasesAddCommand, modelsAliasesRemoveCommand, modelsAliasesListCommand |
// src/commands/models/aliases.ts — model alias management functions
export async function modelsAliasesListCommand(...)
export async function modelsAliasesAddCommand(...)
export async function modelsAliasesRemoveCommand(
aliasRaw: string,
runtime: RuntimeEnv,
)
Related Topics
The low-level extraction layer that converts raw text into structured parse results before alias resolution runs.
The global pluginCommands Map where commands (and their aliases) are stored and looked up.
Authorization checks that run after alias resolution but before the handler is invoked.
Source Files
Key files involved in command aliases
src/
├── plugins/
│ ├── types.ts ← OpenClawPluginCommandDefinition & nativeNames type
│ ├── command-registration.ts ← listPluginInvocationKeys(), validatePluginCommandDefinition()
│ ├── command-registry-state.ts ← getPluginCommandSpecs(), resolvePluginNativeName()
│ └── commands.ts ← matchPluginCommand() alias lookup fallback
├── commands/
│ └── models/
│ └── aliases.ts ← Model alias CRUD (separate system)
└── auto-reply/
└── reply/
└── commands-slash-parse.ts ← Upstream parser (feeds into alias resolution)