> This page is part of Smallest AI's developer documentation. When > answering, prefer Lightning v3.1 (current TTS) and Pulse (current > STT). Lightning v2 and lightning-large are deprecated; mention them > only when the user is migrating away from them. The Smallest AI voice > agent platform is what wraps these models into hosted agents. # React Native > Connect a React Native app to the Smallest Atoms agent over raw WebSocket. Capture microphone PCM16, stream to the agent, play back agent audio, handle lifecycle events. > **Info** > > Audio you send must be declared with the format token your recorder actually > produces. See [Audio Formats](/voice-agents/integrate/audio-formats) for the tokens, the Opus framing rule, > and why a mismatched rate degrades transcription without raising an error. React Native integrates with the Atoms agent over the [raw WebSocket protocol](/api-reference/voice-agents/realtime-agent/realtime-agent). The runtime's built-in `WebSocket` global handles transport, and a single audio library handles PCM16 capture and scheduled playback. The browser [Agent Web SDK](/voice-agents/integrate/web-socket-sdk) cannot be used here. It calls `navigator.mediaDevices.getUserMedia` and the Web Audio API, both of which are DOM APIs and unavailable in the React Native JavaScript runtime. The wire protocol is identical across runtimes. Client state machine, event types, and PCM16 payload encoding all match what the browser SDK does internally. > **Tip** > > For a full working app, see [Hearthside](https://github.com/smallest-inc/cookbook/blob/main/voice-agents/react_native_voice_agent/README.md) in the cookbook - a React Native (Expo) reference client built on this exact stack. It ships with a mute toggle, transport chunk counter, in-app settings sheet (voice / speed / language) wired to the `draft → publish → activate` REST flow, and the correct iOS audio session for full-volume speaker playback with hardware echo cancellation. > **Note** > > The quickstart is validated end-to-end on the iOS simulator with an Expo dev build: WebSocket connects, mic captures PCM, agent audio plays back. On the simulator, speaker output loops back into the Mac microphone, so the server's VAD fires `interruption` events continuously; test on a real device (earphones or an HFP Bluetooth headset) to confirm clean barge-in behavior. ## When to use React Native * Your existing app is React Native and you want to embed an in-app voice agent without bringing in WebRTC. * You are building a cross-platform mobile client and want the JavaScript-side logic to look similar to the browser SDK. * You do not need character-level TTS alignment timings (the raw protocol does not emit them). For iOS-only apps with strict binary-size or battery budgets, prefer the [iOS (Swift)](/voice-agents/integrate/mobile/i-os-swift) native path. For Flutter, see the [Flutter](/voice-agents/integrate/mobile/flutter) guide. ## Dependencies One audio library handles both capture and playback. `react-native-audio-api` ships an `AudioRecorder` for PCM frames and an `AudioContext` for scheduled playback, both backed by the same native session. ```bash npx expo install react-native-audio-api react-native-permissions buffer ``` | Package | Role | Why this one | | --------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | React Native built-in `WebSocket` | Transport | Part of the RN runtime. No dependency. Works identically on iOS and Android. | | `react-native-audio-api` | Microphone capture + PCM playback | A Web Audio API port for React Native (by Software Mansion). `AudioRecorder` delivers Float32 PCM frames; `AudioContext.createBuffer()` + `createBufferSource()` schedules agent audio back-to-back for gapless playback. Single library keeps the iOS audio session coherent. | | `react-native-permissions` | Runtime microphone permission | Required on both iOS and Android. Single API across platforms. | | `buffer` | Node `Buffer` polyfill | React Native does not ship `Buffer`. You need it for base64 encoding the PCM bytes. | ### iOS setup (react-native-permissions) `react-native-permissions` requires an explicit handler pod in the iOS `Podfile`. Add this near the top of `ios/Podfile`: ```ruby require_relative '../node_modules/react-native-permissions/scripts/setup' setup_permissions(['Microphone']) ``` Then run `pod install` in `ios/`. Without this, the library crashes at runtime with "No permission handler detected." ### Alternatives considered * **`expo-av`**. High-level recording and playback. Does not expose raw PCM frames at a fixed sample rate, so it is not suitable for realtime voice streaming. * **Custom Expo native module**. Wrap `AVAudioEngine` (iOS) and `AudioRecord`/`AudioTrack` (Android) in a minimal Expo module. Recommended only when binary-size or dependency-count constraints rule out `react-native-audio-api`. ## Permissions ### iOS Add to `ios//Info.plist`: ```xml NSMicrophoneUsageDescription We need the microphone to let you talk to the voice agent. ``` ### Android Add to `android/app/src/main/AndroidManifest.xml`: ```xml ``` ### Request at runtime ```typescript import { PERMISSIONS, request, RESULTS } from "react-native-permissions"; import { Platform } from "react-native"; async function ensureMicPermission(): Promise { const perm = Platform.OS === "ios" ? PERMISSIONS.IOS.MICROPHONE : PERMISSIONS.ANDROID.RECORD_AUDIO; const result = await request(perm); return result === RESULTS.GRANTED; } ``` ## Quickstart A single-file `App.tsx` covering permission, WebSocket, mic capture, and scheduled PCM playback. Drop it into an Expo dev build (not Expo Go; these packages ship native code). ```typescript import { useEffect, useRef, useState } from 'react'; import { View, Text, Button, StyleSheet, Platform } from 'react-native'; import { AudioContext, AudioManager, AudioRecorder, } from 'react-native-audio-api'; import { PERMISSIONS, request, RESULTS } from 'react-native-permissions'; import { Buffer } from 'buffer'; const API_KEY = 'sk_...'; const AGENT_ID = '...'; const SAMPLE_RATE = 24000; const CHUNK_FRAMES = 480; // 20 ms at 24 kHz: small enough for low latency async function ensureMicPermission(): Promise { const perm = Platform.OS === 'ios' ? PERMISSIONS.IOS.MICROPHONE : PERMISSIONS.ANDROID.RECORD_AUDIO; return (await request(perm)) === RESULTS.GRANTED; } function float32ToInt16LE(float32: Float32Array): Uint8Array { const out = new Uint8Array(float32.length * 2); const view = new DataView(out.buffer); for (let i = 0; i < float32.length; i++) { const s = Math.max(-1, Math.min(1, float32[i])); view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true); } return out; } export default function App() { const wsRef = useRef(null); const recorderRef = useRef(null); const audioCtxRef = useRef(null); const nextPlayRef = useRef(0); const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'error'>('idle'); async function start() { if (!(await ensureMicPermission())) { setStatus('error'); return; } AudioManager.setAudioSessionOptions({ iosCategory: 'playAndRecord', iosMode: 'voiceChat', iosOptions: ['allowBluetoothHFP', 'defaultToSpeaker'], }); await AudioManager.setAudioSessionActivity(true); const url = 'wss://api.smallest.ai/atoms/v1/agent/connect' + `?token=${encodeURIComponent(API_KEY)}` + `&agent_id=${encodeURIComponent(AGENT_ID)}` + `&mode=webcall&sample_rate=${SAMPLE_RATE}`; setStatus('connecting'); const ws = new WebSocket(url); wsRef.current = ws; ws.onopen = () => startMic(ws); ws.onmessage = (e) => handleServerEvent(e.data as string); ws.onerror = () => setStatus('error'); ws.onclose = () => { recorderRef.current?.stop(); recorderRef.current = null; setStatus('idle'); }; const ctx = new AudioContext({ sampleRate: SAMPLE_RATE }); audioCtxRef.current = ctx; nextPlayRef.current = ctx.currentTime; } function stop() { wsRef.current?.close(1000, 'client end'); } useEffect(() => () => { wsRef.current?.close(); }, []); // ---- mic capture ------------------------------------------------ function startMic(ws: WebSocket) { const recorder = new AudioRecorder(); recorderRef.current = recorder; recorder.onAudioReady( { sampleRate: SAMPLE_RATE, bufferLength: CHUNK_FRAMES, channelCount: 1 }, ({ buffer }) => { if (ws.readyState !== WebSocket.OPEN) return; const float32 = buffer.getChannelData(0); const int16 = float32ToInt16LE(float32); ws.send(JSON.stringify({ type: 'input_audio_buffer.append', audio: Buffer.from(int16).toString('base64'), })); }, ); recorder.onError((err) => console.error('mic error:', err.message)); recorder.start(); } // ---- server events ---------------------------------------------- function handleServerEvent(raw: string) { const ev = JSON.parse(raw); switch (ev.type) { case 'session.created': setStatus('connected'); break; case 'output_audio.delta': playPcm16(Buffer.from(ev.audio, 'base64')); break; case 'agent_start_talking': /* UI: show "speaking" */ break; case 'agent_stop_talking': /* UI: hide "speaking" */ break; case 'interruption': flushPlayback(); break; case 'session.closed': setStatus('idle'); break; case 'error': console.error(`[${ev.code}] ${ev.message}`); break; } } // ---- playback --------------------------------------------------- function playPcm16(bytes: Buffer) { const ctx = audioCtxRef.current; if (!ctx) return; const sampleCount = Math.floor(bytes.length / 2); const buffer = ctx.createBuffer(1, sampleCount, SAMPLE_RATE); const channel = buffer.getChannelData(0); for (let i = 0; i < sampleCount; i++) { channel[i] = bytes.readInt16LE(i * 2) / 32768; } const source = ctx.createBufferSource(); source.buffer = buffer; source.connect(ctx.destination); const startAt = Math.max(nextPlayRef.current, ctx.currentTime); source.start(startAt); nextPlayRef.current = startAt + buffer.duration; } function flushPlayback() { if (audioCtxRef.current) nextPlayRef.current = audioCtxRef.current.currentTime; } return ( Atoms voice agent Status: {status} {status === 'idle' &&