Plugin Architecture: 8-Step Loading Pipeline and Unidirectional Dependency Principle
Chapter 22ใPlugin Architecture: The 8-Step Loading Pipeline and the Unidirectional Dependency Principle
22.1ใWhy a Deeper Extension Layer Than Skills Is Needed
Skills are powerfulโthey let you extend Agent behavior without writing a single line of code. But Skills have a fundamental constraint: they run at inference-time, not startup-time.
This distinction determines what Skills can and cannot do.
A Skill tells the Agent "how to handle GitHub PR-related questions." When a user asks, the Agent loads and interprets that knowledge, then uses existing tools to perform the task. The entire process happens inside the inference loop, using tools and capabilities the system already has.
But what if you need not "extend the Agent's knowledge" but "add new capability infrastructure to the system"? For example:
- Adding a new message channel (letting the Agent respond to Slack messages)
- Registering a custom HTTP endpoint
- Integrating a new LLM provider
- Launching a background service for continuous monitoring
These requirements share a common thread: they all need to be registered at startup time, not created on-the-fly when a user asks a question. This is precisely why the Plugin system exists.
22.1.1ใThe Fundamental Role of Plugins
A useful analogy:
Plugins are electrical wiring (startup-time); Skills are textbooks (inference-time)
Electrical wiring must be completed when the building is constructedโyou cannot wire the outlets at the moment you want to plug in an appliance. Similarly, Plugins must complete their registration at system startup because the capabilities they provide (message channels, tool endpoints, provider interfaces) must be ready before any request arrives.
Textbooks can be taken out and read when neededโyou consult relevant materials when researching a problem. Skills work exactly this way: loaded on demand at inference time, unloaded when done.
22.2ใThe 8-Step Loading Pipeline in Detail
The heart of the OpenClaw Plugin system is a strictly defined 8-step loading sequence. Each step has a clear input, output, and responsibility boundary.
Discovery
โ
Manifest Reading
โ
Safety Gates
โ
Config Normalization
โ
Enablement Decision
โ
Module Loading
โ
Hook Invocation
โ
Registry Population
Step 1: Discovery
Input: List of Plugin search paths from system configuration
Output: Complete list of candidate Plugin directories
Role: The system traverses all configured Plugin directories and identifies which directories are valid Plugins (by checking for the presence of a plugin.manifest.yaml file).
// Simplified internal Discovery logic
async function discoverPlugins(searchPaths: string[]): Promise<string[]> {
const candidates: string[] = []
for (const searchPath of searchPaths) {
const entries = await fs.readdir(searchPath, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory()) {
const manifestPath = path.join(searchPath, entry.name, 'plugin.manifest.yaml')
if (await fs.exists(manifestPath)) {
candidates.push(path.join(searchPath, entry.name))
}
}
}
}
return candidates
}
Key design: Discovery only checks filesystem structure; it reads no content. This keeps the discovery phase performant while deferring content parsing to the next step.
Step 2: Manifest Reading
Input: Candidate Plugin directory path
Output: Parsed Manifest object (PluginManifest type)
Role: Reads and parses plugin.manifest.yaml, converting it into an in-memory structured object. This step includes basic YAML format validation but no semantic checking.
# plugin.manifest.yaml standard format
id: my-slack-plugin
version: "1.2.0"
displayName: Slack Integration
description: Adds Slack as a message channel
author: MyOrg
entryPoint: dist/index.js # Built version
devEntryPoint: src/index.ts # Development version (jiti hot-reload)
capabilities:
- channel
- httpRoute
permissions:
network:
- "*.slack.com"
- "slack.com"
configuration:
schema:
type: object
properties:
botToken:
type: string
secret: true
signingSecret:
type: string
secret: true
required: [botToken, signingSecret]
Critical significance: The Manifest is the source of truth for the control plane. From the Manifest alone, the system can fully plan:
- What capabilities this Plugin claims to provide
- What configuration is required
- What network permissions are needed
All of this planning happens without loading any runtime code, which is the core value of separating the Manifest from the Plugin Module.
Step 3: Safety Gates
Input: Manifest object + Plugin directory path
Output: Pass/reject decision (with reason)
Role: Security checks against the Plugin before executing any code. Safety Gates are hard checks that cannot be bypassed.
Check 1: Path escape detection
function checkPathEscape(pluginDir: string, entryPoint: string): boolean {
const resolvedEntry = path.resolve(pluginDir, entryPoint)
const resolvedDir = path.resolve(pluginDir)
// entry point must be inside the plugin directory
if (!resolvedEntry.startsWith(resolvedDir + path.sep)) {
throw new PluginSecurityError(
`Entry point escapes plugin directory: ${entryPoint}`
)
}
return true
}
Check 2: Globally-writable directory detection
Plugin directories cannot reside in globally-writable locations (e.g., /tmp, /var/tmp), preventing privilege escalation attacks:
const FORBIDDEN_PREFIXES = ['/tmp', '/var/tmp', '/dev/shm']
function checkGlobalWritable(pluginDir: string): boolean {
for (const prefix of FORBIDDEN_PREFIXES) {
if (pluginDir.startsWith(prefix)) {
throw new PluginSecurityError(
`Plugin installed in globally-writable directory: ${pluginDir}`
)
}
}
return true
}
Check 3: Suspicious ownership detection
The filesystem ownership of the Plugin directory must match the running process user:
async function checkOwnership(pluginDir: string): Promise<boolean> {
const stat = await fs.stat(pluginDir)
const currentUid = process.getuid?.() ?? -1
if (stat.uid !== currentUid && stat.uid !== 0) {
throw new PluginSecurityError(
`Plugin directory owned by unexpected user (uid: ${stat.uid})`
)
}
return true
}
Design principle: All Safety Gate checks complete before any code executes. If any check fails, Plugin loading terminates immediatelyโthe process does not advance to subsequent steps.
Step 4: Config Normalization
Input: Raw user configuration (from openclaw.config.yaml) + schema from Manifest
Output: Normalized Plugin configuration object
Role: Reconciles user-provided configuration against the schema declared in the Manifest: fills in defaults, validates required fields, and decrypts secret fields.
async function normalizePluginConfig(
rawConfig: Record<string, unknown>,
schema: JSONSchema,
secretResolver: SecretResolver
): Promise<NormalizedConfig> {
// 1. JSON Schema validation
const validation = ajv.validate(schema, rawConfig)
if (!validation) {
throw new ConfigValidationError(ajv.errors)
}
// 2. Apply defaults
const withDefaults = applyDefaults(rawConfig, schema)
// 3. Decrypt secret fields
const resolved = await resolveSecrets(withDefaults, schema, secretResolver)
return resolved
}
Step 5: Enablement Decision
Input: Normalized configuration + Manifest capabilities list
Output: enabled: true | false (with reason)
Role: Decides whether the Plugin should be active in the current environment. Reasons for disabling include:
- Explicitly disabled in configuration (
enabled: false) - Missing required configuration (e.g., API key not set)
- Runtime environment does not satisfy Plugin prerequisites
function makeEnablementDecision(
config: NormalizedConfig,
manifest: PluginManifest,
env: RuntimeEnvironment
): EnablementResult {
// Explicit disable
if (config.enabled === false) {
return { enabled: false, reason: 'explicitly-disabled' }
}
// Check required config
for (const requiredKey of manifest.requiredConfigKeys ?? []) {
if (!config[requiredKey]) {
return { enabled: false, reason: `missing-required-config:${requiredKey}` }
}
}
return { enabled: true }
}
Critical advantage: Enablement Decision executes before module loading. If a Plugin is disabled, no Plugin code is executed at allโit's not just skipped registration but the module is never loaded. This has important implications for both performance and security.
Step 6: Module Loading
Input: Plugin directory path + entryPoint from Manifest
Output: Loaded Plugin module (ES Module or CommonJS)
Role: Actually loads the Plugin code into process memory.
Two loading strategies:
async function loadPluginModule(
pluginDir: string,
manifest: PluginManifest,
isDev: boolean
): Promise<PluginModule> {
if (isDev && manifest.devEntryPoint) {
// Development mode: use jiti to load TypeScript source directly (hot-reload)
const jiti = createJiti(pluginDir)
return jiti(manifest.devEntryPoint)
} else {
// Production mode: use native Node.js loader for pre-built JS
return import(path.resolve(pluginDir, manifest.entryPoint))
}
}
jiti hot-reload developer experience:
jiti is a runtime TypeScript execution engine that allows loading .ts files directly without pre-compilation. In development mode, after modifying Plugin source code you only need to restart OpenClaw (or trigger a Plugin hot-reload)โno tsc build step required.
# Start in development mode (auto-detects devEntryPoint)
openclaw --dev
# After modifying src/index.ts
openclaw plugin reload my-slack-plugin
# โ jiti reloads src/index.ts, no build step required
Step 7: Hook Invocation
Input: Loaded Plugin module
Output: Hook registration complete (Plugin module registers its capabilities with the api object)
Role: Calls the setup(api) function exported by the Plugin module, giving the Plugin the opportunity to register all its hooks, providers, channels, tools, etc.
// The interface every Plugin module must export
export interface PluginModule {
setup(api: PluginApi): void | Promise<void>
}
// System-side invocation:
async function invokePluginHooks(
module: PluginModule,
api: PluginApi
): Promise<void> {
await module.setup(api)
}
This is the moment Plugin code actually executes. Before this, the system has only operated on files and configurationโno Plugin-provided logic has run.
Step 8: Registry Population
Input: All capability declarations registered through the api
Output: Updated global Registry (containing all Plugin-contributed capabilities)
Role: Writes Plugin-registered Providers, Channels, Tools, Commands, HTTP Routes, etc. into the corresponding global registries, making them visible and callable to the rest of the system.
// Registry is the system's capability catalog
interface Registry {
providers: Map<string, ProviderRegistration>
channels: Map<string, ChannelRegistration>
tools: Map<string, ToolRegistration>
commands: Map<string, CommandRegistration>
httpRoutes: RouteRegistration[]
contextEngines: Map<string, ContextEngineFactory>
}
22.3ใManifest (Control Plane) vs. Plugin Module (Data Plane): Separation of Responsibilities
This is one of the most important design decisions in Plugin architecture.
Control Plane (Manifest)
The Manifest is metadata the system can read before executing any Plugin code. It answers these questions:
- "What capabilities can this Plugin provide?" (capabilities field)
- "What configuration is needed to enable it?" (configuration.schema)
- "What network access does it require?" (permissions.network)
- "Where is its code?" (entryPoint)
Based on Manifest information, the system can make the following decisions without loading any Plugin code:
- List all installed Plugins and their capabilities (for UI display)
- Validate configuration completeness (report missing config before code executes)
- Decide whether to activate the Plugin in a specific environment
- Conduct security audits (check if Plugin's declared permissions are reasonable)
Data Plane (Plugin Module)
The Plugin Module is executable code that actually implements capabilities. It answers:
- "How exactly do I handle an LLM request?" (createStreamFn implementation)
- "How do I send a message to Slack?" (channel handler implementation)
- "How do I register a tool's schema and handler?"
The Plugin Module only loads after passing all Safety Gates and with an Enablement Decision of true.
Engineering Significance of the Separation
No code execution required
Manifest reading โโโโโโโโโโโโโโโโโโโโโโโโโโโ Capability planning / Config validation / Security audit
Code execution required
Module loading โโโโโโโโโโโโโโโโโโโโโโโโโโโ Actual capability registration / Runtime behavior
This separation allows the system to establish a clear boundary between "planning" and "execution." In microservice architecture, this is analogous to the separation between service discovery and service invocationโyou can enumerate all available services and their interfaces without making any RPC calls.
22.4ใThe Engineering Significance of the Unidirectional Loading Principle
The OpenClaw Plugin system enforces a strict rule:
plugin โ registry โ core
Plugin code can write to the registry; the core system reads from the registry. Plugin code cannot directly modify core global state.
Why It Matters: Preventing Circular Dependencies
If Plugins could directly modify core state, then core behavior would depend on which Plugins were loaded and in what order. This leads to:
โ Consequences of allowing bidirectional dependency:
Plugin A modifies core.state.X
Plugin B depends on core.state.X (now depends on Plugin A being loaded)
core behavior depends on Plugin load order
โ System behavior is non-deterministic, extremely difficult to debug
โ Result of unidirectional dependency:
Plugin A registers capabilities in registry
Plugin B registers capabilities in registry (independent of Plugin A)
core reads capabilities from registry (order-independent)
โ System behavior is completely deterministic, easy to reason about
Why It Matters: Preventing Global State Pollution
Plugins are third-party code. If Plugins could modify core global variables, any buggy Plugin could break the entire system's stability:
// โ Forbidden: Plugin directly modifying core global state
// (assuming this is a prohibited interface)
api.core.globalConfig.maxTokens = 99999 // Affects all requests
api.core.providers.splice(0) // Clears all providers
// โ Allowed: Plugin declaring capabilities through registry
api.registerProvider({
id: 'my-provider',
// ... only affects the my-provider entry in registry
})
Enforcement Mechanism
The Plugin api object is a controlled proxy:
function createPluginApi(pluginId: string, registry: Registry): PluginApi {
return {
// Only exposes register* methodsโa one-way channel for writing to registry
registerProvider: (config) => registry.providers.set(pluginId, config),
registerChannel: (config) => registry.channels.set(pluginId, config),
registerTool: (config) => registry.tools.set(config.id, config),
registerCommand: (config) => registry.commands.set(config.id, config),
registerHttpRoute: (config) => registry.httpRoutes.push({ ...config, pluginId }),
// runtime helpers provide controlled access to runtime capabilities
runtime: createRuntimeHelpers(pluginId),
// NOT exposed: core internal state, other Plugins' registry entries, system globals
}
}
22.5ใSafety Gates Deep Dive
Safety Gates are the most important security barrier in the Plugin loading pipeline. Here is the complete check matrix:
Complete Check List
| Check Category | Check Content | Failure Consequence |
|---|---|---|
| Path safety | Entry point is within Plugin directory | Reject loading |
| Path safety | Plugin directory contains no path traversal (../) |
Reject loading |
| Location safety | Not in globally-writable directory (/tmp etc.) | Reject loading |
| Location safety | Not in system directory (/usr/bin etc.) | Reject loading |
| Ownership | Directory owner matches running user | Reject loading |
| Ownership | Critical files not writable by other users | Reject loading |
| Manifest integrity | Required fields present (id/version/entryPoint) | Reject loading |
| Version compatibility | Plugin API version compatible with current runtime | Reject loading |
The Unskippable Nature of Safety Gates
async function runSafetyGates(
pluginDir: string,
manifest: PluginManifest
): Promise<void> {
const checks = [
checkPathEscape(pluginDir, manifest.entryPoint),
checkGlobalWritable(pluginDir),
checkOwnership(pluginDir),
checkManifestIntegrity(manifest),
checkApiVersionCompatibility(manifest.apiVersion),
]
// All checks run in parallel; any failure fails the whole set
await Promise.all(checks)
// If we reach here, all checks passed
}
Key design: Safety Gates use Promise.allโall checks run in parallel, and any single failure throws an exception that stops subsequent steps. This is not "warn and continue" but "fail and stop."
22.6ใjiti Hot-Reload Developer Experience
jiti is the most important developer experience tool in Plugin development. Understanding how it works is crucial for efficient Plugin development.
How jiti Works
.ts source file
โ
jiti (runtime TypeScript compiler)
โ
Just-in-time compilation (no disk writes)
โ
Execute directly in the Node.js process
jiti uses esbuild to compile TypeScript in memory, producing no .js files. This means your src/index.ts can be executed directly by the Node.js process, as if Node.js natively supported TypeScript.
Development Workflow Comparison
โ Workflow using jiti (devEntryPoint mode):
Modify src/index.ts
โ Trigger Plugin hot-reload
โ jiti recompiles and executes
โ See results immediately (< 1 second)
โ Workflow without jiti:
Modify src/index.ts
โ Run npm run build (tsc compilation, typically 5-30 seconds)
โ Restart OpenClaw
โ See results
Manifest Configuration
# plugin.manifest.yaml
entryPoint: dist/index.js # Used in production mode
devEntryPoint: src/index.ts # Used in development mode (jiti loading)
The system uses the --dev flag or OPENCLAW_DEV=true environment variable to determine which entry point to use.
22.7ใChapter Summary
The 8 steps of the Plugin loading pipeline are not arbitrary design choicesโeach step has engineering necessity:
| Step | Core Value |
|---|---|
| Discovery | Filesystem scan, no code execution |
| Manifest Reading | Control plane parsing, plan activation strategy |
| Safety Gates | Unskippable security barrier |
| Config Normalization | Unify config format, validate completeness |
| Enablement Decision | Final gate before code execution |
| Module Loading | Actually loads Plugin code |
| Hook Invocation | Plugin registers its capabilities |
| Registry Population | Capabilities become visible to the system |
Understanding this pipeline means you can:
- Diagnose Plugin loading failures (pinpoint the specific step)
- Understand why certain config errors require restart to take effect (Steps 3-5 run at startup)
- Design secure Plugins (respect Safety Gate constraints, follow the unidirectional loading principle)
The next chapter dives into the complete Plugin API surface, systematically covering the call order and passthrough rules for all 45 Hooks.