PluginBench
Skill
Pass
Audit score 90

callkit

dpearson2699/swift-ios-skills

Implement native iOS VoIP calling with CallKit and PushKit integration.

What is callkit?

Build VoIP calling features that integrate with iOS's native call UI using CallKit and PushKit. Use this skill when implementing incoming/outgoing call flows, registering for VoIP push notifications, configuring call providers and controllers, handling call actions, coordinating audio sessions, or creating Call Directory extensions for caller ID and call blocking.

  • Configure CXProvider and CXCallController for call management
  • Handle incoming and outgoing call flows with native iOS call UI
  • Register for and receive VoIP push notifications via PushKit
  • Coordinate audio sessions during call lifecycle
  • Implement call actions (answer, end, hold, mute)
  • Create Call Directory extensions for caller ID and call blocking

How to install callkit

npx skills add https://github.com/dpearson2699/swift-ios-skills --skill callkit
Prerequisites
  • Enable Voice over IP background mode in Signing & Capabilities
  • Add Push Notifications capability to your project
  • For call directory extensions, add a Call Directory Extension target
Claude Code
Cursor
Windsurf
Cline

How to use callkit

  1. 1.Create a CXProvider instance at app launch with appropriate configuration (localizedName, supportsVideo, maximumCallsPerCallGroup, etc.)
  2. 2.Implement CXProviderDelegate to handle system call actions (answer, end, hold, mute)
  3. 3.For incoming calls, call provider.reportNewIncomingCall() with a UUID and CXCallUpdate before the PushKit completion handler returns
  4. 4.For outgoing calls, use CXCallController.request() with a CXStartCallAction transaction
  5. 5.Register for VoIP pushes using PKPushRegistry and implement PKPushRegistryDelegate to receive tokens and incoming push payloads
  6. 6.Configure audio sessions in delegate methods before fulfilling call actions
  7. 7.Check PKVoIPPushMetadata.mustReport on iOS 26.4+ to determine if CallKit reporting is required before completion

Use cases

Good for
  • Building a VoIP app that integrates with the native iOS Phone app UI
  • Handling incoming calls via VoIP push notifications with proper CallKit reporting
  • Initiating outgoing calls through the system call controller
  • Managing audio session configuration during call setup and teardown
  • Implementing caller ID lookup and call blocking features via Call Directory extensions
Who it's for
  • iOS app developers building VoIP/calling features
  • Teams implementing real-time communication apps
  • Developers integrating with native iOS calling infrastructure

callkit FAQ

When must I report an incoming call to CallKit?

On iOS 13 SDK+, VoIP call pushes are treated as report-required by default. On iOS 26.4+, check PKVoIPPushMetadata.mustReport: if true, report before the PushKit completion handler returns; if false, no CallKit report is required. Missing a required report before completion can terminate your app.

How do I handle audio session setup during calls?

Implement configureAudioSession() in your CXProviderDelegate methods (provider(_:perform:CXAnswerCallAction) and provider(_:perform:CXStartCallAction)). Configure the audio session before fulfilling the action to ensure audio is ready when the call connects.

Can I use multiple CXProvider instances?

No, create a single CXProvider at app launch and keep it alive for the app lifetime. The skill recommends using a singleton pattern like CallManager.shared.

What's the difference between CXCallController and CXProvider?

CXProvider reports calls to the system and receives call actions from the user. CXCallController requests call actions (start, end, hold, mute) to be performed.

How do I implement caller ID lookup or call blocking?

Create a Call Directory Extension target in your project and implement the Call Directory extension APIs to provide caller ID information and block lists.

Full instructions (SKILL.md)

Source of truth, from dpearson2699/swift-ios-skills.


name: callkit description: "Implement VoIP calling with CallKit and PushKit. Use when building incoming/outgoing call flows, registering for VoIP push notifications, configuring CXProvider and CXCallController, handling call actions, coordinating audio sessions, or creating Call Directory extensions for caller ID and call blocking."

