Openclaw Nodes are powerful Openclaw peripherals, but difficult to build.
This package is a javascript SDK that makes it easy to build custom openclaw nodes. In addition, the package also includes a plugin that automatically discovers and routes tool invocations to authenticated nodes.
Openclaw nodes provide a secure, persistent bridge between external devices/robots and the Openclaw gateway.
Openclaw nodes can be deployed either on separate devices or robots, or on the same machine as the Openclaw gateway, where they act as a proxy for remote devices and robots.
-
Openclaw vs MCP server
A frequently asked question is: What is the difference between OpenClaw nodes and MCP servers?
Feature OpenClaw Node MCP Server Protocol Proprietary WebSocket Handshake Standardized JSON-RPC (Stdio or HTTP) Trust Model Device Pairing: Requires explicit manual approval/code Configuration: Pre-configured via a file or direct command Identity Persistent (Device ID / Name) Ephemeral (Session-based) Transport Network-first (WebSocket) Local-first (Stdio) or Network (HTTP) For real-time, bi-directional hardware control, OpenClaw nodes are the ideal solution.
For heavy-lift, easily crashed jobs like 3D object generation, MCP servers are the best bet. In addition, it’s better to run these jobs in a Docker sandbox.
-
Custom http/websocket server vs MCP server
Another frequently asked question is: Can we use custom http or websocker server to replace MCP server?
In most cases, you can replace MCP servers with custom http/websocket servers. However, if you want your servers to serve Openclaw, Claude code, and other agents, the MCP server is the best bet. Therefore, even though you can use custom http/websocket servers, MCP servers are more preferred.
-
Openclaw-node-package vs Openclaw-node
This package,
openclaw-node-package, is inspired byopenclaw-node. However, their use cases are different.Suppose you want to implement a robot game powered by Openclaw: game engine -> openclaw gateway -> physical robots.
To integrate the openclaw gateway with the game engine, you use
openclaw-node.To bridge the OpenClaw gateway to physical robots, you use this package,
openclaw-node-package.
openclaw-node-package/
├── src/
│ ├── node/
│ │ ├── index.ts # Node factory function (createNode)
│ │ └── cli.ts # CLI entry point for standalone nodes
│ └── plugin/
│ └── index.ts # Plugin factory function (createNodePlugin)
├── dist/ # Compiled output (ESM, CJS, types)
│ ├── node/
│ └── plugin/
├── examples/
│ ├── robot_node/ # Example robot node implementation
│ ├── robot_plugin/ # Example robot plugin implementation
│ └── example.md # Step-by-step example guide
├── package.json
├── tsconfig.json
└── README.md
┌─────────────────────────────────────────────────────────────────────────┐
│ OpenClaw Gateway │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────────┐ │
│ │ Agent │───▶│ Tool │───▶│ Plugin (tool registration)│ │
│ │ (AI/CLI) │ │ Invocation │ │ │ │
│ └─────────────┘ └─────────────┘ └──────────────┬──────────────┘ │
│ │ │
│ │ invokeNode() │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ Node (WebSocket) │ │
│ │ - Receives commands │ │
│ │ - Executes work │ │
│ │ - Returns results │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│
│ WebSocket
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Worker Node Process │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────┐ │
│ │ WebSocket │───▶│ Command │───▶│ Actual Work │ │
│ │ Connection │ │ Handler │ │ (Docker/Hardware) │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
- Agent calls tool (e.g.,
robot_move) - Plugin receives the call and validates parameters
- Plugin calls
api.invokeNode()to delegate to the node - Gateway routes the request to the connected node via WebSocket
- Node receives the command, executes the work
- Node sends result back to gateway
- Plugin receives result and formats it for the agent
In this setup, both the node and plugin run on the same machine as OpenClaw.
~/.openclaw/
├── nodes/
│ └── my_node/
│ ├── index.ts # Combined node + plugin code
│ ├── openclaw.plugin.json # Plugin manifest
│ └── package.json
├── plugins/
│ └── (optional - can use nodes/ version)
└── gateway/ # OpenClaw gateway runs here
Create nodes/my_node/index.ts:
#!/usr/bin/env node
import { createNode } from "openclaw-node-package/node";
import { createNodePlugin } from "openclaw-node-package/plugin";
import { Type } from "@sinclair/typebox";
// ============ NODE IMPLEMENTATION ============
async function executeWork(params: any) {
// Your actual implementation here
console.log("Executing work:", params);
return { success: true, result: "Work completed" };
}
export function startNode() {
const token = process.env.OPENCLAW_GATEWAY_TOKEN;
if (!token) {
throw new Error("OPENCLAW_GATEWAY_TOKEN required");
}
const node = createNode({
token,
name: "my-node",
commands: ["mycommand.execute"],
capabilities: ["my-capability"],
onExecute: async (command, payload) => {
if (command === "mycommand.execute") {
return executeWork(payload);
}
throw new Error(`Unknown command: ${command}`);
},
});
node.on("connected", () => {
console.log("Node connected to gateway");
});
return node.connect();
}
// ============ PLUGIN IMPLEMENTATION ============
export const plugin = createNodePlugin({
nodeId: "my-node",
tools: [
{
name: "my_tool",
label: "My Tool",
description: "Does something useful",
parameters: {
input: Type.String({ description: "Input parameter" }),
},
command: "mycommand.execute",
timeout: 30000,
},
],
});
// ============ CLI ENTRY ============
if (import.meta.url === `file://${process.argv[1]}`) {
startNode().catch(console.error);
}Create nodes/my_node/openclaw.plugin.json:
{
"id": "my_node",
"name": "My Node Plugin",
"version": "1.0.0",
"entry": "index.ts",
"configSchema": {
"type": "object",
"properties": {},
"required": []
}
}Important: The id field must match the directory name exactly. The configSchema field is required.
Create nodes/my_node/package.json:
{
"name": "my-node",
"version": "1.0.0",
"type": "module",
"main": "index.ts",
"openclaw": {
"extensions": ["./index.ts"]
},
"dependencies": {
"openclaw-node-package": "workspace:*",
"@sinclair/typebox": "^0.32.0"
},
"devDependencies": {
"typescript": "^5.6.0"
}
}Important: The openclaw.extensions field is required for OpenClaw to discover and load TypeScript plugins.
If developing within the OpenClaw workspace, add your package to pnpm-workspace.yaml:
packages:
- '.'
- 'plugins/*'
- 'nodes/*'
- 'packages/*'
- 'nodes/my_node' # Add your node hereThen install dependencies:
cd ~/.openclaw
pnpm installAdd to ~/.openclaw/openclaw.json:
{
"plugins": {
"load": ["my_node"]
}
}Terminal 1 - Start the node:
cd ~/.openclaw/nodes/my_node
OPENCLAW_GATEWAY_TOKEN=<token> npx tsx index.tsTerminal 2 - OpenClaw gateway will automatically load the plugin.
In this setup, the node runs on a physical robot (or remote machine), while the plugin runs with OpenClaw.
┌─────────────────────┐ WebSocket ┌─────────────────────┐
│ Physical Robot │ ◄──────────────────────► │ OpenClaw Host │
│ │ │ │
│ ┌───────────────┐ │ │ ┌───────────────┐ │
│ │ Robot Node │ │ │ │ Robot Plugin │ │
│ │ - Hardware │ │ │ │ - Tool reg │ │
│ │ control │ │ │ │ - Delegation │ │
│ │ - Sensors │ │ │ └───────────────┘ │
│ └───────────────┘ │ │ │
└─────────────────────┘ └─────────────────────┘
192.168.1.100 192.168.1.10
Directory structure on robot:
~/robot-controller/
├── package.json
├── tsconfig.json
└── src/
└── robot-node.ts
src/robot-node.ts:
import { createNode } from "openclaw-node-package/node";
// Hardware control imports
import { RobotArm } from "./hardware/arm";
import { Gripper } from "./hardware/gripper";
const arm = new RobotArm();
const gripper = new Gripper();
const node = createNode({
token: process.env.OPENCLAW_GATEWAY_TOKEN!,
gatewayUrl: process.env.OPENCLAW_GATEWAY_URL || "ws://openclaw-host:18789",
name: "physical-robot",
commands: ["robot.move", "robot.grab", "robot.status"],
capabilities: ["robot-arm", "gripper", "sensors"],
onExecute: async (command, payload: any) => {
switch (command) {
case "robot.move":
await arm.moveTo(payload.x, payload.y, payload.z);
return { success: true, position: await arm.getPosition() };
case "robot.grab":
if (payload.action === "grab") {
await gripper.close(payload.force);
} else {
await gripper.open();
}
return { success: true, state: gripper.getState() };
case "robot.status":
return {
success: true,
arm: await arm.getStatus(),
gripper: gripper.getState(),
timestamp: Date.now(),
};
default:
throw new Error(`Unknown command: ${command}`);
}
},
});
node.on("connected", () => {
console.log("Robot connected to OpenClaw gateway");
});
node.on("error", (err) => {
console.error("Robot node error:", err.message);
});
await node.connect();package.json:
{
"name": "robot-controller",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "tsx src/robot-node.ts"
},
"dependencies": {
"openclaw-node-package": "^1.0.0"
},
"devDependencies": {
"tsx": "^4.0.0",
"typescript": "^5.6.0"
}
}Run on robot:
export OPENCLAW_GATEWAY_URL="ws://192.168.1.10:18789"
export OPENCLAW_GATEWAY_TOKEN="<token-from-openclaw>"
npm startDirectory structure:
~/.openclaw/plugins/robot_remote_plugin/
├── openclaw.plugin.json
├── package.json
└── src/
└── index.ts
src/index.ts:
import { createNodePlugin } from "openclaw-node-package/plugin";
import { Type } from "@sinclair/typebox";
export default createNodePlugin({
nodeId: "physical-robot", // Must match node name
tools: [
{
name: "robot_move",
label: "Move Robot Arm",
description: "Moves the robot arm to specified coordinates",
parameters: {
x: Type.Number({ description: "X coordinate (mm)" }),
y: Type.Number({ description: "Y coordinate (mm)" }),
z: Type.Number({ description: "Z coordinate (mm)" }),
speed: Type.Optional(Type.Number({ default: 50 })),
},
command: "robot.move",
timeout: 60000,
},
{
name: "robot_grab",
label: "Control Gripper",
description: "Controls the robot gripper",
parameters: {
action: Type.String({ enum: ["grab", "release"] }),
force: Type.Optional(Type.Number({ default: 50 })),
},
command: "robot.grab",
timeout: 10000,
},
{
name: "robot_status",
label: "Get Robot Status",
description: "Retrieves current robot status and sensor readings",
parameters: {},
command: "robot.status",
timeout: 5000,
},
],
});openclaw.plugin.json:
{
"id": "robot_remote_plugin",
"name": "Remote Robot Controller",
"version": "1.0.0",
"entry": "src/index.ts",
"configSchema": {
"type": "object",
"properties": {},
"required": []
}
}Important: The id must match the directory name, and configSchema is required.
package.json:
{
"name": "robot-remote-plugin",
"version": "1.0.0",
"type": "module",
"main": "src/index.ts",
"openclaw": {
"extensions": ["./src/index.ts"]
},
"dependencies": {
"openclaw-node-package": "workspace:*",
"@sinclair/typebox": "^0.32.0"
}
}Important: The openclaw.extensions field is required for plugin discovery.
Configure OpenClaw (~/.openclaw/openclaw.json):
{
"plugins": {
"load": ["robot_remote_plugin"]
}
}
import { createNode } from "openclaw-node-package/node";
const node = createNode({
// Required
token: process.env.OPENCLAW_GATEWAY_TOKEN!, // Gateway auth token
// Optional
gatewayUrl: "ws://localhost:18789", // Gateway WebSocket URL
name: "my-node", // Node identifier
commands: ["cmd1", "cmd2"], // Commands this node handles
capabilities: ["cap1", "cap2"], // Capabilities advertised
identityPath: "/custom/path/device.json", // Custom device identity path
autoReconnect: true, // Reconnect on disconnect
maxReconnectAttempts: 10, // Max reconnection tries
// Handler for command execution
onExecute: async (command, payload) => {
// Return any serializable object
return { success: true, data: "result" };
},
});import { createNodePlugin } from "openclaw-node-package/plugin";
import { Type } from "@sinclair/typebox";
export default createNodePlugin({
// Required
nodeId: "my-node", // Must match the node's name
// Tool definitions
tools: [
{
name: "tool_name", // Tool identifier (snake_case)
label: "Tool Label", // Display name
description: "What it does", // For AI context
parameters: { // JSON Schema (TypeBox)
param1: Type.String(),
param2: Type.Number(),
},
command: "cmd1", // Node command to invoke
timeout: 30000, // Execution timeout (ms)
},
],
});The node emits these events:
node.on("connected", (info) => {
console.log(`Connected (protocol v${info.protocol})`);
console.log(`Available methods:`, info.methods);
});
node.on("disconnected", (reason) => {
console.log(`Disconnected: ${reason.code} ${reason.message}`);
});
node.on("error", (err) => {
console.error("Node error:", err.message);
});
node.on("reconnecting", (attempt, max) => {
console.log(`Reconnecting (${attempt}/${max})...`);
});
In a pnpm workspace, node_modules are stored in two places:
-
Centralized - All packages share dependencies from the workspace root:
~/.openclaw/node_modules/ # Root workspace dependencies -
Local - Each package has its own
node_modulesfor package-specific links:~/.openclaw/nodes/my_node/node_modules/ # Local to node ~/.openclaw/plugins/my_plugin/node_modules/ # Local to plugin
For workspace packages (recommended for development):
# From workspace root
cd ~/.openclaw
# Install all workspace dependencies
pnpm install
# Add dependency to a specific package
pnpm add --filter my-node some-package
# Add dev dependency
pnpm add -D --filter my-node typescriptFor standalone deployment (robot or production):
# On the robot machine
cd ~/robot-controller
npm install openclaw-node-package
npm installBuild the openclaw-node-package itself:
cd ~/.openclaw/packages/openclaw-node-package
pnpm run buildThis creates:
dist/node/- Node exports (ESM, CJS, types)dist/plugin/- Plugin exports (ESM, CJS, types)
Build your custom node/plugin:
If using TypeScript directly (with tsx):
# No build needed - tsx compiles on-the-fly
npx tsx index.tsIf building for distribution:
cd nodes/my_node
npx tscDevelopment (with tsx):
OPENCLAW_GATEWAY_TOKEN=<token> npx tsx index.tsProduction (compiled):
OPENCLAW_GATEWAY_TOKEN=<token> node dist/index.js
| Variable | Required | Description |
|---|---|---|
OPENCLAW_GATEWAY_TOKEN |
Yes | Authentication token from OpenClaw |
OPENCLAW_GATEWAY_URL |
No | WebSocket URL (default: ws://localhost:18789) |
NODE_NAME |
No | Override node name |
NODE_COMMANDS |
No | Comma-separated list of commands |
- Check
OPENCLAW_GATEWAY_TOKENis valid - Verify
OPENCLAW_GATEWAY_URLis reachable - Check firewall rules for WebSocket port (18789)
- Ensure device identity exists at
~/.openclaw/identity/device.json - Run
openclaw gateway run --token <token>to start gateway with token auth
- Ensure
nodeIdin plugin matches node'snameparameter - Verify node is connected to gateway (check for "Connected to gateway as node" message)
- Check gateway logs for connection errors
- Note: Nodes are identified by their device ID, not their name. Use
openclaw nodes listto see connected nodes
- Plugin
idinopenclaw.plugin.jsonmust match directory name exactly configSchemafield is required in plugin manifest (even if empty)openclaw.extensionsfield is required inpackage.json- Add plugin to
plugins.allowandplugins.entriesinopenclaw.json - Check gateway logs for plugin load errors
- Ensure
type: "module"in package.json - Use
.jsextensions in imports (NodeNext resolution) - Install
@types/nodeas dev dependency - Build packages with
pnpm run buildbefore running
- Use
pnpm install, notnpm install(workspace: protocol requires pnpm) - Add example/node directories to
pnpm-workspace.yamlif they use workspace: dependencies - Run
pnpm installfrom workspace root after adding new packages
Here is an example with a step-by-step guide to illustrate how to install and use the openclaw-node-package in a mock distributed deployment.
MIT