feat: add openclaw-plugin-dev skill

Plugin development reference and workflows based on real development
experience (Dirigent, ContractorAgent, PrismFacet).

Docs: structure, entry-point, hooks, tools, state, config, debugging
Workflows: create-plugin, add-hook

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhi
2026-04-18 17:25:07 +00:00
parent 1d34768019
commit 5a8f490cc2
10 changed files with 465 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
# Config Schema & Install Scripts
## openclaw.plugin.json
```json
{
"$schema": "https://openclaw.ai/schemas/plugin-config.json",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"myToken": { "type": "string" },
"myFlag": { "type": "boolean", "default": false }
}
}
}
```
### Rules
- `additionalProperties: false` is mandatory — OpenClaw validates config against schema
- Removing a config field requires removing it from schema too (or gateway fails to start)
- Sensitive fields (tokens, keys): no `default`, user must configure manually
## Install Script (scripts/install.mjs)
### Install does:
1. Build dist (compile TypeScript)
2. Clean and copy to `~/.openclaw/plugins/<id>/`
3. Update `openclaw.json`:
- Add to `plugins.allow`
- Add to `plugins.load.paths`
- Set `plugins.entries.<id>.enabled = true`
- Set default config via `setIfMissing` (never overwrite existing values)
4. Never set sensitive fields (tokens) — add comments for manual configuration
### Uninstall does:
1. Remove from `plugins.allow`
2. Delete `plugins.entries.<id>`
3. Remove from `plugins.load.paths`
4. Delete install directory
### setIfMissing pattern
```javascript
function setIfMissing(obj, key, value) {
if (obj[key] === undefined || obj[key] === null) {
obj[key] = value;
}
}
```
### Config field changes
When renaming or removing config fields:
1. Update `openclaw.plugin.json` schema first (source of truth)
2. Update `normalizeConfig()` in index.ts
3. Update install script `setIfMissing` calls
4. Document migration steps for users

View File

@@ -0,0 +1,46 @@
# Development & Debugging
## Dev Loop
```bash
# 1. Modify code in plugin/
# 2. Reinstall
node scripts/install.mjs --install
# 3. Restart gateway
openclaw gateway restart
# 4. Watch logs
openclaw logs --follow
# 5. Test
```
## Log Keywords
| Keyword | Meaning |
|---------|---------|
| `plugin registered` | register() completed |
| `startSideCar called` / `already running` | Sidecar status |
| `must NOT have additional properties` | Schema vs config mismatch |
| `EADDRINUSE` | Port conflict (sidecar or HTTP server) |
## Checklist Before Deploy
### General
- [ ] All hook handlers have event dedup (globalThis, not module-level)
- [ ] Gateway lifecycle events protected by globalThis flag
- [ ] Business state on globalThis
- [ ] `openclaw.plugin.json` schema matches actual config fields
- [ ] Install script doesn't set schema-absent fields
- [ ] Sensitive fields not set by install script
- [ ] Install script cleans old dist before copying
### Connection-type plugins
- [ ] Start flag on globalThis (not module-level `let`)
- [ ] Runtime reference on globalThis
- [ ] Shared registries/callbacks on globalThis, init once
- [ ] Cross-plugin API object overwritten every register() but runtime started once
- [ ] Consumer plugins defend against provider not loaded

View File

@@ -0,0 +1,49 @@
# Plugin Entry Point
## Format
```typescript
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
const _G = globalThis as Record<string, unknown>;
const LIFECYCLE_KEY = "_myPluginGatewayLifecycleRegistered";
export default {
id: "my-plugin",
name: "My Plugin",
register(api: OpenClawPluginApi) {
const config = normalizeConfig(api);
// Gateway lifecycle: only once
if (!_G[LIFECYCLE_KEY]) {
_G[LIFECYCLE_KEY] = true;
// Start sidecars, init global resources
api.on("gateway_stop", () => { _G[LIFECYCLE_KEY] = false; });
}
// Agent session hooks: every register() call (dedup inside handler)
registerMyHook(api, config);
// Tools
registerMyTools(api, config);
api.logger.info("my-plugin: registered");
},
};
```
## Why globalThis?
OpenClaw may hot-reload plugins. Module-level variables reset on reload, but `globalThis` persists. All mutable state must be on `globalThis`:
- Startup flags → prevent double initialization
- Dedup sets → prevent double hook execution
- Runtime references → keep connections alive across reloads
- Shared registries → preserve cross-plugin state
## Naming Convention
```
_<pluginId>PluginXxx # Internal state (e.g., _prismFacetRouters)
__<pluginId> # Cross-plugin public API (e.g., __yonexusClient)
```

View File