CallKit

Build VoIP calling features that integrate with the native iOS call UI using CallKit and PushKit. Covers incoming/outgoing call flows, VoIP push registration, audio session coordination, and call directory extensions. Targets Swift 6.3 / iOS 26+.

Contents

Setup

Project Configuration

  1. Enable the Voice over IP background mode in Signing & Capabilities
  2. Add the Push Notifications capability
  3. For call directory extensions, add a Call Directory Extension target

Key Types

TypeRole
CXProviderReports calls to the system, receives call actions
CXCallControllerRequests call actions (start, end, hold, mute)
CXCallUpdateDescribes call metadata (caller name, video, handle)
CXProviderDelegateHandles system call actions and audio session events
PKPushRegistryRegisters for and receives VoIP push notifications
PKVoIPPushMetadataiOS 26.4+ metadata that says whether a VoIP push must be reported

Provider Configuration

Create a single CXProvider at app launch and keep it alive for the app lifetime. Configure it with a CXProviderConfiguration that describes your calling capabilities.

import CallKit

/// CXProvider dispatches all delegate calls to the queue passed to `setDelegate(_:queue:)`.
/// The `let` properties are initialized once and never mutated, making this type
/// safe to share across concurrency domains despite @unchecked Sendable.
final class CallManager: NSObject, @unchecked Sendable {
    static let shared = CallManager()

    let provider: CXProvider
    let callController = CXCallController()

    private override init() {
        let config = CXProviderConfiguration()
        config.localizedName = "My VoIP App"
        config.supportsVideo = true
        config.maximumCallsPerCallGroup = 1
        config.maximumCallGroups = 2
        config.supportedHandleTypes = [.phoneNumber, .emailAddress]
        config.includesCallsInRecents = true

        provider = CXProvider(configuration: config)
        super.init()
        provider.setDelegate(self, queue: nil)
    }
}

Incoming Call Flow

When a required VoIP call push arrives, report the incoming call to CallKit immediately. The system displays the native call UI. You must report required calls before the PushKit completion handler returns -- failure to do so causes the system to terminate your app.

func reportIncomingCall(
    uuid: UUID,
    handle: String,
    hasVideo: Bool
) async throws {
    let update = CXCallUpdate()
    update.remoteHandle = CXHandle(type: .phoneNumber, value: handle)
    update.hasVideo = hasVideo
    update.localizedCallerName = "Jane Doe"

    try await withCheckedThrowingContinuation {
        (continuation: CheckedContinuation<Void, Error>) in
        provider.reportNewIncomingCall(
            with: uuid,
            update: update
        ) { error in
            if let error {
                continuation.resume(throwing: error)
            } else {
                continuation.resume()
            }
        }
    }
}

Handling the Answer Action

Implement CXProviderDelegate to respond when the user answers:

extension CallManager: CXProviderDelegate {
    func providerDidReset(_ provider: CXProvider) {
        // End all calls, reset audio
    }

    func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
        // Prepare audio, then fulfill only after the call is actually ready
        configureAudioSession()
        connectToCallServer(callUUID: action.callUUID) { success in
            if success {
                action.fulfill()
            } else {
                provider.reportCall(
                    with: action.callUUID,
                    endedAt: Date(),
                    reason: .failed
                )
                action.fail()
            }
        }
    }

    func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
        disconnectFromCallServer(callUUID: action.callUUID)
        action.fulfill()
    }
}

Outgoing Call Flow

Use CXCallController to request an outgoing call. The system routes the request through your CXProviderDelegate.

func startOutgoingCall(handle: String, hasVideo: Bool) {
    let uuid = UUID()
    let handle = CXHandle(type: .phoneNumber, value: handle)
    let startAction = CXStartCallAction(call: uuid, handle: handle)
    startAction.isVideo = hasVideo

    let transaction = CXTransaction(action: startAction)
    callController.request(transaction) { error in
        if let error {
            print("Failed to start call: \(error)")
        }
    }
}

