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
- Subscribe to
state/transcript/connectionand render them.connection.phasetells you which screen to show;state.orbdrives the orb / indicator. - On
form:show, render theFormDefinition’s fields. See Forms. - Call
client.updateFormValues(values)on every keystroke (this replaces the prebuilt UI’s DOM reads), thenclient.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 supportCleanup
client.destroy(); // ends any active call + removes all listenersLast updated on