Command Registry

Focus Deep-Dive

Dual-layer registration: CLI core commands and runtime plugin commands

OpenClaw maintains two command registries that work in concert. The CLI core registry lazy-loads 18 built-in commands on demand, while the plugin registry accepts dynamically registered commands from plugins at runtime. Together they form the complete command surface available to users across every channel.

📚 What is this & why does it matter?

The command registry is the single source of truth for "what commands exist." When a user types /doctor, the registry determines whether that command exists, loads its handler module on demand, and dispatches execution. Understanding the registry is critical for adding new commands, diagnosing lazy-load failures, or resolving conflicts between plugin and built-in commands.

💡 Real-world analogy

Think of a phone switchboard at a large company. The core registry is the permanent directory of internal extensions — always available, loaded on first call. The plugin registry is a guest directory: external contractors register their lines at check-in, and those lines are removed when they leave. Both directories feed into the same switchboard, and the operator (parser) routes calls to whichever directory has the matching extension.

Contents

Overview

Primary role

Map command names to handler functions, supporting both lazy-loaded CLI commands and dynamically registered plugin commands.

Main inputs

CoreCliEntry[] for built-in commands; OpenClawPluginCommandDefinition objects for plugin commands.

Key decisions

Lazy vs eager loading, duplicate detection, registry locking during execution, reserved name enforcement.

Outputs / side effects

Registered handlers reachable via pluginCommands Map or Commander.js program tree. No persistent state beyond memory.

flowchart TD
  CLI["CLI Entry (openclaw ...)"]
  CORE["Core CLI Registry"]
  LAZY["Lazy Import Module"]
  COMMANDER["Commander.js Program"]
  PLUGIN_REG["Plugin Registration"]
  VALIDATE["validatePluginCommandDefinition()"]
  PLUGIN_MAP["pluginCommands Map"]
  USER["User Message /command"]
  MATCH["matchPluginCommand()"]
  DISPATCH["Command Dispatch"]

  CLI --> CORE
  CORE -->|"registerCoreCliByName()"| LAZY
  LAZY -->|"await import()"| COMMANDER
  COMMANDER --> DISPATCH

  PLUGIN_REG -->|"registerPluginCommand()"| VALIDATE
  VALIDATE -->|"OK"| PLUGIN_MAP
  USER --> MATCH
  MATCH -->|"Lookup"| PLUGIN_MAP
  MATCH -->|"Found"| DISPATCH

Two distinct paths converge at dispatch. CLI commands flow through Commander.js after lazy loading; plugin commands are matched from the global pluginCommands Map. The registry enforces that no two commands can claim the same invocation key.

Core Types

// src/cli/program/command-registry.ts — Core CLI types
export type CommandRegistration = {
  id: string;
  register: (params: CommandRegisterParams) => void;
};

type CoreCliEntry = {
  commands: CoreCliCommandDescriptor[];
  register: (params: CommandRegisterParams) => Promise<void> | void;
};

type CommandRegisterParams = {
  program: Command;
  ctx: ProgramContext;
  argv: string[];
};
// src/plugins/command-registry-state.ts — Plugin registry state
export type RegisteredPluginCommand = OpenClawPluginCommandDefinition & {
  pluginId: string;
  pluginName?: string;
  pluginRoot?: string;
};

type PluginCommandState = {
  pluginCommands: Map<string, RegisteredPluginCommand>;
  registryLocked: boolean;
};

Decision Points

DecisionWhat controls itWhy it matters
Lazy vs eager loadCore entries use async register() with dynamic import()Startup time: only the invoked command's module is loaded, keeping CLI snappy
Duplicate detectionregisterPluginCommand() checks all invocation keysPrevents two plugins from silently shadowing each other's commands
Reserved name blockingreservedCommands Set in validateCommandName()Plugins cannot override core commands like /help, /status, /model
Registry lockingisPluginCommandRegistryLocked() flagPrevents race conditions when commands are being processed
Name-based lookupregisterCoreCliByName() searches coreEntriesAllows on-demand loading of a specific command without registering all others

How It Works

Phase 1 — CLI Core Registration

The coreEntries array defines 18 built-in commands. Each entry contains a descriptor (name + description) and an async register function that dynamically imports the command module and attaches it to the Commander.js program tree.

