Map command names to handler functions, supporting both lazy-loaded CLI commands and dynamically registered plugin commands.
Command Registry
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
CoreCliEntry[] for built-in commands; OpenClawPluginCommandDefinition objects for plugin commands.
Lazy vs eager loading, duplicate detection, registry locking during execution, reserved name enforcement.
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
| Decision | What controls it | Why it matters |
|---|---|---|
| Lazy vs eager load | Core entries use async register() with dynamic import() | Startup time: only the invoked command's module is loaded, keeping CLI snappy |
| Duplicate detection | registerPluginCommand() checks all invocation keys | Prevents two plugins from silently shadowing each other's commands |
| Reserved name blocking | reservedCommands Set in validateCommandName() | Plugins cannot override core commands like /help, /status, /model |
| Registry locking | isPluginCommandRegistryLocked() flag | Prevents race conditions when commands are being processed |
| Name-based lookup | registerCoreCliByName() searches coreEntries | Allows 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.
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
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,
}));
}
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
| Command | Module | Subcommands |
|---|---|---|
setup | register.setup.js | No |
onboard | register.onboard.js | No |
configure | register.configure.js | No |
config | config-cli.js | Yes (get/set/unset/file/validate) |
backup | register.backup.js | Yes (create/verify) |
doctor | register.doctor.js | No |
dashboard | register.dashboard.js | No |
reset | register.reset.js | No |
uninstall | register.uninstall.js | No |
message | register.message.js | No |
models | register.models.js | Yes (list/set/aliases/fallbacks/auth/scan) |
channels | register.channels.js | Yes (add/list/remove/status) |
agents | register.agents.js | Yes (add/list/delete/bind) |
status | register.status.js | No |
health | register.health.js | No |
sessions | register.sessions.js | No |
tasks | register.tasks.js | No |
mcp | register.mcp.js | Yes |
Edge Cases
| Scenario | Behavior | Rationale |
|---|---|---|
Plugin registers /help | Returns { ok: false, error: "reserved" } | 30 reserved names protect core functionality |
Two plugins register /weather | Second registration fails with duplicate error | First-come-first-served prevents silent shadowing |
| Registration during command processing | Returns { ok: false, error: "locked" } | Registry lock prevents iterator invalidation |
| Plugin unloaded | clearPluginCommandsForPlugin(pluginId) removes its entries | Clean teardown prevents stale handlers |
Related Topics
How raw text is parsed before registry lookup.
How nativeNames create provider-specific command aliases.
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