Skip to main content

Write your first plugin

WednesdayAI’s core is intentionally small. Features that are specific to a use case, platform, or workflow belong in plugins — not in core. This keeps the core stable and the system extensible without requiring a fork. A plugin is a Node.js package whose register(api) function attaches one or more capabilities through the plugin SDK. This guide builds a plugin with one agent tool. It covers both manifest forms, the SDK surface, async safety, optional tools, config schema, and local testing.

Prerequisites

  • Node.js ≥ 24.12.0
  • A running WednesdayAI instance for testing
  • TypeScript familiarity — the plugin SDK is fully typed

Extension points overview

Before writing code, understand where plugins touch the system. Each capability is attached with a dedicated method on the OpenClawPluginApi object passed to your register function:
Extension pointWhat it ownsRegister with
Agent toolAn action the AI can call (search, fetch, compute)api.registerTool(...)
Lifecycle hookA typed runtime callback (transform LLM input/output, gate tools, shape context)api.on(...)
Channel adapterA new chat platformapi.registerChannel(...)
HTTP routeA custom endpoint on the gatewayapi.registerHttpRoute(...)
ProviderA model provider and its auth methodsapi.registerProvider(...)
CommandA chat command that bypasses the LLMapi.registerCommand(...)
This guide builds a tool. For the other extension points, see Agent tools, Hooks, Channel adapters, and the Plugin SDK reference (which also covers providers, commands, services, gateway methods, session-store adapters, and web-search providers).

Step 1: Scaffold the plugin

Create a directory for your plugin. The directory name must match the plugin id exactly.
mkdir my-tool
cd my-tool
npm init -y
npm install --save-dev typescript @types/node
npm install --save-dev openclaw@2026.3.2   # pin to the WednesdayAI fork base version
Add "type": "module" to package.json — the plugin SDK uses ESM:
{
  "name": "my-tool",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/index.js"
}
Create tsconfig.json:
{
  "compilerOptions": {
    "target": "es2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "outDir": "dist",
    "declaration": true
  },
  "include": ["src"]
}

Step 2: Write the manifest

Create openclaw.plugin.json at the plugin root. This file is required — the runtime validates plugin config against it without executing your code, so a missing or invalid manifest blocks loading. The manifest is plain JSON (not JSON5):
{
  "id": "my-tool",
  "name": "My Tool",
  "description": "A plugin that adds a custom agent tool",
  "version": "1.0.0",
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "apiKey": {
        "type": "string",
        "description": "API key for the external service"
      }
    },
    "required": ["apiKey"]
  },
  "uiHints": {
    "apiKey": {
      "label": "API Key",
      "sensitive": true
    }
  }
}
Key rules:
  • id must match the directory name exactly.
  • configSchema is required. If your plugin has no config, use { "type": "object", "additionalProperties": false }.
  • additionalProperties: false ensures typos in user config are caught, not silently ignored.
  • The manifest is plain JSON — do not add comments or trailing commas.
  • Declaring a field in required means the runtime rejects the plugin config at load time if the field is absent. If you also guard for a missing value inside execute(), that guard is belt-and-suspenders for deployments where config validation may be bypassed (e.g. direct API calls). Both are fine to have together.
openclaw.plugin.json vs the package.json "openclaw" key. These are two different things. openclaw.plugin.json is the manifest — it carries id + configSchema and drives config validation. The package.json "openclaw" key is used for workspace/pack discovery (the id, or an extensions array for a multi-plugin pack). A published plugin typically ships both. See the manifest reference.

Step 3: Write the plugin entry point

The plugin’s entry exports a default register function. This function is called by the runtime when the plugin is loaded. Create src/index.ts:
import { Type } from "@sinclair/typebox";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";

export default function register(api: OpenClawPluginApi): void {
  api.registerTool({
    name: "my_tool",
    description: "Describe what this tool does — the AI reads this to decide when to call it.",
    parameters: Type.Object({
      query: Type.String({ description: "The input to process" }),
    }),
    async execute(_id, params) {
      const result = `Processed: ${params.query}`;
      return {
        content: [{ type: "text", text: result }],
      };
    },
  });
}
Points to note:
  • Import from openclaw/plugin-sdk, not from relative paths into core. Relative core imports (../../src/...) work in bundled extensions but break in installed plugins.
  • Tool names are not deduplicated at registration time. If two tools share a name, both are registered and runtime resolution order determines which is called. Use a unique, namespaced name (e.g. my-plugin_my_tool) to avoid conflicts.

