Capability Registration

Deep Dive

How tools, hooks, channels, and providers are registered into the plugin registry

6 Registration Types Validation Layer Conflict Detection TypeScript

📚 What is this & why does it matter?

Capability Registration is the process by which plugins wire their tools, hooks, channels, providers, commands, and services into the central PluginRegistry. Each registration method validates inputs, detects conflicts, normalizes data, and indexes the capability for runtime use. This is where the plugin’s declared capabilities become real, callable entries in the system.

💡 Real-world analogy

Think of capability registration like filing permits at a city hall. Each plugin is a business that submits applications (registration calls) for different licenses (tools, hooks, channels). City hall (the registry) checks each application for uniqueness (no two businesses with the same name), validates the paperwork (type checking), assigns a priority number (hook ordering), and files it in the correct department (capability map). Only after all permits are filed can the business operate.

Contents

Overview

Primary Role

Wire plugin capabilities into the central PluginRegistry via typed registration methods.

Main Inputs

Tool definitions, hook handlers with priority, channel adapters, provider plugins.

Key Decisions

How to validate registrations, detect conflicts (duplicate tool names), assign priorities, bind plugin identity.

Outputs

Populated PluginRegistry with all capabilities indexed and ready for runtime use.

flowchart TD
  subgraph Registration["Registration Methods"]
    RT["registerTool()"]
    RH["registerHook()"]
    RC["registerChannel()"]
    RP["registerProvider()"]
    RCmd["registerCommand()"]
    RSvc["registerService()"]
  end
  subgraph Validation["Validation"]
    NameCheck["Tool name conflicts"]
    PrioritySort["Hook priority ordering"]
    ChanValidate["Channel ID validation"]
    ProvNormalize["Provider normalization"]
  end
  subgraph Registry["Plugin Registry"]
    Tools["tools Map"]
    Hooks["hooks Map"]
    Channels["channels Map"]
    Providers["providers Map"]
    Commands["commands Map"]
  end
  RT --> NameCheck
  RH --> PrioritySort
  RC --> ChanValidate
  RP --> ProvNormalize
  NameCheck --> Tools
  PrioritySort --> Hooks
  ChanValidate --> Channels
  ProvNormalize --> Providers
  RCmd --> Commands
  RSvc --> Registry

Each registration method passes through a validation layer before the capability is indexed in the appropriate map within the PluginRegistry. Validation catches conflicts, normalizes data, and ensures type safety before capabilities become live.

How It Works

registerTool()

Validates tool name uniqueness across all plugins, attaches PluginToolMeta (plugin id, name, version), and adds the tool definition to the tool factory list. Tool names are normalized via normalizeToolName() which lowercases and strips invalid characters.

Tool identity: Every registered tool carries a PluginToolMeta reference so the runtime can trace any tool invocation back to the plugin that provided it.

registerHook()

Accepts a hook name from the PluginHookName union type, a handler function, and a priority number. Hooks are stored in priority-sorted order. At runtime, hooks with the same name execute in priority order (highest first). Equal priorities use the plugin id as a stable tiebreaker.

registerChannel()

Validates the channel ID format, then registers all channel adapters: messaging, outbound, pairing, security, and threading. Each adapter is a typed interface that the channel must implement. The channel ID must be unique across all plugins.

registerProvider()

Normalizes the provider via normalizeRegisteredProvider(), which extracts auth methods, builds the model catalog, and validates capabilities. The normalized provider is then registered with its auth configuration and model list.

registerCommand()

Validates the command definition via validatePluginCommandDefinition(), checking for required fields (name, description, handler), valid command syntax, and argument schemas. Registered commands become available in the CLI and slash-command system.

registerHttpRoute()

Validates the route path pattern and checks for overlapping routes via findOverlappingPluginHttpRoute(). Overlapping routes (e.g., /api/foo/:id and /api/foo/:bar) are flagged as conflicts. Valid routes are added to the HTTP route table for the gateway.

Registration Sequence

sequenceDiagram
  participant Plugin as Plugin
  participant API as OpenClawPluginApi
  participant Reg as registry.ts
  participant Val as Validation
  Plugin->>API: api.registerTool(toolDef)
  API->>Val: Check name uniqueness
  Val-->>API: OK
  API->>Reg: Add to tools registry
  Plugin->>API: api.registerHook('before_agent_start', handler)
  API->>Reg: Add with priority to hooks map
  Plugin->>API: api.registerChannel(channelPlugin)
  API->>Val: Validate channel ID format
  Val-->>API: OK
  API->>Reg: Register channel adapters
  Plugin->>API: api.registerProvider(providerPlugin)
  API->>Val: normalizeRegisteredProvider()
  Val-->>API: Normalized provider
  API->>Reg: Register provider + auth methods

The sequence shows a typical plugin registration flow. Each capability type passes through its specific validation before being committed to the registry. Failures at any step are logged but do not block other registrations from the same plugin.