// src/cli/program/command-registry.ts — lazy registration pattern
const coreEntries: CoreCliEntry[] = [
  {
    commands: [{ name: "doctor", description: "Health checks + quick fixes",
                 hasSubcommands: false }],
    register: async ({ program }) => {
      const mod = await import("./register.doctor.js");
      mod.registerDoctorCommand(program);
    },
  },
  {
    commands: [{ name: "models", description: "Model selection, configuration, auth",
                 hasSubcommands: true }],
    register: async ({ program }) => {
      const mod = await import("./register.models.js");
      mod.registerModelsCommand(program);
    },
  },
  // ... 16 more entries: setup, onboard, configure, config, backup,
  //     dashboard, reset, uninstall, message, mcp, agent, agents,
  //     status, health, sessions, tasks
];

Phase 2 — On-Demand Lookup

// src/cli/program/command-registry.ts — name-based lazy load
export async function registerCoreCliByName(
  program: Command,
  ctx: ProgramContext,
  name: string,
  argv: string[] = process.argv,
): Promise<boolean> {
  const entry = coreEntries.find((candidate) =>
    candidate.commands.some((cmd) => cmd.name === name),
  );
  if (!entry) {
    return false;
  }
  removeEntryCommands(program, entry);
  await entry.register({ program, ctx, argv });
  return true;
}

Phase 3 — Plugin Command Registration

sequenceDiagram
    participant Plugin as Plugin Loader
    participant Validate as validatePluginCommandDefinition()
    participant NameCheck as validateCommandName()
    participant Reserved as reservedCommands Set
    participant Map as pluginCommands Map
    participant Lock as Registry Lock

    Plugin->>Lock: isPluginCommandRegistryLocked()?
    Lock-->>Plugin: false
    Plugin->>Validate: validatePluginCommandDefinition(def)
    Validate->>NameCheck: validateCommandName("tts")
    NameCheck->>Reserved: reservedCommands.has("tts")?
    Reserved-->>NameCheck: false
    NameCheck-->>Validate: null (valid)
    Validate-->>Plugin: null (valid)
    Plugin->>Map: Check for duplicate keys
    Map-->>Plugin: No conflicts
    Plugin->>Map: pluginCommands.set("/tts", registered)
    Map-->>Plugin: { ok: true }
// src/plugins/command-registration.ts — registration with validation
export function registerPluginCommand(
  pluginId: string,
  command: OpenClawPluginCommandDefinition,
  opts?: { pluginName?: string; pluginRoot?: string },
): CommandRegistrationResult {
  if (isPluginCommandRegistryLocked()) {
    return { ok: false, error: "Cannot register commands while processing is in progress" };
  }

  const definitionError = validatePluginCommandDefinition(command);
  if (definitionError) {
    return { ok: false, error: definitionError };
  }

  const invocationKeys = listPluginInvocationKeys(command);
  const key = `/${command.name.trim().toLowerCase()}`;

  for (const invocationKey of invocationKeys) {
    const existing = pluginCommands.get(invocationKey) ??
      Array.from(pluginCommands.values()).find((candidate) =>
        listPluginInvocationKeys(candidate).includes(invocationKey),
      );
    if (existing) {
      return { ok: false,
        error: `Command "${invocationKey.slice(1)}" already registered by plugin "${existing.pluginId}"` };
    }
  }

  pluginCommands.set(key, { ...command, pluginId, pluginName: opts?.pluginName, pluginRoot: opts?.pluginRoot });
  return { ok: true };
}

Code Walkthrough

State initialization — The plugin registry uses a global singleton to survive module re-imports. A Symbol.for key ensures exactly one instance across the process.

// src/plugins/command-registry-state.ts — singleton state
const PLUGIN_COMMAND_STATE_KEY = Symbol.for("openclaw.pluginCommandsState");

const getState = () =>
  resolveGlobalSingleton<PluginCommandState>(PLUGIN_COMMAND_STATE_KEY, () => ({
    pluginCommands: new Map<string, RegisteredPluginCommand>(),
    registryLocked: false,
  }));

Proxy-based access — The exported pluginCommands is a Proxy that delegates all operations to the singleton state. This allows callers to import and use the Map normally, even if the underlying singleton is created lazily.

// src/plugins/command-registry-state.ts — proxy delegation
export const pluginCommands = new Proxy(
  new Map<string, RegisteredPluginCommand>(),
  {
    get(_target, property) {
      const value = Reflect.get(
        getPluginCommandMap(), property, getPluginCommandMap()
      );
      return typeof value === "function"
        ? value.bind(getPluginCommandMap())
        : value;
    },
  },
);

