October 5, 2026 · 8 min · react-native · tutorial · sdk
.mdHow to put a real-time AI avatar in a React Native or Expo app
Add a live, interruptible AI avatar video call to React Native or Expo: install, dev build, server route, native call screen and idle video, with real SDK code.
To put a real-time AI avatar in a React Native or Expo app, you need three pieces: a server route that holds your API key and mints each call, a native WebRTC stack (LiveKit's React Native SDK, which requires an Expo development build rather than Expo Go), and a call screen that renders the avatar's video. With the realtime-avatar SDK the native entry point is realtime-avatar/react-native: call registerGlobals() once, mint the session through useSessionLifecycle, and render AvatarVideoSurface inside RealtimeAvatarLiveKitRoom.
Everything below is taken from the published SDK (realtime-avatar 0.26.0 on npm, October 2026) and its docs. It is honest about one thing up front: on native, the SDK is lower-level than on the web. The one-component AvatarCall wrapper is web-only, so on React Native you compose the room, the session bridge and the video surface yourself. That is about 40 lines, shown in full.
What you need before you start
- A Realtime Avatar API key, kept on your server only. The free sandbox gives 1,020 credits once per account (17 live minutes), and one credit is one second on air.
- An avatar id. Use a public example avatar such as seed-rin-ashfall to start, or create your own from one portrait.
- React Native 0.78 or newer (the SDK's peer range). The SDK's own checks run on React Native 0.81.5 and React 19.2.7, with Node 22.22 or newer for tooling.
- An Expo development build or EAS build. Live media uses native WebRTC modules, so it does not run in Expo Go.
1. Install the SDK and the native media stack
The SDK README lists the exact LiveKit tuple it is tested against. Install it together with the SDK and Zod, its one required runtime peer:
npx expo install expo-video
npm install --save-exact realtime-avatar@0.26.0 zod@4.4.3 \
@livekit/react-native@3.0.0 @livekit/react-native-webrtc@144.2.0 \
livekit-client@2.22.3 @livekit/components-react@2.9.21In a managed Expo project, LiveKit's own instructions add two config plugins so the native modules and permissions are set up at prebuild. Install @livekit/react-native-expo-plugin and @config-plugins/react-native-webrtc, then list them in app.json and make sure the config plugin version matches the WebRTC version you installed:
{
"expo": {
"plugins": [
"@livekit/react-native-expo-plugin",
"@config-plugins/react-native-webrtc"
]
}
}Then build a development client (for example npx expo run:ios, or an EAS development build). This is a native change: a JavaScript-only OTA update cannot add these modules to a binary that does not already contain them.
2. Put the key behind your own route
Your API key must never ship inside the app. The SDK's server adapters mount one route that answers four paths (connect, end, avatars, credits) and decides who may call and what the character knows. Any adapter works: Next.js, TanStack Start, Express, or Hono, which also covers Cloudflare Workers, Bun and Deno. A Hono version:
// server.ts
import { Hono } from "hono";
import { realtimeAvatarHono } from "realtime-avatar/hono";
const app = new Hono();
app.all(
"/api/realtime-avatar/*",
realtimeAvatarHono({
apiKey: () => process.env.REALTIME_AVATAR_API_KEY!,
authorize: async ({ request }) => {
const user = await userFromBearerToken(request); // your auth
if (!user) return new Response("Sign in", { status: 401 });
},
session: async ({ request, avatarId }) => {
const user = await userFromBearerToken(request);
return {
instructions: await promptFor(avatarId),
context: await recentTurns(user!.id, avatarId),
maxSeconds: 600, // hard stop, enforced server-side
};
},
}),
);
export default app;Whatever the app sends for persona, memory, voice or time limit is discarded; only the session callback decides. A mobile app usually authenticates with a bearer token rather than a cookie, which is why the client below passes a fetch wrapper.
3. Register the WebRTC globals once
// index.ts (or the top of app/_layout.tsx with Expo Router)
import { registerGlobals } from "realtime-avatar/react-native";
registerGlobals(); // installs the WebRTC globals React Native does not ship4. The call screen
useSessionLifecycle runs the same session logic as the web SDK: the grant, the busy queue, reconnects and the idle timer. RealtimeAvatarLiveKitRoom wraps LiveKit's native room and owns the OS audio session by default. AvatarVideoSurface layers the portrait, the idle clip and the live video. Native has no built-in video element, so you hand it a player for the idle loop; here that is expo-video.
// AvatarCallScreen.tsx
import { Pressable, Text, View } from "react-native";
import { useVideoPlayer, VideoView } from "expo-video";
import {
AvatarVideoSurface,
RealtimeAvatarLiveKitRoom,
SessionLifecycleRoomBridge,
createProxyClient,
useSessionLifecycle,
type IdleVideoRender,
} from "realtime-avatar/react-native";
// Native has no page origin, so the proxy URL must be absolute.
const client = createProxyClient({
proxyUrl: "https://api.your-app.example.com/api/realtime-avatar",
fetch: async (input, init) => {
const headers = new Headers(init?.headers);
headers.set("Authorization", "Bearer " + (await getSessionToken()));
return fetch(input, { ...init, headers });
},
});
function IdleClip({ url, style, resizeMode }: IdleVideoRender) {
const player = useVideoPlayer(url, (p) => {
p.loop = true;
p.muted = true;
p.play();
});
return <VideoView player={player} style={style} contentFit={resizeMode} nativeControls={false} />;
}
type Props = { avatarId: string; idleVideoUrl: string | null; posterUrl: string | null; onClose: () => void };
export function AvatarCallScreen({ avatarId, idleVideoUrl, posterUrl, onClose }: Props) {
const lifecycle = useSessionLifecycle({ client, session: { avatarId } });
const phase = lifecycle.phase.kind;
return (
<View style={{ flex: 1, backgroundColor: "black" }}>
{lifecycle.grant ? (
<RealtimeAvatarLiveKitRoom
grant={lifecycle.grant}
onConnected={lifecycle.onConnected}
onDisconnected={lifecycle.onDisconnected}
onError={lifecycle.onConnectionError}
>
<SessionLifecycleRoomBridge lifecycle={lifecycle} />
<AvatarVideoSurface
style={{ flex: 1 }}
fit="cover"
idleVideoUrl={idleVideoUrl}
poster={posterUrl}
renderIdleVideo={(idle) => <IdleClip {...idle} />}
/>
</RealtimeAvatarLiveKitRoom>
) : (
<Text style={{ color: "white", padding: 24 }}>
{phase === "queued" ? "All lines are busy, holding your place" : "Connecting"}
</Text>
)}
<Pressable
accessibilityRole="button"
style={{ minHeight: 44, justifyContent: "center", alignItems: "center" }}
onPress={() => {
lifecycle.end();
onClose();
}}
>
<Text style={{ color: "white" }}>End call</Text>
</Pressable>
</View>
);
}Mount this screen only after the user taps a start button, because mounting it starts the connection and the meter. A queued phase is not an error: every slot is busy and the SDK retries for you while holding a place in line. The other phases are idle, requesting, connecting, live, idle-warning, reconnectable and ended; write the copy for the ones your product shows. The SDK ships no user-facing strings.
5. Things that trip people up on mobile
- Expo Go will not work. If the call sits in connecting forever on a fresh project, check that you are running a development build that contains the LiveKit and WebRTC native modules.
- Relay the mint response byte-for-byte. If you hand-roll the server half instead of using an adapter, do not wrap or reshape the JSON grant: the client reads it unvalidated and a wrapped grant never connects.
- One owner for the audio session. RealtimeAvatarLiveKitRoom starts and stops the OS audio session for you. Only pass manageAudioSession={false} if your app manages AudioSession itself.
- Camera sharing is optional and gated twice: your server must allow it with camera: true, and the user must opt in through useAvatarCamera. It needs the iOS camera usage description and the Android CAMERA permission in the binary, so it is another native build, not an OTA.
- Test on physical iOS and Android devices, including background and foreground transitions and redialing. The SDK's own native test suite mocks the native modules, so it does not prove linking, permissions or speaker routing on your build.
What the call includes, and what it does not
Each call is full duplex: the user can interrupt mid-sentence, a cough or a short acknowledgement does not derail the character, and end of turn is judged by what was said rather than by a fixed silence timer. Speech recognition, the language model, a Fish Audio voice and the rendered video are all metered as one rate, seconds on air: $9 a month for 120 minutes on Starter, $24 for 600 on Developer, then $0.08 to $0.095 a minute depending on plan. A single call can run at most 30 minutes: the platform caps max_session_seconds at 1,800, and the server route below asks for 600 (10 minutes) per call, well inside that cap. For an audio-only call when bandwidth is poor, pass mode: "voice" next to avatarId in the session object you give useSessionLifecycle.
What it does not do: it is not a renderer for your own voice agent. There is no LiveKit Agents or Pipecat plugin, and you cannot feed audio from OpenAI Realtime or another voice pipeline into the avatar. You can switch off server speech recognition and drive each turn with text from your own LLM, but the voice is still the platform's.
How other vendors handle React Native
As of October 2026: Tavus documents React Native through Daily's React Native SDK, joining the conversation URL Tavus returns, with your own UI. Beyond Presence says any LiveKit client SDK can join its agent rooms, which includes LiveKit's React Native SDK. bitHuman ships native Swift and Android SDKs instead of a React Native package. Anam's official SDKs are JavaScript and Python, with community Flutter and Kotlin Multiplatform SDKs. If you are on Flutter or fully native Swift and Kotlin, one of those may fit better than a TypeScript SDK.