> 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.
# Android (Kotlin)
> Connect an Android Kotlin app to the Smallest Atoms agent using OkHttp WebSocket and AudioRecord/AudioTrack. Minimum Android 7 (API 24).
> **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.
Native Android applications integrate with the Atoms agent over the [raw WebSocket protocol](/api-reference/voice-agents/realtime-agent/realtime-agent). OkHttp handles transport, and the platform `AudioRecord` and `AudioTrack` classes handle PCM16 capture and playback.
Initialize `AudioRecord` with the `VOICE_COMMUNICATION` audio source to engage the platform's acoustic echo cancellation and noise suppression.
Minimum supported version is Android 7 (API 24), which matches OkHttp 5's API floor.
> **Note**
>
> Validated end-to-end on a Pixel 9 emulator (Android API 35): OkHttp WebSocket connects, `AudioRecord` streams PCM16 with `VOICE_COMMUNICATION` for AEC coupling, and `AudioTrack` plays back agent audio in `MODE_STREAM` with `USAGE_MEDIA` (the `STREAM_VOICE_CALL` path is system-controlled and inaudible on emulators). Verify foreground service and Bluetooth route behavior on physical devices if those flows matter for your app.
## When to use native Android
* Android-only app, or a cross-platform app where Android is the priority.
* You need fine control over the audio pipeline (specific sample rates, buffer sizes, AEC routing).
* You need proper foreground-service handling for calls that continue when the app is backgrounded.
If your app is primarily React Native, see the [React Native](/voice-agents/integrate/mobile/react-native) guide. For Flutter, see [Flutter](/voice-agents/integrate/mobile/flutter).
## Dependencies
```kotlin
// build.gradle.kts (app)
dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}
```
| Dependency | Role | Why this one |
| ------------------------ | ------------------------ | ----------------------------------------------------------------------------------------------------------- |
| OkHttp | WebSocket client | Ubiquitous in Android, production-hardened, handles reconnect/backoff primitives. Alternative: Ktor client. |
| kotlinx-coroutines | Async orchestration | Bridges OkHttp's callback model to suspending functions cleanly. |
| `AudioRecord` (platform) | Microphone PCM16 capture | Zero-dependency, gives raw Int16 access. |
| `AudioTrack` (platform) | PCM16 playback | Same. Streaming mode consumes your buffer at the device sample rate. |
## Manifest permissions
```xml
```
Request `RECORD_AUDIO` at runtime:
```kotlin
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> onMicPermission(granted) }
private fun ensureMicPermission() {
val status = ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
if (status == PackageManager.PERMISSION_GRANTED) onMicPermission(true)
else permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
```
## Audio mode
Set `AudioManager.MODE_IN_COMMUNICATION` for the duration of the call. This signals the Android audio HAL that a bidirectional voice session is active; combined with `MediaRecorder.AudioSource.VOICE_COMMUNICATION` on the capture side, it enables the hardware AEC and NS pipeline. Restore the mode in the `finally` block of your session teardown.
```kotlin
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val previousMode = audioManager.mode
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
try {
runAgentSession()
} finally {
audioManager.mode = previousMode
}
```
Playback routing is handled separately by the `AudioTrack`'s `AudioAttributes` (see [Playback](#playback)). The quickstart uses `USAGE_MEDIA` on the player, which routes to the main speaker by default on both emulators and physical devices, so there is no need to toggle `isSpeakerphoneOn`.
## Quickstart
A full agent session: open the WebSocket, start mic capture, play agent audio, tear down.
```kotlin
import kotlinx.coroutines.*
import okhttp3.*
import okio.ByteString
import java.util.concurrent.TimeUnit
class AtomsAgent(
private val apiKey: String,
private val agentId: String,
) {
private val sampleRate = 24_000
private val client = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS) // unlimited for long-lived WS
.build()
// Own the scope: the microphone coroutine is launched asynchronously from
// OkHttp's onOpen callback, so it must outlive any caller stack frame.
// A short-lived scope (for example, one passed in from lifecycleScope.launch
// whose lambda returns immediately) would be cancelled before onOpen fires,
// and the mic coroutine would silently no-op.
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var webSocket: WebSocket? = null
private var micJob: Job? = null
private val player = AudioPlayer(sampleRate)
fun start() {
val url = HttpUrl.Builder()
.scheme("https") // OkHttp wraps wss:// via https://
.host("api.smallest.ai")
.addPathSegments("atoms/v1/agent/connect")
.addQueryParameter("token", apiKey)
.addQueryParameter("agent_id", agentId)
.addQueryParameter("mode", "webcall")
.addQueryParameter("sample_rate", sampleRate.toString())
.build()
val request = Request.Builder().url(url).build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(ws: WebSocket, response: Response) {
micJob = scope.launch { streamMicrophone(ws) }
player.start()
}
override fun onMessage(ws: WebSocket, text: String) = handleServerEvent(text)
override fun onMessage(ws: WebSocket, bytes: ByteString) = handleServerEvent(bytes.utf8())
override fun onClosing(ws: WebSocket, code: Int, reason: String) { stop() }
override fun onFailure(ws: WebSocket, t: Throwable, r: Response?) { stop() }
})
}
fun stop() {
micJob?.cancel()
player.stop()
webSocket?.close(1000, "client stop")
webSocket = null
scope.cancel()
}
}
```
### Microphone capture
```kotlin
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.util.Base64
import org.json.JSONObject
private suspend fun streamMicrophone(ws: WebSocket) {
val channelConfig = AudioFormat.CHANNEL_IN_MONO
val encoding = AudioFormat.ENCODING_PCM_16BIT
val minBuffer = AudioRecord.getMinBufferSize(sampleRate, channelConfig, encoding)
val bufferSize = (minBuffer * 2).coerceAtLeast(4096)
val record = AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION, // enables platform AEC + NS
sampleRate,
channelConfig,
encoding,
bufferSize,
)
val chunk = ByteArray(bufferSize)
try {
record.startRecording()
while (currentCoroutineContext().isActive) {
val n = record.read(chunk, 0, chunk.size)
if (n > 0) {
val audio = Base64.encodeToString(chunk, 0, n, Base64.NO_WRAP)
val payload = JSONObject().apply {
put("type", "input_audio_buffer.append")
put("audio", audio)
}
ws.send(payload.toString())
}
}
} finally {
record.stop()
record.release()
}
}
```
`MediaRecorder.AudioSource.VOICE_COMMUNICATION` routes capture through the platform's AEC/NS pipeline. Without it, the agent will hear its own output through the microphone and start looping.
### Playback
`AudioTrack` in `MODE_STREAM` accepts writes as fast as you can feed it and plays at the hardware sample rate. Run it on a dedicated thread and queue chunks from the WebSocket callback.
Use `USAGE_MEDIA` (not `USAGE_VOICE_COMMUNICATION`) for the `AudioTrack`. Even though this is a voice call, `USAGE_VOICE_COMMUNICATION` routes to the `STREAM_VOICE_CALL` stream, which is system-controlled: its volume is not settable by a normal app and it is silent on emulators. Capture still uses `MediaRecorder.AudioSource.VOICE_COMMUNICATION` for AEC coupling, which is what matters for echo cancellation.
```kotlin
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioTrack
import java.util.concurrent.LinkedBlockingQueue
class AudioPlayer(private val sampleRate: Int) {
private val channelConfig = AudioFormat.CHANNEL_OUT_MONO
private val encoding = AudioFormat.ENCODING_PCM_16BIT
private val minBuffer = AudioTrack.getMinBufferSize(sampleRate, channelConfig, encoding)
private val queue = LinkedBlockingQueue()
@Volatile private var running = false
private var thread: Thread? = null
private var track: AudioTrack? = null
fun start() {
running = true
track = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(sampleRate)
.setChannelMask(channelConfig)
.setEncoding(encoding)
.build()
)
.setBufferSizeInBytes(minBuffer * 4)
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
.also { it.play() }
thread = Thread {
while (running) {
val chunk = try { queue.poll(50, TimeUnit.MILLISECONDS) } catch (_: InterruptedException) { null } ?: continue
track?.write(chunk, 0, chunk.size)
}
}.also { it.start() }
}
fun enqueue(pcm: ByteArray) { queue.offer(pcm) }
fun flush() {
queue.clear()
track?.flush()
}
fun stop() {
running = false
thread?.join()
track?.stop(); track?.release(); track = null
}
}
```
### Handle server events
```kotlin
private fun handleServerEvent(text: String) {
val json = JSONObject(text)
when (json.optString("type")) {
"session.created" -> { /* update UI on main thread */ }
"output_audio.delta" -> {
val pcm = Base64.decode(json.getString("audio"), Base64.NO_WRAP)
player.enqueue(pcm)
}
"agent_start_talking" -> { /* UI: show "speaking" */ }
"agent_stop_talking" -> { /* UI: hide "speaking" */ }
"interruption" -> player.flush()
"session.closed" -> stop()
"error" -> Log.e("Atoms", "[${json.optString("code")}] ${json.optString("message")}")
}
}
```
## Threading model
* OkHttp `WebSocketListener` callbacks run on OkHttp's internal executor. Do not block them. All UI work must cross to the main looper via `Handler(Looper.getMainLooper()).post { ... }` or a coroutine on `Dispatchers.Main`.
* `AudioRecord.read` in a loop must run off the main thread. Use a background coroutine as shown in the quickstart.
* `AudioTrack.write` is a blocking call when the internal buffer is full. Run it on its own thread (as shown) to avoid stalling your capture loop.
## Audio focus
If the user is playing music or on another call, request audio focus before starting:
```kotlin
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.setAcceptsDelayedFocusGain(false)
.setOnAudioFocusChangeListener { change ->
when (change) {
AudioManager.AUDIOFOCUS_LOSS,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> stop()
}
}
.build()
val result = audioManager.requestAudioFocus(focusRequest)
if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
// another app has exclusive focus; don't start the call
}
```
Abandon focus in `stop()`.
## Background calls
For calls that continue when the user backgrounds the app, run the agent in a foreground service. Without this, Android will silently starve your mic capture on Android 12+.
```kotlin
// Declared in AndroidManifest.xml:
//
class AgentForegroundService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notification = buildOngoingCallNotification()
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL)
return START_STICKY
}
// ... delegate to AtomsAgent from here ...
}
```
The `phoneCall` foreground service type requires the `FOREGROUND_SERVICE_PHONE_CALL` manifest permission (Android 14+).
## Interruption handling
Incoming phone calls and other communication apps revoke audio focus. Handle `AUDIOFOCUS_LOSS` in the listener as shown above and tear down cleanly. On `AUDIOFOCUS_GAIN` after a transient loss, decide whether to auto-resume or prompt the user.
## Bluetooth route changes
Bluetooth headsets connect and disconnect during calls routinely. `AudioRecord` and `AudioTrack` switch routes transparently on most devices. You may want to observe `AudioManager.ACTION_AUDIO_BECOMING_NOISY` to pause the call if wired headphones are unplugged:
```kotlin
val noisyReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) {
pauseCall()
}
}
}
context.registerReceiver(
noisyReceiver,
IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
)
```
## Production hardening
### Reconnect on transient failure
OkHttp's `onFailure` fires on network drops. Reconnect with exponential backoff up to 30 s. Do not retry on 4401/4403 codes (auth failure); check `response?.code` in the failure handler.
### Mic mute while agent speaks
Stop `AudioRecord` briefly on `agent_start_talking` and restart on `agent_stop_talking` if device AEC is underperforming. The user's speech during the agent turn goes undetected, which is usually the right trade-off versus audible self-feedback.
### Battery
An open WebSocket + active `AudioRecord` + `AudioTrack` draws 3–5 % battery per minute. Design session duration accordingly. Always tear down promptly when the user ends the call.
### Logging
Attach an interceptor to OkHttp for debugging the handshake. Remove before shipping.
## Next steps
#### [Realtime Agent WebSocket API](/api-reference/voice-agents/realtime-agent/realtime-agent)
The full wire protocol with every message type, payload, and error code.
#### [iOS (Swift)](/voice-agents/integrate/mobile/i-os-swift)
Native iOS integration with URLSessionWebSocketTask and AVAudioEngine.
#### [Flutter](/voice-agents/integrate/mobile/flutter)
Cross-platform Dart integration.
#### [Error reference](/voice-agents/resources/error-reference)
HTTP status codes returned by every Atoms endpoint.
> Connect an Android Kotlin app to the Smallest Atoms agent using OkHttp WebSocket and AudioRecord/AudioTrack.