PPact
Mobile & SDKs

PactSDK for Swift (iOS, macOS, watchOS, tvOS)

Pure-Swift client for Pact's consent-native CRM. Async/await, white-label theming, offline writes, no third-party deps.

PactSDK is the official Swift package for Pact. It targets iOS 15+, macOS 12+, watchOS 8+, and tvOS 15+ and has zero third-party dependencies — just Foundation and URLSession — so you can drop it into a production app without inheriting an entire HTTP stack.

Install

In Xcode: File ▸ Add Package Dependencies… then enter the package URL:

code
https://github.com/deanjt/pact-sdk-ios

Or pin to the monorepo subpath as a Git submodule. In a Package.swift:

swift
.package(url: "https://github.com/deanjt/pact-sdk-ios", from: "0.1.0"),

Quickstart

swift
import PactSDK

let pact = PactClient(token: ProcessInfo.processInfo.environment["PACT_TOKEN"]!)
pact.onCost = { cost in
    print("call cost: \(cost.actualCents) cents (predicted: \(cost.predictedCents ?? 0))")
}

let accounts = try await pact.accounts.list(limit: 5)
print(accounts.data.data)
print("more pages:", accounts.data.hasMore)

let briefing = try await pact.agents.fire(
    agentId: "daily_briefing",
    input: ["prompt": AnyCodable("My week ahead")]
)
print(briefing.data.status, "byok=\(briefing.data.byok ?? false)")

The full SwiftUI sample lives in packages/sdk-ios/Examples/PactQuickstart.

Auth + tenancy

swift
let pact = PactClient(
    token:   "pact_live_…",
    baseURL: URL(string: "https://api.pact.place")!,
    tenantID: "tenant_uuid"   // optional; required for service-level tokens
)
// Rotate the token from a SwiftUI lifecycle observer:
pact.setToken(newToken)

The same pact_live_* / pact_test_* keys (or OAuth access tokens) used by the JS SDK work here.

swift
let result = try await pact.agents.fire(agentId: "daily_briefing", input: [:])
switch result.data.status {
case "consent_blocked":
    // result.data.consentBlockedSubjects holds the contact ids that opted out.
    showConsentExplainer(for: result.data.consentBlockedSubjects ?? [])
case "ok":
    let cost = result.cost!
    if cost.charged == false {
        // BYOK — your tenant supplied the LLM credential, no Pact charge.
    }
default:
    break
}

Offline writes

swift
// Switch the client offline on network loss; replays happen automatically:
pact.setOnline(false)
do {
    _ = try await pact.activities.log(.init(
        subjectType: "contact", subjectId: id, kind: "call",
        occurredAt: ISO8601DateFormatter().string(from: Date())))
} catch PactError.queuedOffline {
    // Mutation queued — surface a "Saved offline" toast.
}
pact.setOnline(true)            // drains the queue automatically

The client's queue (pact.offline) lives in memory, so writes still queued when the app quits are lost. OfflineQueue(storage: UserDefaultsQueueStorage()) builds a queue that persists across launches, but the client does not use a queue you create yourself.

Real-time events

swift
let sub = pact.subscribeEvents(types: ["agent.run.completed"])
Task {
    for await frame in sub.frames {
        print(frame.type, frame.id)
    }
}
// later: sub.cancel()

Combine

Add the PactSDKCombine product next to PactSDK if your app is built on Combine. It wraps any SDK call in a publisher and exposes the event stream as one:

swift
import Combine
import PactSDK
import PactSDKCombine

var cancellables = Set<AnyCancellable>()

pact.publisher { try await $0.accounts.list(limit: 5) }
    .map(\.data.data)
    .receive(on: DispatchQueue.main)
    .sink(receiveCompletion: { completion in
        if case .failure(PactError.consentBlocked(let subjects, _)) = completion {
            print("blocked by consent:", subjects)
        }
    }, receiveValue: { accounts in
        print(accounts.count)
    })
    .store(in: &cancellables)

pact.eventsPublisher(types: ["agent.run.completed"])
    .sink { frame in print(frame.type, frame.id) }
    .store(in: &cancellables)

Every publisher behaves the same way:

  • Nothing runs until you subscribe. Building a publisher does not send a request, which matters because some calls are billed.
  • Cancelling cancels the request. When the subscription is cancelled, the underlying task is cancelled too. A hand-rolled Future cannot do this: it starts immediately and ignores cancellation.
  • Errors arrive unchanged. A PactError such as .consentBlocked or .rateLimited reaches your completion handler with its payload, just as it would with try await.
  • Each subscription makes its own call. Use share() if several subscribers should see one response.

White-label theming

swift
let theme = PactTheme.resolve(overrides: [
    "colors": ["accentEmber": tenant.brandColor]
])
// theme.colors.accentEmber, theme.spacing.p4, …
// Convert to UIColor:
let ember = PactColor.fromPactHex(theme.colors.accentEmber)

Compatibility notes

ConcernBehaviour
Swift concurrency modeBuilds clean in the Swift 5 and Swift 6 language modes (check: swift build -Xswiftc -swift-version -Xswiftc 6).
SendablePactClient is Sendable, so one client can be shared across tasks. Its mutable state (options, read cache, cost total, online flag, hooks) is guarded by a lock. The offline queue is an actor.
Background URL sessionsConfigure your own URLSession and pass it via Options.session.
CombineFirst-party publishers ship in the separate PactSDKCombine product (see Combine).