@@ -0,0 +1,62 @@
# Hook Registration
## Available Hooks
| Hook | Purpose | Dedup Method |
|------|---------|-------------|
| `before_model_resolve` | Override model/provider before LLM call | WeakSet on event |
| `before_prompt_build` | Inject into system prompt | WeakSet on event |
| `before_agent_start` | Legacy prompt injection | WeakSet on event |
| `agent_end` | Post-conversation actions | Set on runId |
| `message_received` | React to inbound messages | — |
| `llm_input` / `llm_output` | Observe LLM traffic | — |
| `before_tool_call` / `after_tool_call` | Observe tool execution | — |
| `gateway_start` / `gateway_stop` | Gateway lifecycle | globalThis flag |
## Dedup Patterns
### WeakSet (for event-object hooks)
```typescript
const _G = globalThis as Record<string, unknown>;
const DEDUP_KEY = "_myPluginHookDedup";
if (!(_G[DEDUP_KEY] instanceof WeakSet)) _G[DEDUP_KEY] = new WeakSet<object>();
const dedup = _G[DEDUP_KEY] as WeakSet<object>;
api.on("before_model_resolve", async (event, ctx) => {
if (dedup.has(event as object)) return;
dedup.add(event as object);
// ... handler logic
});
```
### Set with runId (for agent_end)
```typescript
const DEDUP_KEY = "_myPluginAgentEndDedup";
if (!(_G[DEDUP_KEY] instanceof Set)) _G[DEDUP_KEY] = new Set<string>();
const dedup = _G[DEDUP_KEY] as Set<string>;
api.on("agent_end", async (event, ctx) => {
const runId = (event as any).runId as string;
if (runId) {
if (dedup.has(runId)) return;
dedup.add(runId);
if (dedup.size > 500) dedup.delete(dedup.values().next().value!);
}
// ... handler logic
});
```
## Prompt Injection Hooks
`before_prompt_build` and `before_agent_start` can return prompt mutation fields:
| Field | Caching | Use for |
|-------|---------|---------|
| `prependSystemContext` | Cached | Static role/identity prompts |
| `appendSystemContext` | Cached | Static supplementary guidance |
| `prependContext` | Not cached | Per-turn dynamic context |
| `systemPrompt` | — | Full system prompt replacement (rarely used) |
Requires `plugins.entries.<id>.hooks.allowPromptInjection: true` in openclaw.json.

View File

@@ -0,0 +1,48 @@
# State Management
## Where to Store What
| Data Type | Location | Reason |
|-----------|----------|--------|
| Business state (maps, sets, caches) | `globalThis` | Module vars reset on hot reload |
| Event dedup (WeakSet/Set) | `globalThis` | Same |
| Gateway lifecycle flags | `globalThis` | Prevent double init |
| Connection flags (WebSocket/TCP) | `globalThis` | Prevent duplicate connections |
| Runtime references | `globalThis` | Closures need living instance |
| Cross-plugin API objects (`__pluginId`) | `globalThis` | Other plugins access via globalThis |
| Pure utility functions | Module-level | No state needed |
| Persistent data | File + memory cache | Survives gateway restart |
## Cross-Plugin API Pattern
### Provider side
```typescript
const _G = globalThis as Record<string, unknown>;
// Init shared objects once
if (!(_G["_myRegistry"] instanceof MyRegistry)) {
_G["_myRegistry"] = new MyRegistry();
}
// Overwrite public API every register() (updates closures)
_G["__myPlugin"] = {
registry: _G["_myRegistry"],
send: (msg) => (_G["_myRuntime"] as Runtime)?.send(msg) ?? false,
};
```
### Consumer side
```typescript
const provider = (globalThis as any)["__myPlugin"];
if (!provider) {
console.error("[consumer] __myPlugin not found");
return;
}
provider.registry.register("my_rule", handler);
```
### Load Order
Provider must be listed before consumer in `plugins.allow`. Consumer must defend against provider not being loaded.

View File

@@ -0,0 +1,26 @@
# Plugin Project Structure
```
proj-root/
plugin/ # Installable plugin (copied to ~/.openclaw/plugins/<id>/)
index.ts # Entry: export default { id, name, register }
openclaw.plugin.json # Config schema declaration
package.json # name, version, type: module
hooks/ # Hook handlers (one file per hook)
tools/ # Tool registrations
core/ # Pure business logic (no plugin-sdk imports)
web/ # HTTP routes (optional)
services/ # Sidecar processes (optional, installed alongside plugin)
skills/ # OpenClaw skills provided by this plugin (optional)
routers/ # Drop-in modules (plugin-specific, e.g., PrismFacet routers)
scripts/
install.mjs # --install / --uninstall / --update
docs/ # Documentation
```
## Conventions
- File names: kebab-case (`before-model-resolve.ts`)
- Export names: camelCase (`registerBeforeModelResolve`)
- `plugin/core/` must not import from `openclaw/plugin-sdk` — keeps logic unit-testable
- Hook registration logic goes in `plugin/hooks/`, not in `index.ts`

View File

@@ -0,0 +1,40 @@
# Tool Registration
## Interface
```typescript
// Without agent context
api.registerTool({
name: "my-tool",
description: "Does something",
inputSchema: {
type: "object",
properties: {
param: { type: "string", description: "..." },
},
required: ["param"],
},
execute: async (toolCallId, params) => {
const { param } = params as { param: string };
return { result: "ok" };
},
});
// With agent context (factory function)
api.registerTool((ctx) => ({
name: "my-contextual-tool",
description: "...",
inputSchema: { /* ... */ },
execute: async (toolCallId, params) => {
const agentId = ctx.agentId;
return { result: agentId };
},
}));
```
## Key Points
- Interface is `execute: async (toolCallId, params)`, NOT `handler:`
- Use `inputSchema` (JSON Schema), NOT `parameters`
- Return `{ result: "..." }` object
- Factory form `(ctx) => ({...})` gives access to agent context (agentId, sessionKey, etc.)