BetterClaw
Agent API

Streaming replies

Watch chats and replies update live over the /ws/chats WebSocket.

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.

typeSent whenPayload
connectedThe connection is ready.—
chat_upsertedA chat is created or changes (title, archive…).chat: a chat object
chat_deletedA chat is deleted.chatId
message_upsertedA message is created or saved.chatId, message: a message object
message_streamingA 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" }
  ]
}
FieldDescription
contentThe 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.
statusstreaming, then complete or error on the last frame.
thinkingThe agent's live reasoning, when it shares it. Optional.
todosThe agent's live checklist: id, content, and status (pending, in_progress, completed). Optional.
subagentsProgress 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:

  1. chat_upserted for the chat, now with new activity.
  2. message_upserted for your message (role: "user").
  3. message_upserted for the empty reply (role: "assistant", status: "streaming").
  4. message_streaming frames as the reply is written.
  5. A last message_streaming with status complete or error.
  6. message_upserted with the saved reply, including any deliverable files and errorMessage.

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

CodeReasonWhat to do
4001missing-tokenSend a key or token. Don't retry without one.
4003auth-failedAn API key was sent in the URL, or the server failed while connecting. Fix the request, then retry.
4004forbidden-workspaceworkspaceId is missing or not the key's, the key lacks chats:read, or its creator left the workspace. Don't retry.
4005key-revokedThe 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
  };
}