Command Aliases

Focus Deep-Dive

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

Primary role

Map multiple invocation keys (primary name + provider-specific aliases) to a single PluginCommandHandler without duplicating registrations.

Main inputs

The nativeNames property on OpenClawPluginCommandDefinition, keyed by provider name (default, telegram, discord).

Key decisions

Which invocation key wins when a user types a command? Is the alias valid? Does it collide with another registered command?

Outputs / side effects

A deduplicated string[] of slash-prefixed invocation keys returned by listPluginInvocationKeys. Used by the matcher and help system.

Note: Command aliases (native names on plugin commands) are distinct from model aliases managed by 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;
};
PropertyTypePurpose
namestringPrimary invocation key — always registered as /name
nativeNames.defaultstring | undefinedCross-platform alias available on all providers
nativeNames.telegramstring | undefinedTelegram-specific alias, used when the bot runs on Telegram
nativeNames.discordstring | undefinedDiscord-specific alias, used when the bot runs in Discord guilds
handlerPluginCommandHandlerThe single handler function invoked regardless of which alias was used

Decision Points

DecisionWhat controls itWhy it matters
Include primary name?push(command.name) always called firstGuarantees 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
DeduplicationSet<string> collects all keysPrevents duplicate entries when default equals name
Normalizationvalue?.trim().toLowerCase()Ensures case-insensitive matching regardless of definition casing
Validation of aliasesvalidateCommandName applied to each alias entryRejects 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}`;
  }
}
Validation first: Alias validation runs before the command is added to the global 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,
  }));
}
Provider-aware naming: When Telegram requests the command list, 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

ScenarioInput / ConfigResultRationale
Alias matches primary namename: "help", nativeNames: { default: "help" }Single key: ["/help"]The Set deduplicates identical normalized keys automatically
Case mismatch in aliasnativeNames: { telegram: "TL" }Key: /tlAll values are lowercased via trim().toLowerCase() before insertion
Empty alias stringnativeNames: { discord: "" }Alias is silently skippedThe push helper returns early if !normalized
Undefined aliasnativeNames: { telegram: undefined }Alias is silently skippedOptional chaining value?.trim() produces undefined, caught by the guard
Non-string alias valuenativeNames: { telegram: 42 }Skipped during validationtypeof alias !== "string" guard in the validation loop
Alias collides with another commandTwo plugins both alias /quickFirst registered winsArray.from(...).find() returns the first match in iteration order
Alias with whitespacenativeNames: { default: " my cmd " }Fails validateCommandNameCommand names cannot contain spaces; trimming removes leading/trailing but not inner spaces
Unknown provider keynativeNames: { slack: "shortcut" }Stored but never resolvedlistPluginInvocationKeys only reads default, telegram, discord

Configuration

SettingLocationDefaultDescription
nativeNamesOpenClawPluginCommandDefinitionundefinedOptional record of provider-specific aliases for the command
nativeNames.defaultSame as aboveundefinedCross-platform alias — used on all providers as a secondary invocation key
nativeNames.telegramSame as aboveundefinedTelegram-specific alias — shown in /setbotcommands and used for matching
nativeNames.discordSame as aboveundefinedDiscord-specific alias — shown in guild command registration
provider paramgetPluginCommandSpecs(provider?)undefinedWhen 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.

AspectCommand Aliases (nativeNames)Model Aliases (models/aliases.ts)
Defined byPlugin developers at registration timeEnd users via /models aliases add
ScopeSlash command invocation keysLLM model identifier shortcuts
StorageIn-memory pluginCommands MapPersistent runtime environment
ManagementAutomatic via plugin lifecycleManual 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,
)
Command Parsing

The low-level extraction layer that converts raw text into structured parse results before alias resolution runs.

Command Registry

The global pluginCommands Map where commands (and their aliases) are stored and looked up.

Permission Gating

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)