Delegate Methods for Outgoing Calls

extension CallManager {
    func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
        configureAudioSession()
        // Begin connecting to server
        provider.reportOutgoingCall(
            with: action.callUUID,
            startedConnectingAt: Date()
        )

        connectToServer(callUUID: action.callUUID) {
            provider.reportOutgoingCall(
                with: action.callUUID,
                connectedAt: Date()
            )
        }
        action.fulfill()
    }
}

PushKit VoIP Registration

Register for VoIP pushes at every app launch and send token changes to your server. For iOS 13 SDK+ apps, every report-required VoIP call push must be reported before PushKit completion using CallKit, or LiveCommunicationKit for apps built on that framework. On iOS 26.4+, PKVoIPPushMetadata.mustReport is the gate: true means report before completion; false means no CallKit or LiveCommunicationKit report is required. Missing a required report before completion can terminate the app, and repeated failures may stop VoIP delivery.

PathReport decisionCompletion timing
iOS 26.4+ mustReport == trueReport with CallKit or LiveCommunicationKitAfter report callback
iOS 26.4+ mustReport == falseNo CallKit/LiveCommunicationKit report requiredAfter local handling
Older delegateiOS 13 SDK+ treats VoIP call pushes as report-requiredAfter report callback
import PushKit

final class PushManager: NSObject, PKPushRegistryDelegate {
    let registry: PKPushRegistry

    override init() {
        registry = PKPushRegistry(queue: .main)
        super.init()
        registry.delegate = self
        registry.desiredPushTypes = [.voIP]
    }

    func pushRegistry(
        _ registry: PKPushRegistry,
        didUpdate pushCredentials: PKPushCredentials,
        for type: PKPushType
    ) {
        let token = pushCredentials.token
            .map { String(format: "%02x", $0) }
            .joined()
        // Send token to your server
        sendTokenToServer(token)
    }

    @available(iOS 26.4, *)
    func pushRegistry(
        _ registry: PKPushRegistry,
        didReceiveIncomingVoIPPushWith payload: PKPushPayload,
        metadata: PKVoIPPushMetadata,
        withCompletionHandler completion: @escaping @Sendable () -> Void
    ) {
        guard metadata.mustReport else {
            completion()
            return
        }
        handleIncomingVoIPPush(payload, completion: completion)
    }

    // Keep the older callback for iOS 26.0-26.3 and older deployment targets.
    func pushRegistry(
        _ registry: PKPushRegistry,
        didReceiveIncomingPushWith payload: PKPushPayload,
        for type: PKPushType,
        completion: @escaping () -> Void
    ) {
        guard type == .voIP else {
            completion()
            return
        }

        handleIncomingVoIPPush(payload, completion: completion)
    }

    private func handleIncomingVoIPPush(
        _ payload: PKPushPayload,
        completion: @escaping () -> Void
    ) {
        let callUUID = UUID()
        let handle = payload.dictionaryPayload["handle"] as? String ?? "Unknown"

        Task {
            do {
                try await CallManager.shared.reportIncomingCall(
                    uuid: callUUID,
                    handle: handle,
                    hasVideo: false
                )
            } catch {
                // Call was filtered by DND or block list
            }
            completion()
        }
    }
}

Server-side VoIP pushes should use a short lifetime: set apns-expiration to 0 or only a few seconds. After the initial push wakes the app, send hangups and call-detail changes over the app-server connection instead of sending more VoIP pushes.

Audio Session Coordination

CallKit manages audio session activation/deactivation. Configure your audio session when CallKit tells you to, not before. Review answers should name both sides: start media only in provider(_:didActivate:), and stop/tear down media in provider(_:didDeactivate:) or reset paths.

extension CallManager {
    func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
        // Audio session is now active -- start audio engine / WebRTC
        startAudioEngine()
    }

    func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
        // Audio session deactivated -- stop audio engine
        stopAudioEngine()
    }

    func configureAudioSession() {
        let session = AVAudioSession.sharedInstance()
        do {
            try session.setCategory(
                .playAndRecord,
                mode: .voiceChat,
                options: [.allowBluetooth, .allowBluetoothA2DP]
            )
        } catch {
            print("Audio session configuration failed: \(error)")
        }
    }
}