Async safety

The register() function is called at startup. Any long-running blocking operation inside it delays gateway readiness.
The register function may be synchronous or async. Use async if you need to perform startup I/O (reading a config file, pinging an API). The key constraint is: don’t block indefinitely. Tool execute() functions are always async and run when the AI decides to call the tool. A slow execute() does not block the gateway — but it does block the user’s reply until it completes. Do not do this:
export default function register(api: OpenClawPluginApi): void {
  // Synchronous I/O blocks the event loop during startup
  const config = JSON.parse(fs.readFileSync("./data.json", "utf8"));
  api.registerTool({ ... });
}
Do this instead:
export default async function register(api: OpenClawPluginApi): Promise<void> {
  // Async I/O during startup — does not block the event loop
  const config = JSON.parse(await fs.promises.readFile("./data.json", "utf8"));
  api.registerTool({ ... });
}
For long-running tool operations: Return a progress message immediately if the tool makes a network request that could take more than a few seconds. Set user expectations in the tool description field.

Step 4: Register as optional

By default, registered tools are available to all agents. If your tool is not universally useful (makes outbound network requests, uses a paid API, has side effects), register it as optional:
api.registerTool(
  {
    name: "my_tool",
    description: "...",
    parameters: Type.Object({ query: Type.String() }),
    async execute(_id, params) {
      /* ... */
    },
  },
  { optional: true }, // user must explicitly enable this tool
);
Users enable an optional tool via the top-level tools.allow key in their WednesdayAI config:
{
  tools: {
    allow: ["my_tool"],
  },
}
Mark tools as optional whenever they make outbound requests, use credentials, or have meaningful side effects. This gives operators explicit control over what the AI can do.

Step 5: Access plugin config in execute

If your manifest declares a configSchema, users set values under the plugin’s id in their WednesdayAI config. The config is available on the api object passed to register. Capture it in a closure so execute() can access it:
export default function register(api: OpenClawPluginApi): void {
  // api.pluginConfig is Record<string, unknown> | undefined
  const pluginConfig = api.pluginConfig;

  api.registerTool({
    name: "my_tool",
    description: "...",
    parameters: Type.Object({ query: Type.String() }),
    async execute(_id, params) {
      const apiKey = pluginConfig?.apiKey as string | undefined;
      if (!apiKey) {
        return {
          content: [{ type: "text", text: "Plugin not configured: apiKey is missing." }],
        };
      }
      // use apiKey ...
      return { content: [{ type: "text", text: `Done: ${params.query}` }] };
    },
  });
}
Always handle missing config gracefully — return a helpful message rather than throwing.

Step 6: Build and install locally

# Build
npx tsc

# Pack
npm pack
# Produces: my-tool-1.0.0.tgz

# Install into WednesdayAI
openclaw plugins install ./my-tool-1.0.0.tgz

# Verify it loaded
openclaw plugins list
Expected output from openclaw plugins list:
my-tool  1.0.0  loaded

Step 7: Test it

Send a message to your WednesdayAI bot that should invoke the tool. If you registered it as optional, first add "my_tool" to tools.allow in your WednesdayAI config (see Step 4). To watch tool calls in the logs on Linux:
journalctl --user -u openclaw-gateway -f | grep my_tool
Expected: a log line showing the tool being called with your params and a successful return.

Other extension points

Once you are comfortable registering a tool, the same register(api) entry attaches everything else:
  • Lifecycle hooksapi.on(name, handler) to transform LLM input/output, gate tool calls, or shape context. See Hooks.
  • Channel adaptersapi.registerChannel(...) to add a new messaging platform. See Channel adapters.
  • HTTP routes and gateway methodsapi.registerHttpRoute(...) / api.registerGatewayMethod(...).
  • Commands and CLIapi.registerCommand(...) for chat commands that bypass the LLM; api.registerCli(...) for CLI subcommands.
  • Servicesapi.registerService(...) for start/stop background work.
  • Providers, session stores, web searchapi.registerProvider(...), api.registerSessionStoreAdapter(...), api.registerWebSearchProvider(...).
The full surface is documented in the Plugin SDK reference.

What’s next