diff --git a/websocket/openclaw/README.md b/websocket/openclaw/README.md new file mode 100644 index 0000000..06ae043 --- /dev/null +++ b/websocket/openclaw/README.md @@ -0,0 +1,66 @@ +# WebSocket example: OpenClaw Gateway simulation + +Simulates a subset of the [OpenClaw Gateway WebSocket protocol](https://docs.openclaw.ai/gateway/protocol) — enough for an operator client (e.g. the lucinate TUI) to connect, browse a single agent and session, and hold a simple chat: + +1. On connection, the server immediately sends a `connect.challenge` event and starts a periodic `tick` keepalive every 15 seconds. The `ts`/`timestamp` fields are numbers (epoch millis), matching the protocol's `int64` typing. +2. A `connect` request frame is answered with a `hello-ok` response (protocol 4), echoing the request's `id`. The hello snapshot advertises a `main` agent and its `main` session. +3. An `agents.list` request returns a single agent, `main` (also the default). +4. A `sessions.list` request returns one `main` session. +5. A `cron.list` request returns no jobs. +6. A `sessions.create` request returns the `main` session key (there is a single session, so create/resume always lands on `main`). +7. A `chat.history` request returns a short seed conversation, so the chat screen has something to render. Return `{"messages":[]}` for an empty history. +8. A `models.list` request returns a few models. The `main` session's current model (from `sessions.list`) is one of them, so the model picker highlights it. +9. A `sessions.patch` request (used to set the session model, thinking level, etc.) is acknowledged with an `ok` `res` frame — it is a void RPC, so no payload is needed. +10. A `chat.send` request is acknowledged with a `res` frame (`runId`/`status`), followed by streamed `agent` (tool start/result) and `chat` (`delta` then `final`) events with realistic delays. The `runId` is the request id and the `sessionKey` is echoed from the request, so the client routes the events to the active run. + +The resources use a wildcard path (`path: /*`), so the mock accepts a WebSocket connection on any path — mirroring the real OpenClaw gateway, which multiplexes on a single port and routes the upgrade by the `Upgrade: websocket` header rather than a specific URL path. Connect on whatever path your client uses (e.g. `/ws`). + +## Run + +```bash +imposter ./websocket/openclaw +``` + +## Try it + +Using [websocat](https://github.com/vi/websocat): + +```bash +websocat ws://localhost:8080/ws +``` + +You'll receive the challenge event immediately. Then paste a handshake: + +```json +{"type":"req","id":"req-1","method":"connect","params":{"minProtocol":3,"maxProtocol":4,"client":{"id":"cli","version":"0.1.0","platform":"go","mode":"cli"}}} +``` + +to receive `hello-ok`. List the agent, session and cron jobs: + +```json +{"type":"req","id":"req-2","method":"agents.list","params":{}} +{"type":"req","id":"req-3","method":"sessions.list","params":{"agentId":"main"}} +{"type":"req","id":"req-4","method":"cron.list","params":{"enabled":"all"}} +``` + +open the session and load its history: + +```json +{"type":"req","id":"req-5","method":"sessions.create","params":{"agentId":"main","key":"main"}} +{"type":"req","id":"req-6","method":"chat.history","params":{"sessionKey":"main","limit":50}} +``` + +list models and switch the session's model: + +```json +{"type":"req","id":"req-7","method":"models.list","params":{}} +{"type":"req","id":"req-8","method":"sessions.patch","params":{"key":"main","model":"claude-sonnet-5"}} +``` + +and send a chat message: + +```json +{"type":"req","id":"req-9","method":"chat.send","params":{"sessionKey":"main","message":"hello","idempotencyKey":"idem-1"}} +``` + +to receive an acknowledgement followed by streamed tool and chat events. Leave the connection open to observe the periodic `tick` events. diff --git a/websocket/openclaw/agent-tool-result.json b/websocket/openclaw/agent-tool-result.json new file mode 100644 index 0000000..788b137 --- /dev/null +++ b/websocket/openclaw/agent-tool-result.json @@ -0,0 +1,17 @@ +{ + "type": "event", + "event": "agent", + "payload": { + "runId": "${stores.request.reqId}", + "seq": 2, + "stream": "tool", + "ts": ${datetime.now.millis}, + "data": { + "phase": "result", + "name": "read_file", + "toolCallId": "tc-1", + "isError": false, + "result": { "bytes": 1024 } + } + } +} diff --git a/websocket/openclaw/agent-tool-start.json b/websocket/openclaw/agent-tool-start.json new file mode 100644 index 0000000..3494248 --- /dev/null +++ b/websocket/openclaw/agent-tool-start.json @@ -0,0 +1,16 @@ +{ + "type": "event", + "event": "agent", + "payload": { + "runId": "${stores.request.reqId}", + "seq": 1, + "stream": "tool", + "ts": ${datetime.now.millis}, + "data": { + "phase": "start", + "name": "read_file", + "toolCallId": "tc-1", + "args": { "path": "README.md" } + } + } +} diff --git a/websocket/openclaw/chat-event-delta.json b/websocket/openclaw/chat-event-delta.json new file mode 100644 index 0000000..cbc1f4c --- /dev/null +++ b/websocket/openclaw/chat-event-delta.json @@ -0,0 +1,11 @@ +{ + "type": "event", + "event": "chat", + "payload": { + "runId": "${stores.request.reqId}", + "sessionKey": "${stores.request.sessionKey}", + "seq": 1, + "state": "delta", + "message": "Here's the first part of the reply, " + } +} diff --git a/websocket/openclaw/chat-event-final.json b/websocket/openclaw/chat-event-final.json new file mode 100644 index 0000000..9f99490 --- /dev/null +++ b/websocket/openclaw/chat-event-final.json @@ -0,0 +1,17 @@ +{ + "type": "event", + "event": "chat", + "payload": { + "runId": "${stores.request.reqId}", + "sessionKey": "${stores.request.sessionKey}", + "seq": 2, + "state": "final", + "message": { + "role": "assistant", + "content": [ + { "type": "text", "text": "Here's the first part of the reply, and here is the rest." } + ] + }, + "stopReason": "end_turn" + } +} diff --git a/websocket/openclaw/chat-history.json b/websocket/openclaw/chat-history.json new file mode 100644 index 0000000..e77f476 --- /dev/null +++ b/websocket/openclaw/chat-history.json @@ -0,0 +1,21 @@ +{ + "type": "res", + "id": "${stores.request.reqId}", + "ok": true, + "payload": { + "messages": [ + { + "role": "user", + "content": "Hello!", + "timestamp": ${datetime.now.millis} + }, + { + "role": "assistant", + "content": [ + { "type": "text", "text": "Hi! I'm the mock main agent. Ask me anything." } + ], + "timestamp": ${datetime.now.millis} + } + ] + } +} diff --git a/websocket/openclaw/hello-ok.json b/websocket/openclaw/hello-ok.json new file mode 100644 index 0000000..3cae16b --- /dev/null +++ b/websocket/openclaw/hello-ok.json @@ -0,0 +1,37 @@ +{ + "type": "res", + "id": "${stores.request.reqId}", + "ok": true, + "payload": { + "type": "hello-ok", + "protocol": 4, + "server": { + "version": "mock", + "connId": "${random.uuid()}" + }, + "features": { + "methods": ["connect", "chat.send", "chat.history", "chat.abort"], + "events": ["connect.challenge", "tick", "chat", "agent"] + }, + "snapshot": { + "presence": [], + "health": {}, + "stateVersion": { "presence": 0, "health": 0 }, + "uptimeMs": 0, + "sessionDefaults": { + "defaultAgentId": "main", + "mainKey": "main", + "mainSessionKey": "main" + } + }, + "auth": { + "role": "operator", + "scopes": ["operator.read", "operator.write", "operator.admin", "operator.approvals"] + }, + "policy": { + "maxPayload": 26214400, + "maxBufferedBytes": 52428800, + "tickIntervalMs": 15000 + } + } +} diff --git a/websocket/openclaw/imposter-config.yaml b/websocket/openclaw/imposter-config.yaml new file mode 100644 index 0000000..9889098 --- /dev/null +++ b/websocket/openclaw/imposter-config.yaml @@ -0,0 +1,197 @@ +# Simulates a subset of the OpenClaw Gateway WebSocket API: +# - on connect, the server sends a connect.challenge event and starts a periodic tick +# - a 'connect' request is answered with hello-ok, echoing the request ID +# - a 'chat.send' request is answered with a res frame (runId/status), followed by +# streamed agent (tool) and chat (delta/final) events, echoing the run's session +# - 'agents.list', 'sessions.list' and 'cron.list' requests are answered with a +# single 'main' agent, one 'main' session, and no cron jobs respectively +# - 'sessions.create' returns the 'main' session key; 'chat.history' returns a +# short seed conversation so the chat screen has something to render +# - 'models.list' returns a few models; 'sessions.patch' acknowledges setting +# the session model (or other settings) +plugin: websocket + +resources: + # Connection opened: send the challenge and start the keepalive tick. + # The challenge 'ts' and tick 'timestamp' are numbers (epoch millis), not + # strings - the gateway protocol types them as int64. + - path: /* + on: open + response: + content: '{"type":"event","event":"connect.challenge","payload":{"nonce":"${random.uuid()}","ts":${datetime.now.millis}}}' + template: true + schedule: + - every: 15s + response: + content: '{"type":"event","event":"tick","payload":{"timestamp":${datetime.now.millis}}}' + template: true + + # Handshake request: reply with hello-ok, echoing the request ID + - path: /* + requestBody: + allOf: + - jsonPath: $.type + value: req + - jsonPath: $.method + value: connect + capture: + reqId: + requestBody: + jsonPath: $.id + response: + file: hello-ok.json + template: true + + # chat.send request: acknowledge with runId/status, then stream a tool + # agent event and chat delta/final events. runId is the request id; the + # session key is echoed from the request so the client routes the events. + - path: /* + requestBody: + allOf: + - jsonPath: $.type + value: req + - jsonPath: $.method + value: chat.send + capture: + reqId: + requestBody: + jsonPath: $.id + sessionKey: + requestBody: + jsonPath: $.params.sessionKey + responses: + - content: '{"type":"res","id":"${stores.request.reqId}","ok":true,"payload":{"runId":"${stores.request.reqId}","status":"started"}}' + template: true + - file: agent-tool-start.json + delay: + exact: 300 + template: true + - file: agent-tool-result.json + delay: + exact: 300 + template: true + - file: chat-event-delta.json + delay: + min: 200 + max: 600 + template: true + - file: chat-event-final.json + delay: + exact: 400 + template: true + + # agents.list: a single agent, 'main', which is also the default + - path: /* + requestBody: + allOf: + - jsonPath: $.type + value: req + - jsonPath: $.method + value: agents.list + capture: + reqId: + requestBody: + jsonPath: $.id + response: + content: '{"type":"res","id":"${stores.request.reqId}","ok":true,"payload":{"defaultId":"main","mainKey":"main","scope":"global","agents":[{"id":"main","name":"main"}]}}' + template: true + + # sessions.list: one 'main' session for the main agent + - path: /* + requestBody: + allOf: + - jsonPath: $.type + value: req + - jsonPath: $.method + value: sessions.list + capture: + reqId: + requestBody: + jsonPath: $.id + response: + content: '{"type":"res","id":"${stores.request.reqId}","ok":true,"payload":{"sessions":[{"key":"main","derivedTitle":"Main conversation","lastMessagePreview":"","updatedAt":${datetime.now.millis},"model":"claude-opus-4-8"}]}}' + template: true + + # cron.list: no scheduled jobs + - path: /* + requestBody: + allOf: + - jsonPath: $.type + value: req + - jsonPath: $.method + value: cron.list + capture: + reqId: + requestBody: + jsonPath: $.id + response: + content: '{"type":"res","id":"${stores.request.reqId}","ok":true,"payload":{"jobs":[],"total":0,"offset":0,"limit":50,"hasMore":false}}' + template: true + + # sessions.create: create or resume a session. There is a single session, + # so the created key is always 'main' - the client opens the chat on it. + - path: /* + requestBody: + allOf: + - jsonPath: $.type + value: req + - jsonPath: $.method + value: sessions.create + capture: + reqId: + requestBody: + jsonPath: $.id + response: + content: '{"type":"res","id":"${stores.request.reqId}","ok":true,"payload":{"key":"main"}}' + template: true + + # chat.history: prior messages for the session, so the chat screen renders + # a short seed conversation. Return {"messages":[]} for an empty history. + - path: /* + requestBody: + allOf: + - jsonPath: $.type + value: req + - jsonPath: $.method + value: chat.history + capture: + reqId: + requestBody: + jsonPath: $.id + response: + file: chat-history.json + template: true + + # models.list: the models the session can switch between. The 'main' session's + # current model (see sessions.list) is one of these, so the picker highlights it. + - path: /* + requestBody: + allOf: + - jsonPath: $.type + value: req + - jsonPath: $.method + value: models.list + capture: + reqId: + requestBody: + jsonPath: $.id + response: + file: models.json + template: true + + # sessions.patch: set the model (or other settings) for a session. This is a + # void RPC - the client only needs an ok:true acknowledgement. + - path: /* + requestBody: + allOf: + - jsonPath: $.type + value: req + - jsonPath: $.method + value: sessions.patch + capture: + reqId: + requestBody: + jsonPath: $.id + response: + content: '{"type":"res","id":"${stores.request.reqId}","ok":true}' + template: true diff --git a/websocket/openclaw/models.json b/websocket/openclaw/models.json new file mode 100644 index 0000000..76834e5 --- /dev/null +++ b/websocket/openclaw/models.json @@ -0,0 +1,12 @@ +{ + "type": "res", + "id": "${stores.request.reqId}", + "ok": true, + "payload": { + "models": [ + { "id": "claude-opus-4-8", "name": "Claude Opus 4.8", "provider": "anthropic", "contextWindow": 1000000, "reasoning": true }, + { "id": "claude-sonnet-5", "name": "Claude Sonnet 5", "provider": "anthropic", "contextWindow": 1000000, "reasoning": true }, + { "id": "claude-haiku-4-5", "name": "Claude Haiku 4.5", "provider": "anthropic", "contextWindow": 200000, "reasoning": false } + ] + } +}