const GW_URL = process.env.GW_WSS === 'true' ? 'wss://127.0.0.1:18789' : 'ws://127.0.0.1:18789';
const GW_TOKEN = process.env.OPENCLAW_TOKEN || autoLoadedFromOpenclawConfig;Auto-load token: reads ~/.openclaw/openclaw.json → gateway.auth.token.
TLS toggle: Set GW_WSS=true to use wss:// instead of ws://.
Origin header: Derived from GW_URL — ws:// → http://, wss:// → https://.
On WebSocket open, gateway sends a connect.challenge event:
{ "type": "event", "event": "connect.challenge", "payload": { "nonce": "...", "ts": 1234567890 } }Device identity is loaded from ~/.openclaw/identity/device.json and device-auth.json at startup.
loadDeviceIdentity() — reads and validates:
device.json→deviceId,publicKeyPem,privateKeyPemdevice-auth.json→tokens.operator.token(used asoperatorToken)
If identity files are missing or fields incomplete, deviceIdentity is null and auth falls back to token-only — the UI connects using just auth: { token: GW_TOKEN } without device signing. A warning is logged: "No device identity available, using token-only auth". Token-only auth requires gateway.remote.token in openclaw.json to match gateway.auth.token.
Payload construction (buildDeviceAuthPayloadV3()):
const payload = [
'v3', // protocol version
deviceId, // from device.json
'openclaw-control-ui', // clientId
'webchat', // clientMode
'operator', // role
scopesStr, // comma-joined scope list
String(signedAtMs), // Date.now() at signing time
operatorToken, // from device-auth.json
nonce, // from challenge
process.platform, // runtime OS (e.g. 'linux', 'darwin')
'' // deviceFamily (currently empty)
].join('|');Signing (signDevicePayload()):
- Creates
crypto.createPrivateKey(privateKeyPem) - Signs the
v3|...payload bytes with Ed25519 - Returns base64url-encoded signature
Public key extraction (publicKeyRawBase64UrlFromPem()):
- Creates
crypto.createPublicKey(publicKeyPem) - Exports SPKI DER format
- Strips the 10-byte ED25519 OID prefix (
302a300506032b6570032100) - Returns raw 32-byte public key as base64url
{
"type": "req",
"id": "connectRespId",
"method": "connect",
"params": {
"minProtocol": 4,
"maxProtocol": 4,
"client": {
"id": "openclaw-control-ui",
"version": "1.0.0",
"platform": "linux",
"mode": "webchat"
},
"scopes": [
"operator.read", "operator.write", "operator.admin",
"sessions.subscribe", "sessions.unsubscribe",
"sessions.list", "sessions.history",
"sessions.send", "sessions.reset", "sessions.create"
],
"caps": ["tool-events", "llm-events"],
"auth": {
"token": "GW_TOKEN_VALUE"
},
"role": "operator",
"userAgent": "miniclaw-ui/1.0"
}
}When device identity IS available, the following additional fields are included:
{
"auth": {
"token": "GW_TOKEN_VALUE",
"deviceToken": "operatorToken"
},
"device": {
"id": "device_uuid",
"publicKey": "base64url_raw_ed25519_key",
"signature": "base64url_ed25519_sig",
"signedAt": 1700000000000,
"nonce": "challenge_nonce"
}
}Key fields:
minProtocol/maxProtocol: 4 (not 3)client.platform:process.platform— runtime OS value (linux,darwin, etc.)client.mode:webchatauth.token:GW_TOKENenv var or auto-loaded fromopenclaw.json(always sent)auth.deviceToken: only sent whendeviceIdentityis non-null (set tooperatorTokenfrom device-auth.json)role:'operator'— top-level param, not nested inauthdevice: only sent whendeviceIdentityis non-null; includesid,publicKey,signature,signedAt,nonce
reswith matchingidwheremsg.ok === true→ auth successful- Sets
gwReady = true, broadcastsgateway.connectedto all browser clients - Clears
deletedSessionstracking on fresh connection
After auth success (100ms and 200ms delays respectively):
- Send
sessions.subscriberequest - Send
sessions.listrequest → receive array of session summaries
| Event | Description | Key Payload Fields |
|---|---|---|
session.tool |
Tool/LLM lifecycle | sessionKey, runId, stream, data.{phase,name,args,result,error} |
session.message |
User/assistant messages | sessionKey, message.{role,content} |
agent.turn |
Agent turn events | sessionKey, role, content |
sessions.tokens |
Token updates | sessionKey, tokens.{inputTokens,outputTokens,totalTokens,...} |
agent |
Agent events (v2026.5.28+) | sessionKey, runId, stream, data — tool events directed to toolEventRecipients |
sessions.changed |
Session create/reset/end | sessionKey, reason (create/send/steer/deleted), enriched with full session row data |
connect.challenge |
Auth challenge | nonce, ts |
| Stream | Phases | Meaning |
|---|---|---|
tool |
start, done, result, update |
Tool execution |
lifecycle |
start, end, error |
LLM run |
assistant |
— | Assistant text delta |
thinking |
— | Thinking/reasoning delta |
user |
— | User text delta (SILENCED — never stored) |
⚠ User stream echo: Gateway echoes user text via
session.toolwithstream='user'. This is intentionally dropped (return nullinconvertToFrontendEvent). The canonical user text is stored when the browser sends achatmessage, not from gateway echoes.
When sessions.list response arrives:
- Gateway sessions not in local memory → removed from memory + disk
- Sessions explicitly deleted by user (
deletedSessionsSet) are skipped even if still in gateway response - For each remaining session,
calculateActualContext()computes context tokens from local events (sum of text lengths / 4), preferring local calculation over gateway's value
{ "type": "chat", "sessionKey": "agent:main:main", "message": "Hello" }- Server creates a
chatReqId(random 8-char base36) - Records
chatReqId → { ws, sessionKey }inchatRequestsMap - Creates canonical
user_textevent viasession.addEvent() - Forwards to gateway:
sessions.sendwith{ key, message } - Sends immediate
chat_ackto browser:
{ "type": "chat_ack", "ok": true, "sessionKey": "agent:main:main", "ts": 1700000000000 }When gateway responds with matching id:
- Looks up
chatRequestsentry - Sends
chat_deliveredto the originating browser client:
{ "type": "chat_delivered", "ok": true, "sessionKey": "agent:main:main", "ts": 1700000000000 }- Removes entry from
chatRequests
chatRequests is keyed by request ID (not session key), allowing multiple concurrent chats from different browser clients to be routed correctly without races.
On close event:
- Sets
gwReady = false - Clears
connectResponseIdandpendingSubId - Broadcasts
gateway.disconnectedevent with{ code, reason } - Retries connection in 3 seconds (
setTimeout(connectGateway, 3000))
operator.read, operator.write, operator.admin,
sessions.subscribe, sessions.unsubscribe,
sessions.list, sessions.history,
sessions.send, sessions.reset, sessions.create
| Method | Params | Description |
|---|---|---|
connect |
{ minProtocol, maxProtocol, client, scopes, caps, auth, role, device } |
Authenticate and connect |
sessions.subscribe |
{} |
Subscribe to session events |
sessions.list |
{} |
List all gateway sessions |
sessions.send |
{ key, message } |
Send chat message |
sessions.create |
{ key } |
Create new session |
sessions.reset |
{ key, reason } |
Reset session (clear events) |
sessions.abort |
{ key } |
Abort running LLM |
sessions.delete |
{ key } |
Delete session permanently |
- index.md — Architecture overview
- session-management.md — Session lifecycle
- message-processing.md — Event conversion
- authentication.md — UI auth
- file-sharing.md — File share system