@zf-tech/openclaw-websocket
v0.2.11
Published
OpenClaw WebSocket chat channel plugin - Connect your apps to OpenClaw via WebSocket
Downloads
440
Maintainers
Readme
openclaw-websocket
OpenClaw WebSocket channel plugin for talking to OpenClaw agents over a single JSON-over-WebSocket protocol.
This plugin is now WebSocket-only. Standalone HTTP SSE and SSE-style frames over WebSocket are not supported anymore.
README.md is the source of truth. README_CN.md may lag behind.
Features
- Real-time bidirectional chat over WebSocket
- Streaming replies with
chat.streamand final replies withchat.response - Optional external authentication on connection
- Direct-message access control with
open,allowlist,pairing, ordisabled - Optional dynamic agent creation for one-agent-per-user setups
- Optional Redis-backed session state with in-memory fallback
- Direct and group chat routing
- Reply quotes and media-path metadata pass-through to the agent context
Important Behavior
- The protocol is JSON over WebSocket only.
- Connection identity is fixed at connect time.
- If authentication succeeds, the connection identity comes from the auth service response.
chat.send.senderIdandchat.send.senderNameare accepted for backward compatibility but ignored.- Dynamic agent creation is disabled by default.
Requirements
- OpenClaw
2026.4.8or newer
Installation
# Install from npm
openclaw plugins install @zf-tech/openclaw-websocket
# Or install from GitHub
openclaw plugins install github:zf-tech/openclaw-websocketUpdating
Prefer OpenClaw's built-in update flow:
openclaw plugins update openclaw-websocketAvoid deleting the plugin directory manually before reinstalling. That can leave
you in a temporary invalid-config state where channels.websocket exists in
config but the channel plugin is not loaded yet.
Quick Start
Local Development Without Auth
For local testing, disable auth explicitly:
{
"channels": {
"websocket": {
"enabled": true,
"host": "0.0.0.0",
"port": 18800,
"path": "/ws",
"sessionRouting": {
"directMode": "per-connection"
},
"auth": {
"enabled": false,
"required": false
}
}
}
}Start OpenClaw:
openclaw startConnect with wscat:
npm install -g wscat
wscat -c "ws://127.0.0.1:18800/ws?senderId=user1&senderName=John"Then send:
{"type":"chat.send","content":"Hello!"}On a successful connection the server sends a welcome chat.response.
Production With Auth
Auth is enabled by default. A typical production config looks like this:
{
"channels": {
"websocket": {
"enabled": true,
"host": "0.0.0.0",
"port": 18800,
"path": "/ws",
"sessionRouting": {
"directMode": "per-connection"
},
"auth": {
"enabled": true,
"endpoint": "http://localhost:3000/api/auth/verify",
"timeout": 5000,
"required": true
}
}
}
}Connect with a token:
ws://127.0.0.1:18800/ws?token=YOUR_JWT_TOKENFull Configuration Example
{
"channels": {
"websocket": {
"enabled": true,
"host": "0.0.0.0",
"port": 18800,
"path": "/ws",
"dmPolicy": "open",
"allowFrom": [],
"auth": {
"enabled": true,
"endpoint": "http://localhost:3000/api/auth/verify",
"timeout": 5000,
"required": true
},
"stream": {
"partialDebounceMs": 80
},
"dynamicAgentCreation": {
"enabled": false,
"workspaceTemplate": "~/.openclaw/workspace-{agentId}",
"agentDirTemplate": "~/.openclaw/agents/{agentId}/agent",
"maxAgents": 100
},
"redis": {
"enabled": false,
"url": "redis://127.0.0.1:6379",
"keyPrefix": "openclaw:websocket",
"sessionTtlSeconds": 86400
},
"limits": {
"maxConnections": 1000,
"maxInboundMessageBytes": 2097152,
"maxWsPayloadBytes": 2097152,
"maxWsBufferedAmountBytes": 16777216,
"dropNonEssentialWhenBufferedOverBytes": 8388608,
"inboundRateLimit": {
"maxPerMinute": 600,
"burst": 60
}
}
}
}
}Configuration Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| enabled | boolean | true | Enable or disable the channel |
| host | string | "0.0.0.0" | Bind address |
| port | number | 18800 | WebSocket listen port |
| path | string | "/ws" | WebSocket endpoint path |
| dmPolicy | "open" \| "allowlist" \| "pairing" \| "disabled" | "open" | Direct-message access control |
| allowFrom | string[] | [] | Sender allowlist used by allowlist and pairing modes |
| auth.enabled | boolean | true | Enable external auth |
| auth.endpoint | string | "http://localhost:3000/api/auth/verify" | Auth service verify URL |
| auth.timeout | number | 5000 | Auth request timeout in milliseconds |
| auth.required | boolean | true | Reject missing or invalid tokens when true |
| stream.partialDebounceMs | number | 80 | Debounce partial stream chunks before sending chat.stream |
| dynamicAgentCreation.enabled | boolean | false | Auto-create a dedicated agent for an unmatched direct-message sender |
| dynamicAgentCreation.workspaceTemplate | string | "~/.openclaw/workspace-{agentId}" | Workspace path template when dynamic agents are enabled |
| dynamicAgentCreation.agentDirTemplate | string | "~/.openclaw/agents/{agentId}/agent" | Agent directory template when dynamic agents are enabled |
| dynamicAgentCreation.maxAgents | number | - | Hard cap for auto-created agents |
| redis.enabled | boolean | false | Enable Redis-backed session state |
| redis.url | string | "redis://127.0.0.1:6379" | Redis connection URL |
| redis.keyPrefix | string | "openclaw:websocket" | Redis key prefix |
| redis.sessionTtlSeconds | number | 86400 | Session retention TTL |
| limits.maxConnections | number | - | Maximum concurrent WebSocket connections |
| limits.maxInboundMessageBytes | number | 2097152 | Maximum inbound message size in bytes |
| limits.maxWsPayloadBytes | number | 2097152 | ws server maxPayload limit |
| limits.maxWsBufferedAmountBytes | number | 16777216 | Hard outbound buffer limit; connection closes when exceeded |
| limits.dropNonEssentialWhenBufferedOverBytes | number | 8388608 | Drop chat.typing and chat.stream when the buffer is above this threshold |
| limits.inboundRateLimit.maxPerMinute | number | 600 | Token refill rate per minute |
| limits.inboundRateLimit.burst | number | 60 | Token bucket burst capacity |
Authentication
The plugin calls auth.endpoint once per connection and sends all WebSocket query parameters as JSON.
If the client connects to:
ws://127.0.0.1:18800/ws?token=abc123&tokenType=local&senderId=ignored-when-auth-succeedsThe auth service receives:
{
"token": "abc123",
"tokenType": "local",
"senderId": "ignored-when-auth-succeeds"
}If tokenType is omitted, the plugin adds "tokenType": "local".
Expected auth response:
{
"success": true,
"data": {
"userId": "user_123",
"username": "John"
}
}Behavior:
- When auth succeeds,
userIdandusernamebecome the connection identity. - When
auth.requiredistrue, missing or invalid tokens close the connection. - When
auth.requiredisfalse, missing or invalid tokens fall back to an anonymous connection.
Environment Variables
The plugin currently supports these auth overrides:
| Variable | Description |
|----------|-------------|
| WS_AUTH_ENABLED | Override auth.enabled with true / false / 1 / 0 |
| WS_AUTH_ENDPOINT | Override auth.endpoint |
Example:
WS_AUTH_ENABLED=true \
WS_AUTH_ENDPOINT=http://auth-service:3000/api/auth/verify \
openclaw startConnection Identity
Identity is set once, at connection time.
- With successful auth: identity comes from the auth response.
- Without successful auth: identity comes from URL query params
senderIdandsenderName. - If auth is disabled and
senderIdis omitted, the server generates a random connection ID, so reconnects will not resume the same direct-message session.
Legacy inbound fields senderId, senderName, and connectionId are accepted for backward compatibility, but they do not change identity. sessionKey is now a first-class routing input when provided.
Direct-Message Access Control
dmPolicy applies to direct messages only. Group messages still route by groupId.
open: allow all direct messagesallowlist: allow only senders inallowFromor in the pairing approval storepairing: unknown senders receive a pairing code and their original message is not processeddisabled: block all direct messages
Matching rules:
- Allowlist entries are exact sender IDs.
*matches any sender.- When auth succeeds, the sender ID used for policy checks is the authenticated
userId.
Pairing Workflow
When dmPolicy is pairing, the plugin uses OpenClaw's standard pairing store for the websocket channel.
List pending requests:
openclaw pairing list websocketApprove a code:
openclaw pairing approve websocket <CODE>Once approved, that sender can chat without receiving another pairing prompt.
Dynamic Agent Creation
When enabled, unmatched direct-message senders can get their own dedicated agent and workspace.
{
"channels": {
"websocket": {
"dynamicAgentCreation": {
"enabled": true,
"workspaceTemplate": "~/.openclaw/workspace-{agentId}",
"agentDirTemplate": "~/.openclaw/agents/{agentId}/agent",
"maxAgents": 100
}
}
}
}Template variables:
{agentId}: generated stable agent ID prefixed withws-{userId}: filesystem-safe sender ID token{userIdSafe}: same as{userId}; kept as an explicit alias
Notes:
- Unsafe characters in sender IDs are sanitized before they are used in file paths.
- A short hash is added when needed to avoid collisions.
- Raw sender IDs are still used for routing and bindings; sanitized values are only for agent IDs and filesystem paths.
Session Behavior
- Direct-message sessions are keyed by the resolved sender identity.
- You can switch direct-message routing to per-connection mode with
channels.websocket.sessionRouting.directMode = "per-connection". - Group sessions are keyed by
groupId. - One user's direct-message history is separate from another user's.
- Group messages in the same
groupIdshare the same session route. - If a message includes
sessionKey, that session key overrides the default sender/group-derived route for this WebSocket conversation.
Message Protocol
WebSocket URL Parameters
| Parameter | Required | Description |
|-----------|----------|-------------|
| token | No | Auth token; required when auth.enabled=true and auth.required=true |
| tokenType | No | Optional auth hint; defaults to local |
| senderId | No | Connection identity when auth does not provide one |
| senderName | No | Connection display name when auth does not provide one |
Send Messages
{
"type": "chat.send",
"messageId": "msg_001",
"sessionKey": "agent:cpq-agent:websocket:direct:chat-tab-1",
"content": "Hello!",
"chatType": "direct",
"replyToBody": "Previous message text",
"mediaPath": "/path/to/file.png",
"mediaType": "image/png",
"mediaPaths": ["/path/a.png", "/path/b.png"],
"mediaTypes": ["image/png", "image/png"],
"customData": {
"priority": "high",
"source": "api"
}
}Fields:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| type | string | Yes | Must be "chat.send" |
| messageId | string | No | Client-supplied message ID; auto-generated if omitted |
| sessionKey | string | No | Explicit OpenClaw session key used for routing this conversation |
| content | string | Yes | Non-empty message text |
| chatType | "direct" \| "group" | No | Defaults to "direct" |
| groupId | string | Group only | Required when chatType="group" |
| groupSubject | string | No | Human-readable group label |
| replyToMessageId | string | No | Reply target ID |
| replyToBody | string | No | Reply quote text included in the agent envelope |
| mediaPath | string | No | Single media path forwarded to the agent context |
| mediaType | string | No | MIME type for mediaPath |
| mediaPaths | string[] | No | Multiple media paths forwarded to the agent context |
| mediaTypes | string[] | No | MIME types for mediaPaths |
| customData | object | No | Custom context data forwarded as CustomData; non-reserved keys are also preserved for compatibility |
Notes:
- Message-level identity overrides are ignored.
sessionKeychanges routing, but does not change sender identity.- If you do not send
sessionKey, direct-message routing still followssessionRouting.directMode. - Group messages without
groupIdare rejected. customDatacannot overwrite reserved system context fields.- Media fields are pass-through metadata and file-path hints only; this plugin does not upload or host media blobs.
Receive Messages
// Typing indicator
{ "type": "chat.typing" }
// Partial streaming chunk
{ "type": "chat.stream", "messageId": "msg_001", "content": "Partial...", "done": false }
// Final reply
{ "type": "chat.response", "messageId": "msg_001", "content": "Complete reply", "done": true }
// Error
{ "type": "chat.error", "messageId": "msg_001", "error": "Error message" }Response types:
| Type | Description |
|------|-------------|
| chat.typing | Agent reply has started |
| chat.stream | Partial streaming content |
| chat.response | Final or informational response |
| chat.error | Validation, access-control, auth, or processing error |
Example Flows
Direct Message
wscat -c "ws://127.0.0.1:18800/ws?senderId=alice&senderName=Alice"{"type":"chat.send","content":"My name is Alice"}
{"type":"chat.send","content":"What is my name?"}Group Message
{"type":"chat.send","content":"Hello team","chatType":"group","groupId":"dev-team","groupSubject":"Dev Team"}Pairing-Protected DM
{"type":"chat.send","content":"Hello"}If dmPolicy is pairing and the sender is unknown, the plugin replies with a pairing code and does not process the original message until the code is approved.
Code Examples
JavaScript / Node.js
const WebSocket = require("ws");
const ws = new WebSocket("ws://127.0.0.1:18800/ws?senderId=user1&senderName=John");
ws.on("open", () => {
console.log("Connected");
ws.send(JSON.stringify({
type: "chat.send",
content: "Hello, how are you?"
}));
});
ws.on("message", (data) => {
const msg = JSON.parse(data.toString());
switch (msg.type) {
case "chat.typing":
console.log("Agent is typing...");
break;
case "chat.stream":
process.stdout.write(msg.content ?? "");
break;
case "chat.response":
console.log("\nAgent:", msg.content);
break;
case "chat.error":
console.error("Error:", msg.error);
break;
}
});Python
import json
import websocket
def on_message(ws, message):
msg = json.loads(message)
if msg["type"] == "chat.typing":
print("Agent is typing...")
elif msg["type"] == "chat.stream":
print(msg.get("content", ""), end="")
elif msg["type"] == "chat.response":
print("\nAgent:", msg.get("content"))
elif msg["type"] == "chat.error":
print("Error:", msg.get("error"))
def on_open(ws):
ws.send(json.dumps({
"type": "chat.send",
"content": "Hello!"
}))
ws = websocket.WebSocketApp(
"ws://127.0.0.1:18800/ws?senderId=user1&senderName=John",
on_open=on_open,
on_message=on_message,
)
ws.run_forever()Redis Session Store
If Redis is enabled, session state is written to Redis with the configured TTL. If Redis cannot be reached, the plugin logs the error and falls back to in-memory storage.
Migration Notes
If you are upgrading from an older build of this plugin:
- Remove
stream.mode - Remove
sse.* - Remove
limits.maxSseBodyBytes - Remove
limits.maxSseBufferedAmountBytes - Update clients to consume plain JSON WebSocket messages only
- Stop relying on message-level
senderIdandsenderNameoverrides - If you use dynamic agent creation, note that it is now disabled by default
Contributing
Pull requests are welcome.
