Streaming replies
Streaming replies
Instead of fetching a chat until its reply is done, open one WebSocket and BetterClaw pushes every
change as it happens: new chats, new messages, and replies as they're written. It needs the
chats:read permission.
wss://api.betterclaw.io/ws/chats?workspaceId={workspaceId}
Connecting
workspaceId is required and must be the key's workspace.
From a server, send your API key (or a session token) in the Authorization header of the
connection request:
import WebSocket from 'ws';
const ws = new WebSocket(`wss://api.betterclaw.io/ws/chats?workspaceId=${workspaceId}`, {
headers: { Authorization: `Bearer ${process.env.BETTERCLAW_API_KEY}` },
});
From a browser, which can't set headers on a WebSocket, pass a
session token as token:
const ws = new WebSocket(`wss://api.betterclaw.io/ws/chats?workspaceId=${workspaceId}&token=${token}`);
Never put an API key in the URL: the connection is closed straight away (code 4003).
The stream only sends; there's nothing to subscribe to. You receive events for every chat in the
workspace, so filter by chatId for the ones you care about. The server pings the connection
every 25 seconds; WebSocket libraries and browsers answer those pings for you.
Events
Every event is a JSON text frame with a type.
type | Sent when | Payload |
|---|---|---|
connected | The connection is ready. | — |
chat_upserted | A chat is created or changes (title, archive…). | chat: a chat object |
chat_deleted | A chat is deleted. | chatId |
message_upserted | A message is created or saved. | chatId, message: a message object |
message_streaming | A reply is being written. | See below |
message_streaming
{
"type": "message_streaming",
"chatId": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
"messageId": "c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f",
"content": "Here's the draft. Revenue is up 8% week",
"status": "streaming",
"todos": [
{ "id": "1", "content": "Read numbers.csv", "status": "completed" },
{ "id": "2", "content": "Write the report", "status": "in_progress" }
]
}
| Field | Description |
|---|---|
content | The whole reply so far, not just the new text. Replace what you show; to print only new text, slice off what you've already printed. |
status | streaming, then complete or error on the last frame. |
thinking | The agent's live reasoning, when it shares it. Optional. |
todos | The agent's live checklist: id, content, and status (pending, in_progress, completed). Optional. |
subagents | Progress of helper agents it started for this reply. Optional. |
Frames arrive up to about four times a second. thinking, todos, and subagents are only
present while the reply is still being written.
What one turn looks like
After you send a message, the stream carries:
chat_upsertedfor the chat, now with new activity.message_upsertedfor your message (role: "user").message_upsertedfor the empty reply (role: "assistant",status: "streaming").message_streamingframes as the reply is written.- A last
message_streamingwithstatuscompleteorerror. message_upsertedwith the saved reply, including anydeliverablefiles anderrorMessage.
If the reply fails before the agent starts writing, for example because the agent couldn't start,
you get step 6 with status: "error" and no message_streaming frames. Treat message_upserted
with a finished status as the reply's final state.
Reconnecting
Connections drop. When you reconnect, the stream re-sends replies that are still being written
(a message_upserted followed by the latest message_streaming). Anything that finished while
you were away is not re-sent, so fetch the chats you're following with
GET /chats/{chatId} after reconnecting.
An open connection stays open after the session token it used expires. You only need a fresh token to reconnect.
Close codes
| Code | Reason | What to do |
|---|---|---|
4001 | missing-token | Send a key or token. Don't retry without one. |
4003 | auth-failed | An API key was sent in the URL, or the server failed while connecting. Fix the request, then retry. |
4004 | forbidden-workspace | workspaceId is missing or not the key's, the key lacks chats:read, or its creator left the workspace. Don't retry. |
4005 | key-revoked | The key was revoked, or the session token is invalid or expired. Get a new session token and reconnect; if that fails with 401, the key is gone. |
Any other close is a dropped connection: reconnect with a back-off.
Example: a browser chat stream
async function openStream(onReply) {
// Your own endpoint that mints a session token (see Authentication).
const { token, workspaceId } = await (await fetch('/betterclaw-token', { method: 'POST' })).json();
const ws = new WebSocket(`wss://api.betterclaw.io/ws/chats?workspaceId=${workspaceId}&token=${token}`);
ws.onmessage = (e) => {
const frame = JSON.parse(e.data);
if (frame.type === 'message_streaming') onReply(frame.chatId, frame.messageId, frame.content, frame.status);
if (frame.type === 'message_upserted' && frame.message.role === 'assistant') {
onReply(frame.chatId, frame.message.id, frame.message.content, frame.message.status);
}
};
ws.onclose = (e) => {
if (e.code === 4001 || e.code === 4004) return; // won't succeed on retry
setTimeout(() => openStream(onReply), 2000); // a fresh token covers 4005 too
};
}