@brenox/react@0.1.3@brenox/sdk@0.1.3Live demoReactEmbedWebRTCRealtime

Live Chat — step-by-step tutorial

Embed-first chat demo: backend provisions users with BrenoxServer, frontend chats with BrenoxClient — messaging, voice/video calls, Alice & Bob in two tabs.

Run it yourself

This is a hands-on tutorial. The demo runs locally — it needs a backend embed API (your API key) plus the Brenox engine, so it isn't hosted here. Clone the GitHub repo and follow the steps below to run Alice & Bob in two tabs.

Overview

  1. 1

    Project setup

    Scaffold the app plus a small embed API server.

  2. 2

    Embed backend

    Provision users and create a shared room with BrenoxServer.

  3. 3

    Session tokens

    Issue user JWTs from your backend via POST /v1/sessions.

  4. 4

    Embed launcher UI

    Open chat as Alice or Bob — simulating two end users.

  5. 5

    SDK client

    BrenoxClient + BrenoxProvider with the issued token.

  6. 6

    Shared channel session

    One WebSocket for chat, typing, and call signaling.

  7. 7

    Realtime messaging

    REST history + WebSocket live delivery on one channel connection.

  8. 8

    Typing & presence

    Typing indicators and live channel events.

  9. 9

    Voice & video calls

    Call signaling + WebRTC mesh for Alice ↔ Bob.

  10. 10

    Notifications

    In-app notifications with useNotifications.

  11. 11

    Attachments

    Upload files and attach them to messages.

  12. 12

    Run & deploy

    Local two-user test, troubleshooting, and production deployment.

1. Project setup

Step 1

Scaffold the app plus a small embed API server.

This demo mirrors how your product embeds Brenox: a trusted backend (API key) provisions users and issues session tokens; your frontend uses BrenoxClient with those tokens — end users never see a Brenox signup screen.

The repo has two surfaces:

  • server/ — Node embed API (BrenoxServer, sandbox API key)
  • src/ — Vite + React chat UI (BrenoxClient + @brenox/react)

Install packages and copy .env.example. You need a sandbox key (bx_test_*) from the Brenox console.

Run the embed API with npm run dev:server (loads .env via --env-file).

Scaffold & installbash
git clone https://github.com/brainArt16/brenox-demo-chat.git
cd brenox-demo-chat
npm install
cp .env.example .env
# Add bx_test_* key from www.breno-x.com/apps → API Keys

# Terminal 1 — embed API (loads .env automatically)
npm run dev:server

# Terminal 2 — chat UI
npm run dev
Files in this step
  • package.json
  • .env.example
  • server/index.mjs
Next: Embed backend

2. Embed backend

Step 2

Provision users and create a shared room with BrenoxServer.

Your backend uses BrenoxServer with an API key — never expose the key in the browser.

On first request, the demo server:

  1. Provisions Alice and Bob via POST /v1/users (external_id)
  2. Creates a shared channel general in your app's workspace (idempotent on restart)
  3. Persists workspace_id / channel_id to server/.demo-room.json so restarts reuse the same room

This is the same flow you'd run when a customer signs up in your SaaS product.

Bootstrap room with BrenoxServertypescript
import { BrenoxServer } from "@brenox/sdk/server";

const server = new BrenoxServer({
  baseUrl: process.env.BRENOX_API_URL!,
  apiKey: process.env.BRENOX_API_KEY!,
});

async function ensureRoom() {
  await server.users.provision({ external_id: "demo-alice", username: "Alice" });
  await server.users.provision({ external_id: "demo-bob", username: "Bob" });
  const channel = await server.channels.create(
    { name: "general" },
    "demo-channel-general", // idempotency key
  );
  // Persist workspaceId + channelId to server/.demo-room.json
  return { workspaceId: channel.workspace_id, channelId: channel.id };
}
Files in this step
  • server/index.mjs
Next: Session tokens

3. Session tokens

Step 3

Issue user JWTs from your backend via POST /v1/sessions.

Provisioned users don't have passwords your app knows. Instead, your backend calls POST /v1/sessions with the user's external_id and optional channel_id.

Brenox returns a user JWT plus workspace/channel context. The SDK exposes this as server.sessions.create().

Your frontend stores the token in sessionStorage (one session per browser tab) and uses all realtime features — WebSocket, typing, presence, calls — without Brenox login forms.

Issue embed session (POST /v1/sessions)typescript
// Your backend — never expose BRENOX_API_KEY to the browser
const session = await server.sessions.create({
  external_id: "demo-alice",
  channel_id: room.channelId,
});

