@voqalize/client-react
v0.1.1
Published
React SDK to embed a Voqalize voice agent — WebRTC transport, session bootstrap, and hooks/components.
Maintainers
Readme
@voqalize/client-react
Embed a Voqalize voice agent in a React app. Ships a one-call session bootstrap and a hook + component that manage the whole voice-session lifecycle.
You bring the brain, we bring the voice.
The media transport is pipecat's own SmallWebRTCTransport, not one of ours.
A Voqalize session's connection details are an offer endpoint and a token, which
is exactly what it already speaks — so the WebRTC half of your app is stock
pipecat, upgradable and debuggable with everything written about it. What this
package adds is the half pipecat leaves to you: minting the session, the
microphone and its failure modes, and the UI around a live call.
Install
pnpm add @voqalize/client-react
# peers you already have in a React app:
pnpm add @pipecat-ai/client-js @pipecat-ai/client-react react react-dom
# and the transport that carries the call:
pnpm add @pipecat-ai/small-webrtc-transportWorking from this repo? The package lives at
sdk/react/and is part of the repo'spnpmworkspace — build it in place withpnpm --filter @voqalize/client-react buildand reference it as a workspace dependency ("@voqalize/client-react": "workspace:*").
Quick start — <VoqalAgent/>
The smallest possible embed. Give it your API root, tenant slug, a publishable
(pk_...) key, and the agent id. It mints a session, connects, plays the agent's
audio, and shows a minimal status + mic/end bar.
import { VoqalAgent } from "@voqalize/client-react";
export function Support() {
return (
<VoqalAgent
apiBase="https://app.voqalize.com/api/v1"
publishableKey={import.meta.env.VITE_VOQAL_PK}
agentId="06a2…"
/>
);
}pk_ keys are safe to ship in browser code.
The three required props
apiBase— control-plane root including the API version; the SDK appends/sessions.create_and_start. Production:https://app.voqalize.com/api/v1. Behind a Vite/Next dev proxy, a relative"/api/v1"works too.publishableKey— apk_…key (origin-allowlisted; browser-safe).agentId—agent.idfrom the MCPcreate_agent/list_agents.
There is no workspace prop. A pk_ key belongs to exactly one workspace, so
the server reads it off the key. An earlier version took a tenantSlug and
posted to /{slug}/…; that route and that prop were both removed on 2026-08-09.
The voice and the language are not props. They are declared on the brain, in Python:
class ConciergeBrain(Brain):
voice = "omnivoice/gauri"
language = "hi" # sets the recognizer AND the TTS voice togetheror, when the language depends on this caller, with one
session.configure_language("ta", voice="omnivoice/gauri") in on_session_start
(the same call switches language mid-call). The agent record carries no
stt/tts fields at all, so the brain is the single owner — which matters
because language picks both the recognizer and the voice-cloning reference
clip, and a half-applied pair errors nowhere: you get the right words in the
wrong speaker's accent, invisible to any transcript-based check.
A pipeline prop does still exist on <VoqalAgent/> and useVoqalSession, for a
page that is genuinely the pipeline's authority — a console auditioning voices, an
A/B harness. A brain that declares or configures a voice overrides it, because the
brain speaks last.
Custom UI — render prop
Pass a function child to own all the markup. Audio playback is still wired for you.
<VoqalAgent apiBase="/api/v1" publishableKey={pk} agentId={id}>
{({ connectionState, botState, isUserSpeaking, error, disconnect, enableMic, sendMessage }) => (
<MyPanel state={connectionState} bot={botState} onEnd={disconnect} />
)}
</VoqalAgent>Hook — useVoqalSession
For full control over layout and providers:
import { useVoqalSession } from "@voqalize/client-react";
import { PipecatClientProvider } from "@pipecat-ai/client-react";
function Widget() {
const session = useVoqalSession({
apiBase: "/api/v1",
publishableKey: pk,
agentId: id,
// No `pipeline`: the brain declares the voice and the language.
payload: { surface: "web", user: { name: "Ada" } },
onServerMessage: (msg) => {
// Already unwrapped past the `{ data }` quirk.
if (msg.type === "ui_command") drive(msg);
},
});
return (
<div>
<button onClick={session.connect} disabled={session.connectionState !== "idle"}>
Talk
</button>
{session.client && (
<PipecatClientProvider client={session.client}>
{/* your voice-ui-kit components, etc. */}
</PipecatClientProvider>
)}
</div>
);
}Driving your UI from voice (the two-way message contract)
For agents that change the screen (a cart, a form, a map), brain and browser exchange JSON with fixed shapes.
Brain → browser. The brain's interaction.action(name, {...args}) arrives as a
ui_command server message — { type, action, action_id } plus the args
spread onto the top level. useUiCommand subscribes, strips that envelope and
dispatches by name, so a handler sees the args alone:
import { useUiCommand } from "@voqalize/client-react";
const { client } = useVoqalSession({ ...props });
useUiCommand(client, {
add_to_cart: ({ sku, qty }) => addToCart(sku, qty),
});Type it against the brain by declaring the command map — one entry per
voqalize.sdk.Action subclass, Python being the source of truth — and passing it
explicitly (an inline handler map gives TypeScript nothing to infer from):
interface ShopCommands {
add_to_cart: { sku: string; qty: number };
}
useUiCommand<ShopCommands>(client, {
add_to_cart: ({ sku, qty }) => addToCart(sku, qty), // sku: string, qty: number
});Every handler is optional, and an unknown action is not an error — brain and
page ship separately, so it falls through to an optional "*" wildcard, else
console.debug. Handlers are read through a ref, so an inline object literal never
re-subscribes. createUiCommandHandlers<T>(...) pins a map defined away from the
call site; uiCommandArgs(command) is the envelope-stripping on its own.
onServerMessage remains the raw escape hatch for non-ui_command traffic.
Browser → brain. Call sendMessage(type, data) (from the render-prop /
useVoqalSession). The brain receives it as on_client_message(message.type, message.data):
<VoqalAgent {...props}>
{(session) => (
<button onClick={() => session.sendMessage("cart_edited", { removed: "oat-milk" })}>
Remove
</button>
)}
</VoqalAgent>If the brain passed a callback= to .action(...), reply with
sendMessage("action_outcome", { action_id, status: "done", result }) and the SDK
routes it back to that callback.
The microphone
Every session needs one, and the browser will not give you one quietly. Three things follow from that, and the SDK makes all three visible rather than leaving you to discover them in a support ticket:
- The page must be a secure context.
https://, orlocalhostin development. On plainhttp://a browser does not expose microphones at all, andconnect()fails withMicrophoneError { problem: "insecure-context" }. - A permission prompt can stay open forever.
connectionStatebecomes"awaiting-microphone"while it is — render something that tells the user to look for the dialog, because "Connecting…" tells them to wait, which is the opposite of what they should do. After 30 s the connect fails withproblem: "no-response". - No microphone means no call. A blocked, missing or already-in-use
microphone rejects
connect()rather than joining a call the user cannot speak into. The rejection is aMicrophoneErrorwhosemessageis written for the person in front of the browser;problem("denied","no-microphone","in-use", …) is what you branch on.
The handle carries the typed error, so a render-prop UI never has to catch
anything — and with autoConnect there is no promise to catch:
const session = useVoqalSession({ ...props });
if (session.connectionState === "error") {
// A blocked microphone is the user's to fix and is already worded for them;
// anything else is ours, and telling them to check their connection is the
// most useful thing we can say.
return session.microphoneError ? (
<MicHelp problem={session.microphoneError.problem}>
{session.microphoneError.message}
</MicHelp>
) : (
<GenericFailure onRetry={session.connect} />
);
}requestMicrophone() is exported too, if you want to ask for permission (and
render the outcome) before minting a session at all.
Minting on your own backend
publishableKey puts the decision to start a call in the page. That is right for
a public demo and wrong the moment starting a call depends on something the
browser must not be trusted with — who the caller is, whether they still have
credit, which agent they are entitled to. For that, swap the key for a route on
your own server:
<VoqalAgent connectEndpoint="/api/voice/start" connectData={{ orderId }} />The hook POSTs there with credentials: "include", so the session cookie you
already set is what authorizes it. Your route holds whatever credential it likes
— a secret (sk_…) key, an internal service token — mints the session against
the Voqalize API, and returns that response's connect_params verbatim:
{
"webrtc_request_params": {
"endpoint": "https://n1.signal.voqalize.com/webrtc",
"headers": { "Authorization": "Bearer <session token>" }
},
"session_id": "01J…"
}Nothing else changes: same hook, same handle, same UI. The two paths differ only in who is trusted to say a call may start.
Low level
Connecting is pipecat's two-step — ask something that holds a credential where
the bot is, then negotiate WebRTC against the address you were given — and both
halves are exported so you can drive a raw PipecatClient yourself:
createSession({ apiBase, publishableKey, agentId, pipeline?, payload? })— step one against the Voqalize control plane. Returns parameters ready forpc.connect(...). ThrowsVoqalSessionErroron failure. (pipelineis the escape hatch above; normal embeds passpayloadonly.)toConnectParams(body)— turn any minted session'sconnect_paramsinto those same parameters. Run every server response through this, including your own backend's: pipecat builds each request withheaders.entries(), so the plain object a JSON body gives you throws aTypeErrorat the offer POST rather than failing anywhere legible. This is what makes it aHeaders.
import { PipecatClient } from "@pipecat-ai/client-js";
import { SmallWebRTCTransport } from "@pipecat-ai/small-webrtc-transport";
import { toConnectParams } from "@voqalize/client-react";
const pc = new PipecatClient({ transport: new SmallWebRTCTransport(), enableMic: true });
await pc.connect(toConnectParams(await mintOnMyServer()));Exports
VoqalAgent, useVoqalSession, useUiCommand, createUiCommandHandlers,
uiCommandArgs, createSession, toConnectParams, VoqalSessionError,
MicrophoneError, requestMicrophone, plus their TypeScript types
(VoqalConnectParams, UiCommand, UiCommandArgs, UiCommandHandlers,
MicrophoneProblem, …).
