React SDK
Ship a production voice agent inside your React app in an afternoon, on the web or in React Native. useAethexCall() hides every line of WebRTC, signaling, ICE, and audio plumbing behind one headless hook, so you build the experience and we run the real-time transport. It is SSR-safe, StrictMode-safe, and ready for the Next.js App Router, and the same hook runs natively on iOS and Android via Expo.
Install
Add the package. react and react-dom are peer dependencies for the hook; the framework-agnostic ./core entry needs neither.
npm install @aethexai/react react react-domYour first call
This is the entire integration. No sockets to wire, no peer connections to babysit, no <audio> elements to manage. Call start() on a user gesture and the agent is live. start() never throws, so you drive your UI straight from status and error.
"use client"
import { useAethexCall, useAudioLevel } from "@aethexai/react"
export function CallButton() {
const { status, isConnected, start, stop, remoteStream, error } = useAethexCall({
agentId: "8a1b2c3d-4e5f-6789-abcd-ef0123456789",
apiBaseUrl: "https://your-proxy.example.com", // your proxy, never a key
onConnected: () => console.log("live"),
onError: (e) => console.error(e.code, e.recoverable),
})
const { level } = useAudioLevel(remoteStream)
if (error) return <p>Call failed: {error.code}</p>
return (
<button onClick={isConnected ? stop : start}>
{status === "connecting" ? "Connecting..." : isConnected ? `Hang up (${level.toFixed(2)})` : "Call"}
</button>
)
}useAethexCall
One hook runs the whole call. Give it an agent and your proxy URL, get back reactive state plus a handful of controls.
Options
agentId is always required. Supply exactly one of apiBaseUrl (your server-side proxy) or getToken (ephemeral token per call). Everything else tunes lifecycle callbacks, resilience, and transport.
interface UseAethexCallOptions {
agentId: string // the agent to call
apiBaseUrl?: string // your proxy (holds the key server-side)...
getToken?: () => string | Promise<string> // ...or mint a token per call, no proxy
// Lifecycle callbacks
onConnected?: () => void
onEnded?: () => void
onError?: (error: AethexError) => void
onMetrics?: (metrics: PipelineMetrics) => void
// Resilience & transport
iceRestart?: boolean // auto-heal dropped calls (default true)
maxIceRestarts?: number // consecutive attempts (default 1)
timeoutMs?: number // signaling timeout in ms (default 15000)
headers?: Record<string, string> // e.g. a short-lived proxy token
audioConstraints?: MediaTrackConstraints
}Returns
Reactive state you can render straight into your UI, plus start, stop, and interrupt. status moves through five values: idle, connecting, connected, ended, and error.
interface UseAethexCallResult {
status: "idle" | "connecting" | "connected" | "ended" | "error"
isConnecting: boolean
isConnected: boolean
remoteStream: MediaStream | null // pass to useAudioLevel()
metrics: PipelineMetrics | null
error: AethexError | null // .code and .recoverable
sessionId: string | null
start: () => Promise<void> // never throws
stop: () => void // idempotent
interrupt: () => void // barge in on the agent
getRemoteStatus: () => Promise<SessionStatusResponse>
}Live audio visualizer
Feed the agent's remoteStream to useAudioLevel for a smoothed 0-to-1 amplitude on every animation frame. Drive a bar, a ring, or a pulsing orb, whatever fits your brand.
import { useAudioLevel } from "@aethexai/react"
const { level } = useAudioLevel(remoteStream) // 0..1, smoothed per animation frame
// drive a bar, a ring, a pulsing orb, whatever fits your brandDrop-in widgets
Would rather not build UI at all? Import a ready-made, fully accessible component and go live in one line. Native buttons with full keyboard support, a single aria-live status region, and a visualizer that gracefully degrades under prefers-reduced-motion. The floating AethexVoiceOrb gives you the classic bottom-right voice bubble with zero layout work.
import { AethexCallButton, AethexVoiceWidget, AethexVoiceOrb } from "@aethexai/react/widgets"
// A one-line call button
<AethexCallButton agentId={AGENT_ID} apiBaseUrl={PROXY_URL} />
// A full widget with a live visualizer and a title
<AethexVoiceWidget agentId={AGENT_ID} apiBaseUrl={PROXY_URL} title="Talk to support" />
// A floating voice orb (bottom-right by default)
<AethexVoiceOrb agentId={AGENT_ID} apiBaseUrl={PROXY_URL} />Transcripts
Pull the full turn-by-turn transcript once a call has ended, keyed by its sessionId.
import { getTranscript } from "@aethexai/react"
const turns = await getTranscript({ apiBaseUrl, sessionId })Calls that survive the real world
Real conversations happen on real networks. When an established call's connection drops, say a walk from wifi to cellular, the SDK renegotiates and heals it automatically instead of hanging up. It is on by default; turn the dial up with maxIceRestarts, or set iceRestart: false to fail fast.
useAethexCall({ agentId, apiBaseUrl }) // auto-heal on (default)
useAethexCall({ agentId, apiBaseUrl, maxIceRestarts: 2 }) // recover harder
useAethexCall({ agentId, apiBaseUrl, iceRestart: false }) // fail fast insteadServer-side session status
getRemoteStatus() returns the platform's own view of the session, its duration, turn count, and lifecycle, distinct from the local status. It works during the call and after it ends.
const { getRemoteStatus } = useAethexCall({ agentId, apiBaseUrl })
// the server's own view, works during and after the call
const s = await getRemoteStatus()
// { session_id, status, duration_s, turn_count, ... }Typed errors, no guesswork
Every failure is an AethexError with a stable, switchable code, so you never match on message strings. Check error.recoverable to decide whether to retry, and narrow with isAethexError().
import { isAethexError } from "@aethexai/react"
// Every failure is a typed AethexError with a stable .code:
type AethexErrorCode =
| "unsupported_browser" // no WebRTC / getUserMedia
| "mic_denied" // user or policy blocked the microphone
| "mic_missing" // no audio input device
| "connect_failed" // could not open the session
| "offer_failed" // could not negotiate media
| "quota_exceeded" // 429, honors Retry-After
| "payment_required" // 402, billing
| "capacity" // 503, no voice capacity right now
| "peer_failed" // the connection dropped and could not recover
| "timeout" // a signaling request timed out
| "aborted" // the call was stopped mid-flight
| "network" // offline, CORS, or DNS
| "unknown"
if (isAethexError(error) && error.recoverable) retry()Security
Your ae_live_... key lives on a proxy you host and never touches the browser; apiBaseUrl points at that proxy. WebRTC and microphone access require HTTPS (or localhost).
apiBaseUrl looks like an API key. Never ship a key to the client.Ephemeral tokens (skip the proxy)
Instead of hosting a proxy, mint a short-lived token on your server and hand it to the client with getToken. Pass getToken and drop apiBaseUrl, and the SDK talks to the Aethex API directly, so your only server-side code is a one-line route that calls POST /conversation/token with your API key. The token is scoped to one agent and expires after a short TTL (at most the agent's configured maximum call duration), so it is safe in the browser. Mint a fresh token for each call to limit the exposure window. See the Conversation API for the mint endpoint.
useAethexCall({
agentId,
// Drop apiBaseUrl; the SDK talks to the Aethex API directly with this token.
getToken: async () => {
const res = await fetch("/api/aethex-token", { method: "POST" })
if (!res.ok) throw new Error("Token mint failed")
return (await res.json()).token
},
})React Native (Expo)
The same useAethexCall hook runs in React Native: @aethexai/react ships a native build that Metro resolves automatically, so the import is identical. On native it runs on react-native-webrtc and routes the agent's audio to the loudspeaker. React Native has no CORS, so connect directly with the ephemeral-token flow (getToken), no proxy at all. WebRTC is a native module, so this needs an Expo development build (it does not run in Expo Go). The AethexVoiceOrb widget renders natively via Skia, so you can drop it straight in; the button and panel widgets are web-only.
# WebRTC is a native module, so this needs an Expo dev build (not Expo Go).
npx expo install react-native-webrtc @config-plugins/react-native-webrtc expo-dev-client
# app.json -> expo.plugins:
# ["@config-plugins/react-native-webrtc",
# { "microphonePermission": "Allow $(PRODUCT_NAME) to use your microphone." }]
# For the AethexVoiceOrb widget: Skia, its reanimated peer, and loudspeaker routing.
npx expo install @shopify/react-native-skia react-native-reanimated react-native-incall-manager
# babel.config.js: add "react-native-reanimated/plugin" (last in the plugins list)
npx expo prebuild --clean
npx expo run:ios --device # iOS needs a real device (the Simulator has no mic)
npx expo run:android # an emulator is fine with host-mic inputThe voice orb, natively
AethexVoiceOrb is the fastest way to ship a native call: the same import as on the web renders a Skia orb on iOS and Android, with the loudspeaker routing, mute, hang-up, and an optional post-call rating handled for you. Seed its colour with agentName and pick a texture with orbType. On native the audio-level hooks read 0 (no Web Audio), so the orb self-animates during the call rather than reacting to the audio.
import { AethexVoiceOrb } from "@aethexai/react/widgets"
// Same import as web; Metro resolves the Skia-rendered native orb.
<AethexVoiceOrb
agentId={AGENT_ID}
agentName="Kora" // seeds the orb's colour + texture
getToken={getToken} // mint on your server; no key on the device
orbType="aurora"
controls // mute + hang up
feedback // post-call rating
/>Framework-agnostic core
Not on React? The same engine ships as a pure-TypeScript VoiceCall class under @aethexai/react/core, callbacks instead of hooks, everything else identical.
import { VoiceCall } from "@aethexai/react/core"
const call = new VoiceCall({
agentId: AGENT_ID,
apiBaseUrl: PROXY_URL, // never the direct API or a key
callbacks: {
onStatusChange: (s) => console.log(s),
onRemoteStream: (stream) => { /* auto-played via a managed <audio> */ },
onError: (err) => console.error(err.code, err.recoverable),
onClose: () => console.log("call ended"),
},
})
await call.start()
call.stop() // idempotent teardown