npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-sdk

Pure 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

Two channels, one client — pick what your product needs:

  • Text chat — runs anywhere fetch works. 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_ids and merchant_ids as 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 cleanup

The 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 call send() while a turn is streaming. Wait for the previous send to settle, or call cancel() first.
  • SessionError('pipeline <code>: <message>') thrown after a pipeline-error event so awaiters get a thrown error too — you can listen via pipeline-error for 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:

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 useRef so 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 (69995 paisa, 1000 JPY).
  • currency — ISO 4217 3-letter code ("INR", "USD", "JPY", …).
  • locale — optional BCP-47 locale ("en-IN"); defaults to the browser's navigator.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 snowboardScenario

Pass 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 exits

Two 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 eventssession.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 helperssession.onUserTranscript(...), session.onAssistantSpeaking(...), etc. Thin wrappers over the raw events that pre-filter (e.g. only user transcripts) or aggregate (e.g. onAssistantSpeaking merges tts-start / tts-chunk / tts-end into one handler). Each returns an VoiceUnsubscribe function, so no paired off call 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
                ↘ error

Media 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 SessionError if the session isn't connected or closes before completion.
  • Rejects with InvalidRequestError if text is 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 saidonAssistantTranscript (or 'transcript' with role filter)
  • What the user is hearingonAssistantSpeaking (or 'tts-*' events)
  • In stream modeonAssistantSpeaking / '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 with chat.on('*', handler) against ChatSessionEventMap.

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 });
  }
});
  • kind is the kebab-case event name ('ui-emit', 'assistant-token', 'turn-end', …).
  • payload is 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 onEvent are 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