Official ElevenAgents SDK for Android.
agentId) and private agents (pre‑issued conversationToken for voice or signedUrl for text‑only)Add Maven Central and the SDK dependency to your Gradle configuration.
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
dependencies {
// ElevenAgents SDK (Android)
implementation("io.elevenlabs:elevenlabs-android:<latest>")
// Kotlin coroutines, AndroidX, etc., as needed by your app
}
You have to request the android.permission.RECORD_AUDIO runtime permission yourself before starting a voice session. Text‑only sessions don't need this permission.
Permissions (and a service) are added to your AndroidManifest.xml automatically by the LiveKit SDK.
Certain ones are not needed to use the ElevenLabs SDK so you can remove them if don't need them:
<manifest>
[...]
<uses-permission android:name="android.permission.CAMERA" tools:node="remove" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" tools:node="remove" />
[...]
<application>
[...]
<!--suppress AndroidDomInspection -->
<service
android:name="io.livekit.android.room.track.screencapture.ScreenCaptureService"
tools:node="remove" />
</application>
</manifest>
ConversationConfig requires exactly one of three credentials, depending on the agent and transport:
| Agent | Transport | Field |
|---|---|---|
| Public | Voice or text | agentId |
| Private | Voice (LiveKit/WebRTC) | conversationToken |
| Private | Text‑only (raw WebSocket) | signedUrl |
Private credentials are provisioned by your backend — never ship API keys.
import io.elevenlabs.ConversationClient
import io.elevenlabs.ConversationConfig
import io.elevenlabs.ConversationSession
import io.elevenlabs.ClientTool
import io.elevenlabs.ClientToolResult
// Start a public agent session (token generated for you)
val config = ConversationConfig(
agentId = "<your_public_agent_id>", // OR conversationToken = "<token>" (voice) / signedUrl = "wss://…" (text-only)
userId = "your-user-id",
audioInputSampleRate = "48000", // Optional parameter, defaults to 48kHz. Lower values can help with audio input issues on slower connections
apiEndpoint = "https://api.elevenlabs.io", // Optional: Custom API endpoint
websocketUrl = "wss://livekit.rtc.elevenlabs.io", // Optional: Custom WebSocket URL
// Optional callbacks
onConnect = { conversationId ->
// Connected, you can store conversationId via session.getId() too
},
onDisconnect = { reason ->
// Disconnected, reason indicates who initiated the disconnect, either "Agent", "User" or "Error"
},
onMessage = { source, messageJson ->
// Raw JSON messages from data channel; useful for logging/telemetry
},
onModeChange = { mode ->
// ConversationMode.SPEAKING | ConversationMode.LISTENING — drive UI indicators
},
onStatusChange = { status ->
// ConversationStatus enum: CONNECTED, CONNECTING, DISCONNECTED, DISCONNECTING, ERROR
},
onCanSendFeedbackChange = { canSend ->
// Enable/disable thumbs up/down
},
onUnhandledClientToolCall = { call ->
// Agent requested a client tool not registered on the device
},
onVadScore = { score ->
// Voice Activity Detection score, range from 0 to 1 where higher values indicate higher confidence of speech
},
onAudioLevelChanged = { level ->
// Agent audio level (volume), range from 0.0 to 1.0
// Log.d("MyApp", "Agent audio level: $level")
},
onAudioFrame = { frame ->
// Decoded PCM chunk from the agent's audio track (little-endian).
// The ByteBuffer is only valid for the duration of this callback — copy what you need.
// Useful for visualizations, recording, or custom DSP.
},
onUserTranscriptEvent = { text, eventId ->
// User's speech transcribed to text (finalized)
},
onTentativeUserTranscriptEvent = { text, eventId ->
// In-progress user transcript
},
onAgentResponseEvent = { text, eventId ->
// Agent's finalized text response
},
onAgentResponsePartEvent = { partType, text, eventId ->
// Streaming agent text part: AgentResponsePartType.START / DELTA / STOP
},
onAgentResponseCorrectionEvent = { text, eventId ->
// Agent response was corrected after interruption
},
onAgentToolResponse = { toolName, toolCallId, toolType, isError ->
// Agent tool execution completed
},
onConversationInitiationMetadata = { conversationId, agentOutputFormat, userInputFormat ->
// Conversation metadata including audio formats
},
onInterruption = { eventId ->
// User interrupted the agent while speaking
},
// List of client tools the agent can invoke
clientTools = mapOf(
"logMessage" to object : ClientTool {
override suspend fun execute(parameters: Map<String, Any>): ClientToolResult? {
val message = parameters["message"] as? String
Log.d("ExampleApp", "[INFO] Client Tool Log: $message")
return ClientToolResult.success("Message logged successfully")
}
}
),
)
Note: If a tool is configured with
expects_response=falseon the server, returnnullfromexecuteto skip sending a tool result back to the agent.
// In an Activity context
val session: ConversationSession = ConversationClient.startSession(config, this)
// Send messages via the data channel
session.sendUserMessage("Hello!")
session.sendContextualUpdate("User navigated to the settings screen")
session.sendUserActivity() // useful while user is typing
// Feedback for the latest agent response
session.sendFeedback(isPositive = true) // or false
// Microphone control
session.toggleMute() // toggle
session.setMicMuted(true) // explicit
// Conversation ID
val id: String? = session.getId() // e.g., "conv_123" once connected
// End the session
session.endSession()
agentId in ConversationConfig. The SDK requests a conversation token from ElevenLabs without needing an API key on device.conversationToken in ConversationConfig. Your backend mints the WebRTC token via /v1/convai/conversation/token?agent_id=… using your API key.signedUrl in ConversationConfig. Your backend signs a WebSocket URL via /v1/convai/conversation/get-signed-url?agent_id=… using your API key.Never embed API keys in clients. ConversationConfig enforces that exactly one credential is set, and that the credential matches the transport (textOnly = true → signedUrl, textOnly = false → conversationToken).
For text‑only conversations, set textOnly = true on ConversationConfig. The SDK switches transports automatically:
wss://api.elevenlabs.io/v1/convai/conversation).The transport switch is required because LiveKit drops rooms that never publish an audio or video track, which would tear down a text‑only conversation after a few seconds. Text‑only sessions don't need RECORD_AUDIO.
val session = ConversationClient.startSession(
ConversationConfig(
agentId = "<your_public_agent_id>",
textOnly = true,
onAgentResponseEvent = { reply, _ -> /* render reply */ },
),
this,
)
session.sendUserMessage("Hello!")
For private agents, pass the signed WebSocket URL returned by your backend's call to /v1/convai/conversation/get-signed-url?agent_id=… as signedUrl. The SDK opens it verbatim — no need to pass agentId separately.
val signedUrl = backendApi.fetchSignedUrl() // e.g., wss://api.elevenlabs.io/v1/convai/conversation?agent_id=…&conversation_signature=…
val session = ConversationClient.startSession(
ConversationConfig(
signedUrl = signedUrl,
textOnly = true,
),
this,
)
The text‑only WebSocket lives on the same host as apiEndpoint, so data residency is honored automatically:
ConversationConfig(
agentId = "<your_public_agent_id>",
textOnly = true,
apiEndpoint = "https://api.eu.residency.elevenlabs.io",
)
For self-hosted or custom deployments, you can configure custom endpoints:
val config = ConversationConfig(
agentId = "<your_agent_id>",
apiEndpoint = "https://custom-api.example.com", // Custom API endpoint (default: "https://api.elevenlabs.io")
websocketUrl = "wss://custom-webrtc.example.com" // Custom WebSocket URL (default: "wss://livekit.rtc.elevenlabs.io")
)
Both parameters are optional and default to the standard ElevenLabs production endpoints.
Note: If you are using data residency, make sure that both apiEndpoint and websocketUrl point to the same geographic region. For example https://api.eu.residency.elevenlabs.io and wss://livekit.rtc.eu.residency.elevenlabs.io respectively. A mismatch will result in errors when authenticating.
session.getId().DisconnectionDetails.User - Your code ended the conversation by calling endSession()/disconnect(). Never fired for a connection that timed out or was closed by the remote side on its own.DisconnectionDetails.Agent - The remote side closed the connection without a local endSession()/disconnect() call. For text-only (WebSocket) sessions this also covers a server-enforced idle/inactivity timeout, since the SDK can't distinguish that from the agent gracefully ending the call - both close the socket normally.DisconnectionDetails.Error(exception: Exception) - Connection error occurredsource is "ai" or "user".ConversationMode.SPEAKING or ConversationMode.LISTENING; drive your speaking indicator.CONNECTED, CONNECTING, DISCONNECTED, DISCONNECTING, ERROR.partType is START, DELTA, or STOP.Deprecated:
onUserTranscript(transcript: String),onAgentResponse(response: String), andonAgentResponseCorrection(originalResponse: String, correctedResponse: String)are superseded by the…Eventcallbacks above, which also surface the server event id. They still fire for backward compatibility.
AudioFrame exposes audioData: ByteBuffer (little-endian PCM), bitsPerSample, sampleRate, channelCount, numberOfFrames, and absoluteCaptureTimestampMs. The ByteBuffer is only valid for the duration of the callback — copy the bytes if you need to keep them. Useful for waveform visualizations, recording, or custom audio processing. The callback runs on LiveKit's audio thread, so keep work short and avoid blocking.Register client tools to allow the agent to call local capabilities on the device.
val config = ConversationConfig(
agentId = "<public_agent>",
clientTools = mapOf(
"logMessage" to object : io.elevenlabs.ClientTool {
override suspend fun execute(parameters: Map<String, Any>): io.elevenlabs.ClientToolResult? {
val message = parameters["message"] as? String ?: return io.elevenlabs.ClientToolResult.failure("Missing 'message'")
android.util.Log.d("ClientTool", "Log: $message")
return null // No response needed for fire-and-forget tools
}
}
)
)
When the agent issues a client_tool_call, the SDK executes the matching tool and responds with a client_tool_result. If the tool is not registered:
onUnhandledClientToolCall callback is provided, it will be invoked and you must handle the response manually using sendToolResult()For runtime-defined tools or tools that can't be registered upfront, you can handle them dynamically using the onUnhandledClientToolCall callback combined with sendToolResult():
val config = ConversationConfig(
agentId = "<public_agent>",
onUnhandledClientToolCall = { toolCall ->
// Handle dynamic tool execution
when (toolCall.toolName) {
"getDeviceInfo" -> {
// Send result as a string
session.sendToolResult(toolCall.toolCallId, "Device: ${Build.MODEL}", isError = false)
}
"fetchUserData" -> {
// Perform async operation
coroutineScope.launch {
val data = fetchDataFromAPI(toolCall.parameters)
session.sendToolResult(toolCall.toolCallId, data, isError = false)
}
}
else -> {
// Unknown tool - send error
session.sendToolResult(toolCall.toolCallId, "Unknown tool: ${toolCall.toolName}", isError = true)
}
}
}
)
Key methods:
session.sendToolResult(toolCallId, result, isError): Send tool execution results back to the agent manually. The result parameter is a string (use JSON string for complex data). Use this in the onUnhandledClientToolCall callback to respond to dynamic tool calls.toolCall.expectsResponse: Check this property to determine if the agent expects a response. If false, the tool is fire-and-forget and you can skip calling sendToolResult().This approach is useful for:
session.sendUserMessage(text: String): user message that should elicit a response from the agentsession.sendContextualUpdate(text: String): context that should not prompt a response from the agentsession.sendUserActivity(): signal that the user is typing/activeUse onCanSendFeedbackChange to enable your thumbs up/down UI when feedback is allowed. When pressed:
session.sendFeedback(isPositive = true) // like
session.sendFeedback(isPositive = false) // dislike
The SDK ensures duplicates are not sent for the same/older agent event.
session.toggleMute()
session.setMicMuted(true) // mute
session.setMicMuted(false) // unmute
Observe session.isMuted to update the UI label between "Mute" and "Unmute".
The SDK uses Kotlin StateFlow for reactive state management. The ConversationSession exposes four StateFlow properties:
status: StateFlow<ConversationStatus> - Connection status (CONNECTED, CONNECTING, DISCONNECTED, etc.)mode: StateFlow<ConversationMode> - Conversation mode (SPEAKING, LISTENING)isMuted: StateFlow<Boolean> - Microphone mute stateaudioLevel: StateFlow<Float> - Agent audio level (0.0 to 1.0)Collect flows in your ViewModel's coroutine scope:
class MyViewModel : ViewModel() {
private val _statusText = MutableLiveData<String>()
val statusText: LiveData<String> = _statusText
fun observeSession(session: ConversationSession) {
viewModelScope.launch {
session.status.collect { status ->
_statusText.value = when (status) {
ConversationStatus.CONNECTED -> "Connected"
ConversationStatus.CONNECTING -> "Connecting..."
ConversationStatus.DISCONNECTED -> "Disconnected"
ConversationStatus.DISCONNECTING -> "Disconnecting..."
ConversationStatus.ERROR -> "Error"
}
}
}
viewModelScope.launch {
session.mode.collect { mode ->
// Update UI based on speaking/listening mode
when (mode) {
ConversationMode.SPEAKING -> showSpeakingIndicator()
ConversationMode.LISTENING -> showListeningIndicator()
}
}
}
viewModelScope.launch {
session.audioLevel.collect { level ->
// Agent audio level updates during speech
Log.d("MyViewModel", "Audio level: $level")
}
}
}
}
Use lifecycleScope with repeatOnLifecycle for lifecycle-aware collection:
class MyActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val session = ConversationClient.startSession(config, this)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
session.status.collect { status ->
updateStatusUI(status)
}
}
launch {
session.isMuted.collect { muted ->
muteButton.text = if (muted) "Unmute" else "Mute"
}
}
launch {
session.audioLevel.collect { level ->
// Agent audio level updates
Log.d("MyActivity", "Audio level: $level")
}
}
}
}
}
}
If you prefer LiveData, use the provided extension function:
import io.elevenlabs.utils.asLiveData
val statusLiveData: LiveData<ConversationStatus> = session.status.asLiveData()
val modeLiveData: LiveData<ConversationMode> = session.mode.asLiveData()
val audioLevelLiveData: LiveData<Float> = session.audioLevel.asLiveData()
statusLiveData.observe(this) { status ->
// Handle status changes
}
audioLevelLiveData.observe(this) { level ->
// Handle audio level changes
Log.d("MyActivity", "Audio level: $level")
}
This repository includes an example app demonstrating:
sendUserActivity()Run:
./gradlew example-app:assembleDebug
Install the APK on an emulator or device (note: emulators may have audio routing limitations). Use Android Studio for best results.
Ensure to allow the virtual microphone to use host audio input in the emulator settings.

