Skip to main content
ai.poly:voice places live, two-way WebRTC voice calls to a PolyAI agent. It is the companion artifact to the Android SDK’s chat product. It ships separately so chat-only apps stay lean (the call path pulls in the native libwebrtc audio engine), and it reuses the messaging Configuration plus the same CallState / PolyError.Voice / Environment vocabulary. Calls are user-initiated: the user taps to call your agent. Inbound (push-triggered) calls are not supported.

Source on GitHub

polyai/android-sdk: the full polyvoice technical guide and runnable voice example apps.

Installation

The 0.11.0 voice artifacts are compiled with Kotlin 2.4, so they require Kotlin plugin 2.4.10 or newer. In a fresh Android Studio project, set the Kotlin version first:
Then add the dependencies to the app module, not the top-level project file:
mavenCentral() is already present in new Android Studio projects. If your project removed it, restore it under dependencyResolutionManagement.repositories in settings.gradle.kts.

Initialize once, then call

Both credentials go on the shared Configuration in PolyMessaging.initialize(...). PolyVoice.call(context) then reads the initialized configuration, the same way PolyMessaging.chat() does for chat:
  • Both credentials are required and distinct.
  • The WebRTC token belongs in Configuration.webrtcToken. Do not set it on VoiceOptions.
  • PolyVoice.call(context) reads the configuration set by PolyMessaging.initialize(...). Call initialize first.
  • If Configuration.webrtcToken is missing, PolyVoice.call(...) throws PolyError.InvalidConfiguration.
For a call that needs a different connector than the one initialize(...) set, pass an explicit Configuration with both credentials:
CallState, PolyError.Voice.*, Configuration, and Environment are the same types from ai.poly:messaging.

Quick start

The following is a complete foreground-only calling app for the current Android Studio Empty Activity Compose template. Create a project with minimum SDK 24 or newer, update Kotlin and add the dependencies above, then replace the generated activity. No manifest changes or extra application class are needed for this first call.
1

Paste one file: MainActivity.kt

Open the MainActivity.kt Android Studio generated. Keep its first package … line, delete everything below that line, and paste the block below directly after it. Replace the two credential placeholders with your connector token and WebRTC token from Agent Studio › Connector Settings.
2

Sync and run

Click Sync Project with Gradle Files, select a physical Android device, and run the app. Tap Start call and allow microphone access. The status moves through Connecting to Connected.
What the quick start covers:
  • Runtime permission: it requests RECORD_AUDIO before start(), and shows a message if the user denies it.
  • State: it observes call.state with collectAsStateWithLifecycle() and renders the connecting, connected, ended and failed states. start() returning means setup is under way, not that the call is already connected.
  • Controls: start, mute and end.
  • Cleanup: the screen holds one VoiceCall and calls call.close() in DisposableEffect when it leaves the composition.
Initializing inside MainActivity is acceptable only for this one-file first test. It runs again if Android recreates the activity. In a production app, call PolyMessaging.initialize(...) once in Application.onCreate().
This first call works only while the app is in the foreground. Once it works, add the foreground service before testing background calls. If Gradle reports Unresolved reference 'implementation', the dependency lines were added to the top-level build.gradle.kts; move them into app/build.gradle.kts. If it reports incompatible Kotlin metadata version 2.4.0, update the Kotlin version as shown under Installation, then sync again.

Credentials

A voice call needs two credentials, both on your agent in Agent Studio › Connector Settings (the same connector you use for chat): The API key authenticates the connector. The WebRTC token authenticates the media backend. Two more values have sensible defaults, so most apps don’t set them:
  • environment defaults to Environment.US. Set .UK / .EUW, or .cluster("…") for a named cluster, only if your agent lives in another region. The same Environment also selects the corresponding webrtc-bridge deployment.
  • hostIdentifier (sent as X-Host) defaults to your app’s package name (applicationId).
Custom / self-hosted bridge: the webrtc-bridge host is derived from your Environment. If you run a dev or self-hosted bridge, set VoiceOptions.signalingHost (no scheme, e.g. "webrtc-bridge.example.com"). It is required with Environment.Custom, since the bridge host can’t be derived from a custom messaging endpoint.

How a call connects

