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 patternLiveCommunicationKit pattern
CXProviderConversationManager
CXProviderConfigurationConversationManager.Configuration
CXHandleHandle
CXCallUpdateConversation.Update
CXAnswerCallActionJoinConversationAction
CXStartCallActionStartConversationAction
CXEndCallActionEndConversationAction
CXSetMutedCallActionMuteConversationAction
CXSetHeldCallActionPauseConversationAction
CXPlayDTMFCallActionPlayToneAction
Provider delegateConversationManagerDelegate

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.

  1. Build a CallingSystem that owns one ConversationManager and a retained delegate.
  2. Move every old CXHandle creation site through a stable ContactEndpoint mapper.
  3. Update the PushKit path to report Conversation.Update through reportNewIncomingConversation.
  4. Replace outgoing CXStartCallAction requests with StartConversationAction and manager.perform.
  5. Route join, start, end, mute, pause, keypad tone, merge, unmerge, and availability-gated translation actions through one delegate state machine.
  6. Move audio-session activation code to the LiveCommunicationKit delegate callback that replaces your old provider(_:didActivate:) work.
  7. Report group members and activeRemoteMembers whenever the server’s participant state changes.
  8. Decide which calls belong in Recents before shipping.
  9. Add redial handling that enters the same outgoing-call path.
  10. 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-app entitlement path and product requirements.
  • If you are preparing to be a default dialer app, check the com.apple.developer.dialing-app entitlement path, iOS/iPadOS/Mac Catalyst availability, and regional testing constraints in Apple’s current documentation.
  • Keep TelephonyConversationManager and StartCellularConversationAction behind 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.