If you shrink/obfuscate, ensure Gson models and LiveKit are kept. Example rules (adjust as needed):
-keep class io.elevenlabs.** { *; }
-keep class io.livekit.** { *; }
-keepattributes *Annotation*
session.endSession() and that you start a new session instance before reconnectingKotlin
100.0%
Official ElevenAgents SDK for Android.
agentId) and private agents (pre‑issued conversationToken for voice or signedUrl for text‑only)Add Maven Central and the SDK dependency to your Gradle configuration.
pluginManagement {
repositories {
gradlePluginPortal()
google()
mavenCentral()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
dependencies {
// ElevenAgents SDK (Android)
implementation("io.elevenlabs:elevenlabs-android:<latest>")
// Kotlin coroutines, AndroidX, etc., as needed by your app
}
You have to request the android.permission.RECORD_AUDIO runtime permission yourself before starting a voice session. Text‑only sessions don't need this permission.
Permissions (and a service) are added to your AndroidManifest.xml automatically by the LiveKit SDK.
Certain ones are not needed to use the ElevenLabs SDK so you can remove them if don't need them:
<manifest>
[...]
<uses-permission android:name="android.permission.CAMERA" tools:node="remove" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" tools:node="remove" />
[...]
<application>
[...]
<!--suppress AndroidDomInspection -->
<service
android:name="io.livekit.android.room.track.screencapture.ScreenCaptureService"
tools:node="remove" />
</application>
</manifest>
ConversationConfig requires exactly one of three credentials, depending on the agent and transport:
| Agent | Transport | Field |
|---|---|---|
| Public | Voice or text | agentId |
| Private | Voice (LiveKit/WebRTC) | conversationToken |
| Private | Text‑only (raw WebSocket) | signedUrl |
Private credentials are provisioned by your backend — never ship API keys.
import io.elevenlabs.ConversationClient
import io.elevenlabs.ConversationConfig
import io.elevenlabs.ConversationSession
import io.elevenlabs.ClientTool
import io.elevenlabs.ClientToolResult
// Start a public agent session (token generated for you)
val config = ConversationConfig(
agentId = "<your_public_agent_id>", // OR conversationToken = "<token>" (voice) / signedUrl = "wss://…" (text-only)
userId = "your-user-id",
audioInputSampleRate = "48000", // Optional parameter, defaults to 48kHz. Lower values can help with audio input issues on slower connections
apiEndpoint = "https://api.elevenlabs.io", // Optional: Custom API endpoint
websocketUrl = "wss://livekit.rtc.elevenlabs.io", // Optional: Custom WebSocket URL
// Optional callbacks
onConnect = { conversationId ->
// Connected, you can store conversationId via session.getId() too
},
onDisconnect = { reason ->
// Disconnected, reason indicates who initiated the disconnect, either "Agent", "User" or "Error"
},
onMessage = { source, messageJson ->
// Raw JSON messages from data channel; useful for logging/telemetry
},
onModeChange = { mode ->
// ConversationMode.SPEAKING | ConversationMode.LISTENING — drive UI indicators
},
onStatusChange = { status ->
// ConversationStatus enum: CONNECTED, CONNECTING, DISCONNECTED, DISCONNECTING, ERROR
},
onCanSendFeedbackChange = { canSend ->
// Enable/disable thumbs up/down
},
onUnhandledClientToolCall = { call ->
// Agent requested a client tool not registered on the device
},
onVadScore = { score ->
// Voice Activity Detection score, range from 0 to 1 where higher values indicate higher confidence of speech
},
onAudioLevelChanged = { level ->
// Agent audio level (volume), range from 0.0 to 1.0
// Log.d("MyApp", "Agent audio level: $level")
},
onAudioFrame = { frame ->
// Decoded PCM chunk from the agent's audio track (little-endian).
// The ByteBuffer is only valid for the duration of this callback — copy what you need.
// Useful for visualizations, recording, or custom DSP.
},
onUserTranscriptEvent = { text, eventId ->
// User's speech transcribed to text (finalized)
},
onTentativeUserTranscriptEvent = { text, eventId ->
// In-progress user transcript
},
onAgentResponseEvent = { text, eventId ->
// Agent's finalized text response
},
onAgentResponsePartEvent = { partType, text, eventId ->
// Streaming agent text part: AgentResponsePartType.START / DELTA / STOP
},
onAgentResponseCorrectionEvent = { text, eventId ->
// Agent response was corrected after interruption
},
onAgentToolResponse = { toolName, toolCallId, toolType, isError ->
// Agent tool execution completed
},
onConversationInitiationMetadata = { conversationId, agentOutputFormat, userInputFormat ->
// Conversation metadata including audio formats
},
onInterruption = { eventId ->
// User interrupted the agent while speaking
},
// List of client tools the agent can invoke
clientTools = mapOf(
"logMessage" to object : ClientTool {
override suspend fun execute(parameters: Map<String, Any>): ClientToolResult? {
val message = parameters["message"] as? String
Log.d("ExampleApp", "[INFO] Client Tool Log: $message")
return ClientToolResult.success("Message logged successfully")
}
}
),
)
Note: If a tool is configured with
expects_response=falseon the server, returnnullfromexecuteto skip sending a tool result back to the agent.
// In an Activity context
val session: ConversationSession = ConversationClient.startSession(config, this)
// Send messages via the data channel
session.sendUserMessage("Hello!")
session.sendContextualUpdate("User navigated to the settings screen")
session.sendUserActivity() // useful while user is typing
// Feedback for the latest agent response
session.sendFeedback(isPositive = true) // or false
// Microphone control
session.toggleMute() // toggle
session.setMicMuted(true) // explicit
// Conversation ID
val id: String? = session.getId() // e.g., "conv_123" once connected
// End the session
session.endSession()
agentId in ConversationConfig. The SDK requests a conversation token from ElevenLabs without needing an API key on device.conversationToken in ConversationConfig. Your backend mints the WebRTC token via /v1/convai/conversation/token?agent_id=… using your API key.signedUrl in ConversationConfig. Your backend signs a WebSocket URL via /v1/convai/conversation/get-signed-url?agent_id=… using your API key.Never embed API keys in clients. ConversationConfig enforces that exactly one credential is set, and that the credential matches the transport (textOnly = true → signedUrl, textOnly = false → conversationToken).
For text‑only conversations, set textOnly = true on ConversationConfig. The SDK switches transports automatically:
wss://api.elevenlabs.io/v1/convai/conversation).The transport switch is required because LiveKit drops rooms that never publish an audio or video track, which would tear down a text‑only conversation after a few seconds. Text‑only sessions don't need RECORD_AUDIO.
val session = ConversationClient.startSession(
ConversationConfig(
agentId = "<your_public_agent_id>",
textOnly = true,
onAgentResponseEvent = { reply, _ -> /* render reply */ },
),
this,
)
session.sendUserMessage("Hello!")
For private agents, pass the signed WebSocket URL returned by your backend's call to /v1/convai/conversation/get-signed-url?agent_id=… as signedUrl. The SDK opens it verbatim — no need to pass agentId separately.
val signedUrl = backendApi.fetchSignedUrl() // e.g., wss://api.elevenlabs.io/v1/convai/conversation?agent_id=…&conversation_signature=…
val session = ConversationClient.startSession(
ConversationConfig(
signedUrl = signedUrl,
textOnly = true,
),
this,
)
The text‑only WebSocket lives on the same host as apiEndpoint, so data residency is honored automatically:
ConversationConfig(
agentId = "<your_public_agent_id>",
textOnly = true,
apiEndpoint = "https://api.eu.residency.elevenlabs.io",
)
For self-hosted or custom deployments, you can configure custom endpoints:
val config = ConversationConfig(
agentId = "<your_agent_id>",
apiEndpoint = "https://custom-api.example.com", // Custom API endpoint (default: "https://api.elevenlabs.io")
websocketUrl = "wss://custom-webrtc.example.com" // Custom WebSocket URL (default: "wss://livekit.rtc.elevenlabs.io")
)
Both parameters are optional and default to the standard ElevenLabs production endpoints.
Note: If you are using data residency, make sure that both apiEndpoint and websocketUrl point to the same geographic region. For example https://api.eu.residency.elevenlabs.io and wss://livekit.rtc.eu.residency.elevenlabs.io respectively. A mismatch will result in errors when authenticating.
session.getId().DisconnectionDetails.User - Your code ended the conversation by calling endSession()/disconnect(). Never fired for a connection that timed out or was closed by the remote side on its own.DisconnectionDetails.Agent - The remote side closed the connection without a local endSession()/disconnect() call. For text-only (WebSocket) sessions this also covers a server-enforced idle/inactivity timeout, since the SDK can't distinguish that from the agent gracefully ending the call - both close the socket normally.DisconnectionDetails.Error(exception: Exception) - Connection error occurredsource is "ai" or "user".ConversationMode.SPEAKING or ConversationMode.LISTENING; drive your speaking indicator.CONNECTED, CONNECTING, DISCONNECTED, DISCONNECTING, ERROR.partType is START, DELTA, or STOP.Deprecated:
onUserTranscript(transcript: String),onAgentResponse(response: String), andonAgentResponseCorrection(originalResponse: String, correctedResponse: String)are superseded by the…Eventcallbacks above, which also surface the server event id. They still fire for backward compatibility.
AudioFrame exposes audioData: ByteBuffer (little-endian PCM), bitsPerSample, sampleRate, channelCount, numberOfFrames, and absoluteCaptureTimestampMs. The ByteBuffer is only valid for the duration of the callback — copy the bytes if you need to keep them. Useful for waveform visualizations, recording, or custom audio processing. The callback runs on LiveKit's audio thread, so keep work short and avoid blocking.Register client tools to allow the agent to call local capabilities on the device.
val config = ConversationConfig(
agentId = "<public_agent>",
clientTools = mapOf(
"logMessage" to object : io.elevenlabs.ClientTool {
override suspend fun execute(parameters: Map<String, Any>): io.elevenlabs.ClientToolResult? {
val message = parameters["message"] as? String ?: return io.elevenlabs.ClientToolResult.failure("Missing 'message'")
android.util.Log.d("ClientTool", "Log: $message")
return null // No response needed for fire-and-forget tools
}
}
)
)
When the agent issues a client_tool_call, the SDK executes the matching tool and responds with a client_tool_result. If the tool is not registered:
onUnhandledClientToolCall callback is provided, it will be invoked and you must handle the response manually using sendToolResult()For runtime-defined tools or tools that can't be registered upfront, you can handle them dynamically using the onUnhandledClientToolCall callback combined with sendToolResult():
val config = ConversationConfig(
agentId = "<public_agent>",
onUnhandledClientToolCall = { toolCall ->
// Handle dynamic tool execution
when (toolCall.toolName) {
"getDeviceInfo" -> {
// Send result as a string
session.sendToolResult(toolCall.toolCallId, "Device: ${Build.MODEL}", isError = false)
}
"fetchUserData" -> {
// Perform async operation
coroutineScope.launch {
val data = fetchDataFromAPI(toolCall.parameters)
session.sendToolResult(toolCall.toolCallId, data, isError = false)
}
}
else -> {
// Unknown tool - send error
session.sendToolResult(toolCall.toolCallId, "Unknown tool: ${toolCall.toolName}", isError = true)
}
}
}
)
Key methods:
session.sendToolResult(toolCallId, result, isError): Send tool execution results back to the agent manually. The result parameter is a string (use JSON string for complex data). Use this in the onUnhandledClientToolCall callback to respond to dynamic tool calls.toolCall.expectsResponse: Check this property to determine if the agent expects a response. If false, the tool is fire-and-forget and you can skip calling sendToolResult().This approach is useful for:
session.sendUserMessage(text: String): user message that should elicit a response from the agentsession.sendContextualUpdate(text: String): context that should not prompt a response from the agentsession.sendUserActivity(): signal that the user is typing/activeUse onCanSendFeedbackChange to enable your thumbs up/down UI when feedback is allowed. When pressed:
session.sendFeedback(isPositive = true) // like
session.sendFeedback(isPositive = false) // dislike
The SDK ensures duplicates are not sent for the same/older agent event.
session.toggleMute()
session.setMicMuted(true) // mute
session.setMicMuted(false) // unmute
Observe session.isMuted to update the UI label between "Mute" and "Unmute".
The SDK uses Kotlin StateFlow for reactive state management. The ConversationSession exposes four StateFlow properties:
status: StateFlow<ConversationStatus> - Connection status (CONNECTED, CONNECTING, DISCONNECTED, etc.)mode: StateFlow<ConversationMode> - Conversation mode (SPEAKING, LISTENING)isMuted: StateFlow<Boolean> - Microphone mute stateaudioLevel: StateFlow<Float> - Agent audio level (0.0 to 1.0)Collect flows in your ViewModel's coroutine scope:
class MyViewModel : ViewModel() {
private val _statusText = MutableLiveData<String>()
val statusText: LiveData<String> = _statusText
fun observeSession(session: ConversationSession) {
viewModelScope.launch {
session.status.collect { status ->
_statusText.value = when (status) {
ConversationStatus.CONNECTED -> "Connected"
ConversationStatus.CONNECTING -> "Connecting..."
ConversationStatus.DISCONNECTED -> "Disconnected"
ConversationStatus.DISCONNECTING -> "Disconnecting..."
ConversationStatus.ERROR -> "Error"
}
}
}
viewModelScope.launch {
session.mode.collect { mode ->
// Update UI based on speaking/listening mode
when (mode) {
ConversationMode.SPEAKING -> showSpeakingIndicator()
ConversationMode.LISTENING -> showListeningIndicator()
}
}
}
viewModelScope.launch {
session.audioLevel.collect { level ->
// Agent audio level updates during speech
Log.d("MyViewModel", "Audio level: $level")
}
}
}
}
Use lifecycleScope with repeatOnLifecycle for lifecycle-aware collection:
class MyActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val session = ConversationClient.startSession(config, this)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
session.status.collect { status ->
updateStatusUI(status)
}
}
launch {
session.isMuted.collect { muted ->
muteButton.text = if (muted) "Unmute" else "Mute"
}
}
launch {
session.audioLevel.collect { level ->
// Agent audio level updates
Log.d("MyActivity", "Audio level: $level")
}
}
}
}
}
}
If you prefer LiveData, use the provided extension function:
import io.elevenlabs.utils.asLiveData
val statusLiveData: LiveData<ConversationStatus> = session.status.asLiveData()
val modeLiveData: LiveData<ConversationMode> = session.mode.asLiveData()
val audioLevelLiveData: LiveData<Float> = session.audioLevel.asLiveData()
statusLiveData.observe(this) { status ->
// Handle status changes
}
audioLevelLiveData.observe(this) { level ->
// Handle audio level changes
Log.d("MyActivity", "Audio level: $level")
}
This repository includes an example app demonstrating:
sendUserActivity()Run:
./gradlew example-app:assembleDebug
Install the APK on an emulator or device (note: emulators may have audio routing limitations). Use Android Studio for best results.
Ensure to allow the virtual microphone to use host audio input in the emulator settings.

If you shrink/obfuscate, ensure Gson models and LiveKit are kept. Example rules (adjust as needed):
-keep class io.elevenlabs.** { *; }
-keep class io.livekit.** { *; }
-keepattributes *Annotation*
session.endSession() and that you start a new session instance before reconnectingKotlin
100.0%