Call Directory Extension and Manager

Use Call Directory for preloaded caller ID/blocking, not per-call API lookup. The extension loads sorted bulk data in beginRequest(with:); the main app uses CXCallDirectoryManager to check enabled status, open Call Blocking & Identification settings when disabled, and reload after data changes. Store CXCallDirectoryPhoneNumber as country code plus digits in ascending order (for example 18005551234), not a formatted string.

import CallKit

final class CallDirectoryHandler: CXCallDirectoryProvider {
    override func beginRequest(
        with context: CXCallDirectoryExtensionContext
    ) {
        if context.isIncremental {
            addOrRemoveIncrementalEntries(to: context)
        } else {
            addAllEntries(to: context)
        }
        context.completeRequest()
    }

    private func addAllEntries(
        to context: CXCallDirectoryExtensionContext
    ) {
        // Country code + digits, sorted in ascending order
        let blockedNumbers: [CXCallDirectoryPhoneNumber] = [
            18005551234, 18005555678
        ]
        for number in blockedNumbers {
            context.addBlockingEntry(
                withNextSequentialPhoneNumber: number
            )
        }

        let identifiedNumbers: [(CXCallDirectoryPhoneNumber, String)] = [
            (18005551111, "Local Pizza"),
            (18005552222, "Dentist Office")
        ]
        for (number, label) in identifiedNumbers {
            context.addIdentificationEntry(
                withNextSequentialPhoneNumber: number,
                label: label
            )
        }
    }
}

Main-App Manager: Status, Settings, Reload

let manager = CXCallDirectoryManager.sharedInstance
manager.getEnabledStatusForExtension(withIdentifier: extensionID) { status, _ in
    guard status == .enabled else {
        manager.openSettings { _ in } // Call Blocking & Identification
        return
    }
    manager.reloadExtension(withIdentifier: extensionID) { _ in }
}

Check getEnabledStatusForExtension(...) before assuming the extension is active, use openSettings(...) for Call Blocking & Identification when disabled, and call reloadExtension(...) after data changes. Route APNs auth-key rotation and normal remote-notification setup to push-notifications.

Common Mistakes

DON'T: Fail to report a required call on VoIP push receipt

Follow the PushKit report rules above: iOS 13 SDK+ apps must report report-required VoIP call pushes before completion, and on iOS 26.4+ PKVoIPPushMetadata.mustReport identifies which pushes are required. Missing a required report can terminate the app; repeated failures may stop VoIP delivery.

Do not treat a required VoIP push as a data-only notification. Report the call to CallKit and call the PushKit completion handler from the report completion.

DON'T: Fulfill answer before the call is connected

When the user answers before your app has established the server/media connection, leave the CXAnswerCallAction pending while connecting. Fulfill it after the call is ready; if connection fails, fail the action and report the call ended with .failed.

DON'T: Start audio before CallKit activates the session

Starting your audio engine before provider(_:didActivate:) causes silence or immediate deactivation. CallKit manages session priority with the system.

Prepare audio in the answer/start action if needed, then start media only from provider(_:didActivate:).

For iOS 26 call translation, set CXProviderConfiguration.supportsAudioTranslation when your service supports it and handle CXSetTranslatingCallAction. If a person mutes during a translated call, mute app input with CXSetMutedCallAction; do not deactivate upstream audio that translated audio depends on.

For encrypted VoIP metadata, use CXProvider.reportNewIncomingVoIPPushPayload only from a notification service extension when the server cannot determine whether encrypted content is a VoIP call or other data. That path requires the com.apple.developer.usernotifications.filtering entitlement; otherwise send a normal PushKit VoIP push.

DON'T: Forget to call action.fulfill() or action.fail()

