diff --git a/openclaw-plugin-dev/SKILL.md b/openclaw-plugin-dev/SKILL.md new file mode 100644 index 0000000..c27de7c --- /dev/null +++ b/openclaw-plugin-dev/SKILL.md @@ -0,0 +1,21 @@ +--- +name: openclaw-plugin-dev +description: OpenClaw plugin development — structure, conventions, hooks, tools, install scripts, and debugging. Use when creating, modifying, or debugging OpenClaw plugins. +--- + +> Reference docs provide the full specification. Workflows guide specific tasks. + +## Reference + +- `{baseDir}/docs/structure.md` — Project layout, directory conventions, file naming +- `{baseDir}/docs/entry-point.md` — Plugin entry format, globalThis patterns, lifecycle protection +- `{baseDir}/docs/hooks.md` — Hook registration, dedup patterns, available hooks +- `{baseDir}/docs/tools.md` — Tool registration interface (inputSchema + execute) +- `{baseDir}/docs/state.md` — State management rules (globalThis vs module-level) +- `{baseDir}/docs/config.md` — Config schema, install scripts, sensitive fields +- `{baseDir}/docs/debugging.md` — Dev loop, log keywords, smoke testing + +## Workflows + +- `{baseDir}/workflows/create-plugin.md` — When creating a new OpenClaw plugin from scratch. +- `{baseDir}/workflows/add-hook.md` — When adding a new hook handler to an existing plugin. diff --git a/openclaw-plugin-dev/docs/config.md b/openclaw-plugin-dev/docs/config.md new file mode 100644 index 0000000..4e110f5 --- /dev/null +++ b/openclaw-plugin-dev/docs/config.md @@ -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//` +3. Update `openclaw.json`: + - Add to `plugins.allow` + - Add to `plugins.load.paths` + - Set `plugins.entries..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.` +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 diff --git a/openclaw-plugin-dev/docs/debugging.md b/openclaw-plugin-dev/docs/debugging.md new file mode 100644 index 0000000..79be136 --- /dev/null +++ b/openclaw-plugin-dev/docs/debugging.md @@ -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 diff --git a/openclaw-plugin-dev/docs/entry-point.md b/openclaw-plugin-dev/docs/entry-point.md new file mode 100644 index 0000000..826e273 --- /dev/null +++ b/openclaw-plugin-dev/docs/entry-point.md @@ -0,0 +1,49 @@ +# Plugin Entry Point + +## Format + +```typescript +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; + +const _G = globalThis as Record; +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 + +``` +_PluginXxx # Internal state (e.g., _prismFacetRouters) +__ # Cross-plugin public API (e.g., __yonexusClient) +``` diff --git a/openclaw-plugin-dev/docs/hooks.md b/openclaw-plugin-dev/docs/hooks.md new file mode 100644 index 0000000..be8c923 --- /dev/null +++ b/openclaw-plugin-dev/docs/hooks.md @@ -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; +const DEDUP_KEY = "_myPluginHookDedup"; +if (!(_G[DEDUP_KEY] instanceof WeakSet)) _G[DEDUP_KEY] = new WeakSet(); +const dedup = _G[DEDUP_KEY] as WeakSet; + +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(); +const dedup = _G[DEDUP_KEY] as Set; + +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..hooks.allowPromptInjection: true` in openclaw.json. diff --git a/openclaw-plugin-dev/docs/state.md b/openclaw-plugin-dev/docs/state.md new file mode 100644 index 0000000..be85c33 --- /dev/null +++ b/openclaw-plugin-dev/docs/state.md @@ -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; + +// 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. diff --git a/openclaw-plugin-dev/docs/structure.md b/openclaw-plugin-dev/docs/structure.md new file mode 100644 index 0000000..db5508f --- /dev/null +++ b/openclaw-plugin-dev/docs/structure.md @@ -0,0 +1,26 @@ +# Plugin Project Structure + +``` +proj-root/ + plugin/ # Installable plugin (copied to ~/.openclaw/plugins//) + 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` diff --git a/openclaw-plugin-dev/docs/tools.md b/openclaw-plugin-dev/docs/tools.md new file mode 100644 index 0000000..ba4da4b --- /dev/null +++ b/openclaw-plugin-dev/docs/tools.md @@ -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.) diff --git a/openclaw-plugin-dev/workflows/add-hook.md b/openclaw-plugin-dev/workflows/add-hook.md new file mode 100644 index 0000000..ac93671 --- /dev/null +++ b/openclaw-plugin-dev/workflows/add-hook.md @@ -0,0 +1,40 @@ +# Add Hook + +When adding a new hook handler to an existing plugin. + +> See `{baseDir}/docs/hooks.md` for available hooks and dedup patterns. + +## Process + +### 1. Create Hook File + +Add `plugin/hooks/.ts`. Export a registration function: + +```typescript +export function registerMyHook(api, config) { + // Set up dedup on globalThis + // api.on("", handler) +} +``` + +### 2. Add Dedup + +Choose the right dedup pattern: +- `before_model_resolve`, `before_prompt_build` → WeakSet on event object +- `agent_end` → Set on runId with size cap (500) +- `gateway_start/stop` → globalThis flag (in index.ts, not in hook file) + +### 3. Register in index.ts + +Import and call the registration function in the `register()` method. + +Hooks that need to run every register() call (agent-scoped): put outside the lifecycle guard. +Hooks that should run once (gateway-scoped): put inside the lifecycle guard. + +### 4. If Prompt Injection + +If the hook returns `prependSystemContext` or `appendSystemContext`, ensure `allowPromptInjection: true` is set in the plugin's config entry and in the install script. + +### 5. Test + +Reinstall, restart gateway, verify via logs. diff --git a/openclaw-plugin-dev/workflows/create-plugin.md b/openclaw-plugin-dev/workflows/create-plugin.md new file mode 100644 index 0000000..dbb5b24 --- /dev/null +++ b/openclaw-plugin-dev/workflows/create-plugin.md @@ -0,0 +1,72 @@ +# Create Plugin + +When creating a new OpenClaw plugin from scratch. + +> See `{baseDir}/docs/structure.md` for directory layout and conventions. + +## Process + +### 1. Scaffold + +Create the project structure: + +``` +my-plugin/ + plugin/ + index.ts + openclaw.plugin.json + package.json + core/ + hooks/ + tools/ + scripts/ + install.mjs + routers/ # if applicable + package.json # dev dependencies (typescript, @types/node) + tsconfig.plugin.json + .gitignore +``` + +### 2. Define Config Schema + +Write `plugin/openclaw.plugin.json` with `additionalProperties: false`. Only include fields the plugin actually uses. + +### 3. Implement Entry Point + +Follow the pattern in `{baseDir}/docs/entry-point.md`: +- `export default { id, name, register }` +- globalThis lifecycle protection +- Hooks in separate files under `hooks/` +- Tools in separate files under `tools/` +- Business logic in `core/` (no plugin-sdk imports) + +### 4. Implement Hooks + +Follow dedup patterns in `{baseDir}/docs/hooks.md`. Each hook gets its own file. + +### 5. Implement Tools + +Follow the interface in `{baseDir}/docs/tools.md`: `inputSchema` + `execute`. + +### 6. Write Install Script + +Follow `{baseDir}/docs/config.md` for install/uninstall conventions. + +### 7. Build & Test + +```bash +npm run build +node scripts/install.mjs --install +openclaw gateway restart +# Test via Discord or openclaw agent CLI +``` + +### 8. Push + +Create a git repository and push: + +```bash +git-ctrl repo create +git add -A && git commit -m "init: " +git push -u origin main +```