Bridle connects browser users to an agent runtime through a stateless relay hub.
Browser (Nuxt UI) Bridle Hub (NestJS) Agent Runtime
| | |
|--- Socket.IO /ws/client ----->| |
| auth: { token, agentId } |--- Socket.IO /ws/agent ---->|
| | auth: { apiKey, agentId } |
|<--- stream/message ---------|<--- stream/message ---------|
| { text, parts[] } | { text, parts[] } |
The hub is stateless -- it does not store messages or conversation history.
All messages carry a parts array for rich content. The text field is always present as a plain-text shorthand.
enum BridlePartTypes {
Text = 'text',
Image = 'image',
File = 'file',
}{ "type": "text", "text": "Hello, how can I help?" }{ "type": "image", "base64": "<base64-encoded>", "mediaType": "image/jpeg" }Supported media types: image/jpeg, image/png, image/gif, image/webp.
{ "type": "file", "url": "https://example.com/doc.pdf", "name": "doc.pdf", "mimeType": "application/pdf" }| Field | Type | Required | Description |
|---|---|---|---|
url |
string | yes | URL to download the file |
name |
string | yes | Display name |
mimeType |
string | no | MIME type hint |
Clients may omit parts and send text + images instead. The hub converts this to parts via buildParts(text, images). All receivers fall back gracefully: if parts is missing, they build it from text.
Three participants, two connections:
| Connection | Transport | Auth | Direction |
|---|---|---|---|
| Browser <-> Hub | Socket.IO /ws/client |
JWT + agentId | Bidirectional |
| Agent <-> Hub | Socket.IO /ws/agent |
apiKey + agentId | Bidirectional |
The hub assigns each browser a clientId on connection (from JWT sub, or 'admin' for admin users).
All messages between hub and agent include clientId and agentId for routing.
Multiple agents can connect (one per agentId). Multiple browsers can connect simultaneously.
Namespace: /ws/client
Transport: Socket.IO (WebSocket upgrade)
Auth: JWT token + agentId in handshake
io('http://hub-host/ws/client', {
auth: {
token: '<JWT>', // Verified by JwtService
agentId: 'bot-abc-123', // Which bot to chat with
},
})If token or agentId is missing, or the JWT is invalid, the client is disconnected immediately.
Admin detection: if JWT roles includes 'ADMIN', clientId is set to 'admin'.
1. Browser connects to /ws/client with { token, agentId }
2. Hub verifies JWT, extracts clientId from sub (or 'admin')
3. Hub registers browser under agentId
4. Hub emits welcome { clientId } to browser
5. Browser sends/receives messages
6. On disconnect, hub unregisters the clientId
Send a message to the agent. Prefer parts for rich content; text + images is supported for backward compatibility.
{
"text": "Hello, agent",
"parts": [
{ "type": "text", "text": "Hello, agent" },
{ "type": "image", "base64": "<base64>", "mediaType": "image/jpeg" }
]
}| Field | Type | Required | Description |
|---|---|---|---|
text |
string | yes | Plain-text shorthand |
parts |
BridlePart[] | no | Rich content (source of truth). If omitted, built from text + images. |
images |
array | no | Legacy: attached images. Converted to parts if parts is absent. |
Keepalive check. Hub responds with pong.
{}Sent immediately on connection.
{
"clientId": "550e8400-e29b-41d4-a716-446655440000"
}Complete (non-streamed) response from the agent.
{
"type": "message",
"text": "Hello! How can I help you?",
"parts": [
{ "type": "text", "text": "Hello! How can I help you?" }
],
"messageId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"ts": 1712847600000
}Agent has started processing. Display as a typing indicator.
{
"type": "typing",
"ts": 1712847600000
}Partial response chunk. Both text and parts contain the accumulated content so far (not a delta).
{
"type": "stream",
"text": "Hello! How can I",
"parts": [
{ "type": "text", "text": "Hello! How can I" }
],
"messageId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"ts": 1712847600100
}Important: Each
streamevent contains the full accumulated state. The client must replace the entire message, not append.
Final chunk. Marks the end of streaming.
{
"type": "stream_end",
"text": "Hello! How can I help you today?",
"parts": [
{ "type": "text", "text": "Hello! How can I help you today?" }
],
"messageId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"ts": 1712847600200
}Response to ping.
{
"ts": 1712847600000
}Namespace: /ws/agent
Transport: Socket.IO (WebSocket upgrade)
Auth: apiKey + agentId in handshake
io('http://hub-host/ws/agent', {
auth: {
apiKey: process.env.BRIDLE_API_KEY,
agentId: process.env.BRIDLE_AGENT_ID,
}
})apiKey is validated against BRIDLE_API_KEY env var. If missing or wrong, the connection is rejected. agentId is required -- it scopes all routing. Multiple agents can connect (one per agentId).
1. Agent connects to /ws/agent with { apiKey, agentId }
2. Hub validates apiKey, registers agent under agentId
3. Agent emits register {} to confirm readiness
4. Hub forwards browser messages (matching agentId) to agent
5. Agent sends responses back through hub
6. On disconnect, hub unregisters that agentId
A browser user sent a message. Agent should process it and respond.
{
"type": "message",
"clientId": "550e8400-e29b-41d4-a716-446655440000",
"text": "Hello, agent",
"parts": [
{ "type": "text", "text": "Hello, agent" },
{ "type": "image", "base64": "<base64>", "mediaType": "image/jpeg" }
],
"messageId": "7c9e6679-7425-40de-944b-e07fc1f90ae7"
}| Field | Type | Required | Description |
|---|---|---|---|
type |
"message" |
yes | Always "message" |
clientId |
string | yes | Browser client to respond to |
text |
string | yes | Plain-text shorthand |
parts |
BridlePart[] | yes | Rich content parts |
messageId |
string | yes | UUID v4 for this message |
Response to agent's ping.
{}All events must include clientId to route the response to the correct browser.
Agent announces it is ready. Sent after connection or reconnection.
{}Complete (non-streamed) response with rich parts.
{
"clientId": "550e8400-e29b-41d4-a716-446655440000",
"text": "Here is the result:",
"parts": [
{ "type": "text", "text": "Here is the result:" },
{ "type": "image", "base64": "<base64>", "mediaType": "image/png" }
],
"messageId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"ts": 1712847600000
}| Field | Type | Required | Description |
|---|---|---|---|
kind |
'thinking' | 'tool' |
no | Marks this frame as a note — the agent's reasoning, not its answer. See Notes. |
An agent that reasons out loud sends those lines as ordinary message frames
while it works — one per thought, one per tool it reaches for. They are not
the answer, and drawn as answers they are indistinguishable from it: the
visitor reads several bubbles of the agent talking to itself and then, below
them, the reply.
A receiver folds every note of one turn into a single collapsible row above the answer — the SDK calls it the work log.
A frame is a note when either is true:
- It says so —
kind: 'thinking'(a thought) orkind: 'tool'(reaching for something). Runtimes should set this; it is the only signal that survives a turn with no streamed answer. - It arrived during a turn — after
typing, beforestream_end. A runtime raisestypingwhen an answer is coming and closes withstream_end, so whatever it sends in between is by definition not that answer. This is what lets existing runtimes get the work log without a change.
{
"clientId": "550e8400-e29b-41d4-a716-446655440000",
"text": "The user is asking for a story — no tools needed here.",
"parts": [{ "type": "text", "text": "The user is asking for a story — no tools needed here." }],
"kind": "thinking",
"messageId": "…",
"ts": 1712847600000
}Tool notes are conventionally an arrow and the tool's name (→ searchWeb); an
unmarked note that starts with → is read as tool, everything else as
thinking.
A plain message sent with no turn open is an ordinary answer bubble, and
stays one — that is what the reference runtime's send() does, and nothing
about it changed.
Agent is about to start generating a response.
{
"clientId": "550e8400-e29b-41d4-a716-446655440000",
"ts": 1712847600000
}Partial response. Both text and parts are accumulated so far.
{
"clientId": "550e8400-e29b-41d4-a716-446655440000",
"text": "Hello! How can I",
"parts": [
{ "type": "text", "text": "Hello! How can I" }
],
"messageId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"ts": 1712847600100
}Final response.
{
"clientId": "550e8400-e29b-41d4-a716-446655440000",
"text": "Hello! How can I help you today?",
"parts": [
{ "type": "text", "text": "Hello! How can I help you today?" }
],
"messageId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"ts": 1712847600200
}Keepalive from agent. Hub responds with pong.
{}For clients that cannot use WebSocket.
Base path: /api/agent
Fire-and-forget. Sends a message to the agent but does not wait for a response.
Request:
{
"text": "Hello, agent",
"parts": [
{ "type": "text", "text": "Hello, agent" }
]
}parts is optional. If omitted, built from text + images.
Response: 200 OK
{ "ok": true }Synchronous. Sends a message and waits for the complete agent response.
Timeout: 120 seconds.
Request:
{
"text": "Hello, agent",
"parts": [
{ "type": "text", "text": "Hello, agent" }
]
}Response: 200 OK
{
"text": "Hello! How can I help you?",
"messageId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"ts": 1712847600000
}On timeout:
{
"text": "Timeout: no response from agent",
"messageId": "",
"ts": 1712847600000
}Check overall hub status.
Response: 200 OK
{
"ok": true,
"agentConnected": true,
"browserClients": 3
}Check per-agent status.
Response: 200 OK
{
"ok": true,
"agentConnected": true,
"browserClients": 1,
"agentId": "bot-abc-123"
}Bridle uses accumulated text, not deltas.
Each stream event contains the full response generated so far:
stream #1: text = "Hello" parts = [{ type: "text", text: "Hello" }]
stream #2: text = "Hello, how" parts = [{ type: "text", text: "Hello, how" }]
stream #3: text = "Hello, how are?" parts = [{ type: "text", text: "Hello, how are?" }]
stream_end: text = "Hello, how are?" parts = [{ type: "text", text: "Hello, how are?" }]
On each stream event, replace the entire message (both text and parts). Do not append.
The BridleRepository batches stream chunks at 100ms intervals. The agent calls onChunk(accumulated) as it generates text, and the repository flushes the latest accumulated value to the hub every 100ms:
await bridle.streamSend(clientId, async (onChunk) => {
let accumulated = ''
for await (const token of llmStream) {
accumulated += token
onChunk(accumulated) // Full text so far, not just the new token
}
return accumulated // Returned value becomes the stream_end text
})| Accumulated (Bridle) | Delta-based (e.g. Vercel AI SDK) | |
|---|---|---|
| Client complexity | Low -- just replace text + parts | Higher -- must concatenate deltas |
| Bandwidth | O(n^2) over response length | O(n) |
| Missed events | Self-healing -- next event has full state | Data loss -- gaps are permanent |
| Late joiners | Can pick up from any event | Need full history |
The accumulated approach prioritizes simplicity and resilience over bandwidth efficiency.
enum BridlePartTypes {
Text = 'text',
Image = 'image',
File = 'file',
}
interface IBridleTextPart {
type: BridlePartTypes.Text
text: string
}
interface IBridleImagePart {
type: BridlePartTypes.Image
base64: string
mediaType: string // "image/jpeg" | "image/png" | "image/gif" | "image/webp"
}
interface IBridleFilePart {
type: BridlePartTypes.File
url: string
name: string
mimeType?: string
}
type BridlePart = IBridleTextPart | IBridleImagePart | IBridleFilePartinterface IBridleIncomingMessage {
type: 'message'
clientId: string
agentId: string
text: string
parts: BridlePart[]
messageId: string
}interface IBridleOutgoingEvent {
type: 'register' | 'message' | 'stream' | 'stream_end' | 'typing' | 'ping'
clientId?: string
text?: string
parts?: BridlePart[]
messageId?: string
ts?: number
/** On `message` only: this frame is a note, not the answer. */
kind?: 'thinking' | 'tool'
}interface IBridleHealthData {
ok: boolean
agentConnected: boolean // Whether any agent is connected
browserClients: number // Total connected browsers
}interface IBridleAgentHealthData {
ok: boolean
agentConnected: boolean // Whether this agent's runtime is connected
browserClients: number // Browsers connected to this agent
agentId: string // Agent identifier
}interface IBridleMessageData {
id: string
role: 'user' | 'assistant'
text: string
parts: BridlePart[]
ts: number
streaming?: boolean
}| Variable | Where | Required | Description |
|---|---|---|---|
BRIDLE_API_KEY |
Hub + Agent | yes | Shared secret for agent auth. Hub validates, agent sends. |
BRIDLE_AGENT_ID |
Agent | yes | Bot identifier sent in Socket.IO auth handshake |
BRIDLE_URL |
Agent | yes | Hub URL, e.g. http://localhost:3333 |
JWT_SECRET |
Hub | yes | Secret for JWT verification of browser tokens |
Browser Hub Agent
| | |
|-- message ------>| |
| { text, parts } |-- message -------->|
| | { clientId, text, |
| | parts } |
| | | (processing)
| |<-- message --------|
|<-- message ------| { text, parts, |
| { text, parts, | clientId } |
| ts } | |
Browser Hub Agent
| | |
|-- message ------>| |
| { text, parts } |-- message -------->|
| | |
| |<-- typing ---------|
|<-- typing -------| |
| | |
| |<-- stream ---------| (100ms flush)
|<-- stream -------| { text, parts } |
| | |
| |<-- stream ---------| (100ms flush)
|<-- stream -------| { text, parts } |
| | |
| |<-- stream_end -----|
|<-- stream_end ---| { text, parts } |
Browser Hub Agent
| | |
|-- message ------>| |
| { text, parts: | |
| [text] } |-- message -------->|
| | | (processing)
| |<-- message --------|
|<-- message ------| { text, parts: |
| { text, parts: | [text, image] } |
| [text, image] }| |
Browser Hub
| |
|-- message ------>|
| | (no agent for this agentId)
|<-- message ------|
| { text: "Agent is not connected...",
| parts: [{ type: "text", text: "Agent is not connected..." }] }
HTTP Client Hub Agent
| | |
|-- POST /:agentId/message -->| |
| /sync { text, parts } |-- message -------->|
| | | (processing)
| |<-- message --------|
|<-- 200 { text, ts } ------| |
Hub Agent
| |
| (agent disconnects)
| |
| (browsers get "Agent is not connected" on new messages)
| |
|<-- connect --------| (auto-reconnect after 3s)
|<-- register -------|
| |
| (messages resume normally)