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.
- Bidirectional real-time communication over WebSocket
- Streaming support β receive agent responses token by token
- Token-based authentication (static tokens and short-lived issued tokens)
- Multi-chat multiplexing β one connection can run many concurrent
chat_ids - TLS/SSL support (WSS) with enforced TLSv1.2 minimum
- Client allow-list via
allowFrom - Auto-cleanup of dead connections
Add to config.json under channels.websocket:
{
"channels": {
"websocket": {
"enabled": true,
"host": "127.0.0.1",
"port": 8765,
"path": "/",
"websocketRequiresToken": false,
"allowFrom": ["*"],
"streaming": true
}
}
}nanobot gatewayYou should see:
WebSocket server listening on ws://127.0.0.1:8765/
# Using websocat
websocat ws://127.0.0.1:8765/?client_id=alice
# Using Python
import asyncio, json, websockets
async def main():
async with websockets.connect("ws://127.0.0.1:8765/?client_id=alice") as ws:
ready = json.loads(await ws.recv())
print(ready) # {"event": "ready", "chat_id": "...", "client_id": "alice"}
await ws.send(json.dumps({"content": "Hello nanobot!"}))
reply = json.loads(await ws.recv())
print(reply["text"])
asyncio.run(main())ws://{host}:{port}{path}?client_id={id}&token={token}
| Parameter | Required | Description |
|---|---|---|
client_id |
No | Identifier for allowFrom authorization. Auto-generated as anon-xxxxxxxxxxxx if omitted. Truncated to 128 chars. |
token |
Conditional | Authentication token. Required when websocketRequiresToken is true or token (static secret) is configured. |
All frames are JSON text. Each message has an event field.
ready β sent immediately after connection is established:
{
"event": "ready",
"chat_id": "uuid-v4",
"client_id": "alice"
}message β full agent response:
{
"event": "message",
"chat_id": "uuid-v4",
"text": "Hello! How can I help?",
"media": ["/tmp/image.png"],
"reply_to": "msg-id"
}media and reply_to are only present when applicable.
delta β streaming text chunk (only when streaming: true):
{
"event": "delta",
"chat_id": "uuid-v4",
"text": "Hello",
"stream_id": "s1"
}stream_end β signals the end of a streaming segment:
{
"event": "stream_end",
"chat_id": "uuid-v4",
"stream_id": "s1"
}reasoning_delta β incremental model reasoning / thinking chunk for the active assistant turn. Mirrors delta but targets the reasoning bubble above the answer rather than the answer body:
{
"event": "reasoning_delta",
"chat_id": "uuid-v4",
"text": "Let me decompose ",
"stream_id": "r1"
}reasoning_end β close marker for the active reasoning stream. WebUI uses this to lock the in-place bubble and switch from the shimmer header to a static collapsed state:
{
"event": "reasoning_end",
"chat_id": "uuid-v4",
"stream_id": "r1"
}Reasoning frames only flow when the channel's showReasoning is true (default) and the model returns reasoning content (DeepSeek-R1 / Kimi / MiMo / OpenAI reasoning models, Anthropic extended thinking, or inline <think> / <thought> tags). Models without reasoning produce zero reasoning_delta frames.
runtime_model_updated β broadcast when the gateway runtime model changes, for example after /model <preset>:
{
"event": "runtime_model_updated",
"model_name": "openai/gpt-4.1-mini",
"model_preset": "fast"
}model_preset is omitted when no named preset is active. WebUI clients use this event to keep the displayed model badge in sync across slash commands, config reloads, and settings changes.
attached β confirmation for new_chat / attach inbound envelopes (see Multi-chat multiplexing):
{"event": "attached", "chat_id": "uuid-v4"}error β soft error for malformed inbound envelopes. The connection stays open:
{"event": "error", "detail": "invalid chat_id"}Legacy (default chat): send a plain string, or a JSON object with a recognized text field:
"Hello nanobot!"{"content": "Hello nanobot!"}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).
Typed envelopes (multi-chat): any JSON object with a string type field is a typed envelope:
type |
Fields | Effect |
|---|---|---|
new_chat |
β | Server mints a new chat_id, subscribes this connection, replies with attached. |
attach |
chat_id |
Subscribe to an existing chat_id (e.g. after a page reload). Replies with attached. |
message |
chat_id, content |
Send content on chat_id. First use auto-attaches; no explicit attach needed. |
See Multi-chat multiplexing for the full flow.
All fields go under channels.websocket in config.json.
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
Enable the WebSocket server. |
host |
string | "127.0.0.1" |
Bind address. Use "0.0.0.0" to accept external connections. |
port |
int | 8765 |
Listen port. |
path |
string | "/" |
WebSocket upgrade path. Trailing slashes are normalized (root / is preserved). |
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. |
| Field | Type | Default | Description |
|---|---|---|---|
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. |
websocketRequiresToken |
bool | true |
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). |
tokenIssuePath |
string | "" |
HTTP path for issuing short-lived tokens. Must differ from path. See Token Issuance. |
tokenIssueSecret |
string | "" |
Secret required to obtain tokens via the issue endpoint. If empty, any client can obtain tokens (logged as a warning). |
tokenTtlS |
int | 300 |
Time-to-live for issued tokens in seconds (30 β 86,400). |
| Field | Type | Default | Description |
|---|---|---|---|
allowFrom |
list of string | ["*"] |
Allowed client_id values. "*" allows all; [] denies all. |
| Field | Type | Default | Description |
|---|---|---|---|
streaming |
bool | true |
Enable streaming mode. The agent sends delta + stream_end frames instead of a single message. |
| Field | Type | Default | Description |
|---|---|---|---|
pingIntervalS |
float | 20.0 |
WebSocket ping interval in seconds (5 β 300). |
pingTimeoutS |
float | 20.0 |
Time to wait for a pong before closing the connection (5 β 300). |
| Field | Type | Default | Description |
|---|---|---|---|
sslCertfile |
string | "" |
Path to the TLS certificate file (PEM). Both sslCertfile and sslKeyfile must be set to enable WSS. |
sslKeyfile |
string | "" |
Path to the TLS private key file (PEM). Minimum TLS version is enforced as TLSv1.2. |
For production deployments where websocketRequiresToken: true, use short-lived tokens instead of embedding static secrets in clients.
- Client sends
GET {tokenIssuePath}withAuthorization: Bearer {tokenIssueSecret}(orX-Nanobot-Authheader). - Server responds with a one-time-use token:
{"token": "nbwt_aBcDeFg...", "expires_in": 300}- Client opens WebSocket with
?token=nbwt_aBcDeFg...&client_id=.... - The token is consumed (single use) and cannot be reused.
{
"channels": {
"websocket": {
"enabled": true,
"port": 8765,
"path": "/ws",
"tokenIssuePath": "/auth/token",
"tokenIssueSecret": "your-secret-here",
"tokenTtlS": 300,
"websocketRequiresToken": true,
"allowFrom": ["*"],
"streaming": true
}
}
}Client flow:
# 1. Obtain a token
curl -H "Authorization: Bearer your-secret-here" http://127.0.0.1:8765/auth/token
# 2. Connect using the token
websocat "ws://127.0.0.1:8765/ws?client_id=alice&token=nbwt_aBcDeFg..."- Issued tokens are single-use β each token can only complete one handshake.
- Outstanding tokens are capped at 10,000. Requests beyond this return HTTP 429.
- Expired tokens are purged lazily on each issue or validation request.
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).
client server
| --- connect --------------------> |
| <-- {"event":"ready", |
| "chat_id":"d3..."} (default)|
| |
| --- {"type":"new_chat"} ---------> |
| <-- {"event":"attached", |
| "chat_id":"a1..."} |
| |
| --- {"type":"message", |
| "chat_id":"a1...", |
| "content":"hi"} ------------> |
| <-- {"event":"delta", ...} |
| <-- {"event":"stream_end", ...} |
| |
| --- {"type":"attach", | # after page reload
| "chat_id":"a1..."} ---------> |
| <-- {"event":"attached", ...} |
- Every outbound event carries
chat_id. Clients must dispatch by that field. chat_idformat:^[A-Za-z0-9_:-]{1,64}$. Non-matching values returnerror.messageauto-attaches on first use β no separateattachis required for chats the server minted (new_chat) on the same connection.- Errors (invalid envelope, unknown
type, badchat_id) are soft: the server replies with{"event":"error","detail":"..."}and keeps the connection open.
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.
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.
- Timing-safe comparison: Static token validation uses
hmac.compare_digestto prevent timing attacks. - Defense in depth:
allowFromis checked at both the HTTP handshake level and the message level. - chat_id as capability: see 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.
- TLS enforcement: When SSL is enabled, TLSv1.2 is the minimum allowed version.
- Default-secure:
websocketRequiresTokendefaults totrue. Explicitly set it tofalseonly on trusted networks.
Outbound message events may include a media field containing local filesystem paths. Remote clients cannot access these files directly β they need either:
- A shared filesystem mount, or
- An HTTP file server serving the nanobot media directory
{
"channels": {
"websocket": {
"enabled": true,
"host": "0.0.0.0",
"port": 8765,
"websocketRequiresToken": false,
"allowFrom": ["*"],
"streaming": true
}
}
}{
"channels": {
"websocket": {
"enabled": true,
"token": "my-shared-secret",
"allowFrom": ["alice", "bob"]
}
}
}Clients connect with ?token=my-shared-secret&client_id=alice.
{
"channels": {
"websocket": {
"enabled": true,
"host": "0.0.0.0",
"port": 8765,
"path": "/ws",
"tokenIssuePath": "/auth/token",
"tokenIssueSecret": "production-secret",
"websocketRequiresToken": true,
"sslCertfile": "/etc/ssl/certs/server.pem",
"sslKeyfile": "/etc/ssl/private/server-key.pem",
"allowFrom": ["*"]
}
}
}{
"channels": {
"websocket": {
"enabled": true,
"path": "/chat/ws",
"allowFrom": ["*"]
}
}
}Clients connect to ws://127.0.0.1:8765/chat/ws?client_id=.... Trailing slashes are normalized, so /chat/ws/ works the same.