PolyVoice places live, two-way WebRTC voice calls to a PolyAI agent. It is the companion to the iOS SDK’s chat product. It ships as a separate product/pod so chat-only apps never link the WebRTC binary, and it reuses the messaging Configuration plus the same CallState / PolyError vocabulary.
Calls are user-initiated: the user taps to call your agent. Inbound (push-triggered) calls are not supported.
Source on GitHub
polyai/ios-sdk: the full PolyVoice technical guide and runnable voice example apps.
Installation
The SDK is pre-1.0, so pin to the next minor version. A minor bump is allowed to include breaking changes.- Swift Package Manager
- CocoaPods
Add the package and depend on the In Xcode, tick both the PolyMessaging and PolyVoice libraries for your app target when adding the package.
PolyVoice product alongside PolyMessaging:PolyVoice transitively pulls the WebRTC xcframework. PolyMessaging stays source-only, so a chat-only target links only PolyMessaging. With SPM, adding the repo still resolves the WebRTC package for the whole dependency graph, so a chat-only target downloads the xcframework without linking it. With CocoaPods the WebRTC dependency lives solely in PolyVoice, so a chat-only install pulls nothing extra.
Initialize once, then call
Set both credentials once on the sharedConfiguration in PolyMessaging.initialize(...) at launch. PolyVoice.call() then takes no arguments: it reads that Configuration back, the same way PolyMessaging.chat() does for chat.
PolyVoice.call() returns a PolyCall. Observe its state (.idle → .connecting → .connected → .ended / .failed) and call start() / end() / setMuted(_:). PolyVoice.call is @MainActor, so call it from the main actor. It throws PolyError.invalidConfiguration if the connector token or web calling token is blank, or for a .custom environment without signalingHost.
To place a call with a different connector than the one initialize(...) set, pass a Configuration explicitly with PolyVoice.call(config:options:).
CallState, PolyError, and Configuration are the same types from PolyMessaging.
Quick start
The smallest working call, in both toolkits. Create a new Xcode App project, add your connector token and web calling token toPolyMessaging.initialize(...), and run on a physical device. WebRTC media needs real hardware (see Troubleshooting). Read Microphone permission and Backgrounding before your first real call.
- SwiftUI
- UIKit
PolyCall is an ObservableObject: state and audioState are @Published. A view that binds it with @ObservedObject var call: PolyCall re-renders on every state and audio-route change, with no for await loop to write.Examples/SwiftUI/Voice/01-Hello and Examples/UIKit/Voice/01-Hello.
Credentials
A voice call needs two credentials, both on your agent in Agent Studio › Connector Settings (the same connector you use for chat):Configuration.webrtcToken is the normal place for the web calling token: set it once alongside apiKey, and every PolyVoice.call() picks it up. VoiceOptions.webrtcToken is an optional per-call override for apps that juggle multiple agents or tokens. If both are set, the VoiceOptions value wins.
Region: calls default to the US cluster. For a UK / EUW / other-region agent, set the environment on the shared
Configuration, for example Configuration(apiKey: …, environment: .cluster("…")). This is the same Configuration you use for chat.Custom / self-hosted host: VoiceOptions(signalingHost:) overrides the webrtc-bridge host. It is required when the environment is .custom.How a call connects
Calls are placed over PolyAI’swebrtc-bridge. The older webrtc-gateway path has been removed and no longer works. There is nothing to choose between and no API change: the same PolyVoice.call with the same two credentials.
- The SDK calls
POST /api/v1/call, sending the web calling token asAuthorization: Bearer. webrtc-bridgecreates the call ID and returns it, together with the STUN/TURN servers for the call.- ICE gathering completes before the SDP offer is sent.
- The SDP offer and answer are exchanged over HTTPS.
- Once media connects, a second negotiation starts the agent’s audio.
- Teardown uses
DELETE /api/v1/call/{id}.
start() returns as soon as the call is under way, with the state .connecting. Watch 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.
Microphone permission
A call needs the microphone. AddNSMicrophoneUsageDescription to your app’s Info.plist. A call without it crashes on iOS; it does not fail gracefully. The system prompts the user on the first call, and the SDK activates the AVAudioSession for you (under CallKit, the system activates it instead; the permission requirement is unchanged).
Backgrounding
To keep a call running while your app is in the background, enable theaudio background mode in your Info.plist:
playAndRecord AVAudioSession, so with this mode the call keeps running when the app is backgrounded. Without it, iOS suspends the app and the call drops. By default a call is a normal app audio session, not a system phone call. For the system call UI, see CallKit, which additionally requires the voip background mode alongside audio.
Audio routing
The call is accessory-aware by default: a connected wired or Bluetooth headset is used automatically (and followed if connected or removed mid-call); otherwise it falls back to the loudspeaker. SetVoiceOptions(speakerphone: false) to fall back to the earpiece instead.
iOS keeps one active output and routes accessories for you, so the output an app reliably controls is speaker ↔ earpiece. In SwiftUI, read call.audioState from your @ObservedObject, as the quick start does. In UIKit, observe call.audioStates. Switch with call.setAudioDevice(_:):
availableDevices also lists connected headsets and Bluetooth devices (.kind is .earpiece / .speakerphone / .wiredHeadset / .bluetooth) for display. To let users pick among connected outputs the iOS-standard way, use the system route picker (AVRoutePickerView).
CallKit
Opt in withVoiceOptions(callKit: true) to run a call as a system call: the green in-call indicator, lock-screen / AirPods / car-Bluetooth controls, phone-call audio priority, and hold arbitration when a cellular call arrives.
In this mode the SDK never activates or deactivates the audio session itself; CallKit does. Your CXProviderDelegate must forward all three PolyVoice.callKit* callbacks. Without this forwarding, the call connects but has no sound.
- Declare the
voipbackground mode alongsideaudioinUIBackgroundModes. Without it everyCXCallControllertransaction is refused (com.apple.CallKit.error.requesttransaction Code=1) and the call never starts. - Request, don’t command: start / end / mute go through
CXCallControlleractions and are executed in the matchingperformcallback, so the system can arbitrate and the system UI stays in sync. Remote endings (the agent hangs up, a failure) are reported viareportCall(with:endedAt:reason:)instead. - Never activate
AVAudioSessionmanually (AVAudioSession.setActive(true)) during a CallKit call. A self-activated session blocks CallKit’s elevated activation anddidActivatenever fires (the classic “call connects, no audio” bug). - System interruptions move to CallKit: a cellular call arrives as a hold action plus
didDeactivate, not as the SDK’s interruption handling. After the interrupting call ends, iOS may not resume you automatically, so offer a manual un-hold path. - Simulator: CallKit is broken there (iOS 17+ auto-ends calls). Gate on
targetEnvironment(simulator)and fall back to a plain call. - China: Apple rejects CallKit UI for the Chinese App Store. Keep
callKit:behind a region or remote-config gate if you ship there.
Resilience
- Connectivity: STUN/TURN servers come from the bridge’s provision response per call, so calls connect behind symmetric NAT / CGNAT. If that fails, the SDK falls back to public STUN at
stun.cloudflare.com. - Reconnect: a dropped signaling socket reconnects automatically (backoff 1s / 2s / 4s) on the same session before the call is failed.
- Interruptions: an incoming phone call or Siri mutes the mic and restores it; a non-resumable interruption ends the call as
PolyError.voice(.interrupted). - Retryable errors: a post-connect drop surfaces as
PolyError.voice(.disconnected). Both it and.interruptedareisRetryable, so you can offer a one-tap retry.
Troubleshooting
Example apps
One-screen tap-to-call demos ship in both toolkits. Add your connector token and web calling token to thePolyMessaging.initialize(...) call and run on a physical device:
The 02-CallKit examples encode all of the CallKit rules above.
Related pages
iOS SDK
Chat with PolyMessaging: installation, authentication, sessions, and UI
Multichannel agents
Build agents that work across voice, webchat, and mobile

