@juspay/breeze-buddy-client-sdk
v0.3.0
Published
Browser SDK for Buddy AI voice agent — WebRTC voice sessions via Daily.co and Pipecat, plus HTTP/SSE chat (authenticated, demo, and storefront-widget) with SpecStream / A2UI generative-UI op streaming. Framework-agnostic; bring your own renderer.
Readme
@juspay/breeze-buddy-client-sdk
Browser SDK for Buddy AI agents — real-time voice sessions over WebRTC (Daily.co + Pipecat) and text chat sessions over HTTP/SSE. Two channels, one client.
npm install @juspay/breeze-buddy-client-sdkPure TypeScript, zero framework dependencies. Works in React, Vue, Svelte, or vanilla JS.
Stability: pre-1.0. The surface may change between minor versions until
1.0.0.
Table of Contents
- Quick start
- Constructing and starting a session
- Text chat
- Widget mode (storefront-public chat)
- Framework usage — Svelte, React, Vue
- Generative UI — OpenUI Lang
- Mock helper
- Voice — execution modes
- Voice — the
VoiceSessionhandle - Voice — subscription styles
- Voice — using raw events
- Voice — listening to the user (typed helpers)
- Voice — making the assistant speak (typed helpers)
- Voice — transcript vs. speaking
- Voice — wildcard subscription
- Voice events catalog
- Errors
- Low-level API & reference
Quick start
Two channels, one client — pick what your product needs:
- Text chat — runs anywhere
fetchworks. No microphone, no WebRTC. Most chatbot UIs want this. - Voice — real-time, full-duplex audio over WebRTC (Daily). Needs mic permission.
Text chat
import { BuddyClient } from '@juspay/breeze-buddy-client-sdk';
const client = new BuddyClient({
auth: { token: 'your-jwt-token' },
resellerId: 'my-reseller',
merchantId: 'my-merchant'
});
const chat = await client.startChatSession({
templateId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
payload: { customer_name: 'John' },
on: {
'user-committed': (entry) => addUserBubble(entry.content),
'assistant-token': (_d, e) => setLastBubble(e.content),
'assistant-message': (entry) => finalizeBubble(entry.content),
'turn-end': (status) => {
if (status === 'CANCELED') toast('Stopped');
}
}
});
await chat.send('Hi, can I confirm my order?');
// …user types another message…
await chat.send('Actually, change the address.');
await chat.end();The template must include "chat" in supported_channels. Full reference: Text chat.
Voice
SDK creates the lead (full flow):
import { BuddyClient } from '@juspay/breeze-buddy-client-sdk';
const client = new BuddyClient({
auth: { token: 'your-jwt-token' },
resellerId: 'my-reseller',
merchantId: 'my-merchant'
});
const session = await client.startVoiceSession({
templateId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
payload: { customer_name: 'John' }
});
session.on('transcript', (entry) => {
if (entry.role === 'user') console.log('user:', entry.text);
if (entry.role === 'assistant') console.log('assistant:', entry.text);
});
// …later
await session.close();Your backend provisions the Daily room (direct join):
import { joinRoom } from '@juspay/breeze-buddy-client-sdk';
const session = await joinRoom({ roomUrl, token });Same VoiceSession handle returned by both.
Stream mode (deterministic output, no LLM rewriting):
import { joinRoom } from '@juspay/breeze-buddy-client-sdk';
// 1. Join — your backend gave you { roomUrl, token }
const session = await joinRoom({ roomUrl, token });
// 2. Listen to the user — raw 'transcript' event, branch on role
session.on('transcript', (entry) => {
if (entry.role === 'user' && entry.isComplete) {
console.log('user said:', entry.text);
// Decide what the assistant should say next…
}
});
// (optional) Observe the assistant speaking — raw TTS lifecycle events
session.on('tts-start', () => showSpeakingIndicator());
session.on('tts-chunk', (text) => appendWord(text));
session.on('tts-end', () => hideSpeakingIndicator());
// 3. Make the assistant speak — bypasses the LLM in stream mode
await session.assistantSpeak('Hello! How can I help you today?');
await session.close();The voice examples use the raw session.on(event, handler) API. For role-filtered subscriptions see Listening to the user (typed helpers) and Making the assistant speak (typed helpers).
Constructing and starting a session
The BuddyClient is shared between channels — construct it once, then call startVoiceSession(...) for voice or startChatSession(...) for chat. The chat surface is documented in full under Text chat; voice options are described below.
new BuddyClient(options)
Create once per authenticated user. Long-lived — reuse across multiple calls and channels.
| ClientOptions | Type | Req. | Description |
| --------------- | ------------------------------------------ | ---- | ------------------------------------------------------------------- |
| auth | AuthConfig | Yes | { token: TokenInput } — see Token refresh below |
| resellerId | string | Yes | Must be one of the reseller IDs authorized in your JWT claims |
| merchantId | string | Yes | Must be one of the merchant IDs authorized in your JWT claims |
| baseUrl | string | No | API base URL. Defaults to https://clairvoyance.breezelabs.app |
| onEvent | (kind: string, payload: unknown) => void | No | Catch-all observability hook, see Observability |
The JWT carries
reseller_idsandmerchant_idsas authorization lists — a single token may authorize multiple reseller/merchant combos, so you pick one per client.
Token refresh
AuthConfig.token accepts either a static string OR a () => string | Promise<string> factory. Use the factory variant when your bearer is short-lived and rotates on the backend — the SDK calls it on every request, caches the result for 5 s to avoid flooding on bursts of sends, and on any 401 invalidates the cache and retries once before throwing AuthenticationError.
const client = new BuddyClient({
auth: {
token: async () => (await refreshTokenFromBackend()).accessToken
},
resellerId: 'r1',
merchantId: 'm1'
});Same shape on the widget factory — publicWidgetKey: TokenInput so storefront customer-token rotations (Shopify cycles them every 24 h) don't require recreating the session.
const chat = await createWidgetChatSession({
publicWidgetKey: async () => fetchCurrentWidgetKeyFromBackend(),
shopUrl: 'swaroop-juspay.myshopify.com'
});Backwards-compatible — static string tokens keep working byte-for-byte.
client.startVoiceSession(options)
Creates a lead via the API, then auto-connects WebRTC.
| StartVoiceSessionOptions | Type | Req. | Description |
| -------------------------- | ------------------------------------ | ---- | ------------------------------------------------------------ |
| templateId | string | Yes | Template UUID |
| payload | Record<string, unknown> | No | Template-specific payload |
| executionMode | 'production' \| 'test' \| 'stream' | No | Defaults to 'production' |
| requestId | string | No | Unique request ID for idempotency. Auto-generated if omitted |
| on | Partial<VoiceSessionEventMap> | No | Handlers registered before connect — no events missed |
joinRoom(options) — direct join, no client construction
| JoinRoomOptions | Type | Req. | Description |
| ----------------- | ------------------------------- | ---- | ------------------- |
| roomUrl | string | Yes | Daily room URL |
| token | string | Yes | Daily meeting token |
| on | Partial<VoiceSessionEventMap> | No | Initial handlers |
No auth, no resellerId, no merchantId — zero API calls, so none of that is needed.
For text chat (no microphone, no WebRTC), see Text chat below for client.startChatSession(options) and the rest of the chat surface.
Text chat
A ChatSession is the text counterpart to VoiceSession. Same handle shape (getState, on/off, helpers, Symbol.asyncDispose), different transport: HTTP create + SSE-streamed turns. Runs anywhere fetch works — no WebRTC, no microphone.
client.startChatSession(options)
Creates a chat session row server-side, then drives turns via SSE-streamed message endpoints. Returns a ChatSession handle.
| StartChatSessionOptions | Type | Req. | Description |
| ------------------------- | ------------------------------ | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| templateId | string | Yes | Template UUID. Must include "chat" in supported_channels. |
| payload | Record<string, unknown> | No | Render-time variables (same shape as voice payload). |
| initialContext | Record<string, unknown> | No | Structured context surfaced to the agent (cart snapshot, page URL, …). Mirrors the same field on createWidgetChatSession and createDemoChatSession — all three chat factories accept it uniformly from 0.4.0. |
| metadata | Record<string, unknown> | No | Opaque caller context, persisted on chat_session.metadata. |
| stream | boolean | No | Defaults true. If false, assistant-token is suppressed — only assistant-message fires per turn. |
| on | Partial<ChatSessionEventMap> | No | Handlers registered before the create request — no events missed (including the static greeting). |
End-to-end example:
const chat = await client.startChatSession({
templateId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
payload: { customer_name: 'John' },
on: {
'user-committed': (entry) => addUserBubble(entry.content),
'assistant-token': (_d, e) => setLastBubble(e.content),
'assistant-message': (entry) => finalizeBubble(entry.content),
'turn-end': (status) => {
if (status === 'CANCELED') toast('Stopped');
}
}
});
await chat.send('Hi, can I confirm my order?');
// …user types another message…
await chat.send('Actually, change the address.');
await chat.end(); // marks ENDED on the server
await chat.close(); // local cleanupThe ChatSession handle
| Method | Description |
| ------------------------- | ------------------------------------------------------------------------------------------------------------ |
| getState() | Read-only snapshot — sessionId, status, currentNode, transcripts, error. |
| send(text) | Drive one user → assistant turn. Resolves on turn-end. Concurrent calls are rejected. |
| cancel() | Abort the in-flight turn (the "Stop generating" affordance). VoiceSession stays alive for the next send(). |
| end() | Mark the session ENDED on the server. Idempotent. |
| close() | Local-only teardown — abort in-flight, clear listeners, drop transcripts. Does not call the server. |
| [Symbol.asyncDispose]() | Alias for close() — enables await using (ES2024+ engines). |
Events
Subscribe via chat.on(event, handler) or via options.on on startChatSession.
| Event | Handler | Notes |
| --------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------- |
| 'connected' | (sessionId: string) => void | Fires after the server creates the session row. |
| 'disconnected' | (reason: ChatEndedReason \| 'client_closed') => void | Lifecycle terminator. |
| 'state-change' | (status: ChatConnectionStatus) => void | Fires on every status transition. |
| 'error' | (message: string) => void | Surfaces fetch / lifecycle errors. |
| 'user-committed' | (entry: ChatUserMessage) => void | User message persisted server-side. |
| 'assistant-token' | (delta: string, entry: ChatAssistantMessage) => void | Per-token streaming. entry.content already includes delta. |
| 'assistant-message' | (entry: ChatAssistantMessage) => void | Full assistant turn. Also fires for the static greeting from connect. |
| 'turn-end' | (status: ChatTurnEndStatus) => void | 'ACTIVE' \| 'FAILED' \| 'ENDED' \| 'CANCELED'. |
| 'pipeline-error' | (details: ChatPipelineErrorDetails) => void | Backend pipeline failure during a turn. |
| '*' | ChatWildcardHandler | Receives all events: (event, ...args) => void. |
Typed helpers
Sugar over chat.on(...). Each returns an unsubscribe function.
| Helper | Wraps |
| ----------------------------- | --------------------- |
| onUserCommitted(handler) | 'user-committed' |
| onAssistantToken(handler) | 'assistant-token' |
| onAssistantMessage(handler) | 'assistant-message' |
| onTurnEnd(handler) | 'turn-end' |
| onError(handler) | 'error' |
Streaming UI — no manual buffering
assistant-token ships both the new delta and a snapshot of the in-progress message. For React, set the last bubble to entry.content directly — fresh identity per token, no append-loop:
chat.on('assistant-token', (_delta, entry) => {
setLastMessage(entry);
});For vanilla DOM, mutate in place using delta:
const bubble = document.querySelector('.bubble.streaming');
chat.on('assistant-token', (delta) => {
bubble.textContent += delta;
});If you set stream: false when starting the session, assistant-token does not fire — only a single assistant-message per turn.
Cancellation
Two equivalent ways to abort the in-flight turn — pick whichever fits your call site. Both fire turn-end with 'CANCELED' and leave the session in 'connected' so a follow-up send can fire. Combining both is idempotent — the first trigger wins.
Session-level cancel (good for a "Stop generating" button):
stopBtn.addEventListener('click', () => chat.cancel());
chat.on('turn-end', (status) => {
if (status === 'CANCELED') showToast('Stopped.');
});Per-call AbortSignal (good for tying the turn to a route change / React effect):
const ac = new AbortController();
useEffect(() => () => ac.abort(), []);
await chat.send('Find me a duffle bag', { signal: ac.signal });The promise resolves (does not reject) when aborted — abort is a normal outcome, not an error.
end() vs close()
| Method | Calls server /end | Clears listeners | Use when |
| --------- | ------------------- | ---------------- | ----------------------------------------- |
| end() | Yes | No | User clicked "End chat" — mark done |
| close() | No | Yes | Component unmount, page navigation, abort |
Most apps want await chat.end(); await chat.close(); — or, with await using, just rely on Symbol.asyncDispose to call close() and let the server time the row out on its own.
Errors during a turn
Same hierarchy as voice — see Errors. The chat path adds:
SessionError('A send is already in flight…')if you callsend()while a turn is streaming. Wait for the previoussendto settle, or callcancel()first.SessionError('pipeline <code>: <message>')thrown after apipeline-errorevent so awaiters get a thrown error too — you can listen viapipeline-errorfor richer detail.
Widget mode (storefront-public chat)
Storefront embeds (Shopify widget, partner sites, etc.) can't ship a JWT — the merchant's tenant key would leak. createWidgetChatSession() is the JWT-less chat factory: it calls the Clairvoyance widget-public endpoints (/agent/voice/breeze-buddy/widget/session{,/message,/end}) using a short-lived widget_token the server mints at create time. Same ChatSession-shape handle, same send / cancel / end / close, same typed event surface — plus the storefront-only events listed below.
import { createWidgetChatSession, SessionConflictError } from '@juspay/breeze-buddy-client-sdk';
const chat = await createWidgetChatSession({
publicWidgetKey: 'DKt-j-YE-qCZeNcUt_Fh2v4OQ9LjqbyreDjBi7Poe5s',
shopUrl: 'swaroop-juspay.myshopify.com',
customerToken: shopifyCustomerToken, // optional
initialContext: { utm_source: 'hero-cta' }, // optional analytics tags
on: {
'assistant-token': (_d, e) => setLastBubble(e.content),
'ui-emit': ({ ast }) => renderUi(ast),
'function-call-started': (call) => mascotThinking(call.name),
'function-call-completed': () => mascotIdle(),
'turn-end': (status) => {
if (status === 'CANCELED') toast('Stopped');
},
'voice-live-conflict': ({ message }) => toast(message)
}
});
try {
await chat.send('Show me a travel bag under 5K');
} catch (err) {
if (err instanceof SessionConflictError) {
// Voice is currently live on the same conversation. Surface a "voice in
// progress" affordance; the session stays open and the user can retry
// once voice ends.
showVoiceLiveBanner();
} else {
throw err;
}
}
await chat.end();
await chat.close();| CreateWidgetChatSessionOptions | Type | Req. | Description |
| -------------------------------- | ------------------------------------ | ---- | --------------------------------------------------------------------------------------------------- |
| publicWidgetKey | string | Yes | Public widget key from the admin widget-config flow. |
| shopUrl | string | Yes | Shop domain — *.myshopify.com. |
| customerToken | string | No | Optional Shopify Customer Account token, surfaced to the agent for personalisation. |
| baseUrl | string | No | Backend base URL. Defaults to https://clairvoyance.breezelabs.app. |
| initialContext | Record<string, unknown> | No | Opaque metadata persisted on the chat_session row. Useful for analytics tags. |
| on | Partial<WidgetChatSessionEventMap> | No | Handlers registered before the create POST — the static greeting and synchronous events fire. |
| signal | AbortSignal | No | Aborts only the create fetch. Use chat.cancel() per turn or chat.close() for the whole session. |
Widget-only events
In addition to every event from ChatSessionEventMap — including the new generative-UI events listed in the next section — the widget surface adds:
| Event | Handler | Notes |
| ----------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| 'voice-live-conflict' | (details: VoiceLiveConflictDetails) => void | Synthesised from HTTP 409 on session create or /message. The SDK also throws SessionConflictError so awaiters can branch with instanceof. |
Sugar helpers
| Helper | Wraps |
| ---------------------------------- | --------------------------- |
| onUserCommitted(handler) | 'user-committed' |
| onAssistantToken(handler) | 'assistant-token' |
| onAssistantMessage(handler) | 'assistant-message' |
| onTurnEnd(handler) | 'turn-end' |
| onError(handler) | 'error' |
| onUiEmit(handler) | 'ui-emit' |
| onFunctionCallStarted(handler) | 'function-call-started' |
| onFunctionCallCompleted(handler) | 'function-call-completed' |
| onNodeTransition(handler) | 'node-transition' |
| onVoiceLiveConflict(handler) | 'voice-live-conflict' |
Framework usage — Svelte, React, Vue
The widget at breeze-buddy-assist-widget ships a hand-rolled state machine on top of createWidgetChatSession: it lazily creates an assistant bubble on the first assistant-token, finalizes it when a tool call starts, splices ChatUiMessages in between, and reopens a fresh bubble for any post-tool text. Every consumer building a Buddy chat UI would otherwise rewrite the same logic.
Three idiomatic bindings ship the pattern as a one-line hook:
@juspay/breeze-buddy-client-sdk/svelte@juspay/breeze-buddy-client-sdk/react@juspay/breeze-buddy-client-sdk/vue
All three resolve through the same framework-agnostic store at @juspay/breeze-buddy-client-sdk/store — pick the binding that matches your framework, or import the store directly if you want the reactive primitive without a framework wrapper.
Choosing a binding
| Use… | When |
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| /svelte · /react · /vue | Default. Idiomatic ergonomics for your framework; one import, one hook. |
| /store | Same reactive store without a framework wrapper — useful for vanilla JS, custom adapters, or Solid / Preact / etc. |
| createWidgetChatSession / createMockWidgetChatSession | You want fine-grained event control, or you're building your own state model. |
The framework bindings are NOT re-exported from the main entry — a React app importing @juspay/breeze-buddy-client-sdk doesn't pull Svelte or Vue into its bundle. Always import the framework binding from its dedicated sub-export.
Store shape (shared)
Every binding surfaces the same state:
type ChatTextMessage = {
id: string;
kind: 'text';
role: 'user' | 'assistant';
content: string;
streaming: boolean; // true while assistant tokens are still landing
createdAt: number;
};
type ChatUiMessage = {
id: string;
kind: 'ui';
role: 'assistant';
dsl: string; // raw DSL for re-serialization / logging
ast: ParsedProgram | null; // pre-parsed AST ready for UiRenderer
parseError: ParseErrorInfo | null; // set if DSL failed to parse
sourceToolCallId?: string; // correlates to function-call-completed
createdAt: number;
};
type ChatMessage = ChatTextMessage | ChatUiMessage;
interface BuddyChatState {
messages: ChatMessage[];
status: 'idle' | 'connecting' | 'ready' | 'sending' | 'error' | 'closed';
error: BuddyError | null;
sessionId: string | null;
}Methods (also shared): send(text, { signal? }), cancel(), close(), end(), retryLastUser().
Svelte binding
<script lang="ts">
import { onDestroy } from 'svelte';
import { createBuddyChat } from '@juspay/breeze-buddy-client-sdk/svelte';
const chat = createBuddyChat({
publicWidgetKey: 'pk_live_…',
shopUrl: 'my-shop.myshopify.com'
});
let input = $state('');
async function submit() {
const text = input.trim();
if (!text) return;
input = '';
try {
await chat.send(text);
} catch (err) {
// store.error is already set; the catch is here so unhandled rejections
// don't leak past the framework boundary.
}
}
onDestroy(() => void chat.close());
</script>
<!-- $chat autosubscribe — the returned store satisfies Svelte's Readable contract. -->
<ul>
{#each $chat.messages as msg (msg.id)}
{#if msg.kind === 'text'}
<li class:streaming={msg.streaming}>{msg.content}</li>
{:else}
<!-- Hand `msg.dsl` + `msg.ast` to <UiRenderer> — see the Generative UI section. -->
<li>[ui block from {msg.sourceToolCallId}]</li>
{/if}
{/each}
</ul>
<input bind:value={input} disabled={$chat.status === 'sending'} />
<button onclick={submit} disabled={$chat.status === 'sending'}>Send</button>
{#if $chat.error}
<p role="alert">{$chat.error.message}</p>
{/if}Prefer runes? Subscribe by hand:
<script lang="ts">
import { createBuddyChat, type BuddyChatState } from '@juspay/breeze-buddy-client-sdk/svelte';
const chat = createBuddyChat({ publicWidgetKey, shopUrl });
let state = $state<BuddyChatState>(chat.getState());
$effect(() =>
chat.subscribe((s) => {
state = s;
})
);
</script>Available factories: createBuddyChat (widget), createDemoBuddyChat (demo), createMockBuddyChat (in-memory mock).
React binding
import { useBuddyChat } from '@juspay/breeze-buddy-client-sdk/react';
import { useState } from 'react';
export function Chat() {
const chat = useBuddyChat({
publicWidgetKey: 'pk_live_…',
shopUrl: 'my-shop.myshopify.com'
});
const [input, setInput] = useState('');
async function submit() {
const text = input.trim();
if (!text) return;
setInput('');
try {
await chat.send(text);
} catch {
// chat.error is already set; catch to avoid an unhandled rejection.
}
}
return (
<>
<ul>
{chat.messages.map((m) =>
m.kind === 'text' ? (
<li key={m.id} className={m.streaming ? 'streaming' : undefined}>
{m.content}
</li>
) : (
<li key={m.id}>[ui block from {m.sourceToolCallId}]</li>
)
)}
</ul>
<input
value={input}
disabled={chat.status === 'sending'}
onChange={(e) => setInput(e.target.value)}
/>
<button onClick={submit} disabled={chat.status === 'sending'}>
Send
</button>
{chat.error && <p role="alert">{chat.error.message}</p>}
</>
);
}Notes:
- Implemented with
useSyncExternalStore(React 18 idiomatic external-store integration). - Store is created in a
useRefso it survives Strict Mode double-mount;close()runs in the effect cleanup and is idempotent. - Options are captured ONCE on first render. Swap options by remounting the component (e.g., change its
key).
Available hooks: useBuddyChat, useDemoBuddyChat, useMockBuddyChat.
Vue binding
<script setup lang="ts">
import { ref } from 'vue';
import { useBuddyChat } from '@juspay/breeze-buddy-client-sdk/vue';
const { messages, status, error, send } = useBuddyChat({
publicWidgetKey: 'pk_live_…',
shopUrl: 'my-shop.myshopify.com'
});
const input = ref('');
async function submit() {
const text = input.value.trim();
if (!text) return;
input.value = '';
try {
await send(text);
} catch {
// error.value is already set; catch to avoid an unhandled rejection.
}
}
</script>
<template>
<ul>
<li v-for="m in messages" :key="m.id">
<span v-if="m.kind === 'text'" :class="{ streaming: m.streaming }">
{{ m.content }}
</span>
<span v-else>[ui block from {{ m.sourceToolCallId }}]</span>
</li>
</ul>
<input v-model="input" :disabled="status === 'sending'" />
<button :disabled="status === 'sending'" @click="submit">Send</button>
<p v-if="error" role="alert">{{ error.message }}</p>
</template>The composable exposes messages, status, error, sessionId as Refs (so <template> interpolation is reactive) and send / cancel / close / end / retryLastUser as plain functions. The store is closed on onUnmounted.
Available composables: useBuddyChat, useDemoBuddyChat, useMockBuddyChat.
Generative UI — OpenUI Lang
As of 0.6.0 the SDK replaces the typed CardPayload discriminated union with OpenUI Lang — a compact LLM-authored DSL based on Thesys's OpenUI (MIT). The agent emits a small <ui>…</ui> block inline in its assistant text; the server resolves all tool-data references to inline JSON literals (zero hallucination surface) and ships the resolved DSL on a ui_emit SSE frame.
The SDK pre-parses the DSL via the vendored _lang-core parser and emits the typed 'ui-emit' event with both the raw DSL (for re-serialization / logging) and the parsed AST ready for the renderer. Parse failures route to 'ui-parse-error' — your 'ui-emit' handler only ever sees valid programs.
import { type ParsedAction } from '@juspay/breeze-buddy-client-sdk';
chat.on('ui-emit', ({ dsl, ast, sourceToolCallId }) => {
console.log('UI block from tool', sourceToolCallId);
// hand `ast` to UiRenderer (see Svelte example below)
});
chat.on('ui-parse-error', (raw) => {
console.warn('UI parse failed', raw);
});Primitive vocabulary (v0.6.1 — 12 components)
The LLM composes these primitives — generic-by-design, no commerce coupling. Compose Card + Image + Text + Money + Buttons for a product tile; compose Stack + Card + Table + Tag for a KPI dashboard. The same vocabulary serves shopping, support, analytics, and graphs.
| Primitive | Purpose |
| ------------ | ------------------------------------------------------------------------------------ |
| Stack | Vertical column with configurable gap / alignment |
| Row | Horizontal flex row |
| Card | Padded surface — card / sunk / clear variants |
| CardHeader | Title + optional subtitle |
| Image | Aspect-locked, lazy-loaded image |
| Text | Body / caption / heading / subheading / mono |
| Carousel | Horizontally scrolling list with snap |
| Button | Clickable affordance bound to a ParsedAction |
| Buttons | Group container for related buttons |
| Table | Column headers + rows (cells: string / number / boolean) |
| Tag | Coloured chip / status label |
| Money | Currency-formatted amount — takes a minor-unit integer (paisa/cents) + ISO 4217 code |
Money — currency formatting
Prices arrive from the server in minor currency units (paisa for INR, cents for USD, etc.) — the LLM forwards the raw MCP value and lets the client format. Composition example:
Money(p.price, p.currency)The DSL signature is Money(amount, currency, locale?):
amount— number in minor units (69995paisa,1000JPY).currency— ISO 4217 3-letter code ("INR","USD","JPY", …).locale— optional BCP-47 locale ("en-IN"); defaults to the browser'snavigator.language.
Rendering rules: zero-decimal currencies (JPY, KRW, VND, BIF, CLP, DJF, GNF, ISK, KMF, PYG, RWF, UGX, UYI, VUV, XAF, XOF, XPF) skip the division; three-decimal currencies (JOD, KWD, OMR, BHD, IQD, LYD, TND) divide by 1000; everything else divides by 100. Formatting uses Intl.NumberFormat. An unknown ISO code falls back to "<rawAmount> <currency>" rather than crashing the surrounding UI message.
This centralises the scaling logic that used to live in Clairvoyance's tool_result_normalizer.py; the agent now forwards raw MCP data and the client owns currency-aware rendering.
Action vocabulary
The renderer dispatches all button clicks via a single onAction(action: ParsedAction) callback. ParsedAction is a discriminated union:
type ParsedAction =
| { type: 'to_assistant'; message: string; formState?: Record<string, unknown> }
| { type: 'open_url'; url: string; target?: string }
| { type: 'custom'; name: string; payload: Record<string, unknown> };The DSL action syntax is Action([@ToAssistant("msg")]) or Action([@OpenUrl("url")]) (plus lang-core's internal @Run, @Set, @Reset, @Each, @If). No @AddToCart or other domain-specific actions — those round-trip through @ToAssistant("Add it to my cart").
Rendering — Svelte
<script lang="ts">
import { createBuddyChat, UiRenderer } from '@juspay/breeze-buddy-client-sdk/svelte';
const chat = createBuddyChat({
publicWidgetKey: 'pk_test_…',
shopUrl: 'my-shop.myshopify.com'
});
</script>
<ul>
{#each $chat.messages as msg (msg.id)}
<li>
{#if msg.kind === 'text'}{msg.content}{:else if msg.kind === 'ui'}
<UiRenderer dsl={msg.dsl} ast={msg.ast} onAction={chat.handleUiAction} />
{/if}
</li>
{/each}
</ul>chat.handleUiAction is the store's default dispatcher — routes to_assistant through chat.send, opens URLs via window.open, and forwards custom actions to the optional onCustomUiAction hook passed at store creation. Override by passing your own onAction to <UiRenderer> (the widget does this to intercept open_url and fire a host-page CustomEvent before the navigation).
Theming
Each primitive reads --openui-* CSS custom properties for surface / fg / line / shadow / typography / colour tokens with sensible fallbacks (var(--openui-surface, white)). Map them to your design system once in the host:
:root {
--openui-surface: #ffffff;
--openui-fg: #1f2937;
--openui-line: rgba(0, 0, 0, 0.08);
--openui-radius-card: 12px;
--openui-radius-pill: 999px;
--openui-primary: #1f2937;
--openui-success: #1b7e3d;
--openui-danger: #c4322a;
}Custom primitives
Add your own primitive (a Markdown renderer, a chart, a quantity stepper) by registering it on a derived registry. The lang-core parser maps positional args to your Zod prop schema's key order:
import { z } from 'zod';
import {
defaultPrimitives,
defineComponent,
registerPrimitive
} from '@juspay/breeze-buddy-client-sdk';
const Markdown = defineComponent({
name: 'Markdown',
propsSchema: z.object({ source: z.string() })
});
const registry = registerPrimitive(defaultPrimitives(), Markdown);Then pass registry to <UiRenderer> and ship your renderer for Markdown in the Svelte slot.
Parser-only sub-export
If you only need the parser + types (analytics tooling, server-side validation, Storybook fixtures) without dragging in chat / voice / renderer, pull from the lighter sub-export:
import { parseUiDsl, type ParsedProgram } from '@juspay/breeze-buddy-client-sdk/ui';React + Vue
Phase 2 — the React + Vue UiRenderer implementations are stubs in 0.6.0 and ship in 0.7.0. Use the Svelte renderer for now (the widget already does), or write your own React/Vue renderer atop the same parseUiDsl + defaultPrimitives primitives.
Mock helper
createMockWidgetChatSession() returns the same handle as createWidgetChatSession() but never touches the network — handy for Storybook, design QA, and tests where you want to exercise the full event taxonomy without a backend.
import {
createMockWidgetChatSession,
dashboardScenario,
snowboardScenario,
travelBagScenario
} from '@juspay/breeze-buddy-client-sdk/mock';
const chat = await createMockWidgetChatSession({
waitFactor: 0, // 0 = instant; 1 = realistic pacing
scenarios: [travelBagScenario, snowboardScenario, dashboardScenario],
on: {
'ui-emit': ({ ast }) => console.log('UI block', ast.root?.typeName),
'turn-end': (status) => console.log('done', status)
}
});
await chat.send('show me a snowboard'); // matches snowboardScenarioPass your own MockScenario[] to override the canned ones. Each scenario is an array of typed steps (token, ui-emit, function-call-started, wait, etc.) — wire captures from a real session can be pasted in directly. The bundled scenarios now showcase non-commerce use cases too (dashboardScenario, supportTicketScenario) to highlight the use-case-agnosticism of the generative-UI surface.
Execution modes
Voice only. Text chat has no execution-mode concept — it's request/response over HTTP.
| Mode | Wire | Pipeline | Use for |
| -------------- | -------------- | ------------------------- | --------------------------------------------------------- |
| 'production' | DAILY | STT → LLM → TTS | Normal conversational flow (default) |
| 'test' | DAILY_TEST | STT → LLM → TTS (sandbox) | Sandbox with no telephony side effects |
| 'stream' | DAILY_STREAM | STT → TTS (no LLM) | Deterministic, scripted output — compliance, IVR, handoff |
Pick 'stream' when you want the assistant to say exactly what you tell it to, via session.assistantSpeak(text), without the LLM rewriting it.
The VoiceSession handle
Voice only. The text-chat counterpart is
ChatSession.
Returned by both startVoiceSession() and joinRoom(). Everything you can do with a live voice session is listed here.
Lifecycle & mic
| Method | Description |
| ------------------------- | ---------------------------------------------------------------- |
| getState() | Read-only snapshot of current state |
| close() | End the call, release audio, remove listeners, clear transcripts |
| [Symbol.asyncDispose]() | Alias for close() — enables await using (ES2024+ engines) |
| mute() / unmute() | Mic on/off |
| setMicEnabled(enabled) | Explicit set |
Outbound
| Method | Description |
| ----------------------------- | --------------------------------------------------------------------- |
| assistantSpeak(text) | Send text to TTS. Returns Promise<void> resolving on next tts-end |
| sendMessage(msgType, data?) | Low-level RTVI escape hatch for custom backend handlers |
Events
| Method | Description |
| -------------------------------- | ----------------------------------------------------------------------------------------- |
| on(event, handler) | Subscribe to any session event (see Voice events catalog) |
| off(event, handler) | VoiceUnsubscribe |
| onUserTranscript(handler) | Filtered: user transcripts only. Returns VoiceUnsubscribe |
| onAssistantTranscript(handler) | Filtered: assistant transcripts only. Returns VoiceUnsubscribe |
| onToolCall(handler) | Filtered: tool-call transcripts only. Returns VoiceUnsubscribe |
| onUserSpeaking(handler) | User VAD — {start}, {end}. Returns VoiceUnsubscribe |
| onAssistantSpeaking(handler) | Assistant TTS lifecycle — {start}, {chunk, text}, {end}. Returns VoiceUnsubscribe |
Snapshot shape — getState()
type VoiceSessionState = {
status: VoiceConnectionStatus;
isMicEnabled: boolean;
transcripts: VoiceTranscriptEntry[];
assistantAudioTrack: MediaStreamTrack | null;
userAudioTrack: MediaStreamTrack | null;
error: string | null;
};await using — automatic cleanup (ES2024+)
await using session = await joinRoom({ roomUrl, token });
// session.close() runs automatically when the block exitsTwo subscription styles — raw vs. typed helpers
Voice. Text chat has its own helper set — see Typed helpers under Text chat.
The SDK gives you two equivalent ways to react to what happens in a session:
- Raw events —
session.on(eventName, handler)/session.off(eventName, handler). One subscription method, all event names kebab-case. Everything the SDK can tell you is a plain event. This is the primary API. - Typed helpers —
session.onUserTranscript(...),session.onAssistantSpeaking(...), etc. Thin wrappers over the raw events that pre-filter (e.g. only user transcripts) or aggregate (e.g.onAssistantSpeakingmergestts-start/tts-chunk/tts-endinto one handler). Each returns anVoiceUnsubscribefunction, so no pairedoffcall needed.
Pick one style and stay consistent. Mixing them inside a single feature (e.g. subscribing to raw 'transcript' for the user and typed onAssistantSpeaking for the assistant) works — but is noise for readers. The rest of this doc shows raw first, then the typed-helper equivalents.
Using raw events
Voice. The chat equivalent —
chat.on('user-committed', …),chat.on('assistant-token', …), etc. — is shown in Text chat → Events.
Everything the session surfaces comes through session.on(eventName, handler). All event names are kebab-case, matching the Daily / Pipecat / Web-API convention. Handlers registered via on must be removed with session.off(event, handler) when you no longer want them — or they'll be cleaned up automatically at session.close().
Listening to the user
User text (STT output) and user speech activity (VAD) are two independent streams.
User text — 'transcript' event, branch on role:
session.on('transcript', (entry) => {
if (entry.role !== 'user') return;
updateBubble(entry.id, entry.text, entry.isComplete);
});Transcripts stream in place — the same id is emitted multiple times as text grows, with isComplete: true on the final version.
User speech activity (no text) — VAD events:
session.on('user-speech-start', () => showListeningIndicator());
session.on('user-speech-end', () => hideListeningIndicator());Fires near-instantly when the mic picks up speech, well before STT produces text. Useful for "🎙️ listening" indicators.
Observing the assistant
The assistant surfaces on two different pipeline stages. Both come through raw events.
Assistant text — 'transcript' event with role === 'assistant':
session.on('transcript', (entry) => {
if (entry.role !== 'assistant') return;
renderAssistantText(entry.id, entry.text, entry.isComplete);
});Does not fire in
'stream'mode (no LLM). Use the TTS events below as your text source in stream mode.
Assistant TTS lifecycle — what the user actually hears:
session.on('tts-start', () => showSpeakingIndicator());
session.on('tts-chunk', (text) => appendWord(text));
session.on('tts-end', () => hideSpeakingIndicator());Fires in every execution mode, including 'stream'. See Transcript vs. Speaking for when to pick which.
Tool calls — also on 'transcript' with role === 'tool_call':
session.on('transcript', (entry) => {
if (entry.role !== 'tool_call') return;
console.log('tool invoked:', entry.functionName, 'complete=', entry.isComplete);
});Connection and conversation lifecycle
session.on('state-change', (status) => {
if (status === 'connected') showCallUI();
if (status === 'disconnected') showEndedScreen();
if (status === 'error') showErrorScreen();
});
session.on('connected', () => console.log('WebRTC up'));
session.on('assistant-ready', () => enableInput()); // bot pipeline is live
session.on('disconnected', () => console.log('call ended'));
session.on('error', (message) => showError(message));
// Server-emitted conversation events (Breeze Buddy):
session.on('conversation-start', () => markCallStarted());
session.on('conversation-end', (reason) => logEndReason(reason));
session.on('pipeline-error', (details) => logPipelineError(details));Status graph:
idle → connecting → connected → disconnecting → disconnected
↘ errorMedia tracks and mic
session.on('track-started', (track, local) => attachTrack(track, local));
session.on('track-stopped', (track, local) => detachTrack(track, local));
session.on('mic-change', (enabled) => updateMicUI(enabled));Barge-in (raw-event version)
Pipecat's VAD auto-cancels TTS when the user speaks. Detect the overlap with raw events:
let assistantIsSpeaking = false;
session.on('tts-start', () => {
assistantIsSpeaking = true;
});
session.on('tts-end', () => {
assistantIsSpeaking = false;
});
session.on('user-speech-start', () => {
if (assistantIsSpeaking) handleBargeIn();
});Registering handlers before connect
Both client.startVoiceSession({...}) and joinRoom({...}) accept an on map so handlers see every event from 'connecting' onward — no race where a fast 'connected' fires before you subscribe.
const session = await joinRoom({
roomUrl,
token,
on: {
'state-change': (status) => console.log('[state]', status),
transcript: (entry) => appendTranscript(entry),
'tts-start': () => showSpeakingIndicator(),
'tts-end': () => hideSpeakingIndicator()
}
});Listening to the user (typed helpers)
Voice. The chat equivalent is
chat.onUserCommitted(...)— see Typed helpers under Text chat.
Same underlying events as above, pre-filtered. Each helper returns an VoiceUnsubscribe function — call it to remove the handler (no paired off needed).
User text — onUserTranscript
Delivers only entries where role === 'user', so no manual branching:
const unsubscribe = session.onUserTranscript((entry) => {
updateBubble(entry.id, entry.text, entry.isComplete);
});
// …later
unsubscribe();If you only care about the final text:
session.onUserTranscript((entry) => {
if (entry.isComplete) console.log('user said:', entry.text);
});User speech activity — onUserSpeaking
Merges 'user-speech-start' + 'user-speech-end' into one discriminated event:
session.onUserSpeaking((event) => {
if (event.type === 'start') showListeningIndicator();
if (event.type === 'end') hideListeningIndicator();
});No chunk variant — user speech has no text here; that's what onUserTranscript is for.
Making the assistant speak (typed helpers)
Voice. There's no chat counterpart — assistant text is consumed via
chat.onAssistantToken(...)/chat.onAssistantMessage(...).
Same events as in Using raw events, wrapped for ergonomics.
assistantSpeak(text) — push text to TTS
session.assistantSpeak(text) sends text straight to TTS. In 'stream' mode this bypasses the LLM entirely — text is spoken verbatim.
await session.assistantSpeak('Hello, how can I help you today?');
startListening();
await session.assistantSpeak('Please hold while I transfer you.');
transferCall();Signature & behavior:
session.assistantSpeak(text: string): Promise<void>- Resolves on the next
'tts-end'after sending. - Rejects with
SessionErrorif the session isn't connected or closes before completion. - Rejects with
InvalidRequestErroriftextis empty / whitespace-only. - Text over 2000 chars is truncated (with a console warning).
Observing speech — onAssistantSpeaking
Merges 'tts-start' / 'tts-chunk' / 'tts-end' into one discriminated event. Subscribe once, not per call:
session.onAssistantSpeaking((event) => {
switch (event.type) {
case 'start':
showSpeakingIndicator();
break;
case 'chunk':
appendWord(event.text);
break;
case 'end':
hideSpeakingIndicator();
break;
}
});Fires in every execution mode, including 'stream' (because it's tied to TTS, not the LLM).
Observing assistant text — onAssistantTranscript
Delivers only entries where role === 'assistant' — the LLM's streaming response, before it reaches TTS:
session.onAssistantTranscript((entry) => {
renderAssistantBubble(entry.id, entry.text, entry.isComplete);
});Does not fire in 'stream' mode (no LLM). Use onAssistantSpeaking as your text source there.
Tool calls — onToolCall
Delivers only entries where role === 'tool_call':
session.onToolCall((entry) => {
console.log('tool invoked:', entry.functionName, 'complete=', entry.isComplete);
});Why no per-utterance callback on assistantSpeak?
Pipecat's TTS events carry no correlation ID, and the pipeline can produce TTS for reasons other than your call (server-initiated idle prompts, barge-in interruption, template-baked audio). A callback claiming "these events are for your utterance" would lie about a precision the underlying system doesn't provide. The Promise resolves on "the next tts-end" — honest and scoped; for live observation you subscribe to the global stream via onAssistantSpeaking.
Barge-in detection (typed-helper version)
let assistantIsSpeaking = false;
session.onAssistantSpeaking((e) => {
if (e.type === 'start') assistantIsSpeaking = true;
if (e.type === 'end') assistantIsSpeaking = false;
});
session.onUserSpeaking((e) => {
if (e.type === 'start' && assistantIsSpeaking) handleBargeIn();
});Cancelling TTS — TODO (cross-team)
No client-triggerable way to flush the assistant mid-utterance today. The only cancellation path is automatic VAD-driven barge-in. Programmatic flush requires a new on_client_message handler in clairvoyance (at app/ai/voice/agents/breeze_buddy/agent/__init__.py:650-665, where tts-speak is registered); the SDK side is a 3-line session.cancelSpeech() wrapper once the backend ships.
Transcript vs. Speaking
Voice. Chat has no streaming-TTS distinction — text just streams as
assistant-token.
The assistant has two "what it said" streams that fire at different pipeline stages. Pick by use case, not by feel.
| | onAssistantTranscript / 'transcript' (assistant) | onAssistantSpeaking / 'tts-*' |
| ------------------------------------------------- | ---------------------------------------------------- | ---------------------------------- |
| Source | LLM token stream | TTS pipeline output |
| Fires when | Model is generating text | Audio is being synthesized |
| Stream mode (no LLM) | ❌ never fires | ✅ fires — only text stream |
| Production / test mode | ✅ fires (earlier in the pipeline) | ✅ fires (after TTS begins) |
| Handler receives | Streaming AssistantTranscript | 'start' \| 'chunk' \| 'end' |
| Reflects post-processing? (PII, profanity filter) | No — raw LLM output | Yes — what the user actually hears |
| Use for | Render the model's response as text | Sync UI with actual audio |
Rule of thumb:
- What the model said →
onAssistantTranscript(or'transcript'with role filter) - What the user is hearing →
onAssistantSpeaking(or'tts-*'events) - In stream mode →
onAssistantSpeaking/'tts-*'is your only text stream
Symmetric helpers on the user side: onUserTranscript (STT text) vs onUserSpeaking (VAD activity).
Wildcard subscription
Examples below use the voice
VoiceSessionEventMap. The same pattern works for chat — subscribe withchat.on('*', handler)againstChatSessionEventMap.
Pass '*' to session.on to receive every other event in one place — useful for logging, analytics, or mirroring the entire session into a state store.
The handler signature is (eventName, ...originalArgs). eventName is excluded from the wildcard namespace (you won't get '*' for '*'), and the args are the original event's args — so casting per-case gives you full type safety.
import type {
VoiceConnectionStatus,
VoiceConversationEndReason,
VoicePipelineErrorDetails,
VoiceTranscriptEntry
} from '@juspay/breeze-buddy-client-sdk';
session.on('*', (event, ...args) => {
switch (event) {
// --- Connection ---
case 'connected':
onConnected();
break;
case 'disconnected':
onDisconnected();
break;
case 'error': {
const [message] = args as [string];
showError(message);
break;
}
case 'state-change': {
const [status] = args as [VoiceConnectionStatus];
renderStatus(status);
break;
}
case 'assistant-ready':
enableInput();
break;
// --- Conversation lifecycle (server-emitted) ---
case 'conversation-start':
markCallStarted();
break;
case 'conversation-end': {
const [reason] = args as [VoiceConversationEndReason];
logEndReason(reason);
break;
}
case 'pipeline-error': {
const [details] = args as [VoicePipelineErrorDetails];
logPipelineError(details);
break;
}
// --- Media ---
case 'track-started': {
const [track, local] = args as [MediaStreamTrack, boolean];
attachTrack(track, local);
break;
}
case 'track-stopped': {
const [track, local] = args as [MediaStreamTrack, boolean];
detachTrack(track, local);
break;
}
case 'mic-change': {
const [enabled] = args as [boolean];
updateMicUI(enabled);
break;
}
// --- Speech activity (VAD — no text) ---
case 'user-speech-start':
case 'user-speech-end':
case 'assistant-speech-start':
case 'assistant-speech-end':
markSpeechActivity(event);
break;
// --- TTS lifecycle ---
case 'tts-start':
showSpeakingIndicator();
break;
case 'tts-chunk': {
const [text] = args as [string];
appendWord(text);
break;
}
case 'tts-end':
hideSpeakingIndicator();
break;
// --- Transcripts ---
case 'transcript': {
const [entry] = args as [VoiceTranscriptEntry];
if (entry.role === 'user') updateUserBubble(entry);
else if (entry.role === 'assistant') updateAssistantBubble(entry);
else if (entry.role === 'tool_call') logToolCall(entry);
break;
}
// --- Telemetry ---
case 'metrics': {
const [data] = args as [unknown];
pushMetrics(data);
break;
}
}
});The wildcard fires in addition to any specific subscriptions you've made — not instead of them. So you can keep per-event subscriptions for hot paths and use '*' purely for observability.
Voice events catalog
Reference table of every voice event. Subscribe via session.on(event, handler) or via options.on on startVoiceSession / joinRoom. (Chat events are documented in their own Events sub-section under Text chat.)
Connection
| Event | Handler |
| ------------------- | ----------------------------------------- |
| 'connected' | () => void |
| 'disconnected' | () => void |
| 'error' | (message: string) => void |
| 'state-change' | (status: VoiceConnectionStatus) => void |
| 'assistant-ready' | () => void |
Conversation lifecycle (server-emitted)
| Event | Handler |
| ---------------------- | ---------------------------------------------- |
| 'conversation-start' | () => void |
| 'conversation-end' | (reason: VoiceConversationEndReason) => void |
| 'pipeline-error' | (details: VoicePipelineErrorDetails) => void |
Media
| Event | Handler |
| ----------------- | --------------------------------------------------- |
| 'track-started' | (track: MediaStreamTrack, local: boolean) => void |
| 'track-stopped' | (track: MediaStreamTrack, local: boolean) => void |
| 'mic-change' | (enabled: boolean) => void |
Speech activity (VAD — no text)
| Event | Handler |
| -------------------------- | ------------ |
| 'user-speech-start' | () => void |
| 'user-speech-end' | () => void |
| 'assistant-speech-start' | () => void |
| 'assistant-speech-end' | () => void |
TTS lifecycle
| Event | Handler |
| ------------- | ------------------------ |
| 'tts-start' | () => void |
| 'tts-chunk' | (text: string) => void |
| 'tts-end' | () => void |
Transcripts & telemetry
| Event | Handler |
| -------------- | --------------------------------------- |
| 'transcript' | (entry: VoiceTranscriptEntry) => void |
| 'metrics' | (data: unknown) => void |
Wildcard
| Event | Handler |
| ----- | ---------------------------------------------------------------------- |
| '*' | (event: Exclude<VoiceSessionEvent, '*'>, ...args: unknown[]) => void |
See Wildcard subscription for a complete switch/case example.
Errors
All errors extend BuddyError. Branch with instanceof — no string code matching.
import {
BuddyError,
AuthenticationError,
APIError,
NetworkError,
TimeoutError,
InvalidRequestError,
SessionError
} from '@juspay/breeze-buddy-client-sdk';
try {
const session = await client.startVoiceSession({ templateId, payload });
} catch (err) {
if (err instanceof AuthenticationError) return refreshTokenAndRetry();
if (err instanceof NetworkError || err instanceof TimeoutError) return showRetryBanner();
if (err instanceof APIError) console.error(err.statusCode, err.details);
if (err instanceof BuddyError) console.error(err.message);
}| Class | Thrown when |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| BuddyError | Base class — catch-all for SDK errors |
| AuthenticationError | HTTP 401 / 403 |
| APIError | Other non-2xx API responses |
| NetworkError | Fetch failed (offline, DNS, CORS, etc.) |
| TimeoutError | Request exceeded the 30s timeout |
| InvalidRequestError | Invalid SDK usage (e.g. empty assistantSpeak text) |
| SessionError | VoiceSession / ChatSession lifecycle error (e.g. speak before connect) |
| SessionConflictError | HTTP 409 — typically "voice attachment is currently live" in widget mode. Carries statusCode: 409 automatically. |
| NotImplementedError | Preview API surfaced ahead of the server-side counterpart — currently thrown by session.transferTo(...). See Coming next. |
Every instance carries .message, optional .statusCode, and optional .details (raw response body).
Observability
Every factory (BuddyClient constructor, createWidgetChatSession, createDemoChatSession, joinRoom, createMockWidgetChatSession) accepts an onEvent option that fires after every internal emit — perfect for one-shot wiring of PostHog, Sentry breadcrumbs, Datadog spans, or your own analytics.
const client = new BuddyClient({
auth: { token },
resellerId: 'r1',
merchantId: 'm1',
onEvent: (kind, payload) => {
posthog.capture(`buddy.${kind}`, { payload });
}
});kindis the kebab-case event name ('ui-emit','assistant-token','turn-end', …).payloadis the unwrapped single arg when emit was called with one arg, or the tuple when emitted with multiple args (e.g.('assistant-token', delta, entry)→[delta, entry]).- Errors thrown inside
onEventare caught + logged with prefix[@juspay/breeze-buddy-client-sdk:observability]— they never propagate to the consumer.
Use this in place of an on('*', ...) wildcard if you want every consumer of the SDK to behave the same way without each one rewriting