Command name validation — Every name passes through validateCommandName(), which enforces a strict pattern: must start with a letter, contain only [a-z0-9_-], and not collide with any of the 30 reserved built-in names.

// src/plugins/command-registration.ts — name validation
export function validateCommandName(name: string): string | null {
  const trimmed = name.trim().toLowerCase();
  if (!trimmed) return "Command name cannot be empty";
  if (!/^[a-z][a-z0-9_-]*$/.test(trimmed)) {
    return "Command name must start with a letter and contain " +
           "only letters, numbers, hyphens, and underscores";
  }
  reservedCommands ??= new Set([
    "help", "commands", "status", "whoami", "context", "btw",
    "stop", "restart", "reset", "new", "compact", "config",
    "debug", "allowlist", "activation", "skill", "subagents",
    "kill", "steer", "tell", "model", "models", "queue",
    "send", "bash", "exec", "think", "verbose", "reasoning",
    "elevated", "usage",
  ]);
  if (reservedCommands.has(trimmed)) {
    return `Command name "${trimmed}" is reserved by a built-in command`;
  }
  return null;
}

Reserved Command Names

The following 30 names are permanently reserved by the built-in command system. Plugins that attempt to register any of these names receive a validation error.

Session control

help, commands, status, whoami, context, btw

Lifecycle

stop, restart, reset, new, compact, config

Security

debug, allowlist, activation, skill, subagents, kill

Communication

steer, tell, model, models, queue, send

Execution

bash, exec, think, verbose, reasoning, elevated, usage

Plugin Lifecycle & Teardown

When a plugin is unloaded — either during a hot-reload or because the user disabled it — all commands registered by that plugin must be removed. The registry provides two teardown functions:

// src/plugins/command-registry-state.ts — teardown
export function clearPluginCommands(): void {
  pluginCommands.clear();
}

export function clearPluginCommandsForPlugin(pluginId: string): void {
  for (const [key, cmd] of pluginCommands.entries()) {
    if (cmd.pluginId === pluginId) {
      pluginCommands.delete(key);
    }
  }
}

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,
  }));
}
⚠ Registry locking: The registryLocked flag is set to true while a command handler is executing. Any registration attempt during this window is rejected to prevent iterator invalidation on the pluginCommands Map. The lock is automatically released when the handler completes.

Registered Core Commands

CommandModuleSubcommands
setupregister.setup.jsNo
onboardregister.onboard.jsNo
configureregister.configure.jsNo
configconfig-cli.jsYes (get/set/unset/file/validate)
backupregister.backup.jsYes (create/verify)
doctorregister.doctor.jsNo
dashboardregister.dashboard.jsNo
resetregister.reset.jsNo
uninstallregister.uninstall.jsNo
messageregister.message.jsNo
modelsregister.models.jsYes (list/set/aliases/fallbacks/auth/scan)
channelsregister.channels.jsYes (add/list/remove/status)
agentsregister.agents.jsYes (add/list/delete/bind)
statusregister.status.jsNo
healthregister.health.jsNo
sessionsregister.sessions.jsNo
tasksregister.tasks.jsNo
mcpregister.mcp.jsYes

Edge Cases

ScenarioBehaviorRationale
Plugin registers /helpReturns { ok: false, error: "reserved" }30 reserved names protect core functionality
Two plugins register /weatherSecond registration fails with duplicate errorFirst-come-first-served prevents silent shadowing
Registration during command processingReturns { ok: false, error: "locked" }Registry lock prevents iterator invalidation
Plugin unloadedclearPluginCommandsForPlugin(pluginId) removes its entriesClean teardown prevents stale handlers
Command Parsing

How raw text is parsed before registry lookup.

Command Aliases

How nativeNames create provider-specific command aliases.

Permission Gating

Authorization checks that run after registry lookup.

Source Files

Key files involved in command registration
src/
├── cli/program/
│   ├── command-registry.ts           ← Core CLI lazy-load registry (18 commands)
│   ├── core-command-descriptors.ts   ← Command name/description metadata
│   ├── command-tree.ts               ← Commander.js tree management
│   └── register-lazy-command.ts      ← Lazy command loading helper
├── plugins/
│   ├── command-registration.ts       ← registerPluginCommand(), validation
│   ├── command-registry-state.ts     ← pluginCommands Map, locking
│   ├── commands.ts                   ← matchPluginCommand() runtime lookup
│   └── bundle-commands.ts            ← Bundle command utilities
└── commands/                         ← 260+ command implementation files