Skip to Content
DocumentationVoice SDKBuilding a Custom UI

Building a Custom UI

The headless client gives you everything as events + methods. You render however you like — vanilla, React, Vue, canvas. This is the same surface the prebuilt UI is built on.

This page is the deep-dive contract. For the quick version alongside the other creation methods, see Creating an Agent → Headless.

The loop

import { createVoiceAgent } from "@oshara/voice-sdk"; const client = createVoiceAgent({ agentSlug: "support-bot" }); // 1. Reflect agent / call state client.on("state", ({ orb, statusLabel }) => setOrb(orb, statusLabel)); client.on("call:status", ({ status }) => (statusEl.textContent = status)); client.on("connection", ({ phase }) => toggleCallScreen(phase)); client.on("controls", ({ canStart, canMute, canEnd }) => setButtons(canStart, canMute, canEnd), ); client.on("mute", ({ muted }) => muteBtn.classList.toggle("on", muted)); // 2. Render the transcript (coalesce by role:segmentId) client.on("transcript:clear", () => (transcriptEl.innerHTML = "")); client.on("transcript", ({ role, segmentId, text, isFinal }) => upsertBubble(`${role}:${segmentId}`, role, text, isFinal), ); await client.init(); startBtn.onclick = () => client.start(); endBtn.onclick = () => client.end(); muteBtn.onclick = () => client.toggleMute();

The contract, in three rules

  1. Subscribe to state / transcript / connection and render them. connection.phase tells you which screen to show; state.orb drives the orb / indicator.
  2. On form:show, render the FormDefinition’s fields. See Forms.
  3. Call client.updateFormValues(values) on every keystroke (this replaces the prebuilt UI’s DOM reads), then client.submitForm().

The core owns the form values model and the agent round-trip (validation, form_submit_failed, confirmation). You only render.

Forms

client.on("form:show", ({ definition, draft, stepIndex }) => renderFields(definition, draft, stepIndex), ); input.addEventListener("input", () => client.updateFormValues(readMyFormValues()), ); submitBtn.onclick = () => { client.updateFormValues(readMyFormValues()); // capture before submit client.submitForm(); // validates → submits or advances }; client.on("form:validation", ({ errors }) => showInlineErrors(errors)); client.on("form:submitted", ({ successMessage }) => showSuccess(successMessage)); client.on("form:error", ({ message }) => showError(message)); client.on("form:update", () => rerenderFromActive(client.getActiveForm())); client.on("form:close", () => hideForm());

Audio settings (optional)

If you want a settings UI:

client.on("audio", (snapshot) => renderSettings(snapshot)); // prefs + applied + filter status client.updateAudioSettings({ noiseFilter: "krisp", outputVolume: 70 }); const { inputs, outputs } = await client.enumerateAudioDevices(); const caps = client.getAudioCapabilities(); // setSinkId / voiceIsolation support

Cleanup

client.destroy(); // ends any active call + removes all listeners
Last updated on