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:
https://github.com/deanjt/pact-sdk-ios
Or pin to the monorepo subpath as a Git submodule. In a Package.swift:
.package(url: "https://github.com/deanjt/pact-sdk-ios", from: "0.1.0"),
Quickstart
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
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.
Consent + BYOK + cost
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
// 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
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:
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
Futurecannot do this: it starts immediately and ignores cancellation. - Errors arrive unchanged. A
PactErrorsuch as.consentBlockedor.rateLimitedreaches your completion handler with its payload, just as it would withtry await. - Each subscription makes its own call. Use
share()if several subscribers should see one response.
White-label theming
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
| Concern | Behaviour |
|---|---|
| Swift concurrency mode | Builds clean in the Swift 5 and Swift 6 language modes (check: swift build -Xswiftc -swift-version -Xswiftc 6). |
Sendable | PactClient 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 sessions | Configure your own URLSession and pass it via Options.session. |
| Combine | First-party publishers ship in the separate PactSDKCombine product (see Combine). |