Calls are placed over PolyAI’s webrtc-bridge. The older webrtc-gateway path has been removed and no longer works, so there is nothing to choose between.
  1. The SDK calls POST /api/v1/call, sending the WebRTC token as Authorization: Bearer.
  2. webrtc-bridge creates the call ID and returns it, together with the ICE servers for the call.
  3. ICE gathering completes before the SDP offer is sent.
  4. The SDP offer and answer are exchanged over HTTPS.
  5. Once media connects, a second negotiation starts the agent’s audio.
  6. Teardown uses DELETE /api/v1/call/{id}.
start() returns as soon as the call is under way, with the state Connecting. Observe state for Connected. The agent-track negotiation in step 5 runs after that, on your behalf. The call still links to the same messaging session, so the agent transcript is the same. The default fallback STUN server (IceServer.DEFAULT) is stun.cloudflare.com.

Permissions

The SDK’s manifest auto-merges the three permissions every call needs. You don’t declare these:
RECORD_AUDIO is a runtime permission. Your app must still request it before start(); the call fails fast with PolyError.Voice.MediaFailed if it’s missing. Add the rest only for the optional features you use:
BLUETOOTH_CONNECT is not declared by the SDK. Your app adds it and requests the grant when it needs Bluetooth routing.

Call lifecycle

  • Retain one VoiceCall while the owning screen is active.
  • Observe its state StateFlow: collectAsStateWithLifecycle() in Compose, or repeatOnLifecycle(Lifecycle.State.STARTED) in Views.
  • Call call.close() when the owning screen or component is permanently disposed.

Audio output

By default the call follows the connected accessory: a wired or Bluetooth headset is used automatically (and auto-switches when you plug/unplug one mid-call). When nothing is connected it falls back to the loudspeaker (VoiceOptions(speakerphone = false) falls back to the earpiece instead). To let users pin a specific output, observe call.audio and call setAudioDevice. Pass null to return to automatic:
  • AudioDevice.type is one of EARPIECE / SPEAKER_PHONE / WIRED_HEADSET / BLUETOOTH; name is a picker-friendly label. The list updates live as headsets connect and disconnect.
  • Switching is asynchronous. Bluetooth can take a few seconds to engage. Drive your UI off call.audio, not off setAudioDevice returning. Selecting an unavailable device is a no-op.
  • Bluetooth needs BLUETOOTH_CONNECT. The library does not declare this runtime permission for you. Add it to your app and request the grant if you want Bluetooth outputs to appear. Without it, Bluetooth is simply absent, with no crash.

Interruptions (incoming calls, other apps)

The SDK manages audio focus for you: it takes focus on start() and releases it on end() or teardown. It also reacts to losing focus while a call is live:
  • A transient loss (a notification, a navigation prompt) mutes the mic for the duration and restores it automatically when focus returns. The call stays Connected.
  • A permanent loss (the user answers an incoming phone call, or another app starts an exclusive audio session) ends the call. It surfaces as CallState.Failed(PolyError.Voice.Interrupted) and the mic is released.
Observe state and tell the user; the mic is already released:

Background calls

A VoiceCall is a plain object on its own coroutine scope. It is not tied to your Activity or Fragment lifecycle, so the SDK won’t end a call just because your UI is backgrounded. However, when your app goes to the background, Android 9+ cuts mic capture and throttles the WebRTC media and network threads, so the connection silently dies within ~15s and the SDK reports CallState.Failed(PolyError.Voice.Disconnected). To keep a call alive in the background you need two things while the call is active:
  1. A microphone foreground service: grants background mic access and keeps the process foregrounded.
  2. A partial wake lock: keeps the CPU running for the media and network threads.
Start the service before call.start() and stop it when the call ends. The SDK is headless and deliberately doesn’t impose a service (it has no notification UI). A foreground-only call works without any of this. Both voice example apps ship a complete, working CallForegroundService you can copy.

R8 / ProGuard

No keep rules needed in your app. The ai.poly:voice AAR ships consumer R8 rules (applied automatically) that keep org.webrtc.**. libwebrtc is reached by name over JNI from native code, which R8 can’t see, so stripping it would crash the audio engine. If you maintain an unusually aggressive global proguard-rules.pro, the shipped consumer rules still protect the SDK; you don’t add anything.

Example apps

A one-screen tap-to-call demo with the audio-output picker ships in both toolkits. Add your connector token and WebRTC token to the PolyMessaging.initialize(...) call in the example’s Application class and run:

Android SDK

Chat with ai.poly:messaging: installation, authentication, sessions, and UI

Multichannel agents

Build agents that work across voice, webchat, and mobile
Last modified on September 17, 2026