Best for
- 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 extens…
dpearson2699/swift-ios-skills/skills/callkit/SKILL.md
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.
Decision brief
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.
Compatibility matrix
| Platform | Status | Evidence | What to check |
|---|---|---|---|
| Codex | Not declared | No explicit evidence | Portability before use |
| Claude Code | Not declared | No explicit evidence | Portability before use |
| Cursor | Not declared | No explicit evidence | Portability before use |
| Gemini CLI | Not declared | No explicit evidence | Portability before use |
Installation
The source command is displayed only when detected. A safe inspection prompt is always available so your agent can explain every action before execution.
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill "skills/callkit"Inspect the Agent Skill "callkit" from https://github.com/dpearson2699/swift-ios-skills/blob/90c9573272531337962fbb3505036d61ed23389a/skills/callkit/SKILL.md at commit 90c9573272531337962fbb3505036d61ed23389a. List every install step, command, network request, credential, file read/write, external action, and rollback step. Explain whether it fits my task. Do not install or execute anything until I approve.
Workflow
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
[ ] VoIP background mode enabled in capabilities
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
Review the “Key Types” section in the pinned source before continuing.
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.
Permission review
No configured static risk pattern was detected
This is not proof of safety. Runtime behavior, indirect dependencies, and hidden external systems are outside the static scan.
Evidence record
| Signal | Value | Evidence type | Meaning |
|---|---|---|---|
| Quality score | 74/100 | Computed | Documentation, specificity, maintenance, and trust rules |
| Repository stars | 933 | Source | Repository attention, not individual Skill quality |
| Compatibility | 0 platforms | Source | Declared in the catalog source record |
| Usage guide | automated source guide | Editorial | Generated or reviewed according to the visible evidence level |
Pinned source
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.
| Type | Role |
|---|---|
CXProvider | Reports calls to the system, receives call actions |
CXCallController | Requests call actions (start, end, hold, mute) |
CXCallUpdate | Describes call metadata (caller name, video, handle) |
CXProviderDelegate | Handles system call actions and audio session events |
PKPushRegistry | Registers for and receives VoIP push notifications |
PKVoIPPushMetadata | iOS 26.4+ metadata that says whether a VoIP push must be reported |
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)
}
}
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()
}
}
}
}
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()
}
}
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)")
}
}
}
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()
}
}
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.
| Path | Report decision | Completion timing |
|---|---|---|
iOS 26.4+ mustReport == true | Report with CallKit or LiveCommunicationKit | After report callback |
iOS 26.4+ mustReport == false | No CallKit/LiveCommunicationKit report required | After local handling |
| Older delegate | iOS 13 SDK+ treats VoIP call pushes as report-required | After 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.
CallKit owns the audio activation boundary: start media only in
provider(_:didActivate:), and stop or tear it down in
provider(_:didDeactivate:) and 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)")
}
}
}
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
)
}
}
}
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.
| Mistake | Fix |
|---|---|
| Required VoIP push is treated as data-only | Apply the version-specific report rule, report before completion, then invoke the PushKit completion handler. |
| Answer action is fulfilled before media/server readiness | Keep it pending while connecting; fulfill on readiness or fail and report .failed. |
Media starts before provider(_:didActivate:) | Prepare earlier if needed, but start only after activation and stop on deactivation/reset. |
An action path never calls fulfill() or fail() | Give success, cancellation, timeout, and network-error paths one terminal action. |
| Token refresh is ignored | Send every didUpdate pushCredentials token to the server. |
| Call Directory performs per-call networking | Preload sorted entries and reload the extension. |
CXProvider instance created at app launch and retainedCXProviderDelegate set before reporting any callsmustReport is true and may skip when falseapns-expiration of 0 or only a few secondsaction.fulfill() or action.fail() called for every provider delegate actionCXAnswerCallAction fulfilled only after the call server/media connection is readyprovider(_:didActivate:) callbackprovider(_:didDeactivate:) callback.playAndRecord with .voiceChat modedidUpdate pushCredentials callbackPKPushRegistry created at every app launch (not lazily)CXCallDirectoryPhoneNumber documented as country calling code + digitsCXCallDirectoryManager names status check, reload, and settings-opening APIsCXCallUpdate populated with localizedCallerName and remoteHandlestartedConnectingAt and connectedAt timestamps