// Return to your frontend:
// { token: session.token, workspace_id, channel_id, user: session.user }
Files in this step
  • server/index.mjs
Next: Embed launcher UI

4. Embed launcher UI

Step 4

Open chat as Alice or Bob — simulating two end users.

The launcher calls your backend POST /api/session with { persona: "alice" | "bob" }. Your server maps personas to external_id, calls Brenox /v1/sessions, and returns the token.

Open Alice in one browser tab and Bob in another to try realtime chat — each tab keeps its own session via sessionStorage.

Vite proxies /api to the embed server during local dev. Keep npm run dev:server running in a second terminal.

Frontend — open chat as an end usertypescript
// POST /api/session { persona: "alice" | "bob" }
const response = await fetch("/api/session", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ persona: "alice" }),
});
const { token, workspace_id, channel_id } = await response.json();

await client.setToken(token);
// → open chat UI with workspace_id + channel_id
// Use sessionStorage so Alice and Bob can run in separate tabs
Files in this step
  • src/components/EmbedLauncher.tsx
  • src/lib/embed-api.ts
  • vite.config.ts
Next: SDK client

5. SDK client

Step 5

BrenoxClient + BrenoxProvider with the issued token.

Create a shared BrenoxClient with a per-tab sessionStorage token store. After your embed API returns a token, call client.setToken(token) then client.users.me() to load the end-user profile.

Wrap the tree in BrenoxProvider. The chat UI reads workspace_id and channel_id from the session response — no workspace picker needed in an embed flow.

BrenoxClient + per-tab session storagetypescript
import { BrenoxClient, type TokenStore } from "@brenox/sdk";
import { BrenoxProvider } from "@brenox/react";

function sessionStorageTokenStore(key: string): TokenStore {
  return {
    getToken: () => sessionStorage.getItem(key),
    setToken: (value) =>
      value ? sessionStorage.setItem(key, value) : sessionStorage.removeItem(key),
  };
}

export const brenoxClient = new BrenoxClient({
  baseUrl: import.meta.env.VITE_BRENOX_API_URL ?? "https://api.breno-x.com",
  tokenStore: sessionStorageTokenStore("brenox_demo_chat_token"),
});

export default function App() {
  return (
    <BrenoxProvider client={brenoxClient}>
      <DemoApp />
    </BrenoxProvider>
  );
}
Files in this step
  • src/brenox/client.ts
  • src/App.tsx
Next: Shared channel session

6. Shared channel session

Step 6

One WebSocket for chat, typing, and call signaling.

ChannelSessionProvider wraps the in-channel UI and uses useCallSignaling under the hood. Chat and calls share a single ChannelConnection — no duplicate WebSockets to the same room.

ChatPanel reads the connection via useChannelSession(). CallPanel uses the same signaling instance for call.offer, call.answer, and call.ice events.

One WebSocket for chat + callstypescript
import { useCallSignaling } from "@brenox/react";

export function ChannelSessionProvider({ workspaceId, channelId, children }) {
  const { signaling, connectionState, initiate, join, leave } = useCallSignaling(
    workspaceId,
    channelId,
    { autoConnect: true },
  );

  const connection = signaling?.channel ?? null;
  // ChatPanel + CallPanel share this connection
  return (
    <ChannelSessionContext.Provider
      value={{ connection, signaling, connectionState, initiate, join, leave }}
    >
      {children}
    </ChannelSessionContext.Provider>
  );
}
Files in this step
  • src/context/channel-session.tsx
  • src/App.tsx
Next: Realtime messaging

7. Realtime messaging

Step 7

REST history + WebSocket live delivery on one channel connection.

Load history with client.messages.list and subscribe to message.new / message.updated on the shared connection.

Send with connection.sendMessage(text) when connected, or fall back to REST client.messages.send. Show connectionState in the header so users know when live delivery is active.

Attachment metadata is fetched once per message (cached) to avoid rate-limit spikes during dev.

Shared connection + REST historytypescript
const { connection, connectionState } = useChannelSession();

useEffect(() => {
  if (!connection) return;
  const off = connection.on("message.new", (event) => {
    setMessages((prev) => [...prev, toListItem(event.payload, channelId)]);
  });
  return () => off();
}, [connection, channelId]);

// Send when connected
if (connectionState === "connected") {
  connection.sendMessage(text);
}
Files in this step
  • src/components/ChatPanel.tsx
Next: Typing & presence

8. Typing & presence

Step 8

Typing indicators and live channel events.

Call connection.startTyping() / connection.stopTyping() only when connectionState === "connected" — the SDK throws if the WebSocket is not open.

Subscribe to typing.start, presence.*, and member.* on the same connection for a live activity feed. Debounce stopTyping ~1.5s after the last keystroke.

Typing only when connectedtypescript
function handleDraftChange(value: string) {
  setDraft(value);
  if (connectionState !== "connected") return;
  connection?.startTyping();
  window.setTimeout(() => {
    if (connection?.connectionState === "connected") connection.stopTyping();
  }, 1500);
}
Files in this step
  • src/components/ChatPanel.tsx
Next: Voice & video calls

9. Voice & video calls

Step 9

Call signaling + WebRTC mesh for Alice ↔ Bob.

Brenox provides signaling only (useCallSignaling + REST /calls). Your app owns RTCPeerConnection, getUserMedia, and STUN/TURN.

Flow:

  1. Alice clicks Start videoinitiate("video") via REST
  2. Bob sees an incoming call banner → join(callId)
  3. Peers exchange SDP/ICE over the channel WebSocket (call.offer, call.answer, call.ice)
  4. Media flows P2P — configure VITE_ICE_SERVERS for TURN in production

useWebRtcCall in the demo wires signaling events to a 1:1 mesh. Allow mic/camera when prompted.

Signaling + WebRTC meshtypescript
const { signaling, initiate, join, leave } = useChannelSession();

// Alice starts a video call
const call = await initiate("video");
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });

// Bob joins when he receives call.join on the WebSocket
await join(call.id);

// Exchange SDP/ICE via signaling (you implement RTCPeerConnection)
signaling?.on("call.offer", async (event) => {
  // setRemoteDescription → createAnswer → sendAnswer
});
signaling?.sendOffer({ call_id: call.id, to_user_id: bobId, sdp: localSdp });

// Optional STUN/TURN — VITE_ICE_SERVERS in .env
const pc = new RTCPeerConnection({
  iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
});
Files in this step
  • src/components/CallPanel.tsx
  • src/hooks/useWebRtcCall.ts
  • src/webrtc/peer-connection.ts
Next: Notifications

10. Notifications

Step 10

In-app notifications with useNotifications.

useNotifications({ pollIntervalMs: 60_000 }) loads the user's inbox. Combine with notification.new channel events for instant delivery (including call_invite).

Mark items read with markRead(id) or markAllRead().

useNotificationstypescript
const { notifications, markRead, markAllRead } = useNotifications({
  pollIntervalMs: 60_000,
});
Files in this step
  • src/components/NotificationsPanel.tsx
Next: Attachments

11. Attachments

Step 11

Upload files and attach them to messages.

Upload via client.attachments.uploadFile, send the message over REST, then attachToMessage. List files with listByMessage for download links.

Presigned URLs are handled by Brenox — your app only uploads the file bytes.

Upload and attachtypescript
const uploaded = await client.attachments.uploadFile(file, {
  fileName: file.name,
  mimeType: file.type || "application/octet-stream",
});
const message = await client.messages.send(workspaceId, channelId, { content: text });
await client.attachments.attachToMessage(workspaceId, channelId, message.id, [uploaded]);
Files in this step
  • src/components/ChatPanel.tsx
Next: Run & deploy

12. Run & deploy

Step 12

Local two-user test, troubleshooting, and production deployment.

Local development (two terminals)

TerminalCommandURL
Embed APInpm run dev:serverhttp://localhost:3001
Chat UInpm run devhttp://localhost:5173/demos/chat/

Set BRENOX_API_KEY and BRENOX_API_URL=http://localhost:8080 in .env.

Try two users: Alice in one tab, Bob in another — both land in #general. Try a video call: Alice starts, Bob joins.

Engine dev settings — add to brenox-engine/.env and restart the engine:

  • WS_ALLOWED_ORIGINS must include both localhost:5173 and 127.0.0.1:5173
  • Raise API_RATE_LIMIT_PER_MINUTE / HTTP_RATE_LIMIT_PER_IP if you hit 429 during dev

Production: host the static UI on your own domain and deploy the embed API (server/index.mjs) on your backend — it holds the API key and issues session tokens. This tutorial is not a hosted playground; clone the repo and run it locally.

Run locally + engine dev settingsbash
# Terminal 1 — embed API
npm run dev:server

# Terminal 2 — chat UI
npm run dev
# http://localhost:5173/demos/chat/
# Alice in tab 1, Bob in tab 2

# brenox-engine/.env (restart engine after editing)
WS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
API_RATE_LIMIT_PER_MINUTE=1000
HTTP_RATE_LIMIT_PER_IP=2000
Files in this step
  • README.md
  • vite.config.ts
  • .env.example