Skip to main content
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.
Add the package and depend on the PolyVoice product alongside PolyMessaging:
In Xcode, tick both the PolyMessaging and PolyVoice libraries for your app target when adding the package.
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 shared Configuration 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 to PolyMessaging.initialize(...), and run on a physical device. WebRTC media needs real hardware (see Troubleshooting). Read Microphone permission and Backgrounding before your first real call.
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.
Both examples are runnable as-is from 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’s webrtc-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.
  1. The SDK calls POST /api/v1/call, sending the web calling token as Authorization: Bearer.
  2. webrtc-bridge creates the call ID and returns it, together with the STUN/TURN 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. 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. Add NSMicrophoneUsageDescription 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 the audio background mode in your Info.plist:
The SDK holds a 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. Set VoiceOptions(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 with VoiceOptions(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.
Rules the integration must follow:
  • Declare the voip background mode alongside audio in UIBackgroundModes. Without it every CXCallController transaction is refused (com.apple.CallKit.error.requesttransaction Code=1) and the call never starts.
  • Request, don’t command: start / end / mute go through CXCallController actions and are executed in the matching perform callback, so the system can arbitrate and the system UI stays in sync. Remote endings (the agent hangs up, a failure) are reported via reportCall(with:endedAt:reason:) instead.
  • Never activate AVAudioSession manually (AVAudioSession.setActive(true)) during a CallKit call. A self-activated session blocks CallKit’s elevated activation and didActivate never 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.
The 02-CallKit examples (SwiftUI · UIKit) encode all of these rules.

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 .interrupted are isRetryable, 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 the PolyMessaging.initialize(...) call and run on a physical device: The 02-CallKit examples encode all of the CallKit rules above.

iOS SDK

Chat with PolyMessaging: installation, authentication, sessions, and UI

Multichannel agents

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