This chapter is a step-by-step tutorial on how to build a custom tool plugin in a local Ubuntu laptop.
Plugin is different from regular skill. Plugin is a package, that contains one or multiple tools, hooks, channels, providers etc.
The tools, hooks, channels, providers inside a plugin can have their own SKILL.md respectively.
You can specify their SKILL.md's in the skills/ field of the openclaw.plugin.json,
referring to the "plugin manifest".
Those SKILL.md's instruct the openclaw gateway how to use one or more specific tools, hooks, channels, providers of the plugin package,
rather than the entire plugin package.
The names of the tools, hooks, channels, providers of the plugin package, must be identical to the name of definePluginEntry
in the index.ts script of the plugin package,
// index.ts
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { Type } from "@sinclair/typebox";
export default definePluginEntry({
id: "my-plugin",
name: "My Plugin",
description: "Adds a custom tool to OpenClaw",
register(api) {
api.registerTool({
name: "my_tool",
description: "Do a thing",
parameters: Type.Object({ input: Type.String() }),
async execute(_id, params) {
return { content: [{ type: "text", text: `Got: ${params.input}` }] };
},
});
},
});
When the openclaw gateway starts a new session, it scans all the related file directories and their sub-directories,
looking for files named as SKILL.md. The "related file directories" include,
- the default skill directory,
~/.openclaw/skills, - the directories specified in the
extraDirsfield of theopenclaw.jsonconfiguration file, - the directories specified in the
skillsfield of theopenclaw.plugin.jsonof the various plugin packages.
After scanning the skills, the openclaw gateway use them in the same way, no matter it is a regular skill, or the specific skill of a tool inside a plugin package.
Even though it is functional to define the filepaths of the plugin's skills
in the extraDirs field of the openclaw.json configuration file,
and "today those directories are merged into the same low-precedence path as skills.load.extraDirs",
it is logically clearer to define the filepaths in the skills field of the openclaw.plugin.json of the plugin's configuration file.
The fundamental difference between a regular skill and a tool inside a plugin package is that the tool plugin runs inside the openclaw gateway, so that it has access to the session context of the openclaw gateway. However, the regular skill runs outside, it cannot get any internal information of the session.
The appendix at the end of this article gives the detailed proof our statement that "the tool plugin runs inside the openclaw gateway, so that it has access to the session context".
If you have already installed openclaw, double check its package in the directory like
/home/robot/.nvm/versions/node/v24.14.0/lib/node_modules/openclaw/dist/.
Especially, that directory is the only directory for openclaw, and does NOT exist another one simultaneously, like
/home/linuxbrew/.linuxbrew/lib/node_modules/openclaw.
If needed, uninstall openclaw completely, and reinstall it from scratch, cleanly.
- Use the
curlway to install openclaw,
robot@robot-test:~$ curl -fsSL https://openclaw.ai/install.sh | bash -s -- --no-onboard
Here is the log of running this command.
Notice that it will install the openclaw package in a directory like
/home/robot/.nvm/versions/node/v24.14.0/lib/node_modules/openclaw/dist/.
- Install the system daemon service for openclaw gateway,
robot@robot-test:~$ openclaw onboard --install-daemon
Here is the log of running this command.
Notice that do NOT use homebrew to install the skills like gemini and github,
otherwise, it will create another directory for the openclaw package,
/home/linuxbrew/.linuxbrew/lib/node_modules/openclaw.
We built a sample tool plugin hello-tool-plugin, its file structure is displayed below. To understand the entire package, we only need to look into a few key files.
robot@robot-test:~/.openclaw$ pwd
/home/robot/.openclaw
robot@robot-test:~/.openclaw$ ls -la
total 72
drwx------ 11 robot robot 4096 Mar 29 18:12 .
drwxr-x--- 97 robot robot 4096 Mar 29 18:13 ..
drwxrwxr-x 2 robot robot 4096 Mar 30 10:17 devices
drwx------ 2 robot robot 4096 Mar 26 10:26 logs
drwxrwxr-x 3 robot robot 4096 Mar 26 22:05 nodes
-rw------- 1 robot robot 2969 Mar 29 18:12 openclaw.json
drwxrwxr-x 5 robot robot 4096 Mar 28 10:42 plugins
...
robot@robot-test:~/.openclaw$ tree plugins/hello-tool-plugin/
plugins/hello-tool-plugin/
├── package.json
├── openclaw.plugin.json
├── tsconfig.json
├── tsup.config.ts
├── pnpm-lock.yaml
├── skills
│ ├── tool_one
│ │ └── SKILL.md
│ └── tool_two
│ └── SKILL.md
├── src
│ ├── index.ts
│ ├── service.ts
│ ├── plugin-api.ts
│ └── transport
│ ├── factory.ts
│ └── websocket_client.ts
├── node_modules
│ └── ...
└── dist
└── ...
18 directories, 15 files
4.1 openclaw.json
Following is the settings of the openclaw.json
that are related to the skill, tool, hook, and plugin.
{
...
"skills": {
"load": {
"extraDirs": [],
"watch": true
}
},
"tools": {
"profile": "full",
"exec": {
"host": "gateway",
"backgroundMs": 10000,
"timeoutSec": 1800,
"cleanupMs": 1800000,
"notifyOnExit": true,
"notifyOnExitEmptySuccess": false,
"applyPatch": {
"enabled": false,
"allowModels": [
"qwen-max"
]
}
}
},
"hooks": {
"internal": {
"enabled": true,
"entries": {
"command-logger": {
"enabled": true
}
}
}
},
"plugins": {
"enabled": true,
"allow": [
"hello-tool-plugin"
],
"load": {
"paths": [
"/home/robot/.openclaw/plugins"
]
},
"entries": {
"hello-tool-plugin": {
"enabled": true
}
}
},
...
}
openclaw.json is for the entire openclaw system, not only for plugins. It has some settings related to plugins.
-
Skill
The "skills" setting is for regular
SKILL.md'sIn our case, we have
SKILL.md's forhello-tool-pluginplugin package, they are stored in~/.openclaw/plugins/hello-tool-plugin/skillsdirectory. Can we setskills.load.extraDirsto be "~/.openclaw/plugins/hello-tool-plugin/skills"?Yes and no.
It is functional to do so, when starting a new session, the openclaw gateway will scan this directory, and find
tool_one/SKILL.mdandtool_two/SKILL.md.When a user query arrives, the openclaw gateway will compare the user query and the
descriptionof each candidateSKILL.md, and decide whichSKILL.mdis the best candidate.However, if setting the plugin's skill in the
skills.load.extraDirs, the openclaw gateway will treat the plugin skills the same as the regular skills. When calling the skill, the plugin skill will be assigned the same priority as regular skills.{ ... "skills": { "load": { "extraDirs": [], "watch": true } }, ... } -
Tools
In our case, we only need to decide one setting "tools.profile". Both
fullandcodingare okay for our case.{ ... "tools": { "profile": "full", }, ... } -
Plugins
Referring to the official documentation on "plugins" setting,
We set
plugins.enabledtotrue, otherwise all plugins will be disable. In more details, when running commandopenclaw plugins listin a CLI terminal, thestatusof all plugins willdisabled,we add our plugin
hello-tool-plugintoplugins.allow. In this way, when the openclaw gateway loads this plugin, the openclaw gateway will bypass the redundant verification via the "green door" mechanism, thereby enhancing the loading efficiency.By default, the openclaw gateway loads plugins from
~/.openclaw/extensionsand<workspace>/.openclaw/extensions. Withplugins.load.pathssetting, the openclaw gateway will load additional plugins. In our case, the additional plugin is stored in the filepath/home/robot/.openclaw/plugins.We set
plugins.entries.hello-tool-plugin.enabledtotrue, so that when running commandopenclaw plugins listin a CLI terminalhis, thestatusofhello-tool-pluginwill be switched on to beloaded, otherwise, its status will bedisabled.The left screenshot: when setting
plugins.enabledto be globallyfalse, the status of all plugins will bedisabled.The right screenshot: when setting
plugins.enabledto be globallytrue, meanwhile setting only one plugin entry to betrue, the status of that specific pluginhello-tool-pluginwill beloaded.{ ... "plugins": { "enabled": true, "allow": [ "hello-tool-plugin" ], "load": { "paths": [ "/home/robot/.openclaw/plugins" ] }, "entries": { "hello-tool-plugin": { "enabled": true } } }, ... }
4.2 package.json
The filepath of the entry script index.ts is specified in both main and openclaw.extensions of package.json.
Also, the import of index.ts must be specified in dependencies.
{
"name": "@robot-test/hello-tool-plugin",
"type": "module",
"main": "src/index.ts",
"openclaw": {
"extensions": [
"./src/index.ts"
]
},
"dependencies": {
"@sinclair/typebox": "^0.34.0",
"openclaw": "file:/home/robot/.nvm/versions/node/v24.14.0/lib/node_modules/openclaw"
},
"devDependencies": {
}
}
When receiving a user query, the openclaw gateway compares the description of all the available plugins,
in addition to their SKILL.md specified in the skills field, with the user query.
Based on the comparison results, the openclaw gateway selects the best candidate for this user query,
from all the available plugins whose status are loaded.
The SKILL.md of the plugin is specified in the skills field, in our case,
our plugin's skill file directory
is ~/.openclaw/plugins/hello-tool-plugin/skills.
Additionally, we specified two properties in configSchema.properties, nodeUrl and heartbeatMs.
These properties can be accessed from index.ts and other codes, via api.pluginConfig.
We will discuss api.pluginConfig in more details, when discussing index.ts.
{
"id": "hello-tool-plugin",
"name": "Hello Tool Plugin",
"description": "A demo custom tool plugin of openclaw, to be called on intention.",
"skills": ["./skills"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"nodeUrl": {
"type": "string",
"default": "ws://127.0.0.1:9000",
"description": "The WebSocket address of the robot daemon."
},
"heartbeatMs": {
"type": "integer",
"default": 30000,
"description": "Idle time before sending a heartbeat."
}
}
}
}
4.4 index.ts
-
import
To import any external packages, e.g.
openclawand@sinclair/typebox, their filepaths must be specified in thedependenciesfield ofpackage.jsonas mentioned above.import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { Type } from "@sinclair/typebox"; import type { OpenclawPluginApi } from "./plugin-api"; import { registerService } from "./service"; -
api
As discussed in the appendix,
apiis the entry point to the session context. As an instance ofOpenClawPluginApi,apican access many information, including the content of the globalopenclaw.jsonand the plugin package'sopenclaw.plugin.json.The content of the global
openclaw.jsoncan be successfully accessed viaapi.config, as shown in the screenshot below.However, the content of the plugin package's
openclaw.plugin.jsoncan not be accessed viaapi.pluginConfig. For unknown reason, the openclaw gateway regardedapi.pluginConfigasundefined.Additionally,
apican be passed fromindex.tsto other scripts, e.g.registerService(api)ofservice.tsregister(api) { const { logger, config, pluginConfig } = api; ... const plugin_load_str = JSON.stringify(config.plugins.load, null, 2) logger.info(` "openclaw.json" config.plugins.load: '${plugin_load_str}' `); const plugin_nodeurl_str = JSON.stringify(pluginConfig.nodeUrl, null, 2) logger.info(` "openclaw.plugin.json" pluginConfig.nodeUrl: '${plugin_nodeurl_str}' `); // Register the rosbridge WebSocket connection as a manageind service registerService(api); logger.info("[hello-tool-plugin] registerService loaded successfully"); },
We use pnpm to manage our plugin packages.
robot@robot-test:~/.openclaw/plugins/hello-tool-plugin$ pwd
/home/robot/.openclaw/plugins/hello-tool-plugin
robot@robot-test:~/.openclaw/plugins/hello-tool-plugin$ pnpm install
robot@robot-test:~/.openclaw/plugins/hello-tool-plugin$ pnpm build
Openclaw provides a tool openclaw plugins inspect to look into the plugin package, including its errors.
In our case, for some unknown reason, api.pluginConfig did not work properly,
that induced the error "TypeError: Cannot read properties of undefined (reading 'nodeUrl')".
robot@robot-test:~/.openclaw/plugins/hello-tool-plugin$ openclaw plugins inspect "hello-tool-plugin"
21:57:35 [plugins] 🚀 [hello-tool-plugin] rootDir='/home/robot/.openclaw/plugins/hello-tool-plugin'
21:57:35 [plugins]
**************************************************
21:57:35 [plugins] 🚀 [hello-tool-plugin] the content of config.plugins.load
21:57:35 [plugins] "openclaw.json" config.plugins.load: '{
"paths": [
"/home/robot/.openclaw/plugins"
]
}'
21:57:35 [plugins]
21:57:35 [plugins]
**************************************************
21:57:35 [plugins] ⚠️ [hello-tool-plugin] the content of pluginConfig.nodeUrl
21:57:35 [plugins] hello-tool-plugin failed during register from /home/robot/.openclaw/plugins/hello-tool-plugin/src/index.ts: TypeError: Cannot read properties of undefined (reading 'nodeUrl')
🦞 OpenClaw 2026.3.23-2 (7ffe7e4) — Less clicking, more shipping, fewer "where did that file go" moments.
Hello Tool Plugin
id: hello-tool-plugin
A demo custom tool plugin of openclaw, to be called on intention.
Status: error
Format: openclaw
Source: ~/.openclaw/plugins/hello-tool-plugin/src/index.ts
Origin: config
Version: 1.0.0
Shape: non-capability
Capability mode: none
Legacy before_agent_start: no
Diagnostics:
ERROR: plugin failed during register: TypeError: Cannot read properties of undefined (reading 'nodeUrl')
Error: TypeError: Cannot read properties of undefined (reading 'nodeUrl')
Here is the proof that a tool plugin has access to the session context
-
The tool inside a plugin package has access to
api, -
apiis an instance ofOpenClawPluginApi, -
The most important content that
OpenClawPluginApicontains isruntime:PluginRuntime -
PluginRuntimeis an entrance to the session context of the openclaw gateway.