Failing to fulfill or fail an action leaves the call in a limbo state and triggers the timeout handler.

Every provider action path must eventually call fulfill() or fail(), including network-error and cancellation paths.

DON'T: Ignore push token refresh

The VoIP push token can change at any time. If your server has a stale token, pushes silently fail and incoming calls never arrive.

Send the token to your server every time didUpdate pushCredentials fires, not just during first-run onboarding.

DON'T: Use Call Directory for per-call lookup

Call Directory extensions provide preloaded caller ID and blocking data. They cannot ask a web service for the incoming caller during call presentation. Fetch or generate the dataset ahead of time, reload the extension, and add entries in sorted sequential order.

Review Checklist

  • VoIP background mode enabled in capabilities
  • Single CXProvider instance created at app launch and retained
  • CXProviderDelegate set before reporting any calls
  • iOS 26.4+ PushKit path reports when mustReport is true and may skip when false
  • iOS 13 SDK+ PushKit VoIP call pushes report to CallKit before completion
  • VoIP APNs requests use apns-expiration of 0 or only a few seconds
  • Hangups and detail updates use the app-server connection after the initial push
  • action.fulfill() or action.fail() called for every provider delegate action
  • CXAnswerCallAction fulfilled only after the call server/media connection is ready
  • Audio engine started only after provider(_:didActivate:) callback
  • Audio engine stopped in provider(_:didDeactivate:) callback
  • Audio session category set to .playAndRecord with .voiceChat mode
  • VoIP push token sent to server on every didUpdate pushCredentials callback
  • PKPushRegistry created at every app launch (not lazily)
  • Call Directory data is preloaded, not fetched per incoming call
  • CXCallDirectoryPhoneNumber documented as country calling code + digits
  • CXCallDirectoryManager names status check, reload, and settings-opening APIs
  • CXCallUpdate populated with localizedCallerName and remoteHandle
  • Outgoing calls report startedConnectingAt and connectedAt timestamps
  • iOS 26 call translation keeps upstream audio active during mute
  • Encrypted metadata filtering mentions the notification service extension entitlement

References

Related skills

More from dpearson2699/swift-ios-skills and the wider catalog.

CAcarplay logo

carplay

dpearson2699/swift-ios-skills

Build CarPlay-enabled apps for vehicle displays using Apple's template-based UI framework.

1.6k installsAudited
CLcloudkit logo

cloudkit

dpearson2699/swift-ios-skills

Implement CloudKit and iCloud sync for iOS/macOS apps with record CRUD, queries, subscriptions, and SwiftData integration.

1.7k installs
COcontacts-framework logo

contacts-framework

dpearson2699/swift-ios-skills

Read, create, update, and pick contacts using Contacts and ContactsUI frameworks.

2.1k installsAudited
COcore-bluetooth logo

core-bluetooth

dpearson2699/swift-ios-skills

Build Bluetooth Low Energy central and peripheral workflows with Core Bluetooth scanning, connecting, GATT communication, and background modes.

2.2k installsAudited
COcore-data logo

core-data

dpearson2699/swift-ios-skills

Build, review, or improve Core Data persistence in apps that have not adopted SwiftData. Use when working with NSManagedObject subclasses, NSFetchedResultsController for list-driven UI, NSBatchInsertRequest / NSBatchDeleteRequest / NSBatchUpdateRequest for bulk operations, NSPersistentHistoryChangeRequest for persistent history tracking and multi-target sync, NSStagedMigrationManager for staged schema migrations (iOS 17+), NSCompositeAttributeDescription for composite attributes (iOS 17+), or when integrating Core Data threading with Swift Concurrency. For Core Data + SwiftData coexistence or migration, see the swiftdata skill instead.

1.1k installsAudited
COcore-motion logo

core-motion

dpearson2699/swift-ios-skills

Access iOS/watchOS device sensors: accelerometer, gyroscope, magnetometer, pedometer, activity recognition, altitude, and motion data.

2.1k installsAudited