```html SwiftUI: How to Build Call Directory Extension (iOS 17+, 2026)

How to Build a Call Directory Extension in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: CallKit Updated: May 12, 2026
TL;DR

A Call Directory Extension lets your app block incoming calls and show custom caller-ID labels system-wide. You subclass CXCallDirectoryProvider in a separate extension target, populate entries sorted in ascending phone-number order, and reload via CXCallDirectoryManager whenever your data changes.

// CallDirectoryHandler.swift (inside the Extension target)
import CallKit

class CallDirectoryHandler: CXCallDirectoryProvider {

    override func beginRequest(with context: CXCallDirectoryExtensionContext) {
        context.delegate = self
        addBlockingEntries(to: context)
        addIdentificationEntries(to: context)
        context.completeRequest()
    }

    private func addBlockingEntries(to context: CXCallDirectoryExtensionContext) {
        // Numbers MUST be in ascending order
        let blocked: [CXCallDirectoryPhoneNumber] = [
            +12025550100,
            +12025550199
        ]
        blocked.forEach { context.addBlockingEntry(withNextSequentialPhoneNumber: $0) }
    }

    private func addIdentificationEntries(to context: CXCallDirectoryExtensionContext) {
        context.addIdentificationEntry(
            withNextSequentialPhoneNumber: +12025550177,
            label: "Acme Support"
        )
    }
}

Full implementation

A Call Directory Extension lives in its own target but shares a data store — typically a shared App Group UserDefaults or a SQLite database — with your main app. The host app manages the data model and triggers a reload; the extension reads that data during beginRequest(with:) and streams entries to CallKit in ascending numeric order. The SwiftUI view below lets users enable the extension and manage their block list.

// ── MAIN APP ──────────────────────────────────────────────────────────
// CallDirectoryView.swift
import SwiftUI
import CallKit

// Shared store backed by an App Group UserDefaults
// App Group ID must match entitlements in BOTH targets
private let sharedDefaults = UserDefaults(
    suiteName: "group.com.example.myapp"
)!

@Observable
final class CallDirectoryStore {
    var blockedNumbers: [String] = []
    var identifications: [String: String] = [:]   // number → label
    var extensionEnabled = false

    init() {
        load()
        checkStatus()
    }

    func load() {
        blockedNumbers   = sharedDefaults.stringArray(forKey: "blocked") ?? []
        identifications  = (sharedDefaults.dictionary(forKey: "ids") as? [String: String]) ?? [:]
    }

    func save() {
        sharedDefaults.set(blockedNumbers, forKey: "blocked")
        sharedDefaults.set(identifications, forKey: "ids")
        reload()
    }

    func checkStatus() {
        CXCallDirectoryManager.sharedInstance.getEnabledStatusForExtension(
            withIdentifier: "com.example.myapp.CallDirectory"
        ) { [weak self] status, _ in
            DispatchQueue.main.async {
                self?.extensionEnabled = (status == .enabled)
            }
        }
    }

    private func reload() {
        CXCallDirectoryManager.sharedInstance.reloadExtension(
            withIdentifier: "com.example.myapp.CallDirectory"
        ) { error in
            if let error { print("Reload error: \(error)") }
        }
    }
}

struct CallDirectoryView: View {
    @State private var store = CallDirectoryStore()
    @State private var newNumber = ""
    @State private var newLabel  = ""
    @State private var showSettings = false

    var body: some View {
        NavigationStack {
            Form {
                Section {
                    if store.extensionEnabled {
                        Label("Extension enabled", systemImage: "checkmark.shield.fill")
                            .foregroundStyle(.green)
                    } else {
                        VStack(alignment: .leading, spacing: 6) {
                            Label("Extension disabled", systemImage: "exclamationmark.shield.fill")
                                .foregroundStyle(.orange)
                            Button("Open Phone Settings") {
                                if let url = URL(string: UIApplication.openSettingsURLString) {
                                    UIApplication.shared.open(url)
                                }
                            }
                            .font(.footnote)
                        }
                    }
                } header: {
                    Text("Status")
                }

                Section {
                    ForEach(store.blockedNumbers, id: \.self) { num in
                        Label(num, systemImage: "phone.slash")
                    }
                    .onDelete { idx in
                        store.blockedNumbers.remove(atOffsets: idx)
                        store.save()
                    }

                    HStack {
                        TextField("+1 (202) 555-0100", text: $newNumber)
                            .keyboardType(.phonePad)
                        Button {
                            let e164 = e164(newNumber)
                            guard !e164.isEmpty else { return }
                            store.blockedNumbers.append(e164)
                            store.blockedNumbers.sort()
                            store.save()
                            newNumber = ""
                        } label: {
                            Image(systemName: "plus.circle.fill")
                        }
                        .accessibilityLabel("Add blocked number")
                    }
                } header: { Text("Blocked Numbers") }

                Section {
                    ForEach(store.identifications.sorted(by: { $0.key < $1.key }), id: \.key) { num, label in
                        VStack(alignment: .leading) {
                            Text(label).font(.subheadline).bold()
                            Text(num).font(.caption).foregroundStyle(.secondary)
                        }
                    }
                    .onDelete { idx in
                        let keys = store.identifications.keys.sorted()
                        idx.forEach { store.identifications.removeValue(forKey: keys[$0]) }
                        store.save()
                    }
                    HStack {
                        TextField("+1 number", text: $newNumber).keyboardType(.phonePad)
                        TextField("Label", text: $newLabel)
                        Button {
                            let e164 = e164(newNumber)
                            guard !e164.isEmpty, !newLabel.isEmpty else { return }
                            store.identifications[e164] = newLabel
                            store.save()
                            newNumber = ""; newLabel = ""
                        } label: {
                            Image(systemName: "plus.circle.fill")
                        }
                        .accessibilityLabel("Add caller ID label")
                    }
                } header: { Text("Caller ID Labels") }
            }
            .navigationTitle("Call Directory")
            .onAppear { store.checkStatus() }
        }
    }

    private func e164(_ raw: String) -> String {
        // Strip everything except digits and leading +
        let digits = raw.filter(\.isNumber)
        return digits.isEmpty ? "" : "+\(digits)"
    }
}

// ── EXTENSION TARGET ──────────────────────────────────────────────────
// CallDirectoryHandler.swift  (separate target, same App Group)
import CallKit

private let sharedDefaults = UserDefaults(suiteName: "group.com.example.myapp")!

class CallDirectoryHandler: CXCallDirectoryProvider {

    override func beginRequest(with context: CXCallDirectoryExtensionContext) {
        context.delegate = self

        let blockedStrings = sharedDefaults.stringArray(forKey: "blocked") ?? []
        let idsDict = (sharedDefaults.dictionary(forKey: "ids") as? [String: String]) ?? [:]

        // CallKit requires ascending numeric order
        let blockedNumbers: [CXCallDirectoryPhoneNumber] = blockedStrings
            .compactMap { Int64($0.filter(\.isNumber)) }
            .sorted()
        blockedNumbers.forEach {
            context.addBlockingEntry(withNextSequentialPhoneNumber: $0)
        }

        let idNumbers = idsDict.keys
            .compactMap { key -> (CXCallDirectoryPhoneNumber, String)? in
                guard let n = Int64(key.filter(\.isNumber)) else { return nil }
                return (n, idsDict[key]!)
            }
            .sorted { $0.0 < $1.0 }
        idNumbers.forEach {
            context.addIdentificationEntry(
                withNextSequentialPhoneNumber: $0.0,
                label: $0.1
            )
        }

        context.completeRequest()
    }
}

extension CallDirectoryHandler: CXCallDirectoryExtensionContextDelegate {
    func requestFailed(for extensionContext: CXCallDirectoryExtensionContext,
                       withError error: Error) {
        print("CallDirectory requestFailed: \(error)")
    }
}

#Preview {
    CallDirectoryView()
}

How it works

  1. 1
    Shared App Group container — Both the main app and the extension read/write the same UserDefaults(suiteName:). You must enable the App Groups entitlement in both targets and use the identical suite name. Without this, the extension reads an empty data set at launch.
  2. 2
    Ascending numeric order requirementaddBlockingEntry(withNextSequentialPhoneNumber:) and its identification sibling crash the extension if you violate ascending order. The .sorted() calls in beginRequest guarantee safety even when data arrives out of order.
  3. 3
    CXCallDirectoryManager.reloadExtension — Called from the main app after every save, this asks iOS to re-run beginRequest synchronously in the background. The reload replaces the entire set of entries — there is no incremental update API.
  4. 4
    getEnabledStatusForExtension — Returns .enabled, .disabled, or .unknown. Your app can read this but cannot programmatically enable the extension — the user must go to Settings → Phone → Call Blocking & Identification.
  5. 5
    @Observable store in SwiftUI — The @Observable macro (Swift 5.9+, replacing ObservableObject) keeps the UI in sync. @State private var store = CallDirectoryStore() owns the store inside the view; no environment injection is needed for this single-screen flow.

Variants

Incremental updates (iOS 17+ incremental mode)

iOS 17 introduced incremental call directory updates so you don't have to resend every number on reload. Set context.isIncremental = true and use the add/remove APIs.

override func beginRequest(with context: CXCallDirectoryExtensionContext) {
    context.delegate = self
    context.isIncremental = true   // iOS 17+ only

    // Remove numbers no longer blocked
    let removed: [CXCallDirectoryPhoneNumber] = [+12025550100]
    removed.forEach { context.removeBlockingEntry(withPhoneNumber: $0) }

    // Add newly blocked numbers (still must be added in ascending order
    // relative to other NEW additions in this batch)
    let added: [CXCallDirectoryPhoneNumber] = [+12025550211]
    added.forEach { context.addBlockingEntry(withNextSequentialPhoneNumber: $0) }

    context.completeRequest()
}

Persisting a large dataset with SQLite

For block lists with tens of thousands of entries, replace the UserDefaults store with a shared SQLite database (using GRDB or raw sqlite3 C API) stored in the App Group container at FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:). Stream rows from a sorted query directly into addBlockingEntry to avoid loading all numbers into memory at once — CallKit's background extension has a tight memory budget (~6 MB) enforced by the system.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a call directory extension in SwiftUI for iOS 17+.
Use CallKit: CXCallDirectoryProvider, CXCallDirectoryExtensionContext,
CXCallDirectoryManager, CXCallDirectoryPhoneNumber.
Share data via an App Group UserDefaults (suite name: group.com.example.myapp).
Support both blocking and caller ID identification entries.
Sort all entries in ascending numeric order before adding.
Use incremental updates (context.isIncremental = true) for reloads.
Make it accessible (VoiceOver labels on add/delete buttons).
Add a #Preview with realistic sample data (5 blocked, 3 labelled numbers).

In the Soarias Build phase, paste this prompt directly into the implementation canvas — Claude Code will scaffold both the extension target and the SwiftUI management view, then wire the shared App Group entitlement automatically.

Related

FAQ

Does this work on iOS 16?

Call Directory Extensions are available from iOS 10, but the incremental update API (context.isIncremental, removeBlockingEntry) requires iOS 17+. If you target iOS 16, set isIncremental = false and resend the full list on every reload. The @Observable macro used in the SwiftUI view also requires iOS 17 — use ObservableObject + @Published for iOS 16 compatibility.

How many numbers can I add before hitting memory limits?

Apple does not publish a hard cap, but the extension process is typically killed at ~6 MB of memory usage. In practice, a plain [Int64] list can hold around 750 000 numbers in that budget. For larger datasets, stream from SQLite row-by-row rather than loading the full array into memory, and prefer incremental mode (iOS 17+) to minimise per-reload work.

What is the UIKit equivalent?

The CXCallDirectoryProvider base class and the CXCallDirectoryManager APIs are part of CallKit and are framework-level — they are the same in both UIKit and SwiftUI apps. The only UIKit-specific pattern you might see in older code is using UIViewController-based settings screens instead of SwiftUI Form; the extension handler itself is identical.

Last reviewed: 2026-05-12 by the Soarias team.

```