Migrate A Calling App From CallKit To LiveCommunicationKit
If your VoIP app still has a CXProvider, a CXCallController, a pile of CXAction handlers, and a separate in-app call state machine, LiveCommunicationKit is worth a serious look.
Apple’s Create live communication experiences session is direct about the migration path: apps using traditional approaches like CXProvider should consider moving to LiveCommunicationKit. The reason is not just a new class name. LiveCommunicationKit gives your app one system-facing conversation model for the Lock Screen, Dynamic Island, Recents, Siri redial, outgoing calls, incoming PushKit calls, and group conversation controls.
That does not make migration tiny. A mature calling app has signaling, audio routing, account identity, missed-call cleanup, and a hundred strange timing paths. The useful way to migrate is not “replace CXProvider with ConversationManager” in one diff. It is to move the boundary first:
- Keep your media engine and signaling service.
- Replace the CallKit-facing adapter with a LiveCommunicationKit-facing adapter.
- Route every system and in-app call action through one delegate-owned state machine.
- Normalize the identifiers you give the system so Recents and Siri can redial real people, not temporary room tokens.
The code below follows the WWDC26 sample shape and Apple’s LiveCommunicationKit documentation. LiveCommunicationKit and the default calling or dialer app surfaces are active SDK areas, so verify exact symbol names, availability, and entitlement requirements against the SDK you compile with. Treat this as a migration skeleton, not a promise that every beta method name is frozen.
Start with the old mental model
Most CallKit VoIP apps have code that looks like this:
let configuration = CXProviderConfiguration(localizedName: "Acme Talk")
configuration.supportsVideo = true
configuration.supportedHandleTypes = [.phoneNumber, .emailAddress]
let provider = CXProvider(configuration: configuration)
provider.setDelegate(callManager, queue: nil)
Incoming pushes decode a payload, build a CXCallUpdate, and call reportNewIncomingCall. Outgoing calls build a CXStartCallAction through CXCallController. The provider delegate then handles answer, end, mute, hold, and audio-session activation.
The LiveCommunicationKit shape is similar enough to migrate, but the nouns are better aligned with modern calling features:
| CallKit pattern | LiveCommunicationKit pattern |
|---|---|
CXProvider | ConversationManager |
CXProviderConfiguration | ConversationManager.Configuration |
CXHandle | Handle |
CXCallUpdate | Conversation.Update |
CXAnswerCallAction | JoinConversationAction |
CXStartCallAction | StartConversationAction |
CXEndCallAction | EndConversationAction |
CXSetMutedCallAction | MuteConversationAction |
CXSetHeldCallAction | PauseConversationAction |
CXPlayDTMFCallAction | PlayToneAction |
| Provider delegate | ConversationManagerDelegate |
Do not overfit the migration to that table. LiveCommunicationKit also has concepts CallKit apps often bolted on themselves: conversation capabilities, group membership, merge and unmerge actions, Recents participation, and default calling or dialer preparation. Apple’s initiating VoIP conversations article is a good source to keep open while you map your current provider code.
Own one calling system object
Create and retain the ConversationManager early in app launch. If your old app had a CallManager, keep that name if it owns your product state, but give the system integration its own small object. That makes it easier to test migration without touching the media stack.
import LiveCommunicationKit
import UIKit
@MainActor
final class CallingSystem {
let manager: ConversationManager
private let actionDelegate: ConversationActionDelegate
private let stateMachine: ConversationStateMachine
init(media: MediaSessionService, signaling: SignalingService) {
let configuration = ConversationManager.Configuration(
ringtoneName: "IncomingCall.caf",
iconTemplateImageData: UIImage(named: "CallingIconTemplate")?.pngData(),
maximumConversationGroups: 2,
maximumConversationsPerConversationGroup: 4,
includesConversationInRecents: true,
supportsVideo: true,
supportedHandleTypes: [.phoneNumber, .emailAddress]
)
let manager = ConversationManager(configuration: configuration)
let stateMachine = ConversationStateMachine(
media: media,
signaling: signaling
)
let actionDelegate = ConversationActionDelegate(
stateMachine: stateMachine
)
manager.delegate = actionDelegate
self.manager = manager
self.stateMachine = stateMachine
self.actionDelegate = actionDelegate
}
}
There are two small but important details here.
First, retain the delegate yourself. The WWDC sample sets manager.delegate = self, but real apps often split responsibilities across objects. Do not create a delegate inline and let it disappear.
Second, choose includesConversationInRecents intentionally. If a call can be redialed later, include it. If it is a one-time support room, moderated webinar, or anonymous safety flow, keep it out of Recents. Recents is product behavior, not decoration.
If your service identifies people by an app-specific account ID, check the current SDK’s generic handle support and spelling. Apple’s session describes phone number, email address, and generic string handles, but the exact enum cases should be verified in your installed SDK.
Normalize handles before anything rings
The biggest migration mistake is carrying your current call identifier into the system as the person’s identity.
A conversationUUID identifies this live conversation. A Handle identifies a person or reachable endpoint. They are different things.
import LiveCommunicationKit
struct ContactEndpoint: Hashable, Sendable {
enum Kind: Sendable {
case phoneNumber(e164: String)
case email(address: String)
case appAccount(id: String)
}
var kind: Kind
var displayName: String
var liveCommunicationHandle: Handle {
switch kind {
case .phoneNumber(let e164):
return Handle(
type: .phoneNumber,
value: e164,
displayName: displayName
)
case .email(let address):
return Handle(
type: .emailAddress,
value: address.lowercased(),
displayName: displayName
)
case .appAccount(let id):
// Verify the generic handle case name in your SDK.
return Handle(
type: .generic,
value: "acct:\(id)",
displayName: displayName
)
}
}
}
As app-level normalization advice, prefer E.164 values for phone-number handles and a consistent canonical form for email addresses unless your identity system has a stricter rule. For app accounts, use a stable value that your app can resolve after reinstall, device change, or Siri redial.
Do not use an expiring invite token as a handle. That might get the first ring onto the Lock Screen, but it breaks Recents and makes redial impossible to reason about.
Report incoming PushKit calls quickly
The incoming path still starts with PushKit. Apple’s PushKit VoIP documentation explains the old CallKit requirement and the timing pressure: the VoIP push wakes your app, and your app must promptly display the system call interface. The WWDC26 LiveCommunicationKit sample uses PKPushRegistryDelegate to decode the payload and call reportNewIncomingConversation.
Use the authoritative conversation UUID from the push payload. Do not generate a new UUID on the receiving device for the same incoming conversation.
import LiveCommunicationKit
import PushKit
struct IncomingConversationInvite {
var conversationUUID: UUID
var caller: ContactEndpoint
var supportsVideo: Bool
var supportsHold: Bool
var supportsMerge: Bool
init?(payload: PKPushPayload) {
let dictionary = payload.dictionaryPayload
guard
let uuidString = dictionary["conversationUUID"] as? String,
let conversationUUID = UUID(uuidString: uuidString),
let callerName = dictionary["callerName"] as? String,
let callerPhone = dictionary["callerPhone"] as? String
else {
return nil
}
self.conversationUUID = conversationUUID
self.caller = ContactEndpoint(
kind: .phoneNumber(e164: callerPhone),
displayName: callerName
)
self.supportsVideo = dictionary["supportsVideo"] as? Bool ?? false
self.supportsHold = dictionary["supportsHold"] as? Bool ?? true
self.supportsMerge = dictionary["supportsMerge"] as? Bool ?? false
}
var liveCommunicationCapabilities: Conversation.Capabilities {
var capabilities: Conversation.Capabilities = []
if supportsVideo {
capabilities.insert(.video)
}
if supportsHold {
capabilities.insert(.pausing)
}
if supportsMerge {
capabilities.insert(.merging)
}
return capabilities
}
}
final class VoIPPushHandler: NSObject, PKPushRegistryDelegate {
private let manager: ConversationManager
init(manager: ConversationManager) {
self.manager = manager
super.init()
}
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingVoIPPushWith payload: PKPushPayload,
metadata: PKVoIPPushMetadata
) async {
guard let invite = IncomingConversationInvite(payload: payload) else {
return
}
let update = Conversation.Update(
members: [invite.caller.liveCommunicationHandle],
capabilities: invite.liveCommunicationCapabilities
)
do {
try await manager.reportNewIncomingConversation(
uuid: invite.conversationUUID,
update: update
)
} catch {
await markInviteUndeliverable(invite, error: error)
}
}
}
The liveCommunicationCapabilities property is your mapper from payload booleans to SDK capabilities such as .video, .pausing, and .merging; verify the capability collection type name in your SDK. The exact PushKit delegate method differs across SDK eras. If your project still uses the completion-handler method, call the completion handler after the conversation is reported or rejected. If your SDK uses the async method from the WWDC sample, keep the method short and do not start slow contact lookups before reporting the conversation.
The push payload should contain enough to show the system UI immediately:
- The stable conversation UUID from your server.
- The caller’s stable handle.
- A display name fallback.
- Whether the conversation supports video, pausing, merging, or other controls.
Fetch richer profile images and app-only metadata after the report. If the caller hangs up while the device is ringing, prefer your existing signaling connection to tell the app directly rather than sending another VoIP push just to cancel stale UI.
Start outgoing calls through the manager
Outgoing calls are where a LiveCommunicationKit migration can simplify your app. Instead of one path for a button in your app and another path for system UI, create a StartConversationAction and let the manager route it through the same delegate callback.
import LiveCommunicationKit
extension CallingSystem {
func startConversation(
with endpoints: [ContactEndpoint],
isVideo: Bool
) async throws {
let handles = endpoints.map(\.liveCommunicationHandle)
let conversationUUID = UUID()
let action = StartConversationAction(
conversationUUID: conversationUUID,
handles: handles,
isVideo: isVideo
)
try await manager.perform([action])
}
}
That small function should replace your old CXCallController.request(_:) wrapper. The important bit is what happens next: the start action comes back to your ConversationManagerDelegate, where the same state machine that handles Lock Screen actions can do the work.
For outgoing calls, separate “the server accepted the invite” from “the remote person answered.” Your product may ring for 30 seconds before media is flowing. Fulfill or fail the action according to the current SDK’s contract, then report connection, failure, or unanswered state as your signaling layer learns more.
Put every action through one delegate
The delegate is the migration’s center of gravity. Resist the temptation to handle in-app buttons directly in SwiftUI view models and handle system buttons somewhere else. Have the button create an action, perform it through the manager, and let the delegate route it.
import AVFAudio
import LiveCommunicationKit
final class ConversationActionDelegate: NSObject, ConversationManagerDelegate {
private let stateMachine: ConversationStateMachine
init(stateMachine: ConversationStateMachine) {
self.stateMachine = stateMachine
super.init()
}
func conversationManagerDidBegin(_ manager: ConversationManager) {
// The manager is ready. Start any lightweight observation you need.
}
func conversationManagerDidReset(_ manager: ConversationManager) {
// Drop local system-integration state that no longer matches the manager.
}
func conversationManager(
_ manager: ConversationManager,
conversationChanged conversation: Conversation
) {
// Mirror system-visible changes into app UI if needed.
}
func conversationManager(
_ manager: ConversationManager,
didActivate audioSession: AVAudioSession
) {
// Move old provider(_:didActivate:) audio setup here.
}
func conversationManager(
_ manager: ConversationManager,
didDeactivate audioSession: AVAudioSession
) {
// Pause or tear down audio resources that depend on the active session.
}
func conversationManager(
_ manager: ConversationManager,
perform action: ConversationAction
) {
Task { @MainActor in
switch action {
case let action as JoinConversationAction:
stateMachine.join(action, manager: manager)
case let action as StartConversationAction:
stateMachine.start(action, manager: manager)
case let action as EndConversationAction:
stateMachine.end(action, manager: manager)
case let action as MuteConversationAction:
stateMachine.mute(action, manager: manager)
case let action as PauseConversationAction:
stateMachine.pause(action, manager: manager)
case let action as PlayToneAction:
stateMachine.playTone(action, manager: manager)
case let action as MergeConversationAction:
stateMachine.merge(action, manager: manager)
case let action as UnmergeConversationAction:
stateMachine.unmerge(action, manager: manager)
default:
if #available(iOS 26.0, iPadOS 26.0, *),
let action = action as? SetTranslatingAction {
stateMachine.setTranslating(action, manager: manager)
} else {
action.fail()
}
}
}
}
func conversationManager(
_ manager: ConversationManager,
timedOutPerforming action: ConversationAction
) {
action.fail()
}
}
I like making the state machine an explicit type, even if it starts small. It gives you one place to answer boring but crucial questions:
- Do we already know this conversation UUID?
- Is the server invite still valid?
- Are we allowed to join before the media session activates?
- Which participant list should the system see right now?
- Do we fail the action, retry, or report a remote-ended conversation?
Here is the shape for the core call lifecycle actions. Mute, keypad tones, translation, merge, and unmerge follow the same pattern, but their media-service implementation depends heavily on your calling stack. Do not leave those actions as comments in production; every action the delegate accepts must eventually be fulfilled or failed.
import LiveCommunicationKit
@MainActor
final class ConversationStateMachine {
private let media: MediaSessionService
private let signaling: SignalingService
private var localConversations: [UUID: LocalConversation] = [:]
init(media: MediaSessionService, signaling: SignalingService) {
self.media = media
self.signaling = signaling
}
func join(_ action: JoinConversationAction, manager: ConversationManager) {
guard let conversation = manager.conversation(
matching: action.conversationUUID
) else {
action.fail()
return
}
manager.reportConversationEvent(
.conversationStartedConnecting(.now),
for: conversation
)
Task {
do {
try await signaling.acceptInvite(
conversationID: action.conversationUUID
)
try await media.joinConversation(
id: action.conversationUUID
)
manager.reportConversationEvent(
.conversationConnected(.now),
for: conversation
)
action.fulfill(dateConnected: .now)
} catch {
action.fail()
}
}
}
func start(_ action: StartConversationAction, manager: ConversationManager) {
localConversations[action.conversationUUID] = LocalConversation(
id: action.conversationUUID,
phase: .starting
)
Task {
do {
try await signaling.startInvite(
conversationID: action.conversationUUID,
handles: action.handles,
isVideo: action.isVideo
)
action.fulfill(dateStarted: .now)
} catch {
localConversations[action.conversationUUID]?.phase = .failed
action.fail()
}
}
}
func end(_ action: EndConversationAction, manager: ConversationManager) {
Task {
do {
try await media.leaveConversation(id: action.conversationUUID)
try await signaling.endConversation(id: action.conversationUUID)
localConversations[action.conversationUUID]?.phase = .left
action.fulfill(dateEnded: .now)
} catch {
action.fail()
}
}
}
func pause(_ action: PauseConversationAction, manager: ConversationManager) {
Task {
do {
try await media.setPaused(
action.isPaused,
conversationID: action.conversationUUID
)
action.fulfill()
} catch {
action.fail()
}
}
}
func mute(_ action: MuteConversationAction, manager: ConversationManager) {
// Update your capture pipeline, then fulfill or fail the action.
}
func playTone(_ action: PlayToneAction, manager: ConversationManager) {
// Forward keypad tones to your VoIP service if the conversation supports it.
}
@available(iOS 26.0, iPadOS 26.0, *)
func setTranslating(_ action: SetTranslatingAction, manager: ConversationManager) {
// Start or stop your translation integration, then fulfill with the engine used.
}
}
I used a small helper in that example:
private extension ConversationManager {
func conversation(matching uuid: UUID) -> Conversation? {
conversations.first { $0.uuid == uuid }
}
}
The conversationStartedConnecting and conversationConnected event names come directly from the WWDC26 sample. Other event names, action properties, and fulfillment details should be checked against the SDK you ship with. The durable pattern is what matters: validate local state, report the system-visible transition, do your async work, then fulfill or fail the action.
Keep media lifecycle separate from UI lifecycle
One subtle CallKit habit is starting too much work when the incoming UI appears. LiveCommunicationKit’s session describes the incoming conversation as idle while it rings, joining while your app prepares, then joined when media is ready.
That maps cleanly to most VoIP stacks:
enum LocalConversationPhase: Equatable {
case ringing
case starting
case joining
case joined
case pausing
case leaving
case left
case failed
}
Use that local phase for your app logic. Use Conversation.Update and reportConversationEvent for the system’s view of the same call. They should move together, but they are not the same object.
Also keep audio session activation in mind. Apple’s LiveCommunicationKit article documents conversationManager(_:didActivate:) as the callback where the audio session has started, plus conversationManager(_:didDeactivate:) for the other side of that lifecycle. If your current CXProviderDelegate.provider(_:didActivate:) configures WebRTC, AVAudioEngine, echo cancellation, or Bluetooth routing, migrate that behavior to the equivalent LiveCommunicationKit delegate callback rather than burying it in the PushKit handler.
Update group membership like product state
Group calls are where LiveCommunicationKit is noticeably more expressive than a simple one-call CallKit wrapper.
The system needs to know both the invited members and the members actively sending media. Apple’s session calls these out separately: members is the conversation’s participant list, and activeRemoteMembers is the subset with active remote media.
import LiveCommunicationKit
extension ConversationStateMachine {
func reportGroupMembership(
manager: ConversationManager,
conversation: Conversation,
localMember: ContactEndpoint,
invited: [ContactEndpoint],
activeRemote: [ContactEndpoint]
) {
let update = Conversation.Update(
localMember: localMember.liveCommunicationHandle,
members: invited.map(\.liveCommunicationHandle),
activeRemoteMembers: activeRemote.map(\.liveCommunicationHandle),
capabilities: [.merging, .pausing, .unmerging]
)
manager.reportConversationEvent(
.conversationUpdated(update),
for: conversation
)
}
}
That update should happen when your server says somebody joined, dropped, transferred, upgraded to video, or stopped sending media. Do not wait for the call screen to refresh itself. The system UI is only as accurate as the membership you report.
Treat merge and unmerge as server operations
Merge and unmerge are not local UI tricks. If two calls become one group conversation, your backend or media layer has to combine streams, participant permissions, captions, recording state, and encryption context. The system action should trigger that work, then get fulfilled only after your service has a coherent result.
import LiveCommunicationKit
extension ConversationStateMachine {
func merge(_ action: MergeConversationAction, manager: ConversationManager) {
let sourceUUID = action.conversationUUID
let targetUUID = action.conversationUUIDToMergeWith
guard
let source = manager.conversation(matching: sourceUUID),
let target = manager.conversation(matching: targetUUID)
else {
action.fail()
return
}
Task {
do {
let merged = try await signaling.mergeConversations(
sourceID: source.uuid,
targetID: target.uuid
)
let update = Conversation.Update(
localMember: merged.localMember.liveCommunicationHandle,
members: merged.invitedMembers.map(\.liveCommunicationHandle),
activeRemoteMembers: merged.activeRemoteMembers.map(\.liveCommunicationHandle),
capabilities: [.merging, .pausing, .unmerging]
)
manager.reportConversationEvent(
.conversationUpdated(update),
for: target
)
action.fulfill()
} catch {
action.fail()
}
}
}
func unmerge(_ action: UnmergeConversationAction, manager: ConversationManager) {
Task {
do {
let result = try await signaling.unmergeConversation(
id: action.conversationUUID
)
for changedConversation in result.changedConversations {
guard let conversation = manager.conversation(
matching: changedConversation.id
) else {
continue
}
manager.reportConversationEvent(
.conversationUpdated(changedConversation.update),
for: conversation
)
}
action.fulfill()
} catch {
action.fail()
}
}
}
}
The exact UnmergeConversationAction payload is something to verify in the current SDK. Some services unmerge into a restored previous one-to-one call. Others split one participant out into a new conversation. Either way, the migration rule is the same: the system action requests the change; your server decides the media topology; then you report updated membership back through the manager.
Make Recents and Siri redial boring
If you set includesConversationInRecents to true, your handles become part of the user’s calling surface. People may tap Recents, Spotlight, a contact card, or Siri and expect your app to start a new conversation with the same person.
That means the metadata you provide has to survive time:
- Use phone numbers, email addresses, or stable account IDs as handles.
- Keep display names useful when Contacts cannot match the handle.
- Exclude calls that cannot be redialed.
- Route redial into the same
startConversation(with:isVideo:)function your app button uses. - Donate your own App Intent after a completed conversation if your app has a richer call concept than the system can infer.
Apple’s WWDC session says redial support involves the start call intent and that the intent is delivered to your scene as an NSUserActivity. The exact activity type and App Intent wiring should be checked against the SDK and the current App Intents documentation, but the app-side shape should be boring:
import SwiftUI
struct CallingApp: App {
@State private var callingSystem = AppDependencies.shared.callingSystem
var body: some Scene {
WindowGroup {
RootView()
.onContinueUserActivity(RedialRequest.activityType) { activity in
guard let request = RedialRequest(activity: activity) else {
return
}
Task {
try await callingSystem.startConversation(
with: request.endpoints,
isVideo: request.prefersVideo
)
}
}
}
}
}
Do not let redial become a second outgoing-call implementation. It should parse stable handles, resolve them to your app’s contact model if needed, and call the same outgoing action path.
At the end of a completed conversation, donate your app’s own intent or searchable representation if you want Siri and system suggestions to understand your app-specific contact, workspace, or group name. Keep the donated identifier stable and avoid embedding private room secrets in user activity payloads.
Migration checklist
The safest rollout is a thin adapter first, then features.
- Build a
CallingSystemthat owns oneConversationManagerand a retained delegate. - Move every old
CXHandlecreation site through a stableContactEndpointmapper. - Update the PushKit path to report
Conversation.UpdatethroughreportNewIncomingConversation. - Replace outgoing
CXStartCallActionrequests withStartConversationActionandmanager.perform. - Route join, start, end, mute, pause, keypad tone, merge, unmerge, and availability-gated translation actions through one delegate state machine.
- Move audio-session activation code to the LiveCommunicationKit delegate callback that replaces your old
provider(_:didActivate:)work. - Report group
membersandactiveRemoteMemberswhenever the server’s participant state changes. - Decide which calls belong in Recents before shipping.
- Add redial handling that enters the same outgoing-call path.
- Keep old CallKit code behind a feature flag until real-device background, lock-screen, route-change, and poor-network tests pass.
Also check the less glamorous setup:
- Enable the Audio and Voice over IP background modes if your app needs to continue while locked or backgrounded.
- Confirm PushKit token registration and APNs headers still match VoIP requirements.
- Handle stale incoming pushes, duplicate conversation UUIDs, remote hangups, and unanswered outgoing calls.
- Review LiveCommunicationKit privacy implications with your team. Apple’s framework docs note that recipient contact information is provided to the system and may be used for system suggestions.
- If you are preparing to be a default calling app, check the
com.apple.developer.calling-appentitlement path and product requirements. - If you are preparing to be a default dialer app, check the
com.apple.developer.dialing-appentitlement path, iOS/iPadOS/Mac Catalyst availability, and regional testing constraints in Apple’s current documentation. - Keep
TelephonyConversationManagerandStartCellularConversationActionbehind the availability gates documented for the SDK you ship.
The migration is worthwhile because it reduces the split brain that older calling apps tend to grow. The system UI, your in-app UI, PushKit, Recents, Siri, and group controls all become clients of the same conversation state. That is the goal: not just newer API names, but fewer places for the app and the system to disagree about what call the user is actually in.