返回 JoyAI-Echo
websocket.md
1 # WebSocket Server Channel
2
3 Nanobot can act as a WebSocket server, allowing external clients (web apps, CLIs, scripts) to interact with the agent in real time via persistent connections.
4
5 ## Features
6
7 - Bidirectional real-time communication over WebSocket
8 - Streaming support — receive agent responses token by token
9 - Token-based authentication (static tokens and short-lived issued tokens)
10 - Multi-chat multiplexing — one connection can run many concurrent `chat_id`s
11 - TLS/SSL support (WSS) with enforced TLSv1.2 minimum
12 - Client allow-list via `allowFrom`
13 - Auto-cleanup of dead connections
14
15 ## Quick Start
16
17 ### 1. Configure
18
19 Add to `config.json` under `channels.websocket`:
20
21 ```json
22 {
23 "channels": {
24 "websocket": {
25 "enabled": true,
26 "host": "127.0.0.1",
27 "port": 8765,
28 "path": "/",
29 "websocketRequiresToken": false,
30 "allowFrom": ["*"],
31 "streaming": true
32 }
33 }
34 }
35 ```
36
37 ### 2. Start nanobot
38
39 ```bash
40 nanobot gateway
41 ```
42
43 You should see:
44
45 ```text
46 WebSocket server listening on ws://127.0.0.1:8765/
47 ```
48
49 ### 3. Connect a client
50
51 ```bash
52 # Using websocat
53 websocat ws://127.0.0.1:8765/?client_id=alice
54
55 # Using Python
56 import asyncio, json, websockets
57
58 async def main():
59 async with websockets.connect("ws://127.0.0.1:8765/?client_id=alice") as ws:
60 ready = json.loads(await ws.recv())
61 print(ready) # {"event": "ready", "chat_id": "...", "client_id": "alice"}
62 await ws.send(json.dumps({"content": "Hello nanobot!"}))
63 reply = json.loads(await ws.recv())
64 print(reply["text"])
65
66 asyncio.run(main())
67 ```
68
69 ## Connection URL
70
71 ```text
72 ws://{host}:{port}{path}?client_id={id}&token={token}
73 ```
74
75 | Parameter | Required | Description |
76 |-----------|----------|-------------|
77 | `client_id` | No | Identifier for `allowFrom` authorization. Auto-generated as `anon-xxxxxxxxxxxx` if omitted. Truncated to 128 chars. |
78 | `token` | Conditional | Authentication token. Required when `websocketRequiresToken` is `true` or `token` (static secret) is configured. |
79
80 ## Wire Protocol
81
82 All frames are JSON text. Each message has an `event` field.
83
84 ### Server → Client
85
86 **`ready`** — sent immediately after connection is established:
87
88 ```json
89 {
90 "event": "ready",
91 "chat_id": "uuid-v4",
92 "client_id": "alice"
93 }
94 ```
95
96 **`message`** — full agent response:
97
98 ```json
99 {
100 "event": "message",
101 "chat_id": "uuid-v4",
102 "text": "Hello! How can I help?",
103 "media": ["/tmp/image.png"],
104 "reply_to": "msg-id"
105 }
106 ```
107
108 `media` and `reply_to` are only present when applicable.
109
110 **`delta`** — streaming text chunk (only when `streaming: true`):
111
112 ```json
113 {
114 "event": "delta",
115 "chat_id": "uuid-v4",
116 "text": "Hello",
117 "stream_id": "s1"
118 }
119 ```
120
121 **`stream_end`** — signals the end of a streaming segment:
122
123 ```json
124 {
125 "event": "stream_end",
126 "chat_id": "uuid-v4",
127 "stream_id": "s1"
128 }
129 ```
130
131 **`attached`** — confirmation for `new_chat` / `attach` inbound envelopes (see [Multi-chat multiplexing](#multi-chat-multiplexing)):
132
133 ```json
134 {"event": "attached", "chat_id": "uuid-v4"}
135 ```
136
137 **`error`** — soft error for malformed inbound envelopes. The connection stays open:
138
139 ```json
140 {"event": "error", "detail": "invalid chat_id"}
141 ```
142
143 ### Client → Server
144
145 **Legacy (default chat):** send a plain string, or a JSON object with a recognized text field:
146
147 ```json
148 "Hello nanobot!"
149 ```
150
151 ```json
152 {"content": "Hello nanobot!"}
153 ```
154
155 Recognized fields: `content`, `text`, `message` (checked in that order). Invalid JSON is treated as plain text. These frames route to the connection's default `chat_id` (the one announced in `ready`).
156
157 **Typed envelopes (multi-chat):** any JSON object with a string `type` field is a typed envelope:
158
159 | `type` | Fields | Effect |
160 |--------|--------|--------|
161 | `new_chat` | — | Server mints a new `chat_id`, subscribes this connection, replies with `attached`. |
162 | `attach` | `chat_id` | Subscribe to an existing `chat_id` (e.g. after a page reload). Replies with `attached`. |
163 | `message` | `chat_id`, `content` | Send `content` on `chat_id`. First use auto-attaches; no explicit `attach` needed. |
164
165 See [Multi-chat multiplexing](#multi-chat-multiplexing) for the full flow.
166
167 ## Configuration Reference
168
169 All fields go under `channels.websocket` in `config.json`.
170
171 ### Connection
172
173 | Field | Type | Default | Description |
174 |-------|------|---------|-------------|
175 | `enabled` | bool | `false` | Enable the WebSocket server. |
176 | `host` | string | `"127.0.0.1"` | Bind address. Use `"0.0.0.0"` to accept external connections. |
177 | `port` | int | `8765` | Listen port. |
178 | `path` | string | `"/"` | WebSocket upgrade path. Trailing slashes are normalized (root `/` is preserved). |
179 | `maxMessageBytes` | int | `37748736` | Maximum inbound message size in bytes (1 KB – 40 MB). Default (36 MB) is sized to accept up to 4 base64-encoded image attachments at 8 MB each; lower it if the channel only carries text. |
180
181 ### Authentication
182
183 | Field | Type | Default | Description |
184 |-------|------|---------|-------------|
185 | `token` | string | `""` | Static shared secret. When set, clients must provide `?token=<value>` matching this secret (timing-safe comparison). Issued tokens are also accepted as a fallback. |
186 | `websocketRequiresToken` | bool | `false` | When `true` and no static `token` is configured, clients must still present a valid issued token. Set to `false` to allow unauthenticated connections (only safe for local/trusted networks). |
187 | `tokenIssuePath` | string | `""` | HTTP path for issuing short-lived tokens. Must differ from `path`. See [Token Issuance](#token-issuance). |
188 | `tokenIssueSecret` | string | `""` | Secret required to obtain tokens via the issue endpoint. If empty, any client can obtain tokens (logged as a warning). |
189 | `tokenTtlS` | int | `300` | Time-to-live for issued tokens in seconds (30 – 86,400). |
190
191 ### Access Control
192
193 | Field | Type | Default | Description |
194 |-------|------|---------|-------------|
195 | `allowFrom` | list of string | `["*"]` | Allowed `client_id` values. `"*"` allows all; `[]` denies all. |
196
197 ### Streaming
198
199 | Field | Type | Default | Description |
200 |-------|------|---------|-------------|
201 | `streaming` | bool | `true` | Enable streaming mode. The agent sends `delta` + `stream_end` frames instead of a single `message`. |
202
203 ### Keep-alive
204
205 | Field | Type | Default | Description |
206 |-------|------|---------|-------------|
207 | `pingIntervalS` | float | `20.0` | WebSocket ping interval in seconds (5 – 300). |
208 | `pingTimeoutS` | float | `20.0` | Time to wait for a pong before closing the connection (5 – 300). |
209
210 ### TLS/SSL
211
212 | Field | Type | Default | Description |
213 |-------|------|---------|-------------|
214 | `sslCertfile` | string | `""` | Path to the TLS certificate file (PEM). Both `sslCertfile` and `sslKeyfile` must be set to enable WSS. |
215 | `sslKeyfile` | string | `""` | Path to the TLS private key file (PEM). Minimum TLS version is enforced as TLSv1.2. |
216
217 ## Token Issuance
218
219 For production deployments where `websocketRequiresToken: true`, use short-lived tokens instead of embedding static secrets in clients.
220
221 ### How it works
222
223 1. Client sends `GET {tokenIssuePath}` with `Authorization: Bearer {tokenIssueSecret}` (or `X-Nanobot-Auth` header).
224 2. Server responds with a one-time-use token:
225
226 ```json
227 {"token": "nbwt_aBcDeFg...", "expires_in": 300}
228 ```
229
230 3. Client opens WebSocket with `?token=nbwt_aBcDeFg...&client_id=...`.
231 4. The token is consumed (single use) and cannot be reused.
232
233 ### Example setup
234
235 ```json
236 {
237 "channels": {
238 "websocket": {
239 "enabled": true,
240 "port": 8765,
241 "path": "/ws",
242 "tokenIssuePath": "/auth/token",
243 "tokenIssueSecret": "your-secret-here",
244 "tokenTtlS": 300,
245 "websocketRequiresToken": true,
246 "allowFrom": ["*"],
247 "streaming": true
248 }
249 }
250 }
251 ```
252
253 Client flow:
254
255 ```bash
256 # 1. Obtain a token
257 curl -H "Authorization: Bearer your-secret-here" http://127.0.0.1:8765/auth/token
258
259 # 2. Connect using the token
260 websocat "ws://127.0.0.1:8765/ws?client_id=alice&token=nbwt_aBcDeFg..."
261 ```
262
263 ### Limits
264
265 - Issued tokens are single-use — each token can only complete one handshake.
266 - Outstanding tokens are capped at 10,000. Requests beyond this return HTTP 429.
267 - Expired tokens are purged lazily on each issue or validation request.
268
269 ## Multi-chat multiplexing
270
271 A single WebSocket can carry many concurrent chats. The server tracks `chat_id -> {connections}` as a fan-out set, so the same chat can also be mirrored across multiple connections (e.g. two browser tabs).
272
273 ### Typical flow (web UI with a sidebar)
274
275 ```text
276 client server
277 | --- connect --------------------> |
278 | <-- {"event":"ready", |
279 | "chat_id":"d3..."} (default)|
280 | |
281 | --- {"type":"new_chat"} ---------> |
282 | <-- {"event":"attached", |
283 | "chat_id":"a1..."} |
284 | |
285 | --- {"type":"message", |
286 | "chat_id":"a1...", |
287 | "content":"hi"} ------------> |
288 | <-- {"event":"delta", ...} |
289 | <-- {"event":"stream_end", ...} |
290 | |
291 | --- {"type":"attach", | # after page reload
292 | "chat_id":"a1..."} ---------> |
293 | <-- {"event":"attached", ...} |
294 ```
295
296 ### Rules
297
298 - Every outbound event carries `chat_id`. Clients must dispatch by that field.
299 - `chat_id` format: `^[A-Za-z0-9_:-]{1,64}$`. Non-matching values return `error`.
300 - `message` auto-attaches on first use — no separate `attach` is required for chats the server minted (`new_chat`) on the same connection.
301 - Errors (invalid envelope, unknown `type`, bad `chat_id`) are soft: the server replies with `{"event":"error","detail":"..."}` and keeps the connection open.
302
303 ### Backward compatibility
304
305 Legacy clients that only send plain text or `{"content": ...}` keep working unchanged: those frames route to the connection's default `chat_id` (the one from `ready`). No config flag is needed.
306
307 ### Security boundary
308
309 `chat_id` is a *capability*: anyone holding a valid WebSocket auth credential and the chat_id can attach to that conversation and see its output. This is safe for nanobot's local, single-user model. Multi-tenant deployments should namespace chat_ids per user (or introduce a per-tenant auth gate) — nanobot does not do this today.
310
311 ## Security Notes
312
313 - **Timing-safe comparison**: Static token validation uses `hmac.compare_digest` to prevent timing attacks.
314 - **Defense in depth**: `allowFrom` is checked at both the HTTP handshake level and the message level.
315 - **chat_id as capability**: see [Multi-chat multiplexing](#multi-chat-multiplexing). Auth on the WebSocket handshake is the single line of defense; callers who pass it can attach to any chat_id they know.
316 - **TLS enforcement**: When SSL is enabled, TLSv1.2 is the minimum allowed version.
317 - **Local-first default**: `websocketRequiresToken` defaults to `false`. Enable it whenever the service is reachable beyond a trusted local environment.
318
319 ## Media Files
320
321 Outbound `message` events may include a `media` field containing local filesystem paths. Remote clients cannot access these files directly — they need either:
322
323 - A shared filesystem mount, or
324 - An HTTP file server serving the nanobot media directory
325
326 ## Common Patterns
327
328 ### Trusted local network (no auth)
329
330 ```json
331 {
332 "channels": {
333 "websocket": {
334 "enabled": true,
335 "host": "0.0.0.0",
336 "port": 8765,
337 "websocketRequiresToken": false,
338 "allowFrom": ["*"],
339 "streaming": true
340 }
341 }
342 }
343 ```
344
345 ### Static token (simple auth)
346
347 ```json
348 {
349 "channels": {
350 "websocket": {
351 "enabled": true,
352 "token": "my-shared-secret",
353 "allowFrom": ["alice", "bob"]
354 }
355 }
356 }
357 ```
358
359 Clients connect with `?token=my-shared-secret&client_id=alice`.
360
361 ### Public endpoint with issued tokens
362
363 ```json
364 {
365 "channels": {
366 "websocket": {
367 "enabled": true,
368 "host": "0.0.0.0",
369 "port": 8765,
370 "path": "/ws",
371 "tokenIssuePath": "/auth/token",
372 "tokenIssueSecret": "production-secret",
373 "websocketRequiresToken": true,
374 "sslCertfile": "/etc/ssl/certs/server.pem",
375 "sslKeyfile": "/etc/ssl/private/server-key.pem",
376 "allowFrom": ["*"]
377 }
378 }
379 }
380 ```
381
382 ### Custom path
383
384 ```json
385 {
386 "channels": {
387 "websocket": {
388 "enabled": true,
389 "path": "/chat/ws",
390 "allowFrom": ["*"]
391 }
392 }
393 }
394 ```
395
396 Clients connect to `ws://127.0.0.1:8765/chat/ws?client_id=...`. Trailing slashes are normalized, so `/chat/ws/` works the same.
397
397 lines MARKDOWN