Annotated Code Walkthrough

Walk through the key registration patterns in registry.ts:

Tool Registration — Name Normalization

Tool names are normalized to ensure consistency. The normalizeToolName() function lowercases the name, replaces spaces with underscores, and strips non-alphanumeric characters:

// Tool name normalization via normalizeToolName()
function normalizeToolName(name: string): string {
  return name
    .toLowerCase()
    .replace(/\s+/g, "_")
    .replace(/[^a-z0-9_]/g, "");
}

// Registration with name conflict check
function registerTool(toolDef: PluginToolDefinition, pluginId: string): void {
  const normalized = normalizeToolName(toolDef.name);
  if (toolsMap.has(normalized)) {
    logger.warn("Duplicate tool name", { name: normalized, pluginId });
    return;
  }
  toolsMap.set(normalized, {
    ...toolDef,
    name: normalized,
    meta: { pluginId, registeredAt: Date.now() },
  });
}

Hook Ordering — Priority Sort with Tiebreaker

Hooks are sorted by priority (descending — higher priority executes first). When priorities are equal, the plugin id is used as a stable tiebreaker to ensure deterministic execution order:

// Hook priority ordering — high-first, pluginId tiebreaker
function insertHook(
  hookName: PluginHookName,
  handler: HookHandler,
  priority: number,
  pluginId: string
): void {
  const entry = { handler, priority, pluginId };
  const list = hooksMap.get(hookName) ?? [];
  list.push(entry);
  list.sort((a, b) =>
    b.priority !== a.priority
      ? b.priority - a.priority
      : a.pluginId.localeCompare(b.pluginId)
  );
  hooksMap.set(hookName, list);
}

Provider Normalization

Providers go through normalizeRegisteredProvider() which extracts auth methods, builds the model catalog, and validates that required capabilities are declared:

// Provider normalization — auth methods, model catalog, capabilities
function normalizeRegisteredProvider(
  provider: ProviderPlugin
): NormalizedProvider {
  const authMethods = extractAuthMethods(provider);
  const modelCatalog = buildModelCatalog(provider.models ?? []);
  const capabilities = validateCapabilities(provider.capabilities);

  return {
    id: provider.id,
    name: provider.name,
    authMethods,
    modelCatalog,
    capabilities,
    raw: provider,
  };
}

HTTP Route Conflict Detection

Before registering an HTTP route, findOverlappingPluginHttpRoute() scans existing routes for path conflicts. Parameterized segments (e.g., :id) are treated as wildcards during comparison:

// HTTP route conflict detection via findOverlappingPluginHttpRoute()
function findOverlappingPluginHttpRoute(
  newPath: string,
  method: HttpMethod,
  existingRoutes: PluginHttpRoute[]
): PluginHttpRoute | null {
  const newSegments = newPath.split("/").filter(Boolean);
  for (const route of existingRoutes) {
    if (route.method !== method) continue;
    const existingSegments = route.path.split("/").filter(Boolean);
    if (existingSegments.length !== newSegments.length) continue;
    const overlaps = existingSegments.every((seg, i) =>
      seg.startsWith(":") || newSegments[i].startsWith(":") || seg === newSegments[i]
    );
    if (overlaps) return route;
  }
  return null;
}
Conflict handling: When a duplicate tool name or overlapping HTTP route is detected, the second registration is rejected (not overwritten). A warning is logged with both plugin ids so developers can identify and resolve the conflict.

Edge Cases & Error Handling

ScenarioBehaviorImpact
Duplicate tool names across pluginsSecond registration rejected; warning logged with both plugin idsFirst plugin’s tool wins; second plugin should rename
Hook priority tiesStable sort by plugin id (lexicographic ascending)Deterministic order, but may not match developer intent
Channel already registered by another pluginRegistration rejected; error loggedOnly one plugin can own a channel id
Provider with no auth methodsNormalization succeeds but provider cannot authenticateProvider registered but unusable until auth is configured
Command name conflictsValidation rejects duplicate; warning loggedFirst command registration wins
HTTP route overlapsfindOverlappingPluginHttpRoute() detects and rejectsExisting route preserved; new route must use a different path

Directory Map

Source files for Capability Registration
📁 src/plugin-sdk/
├── registry.ts                    # Central PluginRegistry — all capability maps
├── registry-tool.ts               # registerTool() + normalizeToolName()
├── registry-hook.ts               # registerHook() + priority sorting
├── registry-channel.ts            # registerChannel() + adapter validation
├── registry-provider.ts           # registerProvider() + normalizeRegisteredProvider()
├── registry-command.ts            # registerCommand() + validatePluginCommandDefinition()
├── registry-http-route.ts         # registerHttpRoute() + findOverlappingPluginHttpRoute()
├── plugin-tool-meta.ts            # PluginToolMeta type definition
└── plugin-hook-name.ts            # PluginHookName union type