Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions websocket/openclaw/README.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions websocket/openclaw/agent-tool-result.json
Original file line number Diff line number Diff line change
@@ -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 }
}
}
}
16 changes: 16 additions & 0 deletions websocket/openclaw/agent-tool-start.json
Original file line number Diff line number Diff line change
@@ -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" }
}
}
}
11 changes: 11 additions & 0 deletions websocket/openclaw/chat-event-delta.json
Original file line number Diff line number Diff line change
@@ -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, "
}
}
17 changes: 17 additions & 0 deletions websocket/openclaw/chat-event-final.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
21 changes: 21 additions & 0 deletions websocket/openclaw/chat-history.json
Original file line number Diff line number Diff line change
@@ -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}
}
]
}
}
37 changes: 37 additions & 0 deletions websocket/openclaw/hello-ok.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
197 changes: 197 additions & 0 deletions websocket/openclaw/imposter-config.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading