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
Project setup
Scaffold the app plus a small embed API server.
Embed backend
Provision users and create a shared room with BrenoxServer.
Session tokens
Issue user JWTs from your backend via POST /v1/sessions.
Embed launcher UI
Open chat as Alice or Bob — simulating two end users.
SDK client
BrenoxClient + BrenoxProvider with the issued token.
Shared channel session
One WebSocket for chat, typing, and call signaling.
Realtime messaging
REST history + WebSocket live delivery on one channel connection.
Typing & presence
Typing indicators and live channel events.
Voice & video calls
Call signaling + WebRTC mesh for Alice ↔ Bob.
Notifications
In-app notifications with useNotifications.
Attachments
Upload files and attach them to messages.
Run & deploy
Local two-user test, troubleshooting, and production deployment.
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).
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 devProvision 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:
POST /v1/users (external_id)general in your app's workspace (idempotent on restart)workspace_id / channel_id to server/.demo-room.json so restarts reuse the same roomThis is the same flow you'd run when a customer signs up in your SaaS product.
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 };
}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.
// 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 }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.
// 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 tabsBrenoxClient + 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.
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>
);
}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.
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>
);
}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.
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);
}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.
function handleDraftChange(value: string) {
setDraft(value);
if (connectionState !== "connected") return;
connection?.startTyping();
window.setTimeout(() => {
if (connection?.connectionState === "connected") connection.stopTyping();
}, 1500);
}Call signaling + WebRTC mesh for Alice ↔ Bob.
Brenox provides signaling only (useCallSignaling + REST /calls). Your app owns RTCPeerConnection, getUserMedia, and STUN/TURN.
Flow:
initiate("video") via RESTjoin(callId)call.offer, call.answer, call.ice)VITE_ICE_SERVERS for TURN in productionuseWebRtcCall in the demo wires signaling events to a 1:1 mesh. Allow mic/camera when prompted.
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" }],
});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().
const { notifications, markRead, markAllRead } = useNotifications({
pollIntervalMs: 60_000,
});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.
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]);Local two-user test, troubleshooting, and production deployment.
Local development (two terminals)
| Terminal | Command | URL |
|---|---|---|
| Embed API | npm run dev:server | http://localhost:3001 |
| Chat UI | npm run dev | http://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:5173API_RATE_LIMIT_PER_MINUTE / HTTP_RATE_LIMIT_PER_IP if you hit 429 during devProduction: 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.
# 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