iOS integration guide
The iOS SDK is the Swift mirror of the Android SDK, and the three clients follow the same rules: one session-contract constant table (revision 9), one set of generated /api contract artifacts (manifest revision 7), one set of view-reduction golden fixtures, one set of frame-order fixtures. This page follows the same order as the Android guide so that teams on both platforms can compare side by side; the differences (SwiftUI wiring, optimistic posting, the Info.plist declaration, building on Windows) are called out separately. This page was checked against the public binary package tansr-ios-spm 0.4.0 (GitHub Release, 2026-10-09), with the server side checked against @tansr/serve 0.15.1 (npm latest, 2026-10-10).
Versions and the server-side prefix (checked 2026-10-13)
Section titled “Versions and the server-side prefix (checked 2026-10-13)”- Starting with public binary package 0.4.0 (released 2026-10-09, source
f3f31a6), every HTTP request from the SDK goes only through the unified call surface/apithat the server has offered since 0.13.0 (D10: immediate switch; the source no longer contains the/v2,/v3/sdk2or/v3/terminalroute literals). Paths, methods and header names come fromApiContract/ApiOps(81 operations) generated from manifest revision 7; the session family is carried explicitly in the request headertansr-session-family: sdk1 | sdk2-offload-v1. The server must have the/apifacade assembled (@tansr/serve0.13.0 or later; r7 since 0.14.0). When the response-header revision differs from the local r7,TansrAgentClient.onManifestRevisionDrift/AudioClient.onManifestRevisionDriftfire once per client. - Also shipped with 0.4.0: the error model centred on unified error codes (D19:
TansrAgentError.kind == .unified/.contractUnavailable, andApiFacadeErrorfor the SDK 2.0 and terminal service surfaces), the unified response-header gate for SDK1 success responses and event streams (D38),ApiCallOptionsfor the three request headersIdempotency-Key/If-Match/deadline, and the SDK 2.0 device-side interfaces. This page describes these behaviours. - The older 0.3.0 still talks to
/v2directly. On the server,/v2has been an internal adapter entry only since 0.13.0, and 1.0.0 removes the external entry; apps still on 0.3.0 should upgrade to 0.4.0. See deployment and authentication for the timetable.
The phone sends user messages; the server configures the system prompt
Section titled “The phone sends user messages; the server configures the system prompt”Both CreateSessionRequest(prompt=...) and session.send(...) send ordinary user messages; Android / iOS do not pass system through the /api session requests. The shared role is managed in the platform app configuration, a code-customised role is provided by serve’s platform.system, and host notes use platform.systemAppend.
The prompt and policy feature has been available since @tansr/serve 0.8.0, and the platform side went live on 2026-09-14. Under the default fallback, an explicit SDK system (including an empty array) replaces the platform segment; under prepend, the platform segment is kept in front. Platform prompts and policies refresh before the next new turn while the currently running turn stays fixed; foreground/background reconnects, SSE recovery and attach can keep the original session and history, so there is no need to reopen a session to pick up the update. Have the app administrator resolve conflicts between business segments; permissions still go through their own independent mechanism. See application system prompts for details.
Module split
Section titled “Module split”| Module | Kind | Contents |
|---|---|---|
TansrCore |
Pure Swift, testable on any platform | Contract constants and the generated /api contract artifacts (ApiContract / ApiOps with 81 operations, UnifiedContract), the response-header gate ApiContractGate, ApiCallOptions, the KernelEvent type family (47 types + Unknown tolerance), the IR message model, SSE parsing / dispatch, the SessionView reducer and viewStateFromHistory, toolCardArtifact with SearchResultDetails / NetworkResultDetails, protocol-client logic and the transport abstraction, the direct audio surface AudioClient, the reconnect state machine (L1 / L2 / L3), defineTool and the remote tool bridge, the permission / question bridges SessionBridges (fail-closed by default), BridgeDialogState; the SDK 2.0 thin clients Sdk2Client / Sdk2Executor / Sdk2ArchiveSync and the terminal service client TerminalClient |
TansrClient |
Pure Swift | URLSession transport (SSE goes through a delegate bridge, same path on Darwin and FoundationNetworking), the TansrAgent entry factory; the SDK 2.0 SQLite archive receiver / sync store / execution journal (Sdk2SqliteArchiveReceiver / Sdk2SqliteArchiveSyncStore / Sdk2SqliteExecutionJournal) |
TansrUI |
SwiftUI (#if canImport(SwiftUI), compiles to nothing on non-Apple platforms) |
TansrSessionObserver, tansrSessionLifecycle, the default permission / question dialogs tansrBridgeDialogs, TansrMessageList / TansrToolCard / TansrTodoPanel / TansrUsageRow / TansrErrorBanner / TansrConnectionBanner; all replaceable, not tied to a theme, zero AVFoundation dependency |
Distribution channel SwiftPM: the public binary package tansrai/tansr-ios-spm (currently 0.4.0, released 2026-10-09; a remote XCFramework without the SDK implementation source, module names kept as TansrCore / TansrClient / TansrUI; built by Xcode 26.6 / Swift 6.3.3 in Swift 5 language mode); minimum iOS 16+ / macOS 13+, covering iOS device arm64, simulator arm64 / x86_64 and macOS arm64 / x86_64. The SPM tag of the source repository tansr-ios (0.2.0) is published in a private repository, requires repository access, and is meant for integration work by teams with source access.
View stream and event reduction
Section titled “View stream and event reduction”session.view // 当前视图快照(SessionViewState)session.viewUpdates() // AsyncStream<SessionViewState>session.events() // AsyncStream<KernelEvent>session.connectionState // idle → connecting → live → retrying / recovering / authExpired …A SwiftUI host uses TansrSessionObserver(session:) to turn view / connectionState / userSendStates into @Published values that feed re-rendering directly. view is the already reduced view state; do not assemble deltas yourself. Delivery profile SessionViewOptions(delivery: .init(text: .final, thinking: .off)): in the final profile, deltas go into an internal buffer and the whole block is shown once finalised; thinking: .off does not materialise thinking parts (state derivation still sees thinking, so a placeholder UI is possible); session.setDelivery(...) switches at block granularity while running, and switching to .off immediately removes existing thinking content as well. L2 / L3 rebuilds and resume / attach always keep the current profile.
The tool-card artifact helper toolCardArtifact extracts the URL artifacts of imageGen / videoGen / webSearch plus .transcript(model:text:language:durationSec:) / .speech(model:url:b64:mime:format:durationMs:billedChars:); TansrToolCard renders the five shapes by default, and the “play” button of .speech only appears when the host passes the onPlaySpeech slot — TansrUI has no built-in player.
Connection state machine and the three-level recovery chain
Section titled “Connection state machine and the three-level recovery chain”Identical to Android: L1 reconnects with Last-Event-ID after an SSE disconnect and deduplicates received frames by seq; L2 handles a gap in the first frame after reconnecting → discard the projection → metadata → /history → rebuild with viewStateFromHistory → resubscribe from lastSeq; L3 handles metadata 404 or live == false or a cold start holding only a sessionId → CreateSessionRequest(resumeSessionId: savedId) resumes and reloads, discards the old cursor and anchors on history.lastSeq (it does not read lastSeq from the resume response). A disconnect never surfaces as a business code.
lastSeq always continues by value; the dormancy criterion is always live == false, never lastSeq == 0. Since @tansr/serve 0.9.0, the metadata lastSeq of a dormant session is the true watermark persisted in the store, and the new epoch after resume continues from that watermark + 1 rather than from 0 (in 0.8.x both were always 0); the device needs no adaptation for either server version. live == true with lastSeq == 0 goes through L2 only and never resumes. Session contract revision 9 (vendored with 0.4.0) registers 55 event types, of which TansrCore decodes 47; the SDK-only event session.events_dropped (contract-v0.27) is never emitted by serve and never appears in the event stream, and even if it did it would be tolerated as Unknown.
The 401 convergence rule is the same: refresh the token through authProvider at most 2 times; if it is still 401, enter the terminal state failed(unauthorized) with view.lastError { code: 'unauthorized', scope: 'client.auth', recoverable: false, detail: { attempts: 2 } }, the pump stops, and the host must repair the login state and call start() explicitly. The terminal state is failed, not closed — the session has not ended and the server still holds it.
Unified response-header gate (D38, same shape on all three clients): every 2xx from TansrAgentClient.call(), the 200 event stream from openEventStream and every 2xx from AudioClient first pass ApiContractGate.requireUnified — the four headers tansr-contract / tansr-manifest-revision / tansr-domain / tansr-schema-hash must all be present and well-formed; a violation throws TansrAgentError.contractUnavailable(status:reason:), which is not a business success and does not fall back. On a 2xx-shaped contract-unavailable, the event pump enters the failed terminal state directly (no backoff, no reconnect; the typical causes are a baseUrl that does not point at the /api facade, or a proxy / portal page in between), and the host calls start() after repairing the deployment; ≥400 shapes still follow the status-code policy.
Foreground / background: one line of scenePhase wiring
Section titled “Foreground / background: one line of scenePhase wiring”ChatScreen(session: session) .tansrSessionLifecycle(session) // scenePhase 收放;在场即不需手工 start()Going to the background stops the pump and drops the SSE connection (suspension is not fatal; connectionState returns to idle); coming back to the foreground picks the route automatically based on the recovery window: warm (within 60 s by default) → L1 continues directly; past the threshold with a turn that ran while in the background → an L2 history rebuild first; session reclaimed for idleness → 404 → L3 self-heals. The decision is the pure function LifecyclePolicy in TansrCore; the threshold is adjustable through AgentSessionConfig.warmResumeWindowMs. Equivalent wiring for non-SwiftUI hosts: session.start() in the foreground, session.stop() in the background; both are idempotent and stop never loses the session. Once a session has ended (closed), returning to the foreground never revives it automatically.
Optimistic posting (an iOS-specific presentation)
Section titled “Optimistic posting (an iOS-specific presentation)”Calling send / sendBlocks posts the user bubble immediately (userSendStates records sending) and settles it once HTTP 202 confirms; a failure is marked failed and never disappears silently — retrySend(messageId) resubmits with the original message id unchanged (no duplicate bubble), and discardSend(messageId) withdraws it idempotently. L2 / L3 rebuilds always clear the optimistic ledger to avoid collisions (message ids are renumbered from 1 after a rebuild).
Error presentation
Section titled “Error presentation”| Source | Shape | Presentation |
|---|---|---|
| Error inside the session | view.lastError { code, scope, recoverable: false, detail? } |
TansrErrorBanner; when detail.scope == 'plan', use “try again later” wording |
| Advisory | view.notices (recoverable: true) |
Weak hint |
| Connection state | connectionState |
TansrConnectionBanner |
| HTTP surface | TansrAgentError.kind == .unified: unifiedCode is one of the 19 unified codes (invalid_request / unauthorized / forbidden / not_found / gone / conflict / capability_unavailable / capacity_exceeded / payload_too_large / upstream_unavailable / internal_error …), retryAction is one of the 6 actions (none / same-request / query-status / rediscover / rebind …); the original family code stays in domainCode / domainStatus / domainRetryAction, and traceId / requestId are carried as well; a facade contract-unavailable is .contractUnavailable (reason = the violated item); the SDK 2.0 / terminal service surfaces throw the same-shaped ApiFacadeError directly (Sdk2HttpError is marked deprecated). toLegacy(), which restores the old kind from domainCode, is a one-version transition only; old { error: { code } } envelopes without the tansr-contract header still pass through the transition bridge |
Branch on unifiedCode + retryAction; use domainCode only to refine diagnostics |
For capacity_exceeded (429 shape, retryAction = same-request), wait for the whole-second value of Retry-After; the 32 MiB audio body cap is rejected on the device first (.payloadTooLarge, no request sent).
Local tools and the two bridges
Section titled “Local tools and the two bridges”let searchOrders = try defineTool(name: "searchOrders", description: "Search the user's orders") { tool in try tool.string("keyword", description: "search keyword") tool.readOnly = true tool.handler { args in try await db.search(keyword: args.string("keyword")) } // async;返回值宽容归一}
let dialogs = BridgeDialogState() // 或自写 async 回调let session = try await client.openSession(CreateSessionRequest(), bridges: try dialogs.bridges(tools: [searchOrders]))// SwiftUI: .tansrBridgeDialogs(TansrBridgeDialogObserver(state: dialogs))Handlers run as tasks and are matched by callId: replayed duplicate frames are deduplicated, server.tool.cancel cancels the task, a deadlineAt overrun is dropped silently (no reply), a network failure on the reply is retried safely, image results are base64-encoded with an upfront 20 MiB cap self-check, and the permission digest is repeated verbatim by the SDK. An unregistered bridge is fail-closed: the SDK never answers, and the server degrades per the contract (permission → deny-and-continue; question → a structured “decide for yourself”).
Public network tools: executed on the server
Section titled “Public network tools: executed on the server”Once the app enables tools.webFetch, tools.http (requires @tansr/serve 0.12.0 or later) or tools.webSearch in the console, the agent can read web pages, issue HTTP requests or search the web. All of these tools run on your session service host: the phone does not issue network requests on the agent’s behalf, nor does it bypass the existing permission approvals. Capability-flag changes take effect for newly created sessions or sessions reassembled from storage; a running session does not gain or lose tools automatically.
- Since 0.3.0 the public binary SPM package includes the
SearchResultDetails/NetworkResultDetailsdisplay projections, and the existingToolCardArtifactusage is unchanged: search cards keep the sources, truncation and empty-result hints, and web-page and HTTP cards keep the original body and show the actual HTTP status. - The HTTP status does not stand in for tool success or failure; 4xx / 5xx still need checking. An empty search result does not mean a known URL is unreachable. When old history lacks this metadata, keep it unknown rather than inventing it.
- For the capability switches, execution boundaries and the approval rules for cross-host redirects, see public network tools for mobile apps.
SDK 2.0 has shipped on the server with @tansr/serve 0.13.0 / 0.14.0 (the explicit host in @tansr/serve/extensions and the /api unified call surface); on iOS, the device-side interfaces ship with the public package since 0.4.0: Sdk2Client / Sdk2Executor / Sdk2ClientToolAdapter in TansrCore, archive sync Sdk2ArchiveSync and replica reception Sdk2ReplicatedArchive, the terminal service client TerminalClient, and the SQLite archive receiver / sync store / durable execution journal in TansrClient (private directory, keys kept in the Data Protection Keychain). They are additive capabilities: without explicit assembly no sync store is created and no archive-sync-v1 request is sent, and SDK1 session behaviour is unchanged. The release record logs this round as simulator ad-hoc-signed acceptance with no device verification; the encrypted memory publishing, device-storage source-preserving migration and terminal persistence profiles that entered the mainline after 2026-10-10 have not shipped with any SPM version and are not described on this page. See SDK 2.0 extension entry for assembly and boundaries.
Audio: two chains
Section titled “Audio: two chains”- Tool chain: the agent calls
SpeechToText/TextToSpeech, the artifact reaches the device withtool.completed.data, andTansrToolCardrenders it by default (transcript body + duration; speech row + duration + model + billed characters). - Direct chain:
// 麦克风 → 转写 → 填入输入框(不自动发送);m4a 16 kHz mono 上行 data:audio/mp4;base64,…let transcript = try await client.audio.transcribe( sessionId: session.sessionId, data: m4aBytes, mime: "audio/mp4", language: "zh")inputText = transcript.text
// 助手回复 → 语音 → 播放(url ?? b64;url 形约 24 h 时效)let speech = try await client.audio.speak(sessionId: session.sessionId, text: reply, voice: "Cherry")speech.url ?? speech.b64A pure HTTP round trip (POST /api/sessions/:id/audio/transcriptions / …/audio/speech; 2xx responses pass the unified response-header gate as well) that never enters the history; whether the transcript becomes the next user input is the app’s decision. The server calls the platform’s /t1/asr / /t1/tts on your behalf, gated by the capability flags platform.speechToText / textToSpeech and the quota; failures land in .unified per D19: the unified code in unifiedCode (for example capability_unavailable / invalid_request / upstream_unavailable), the platform audio surface’s original code (asr_not_configured / tts_quota_exceeded …) in domainCode. Since @tansr/serve 0.9.0, upstream 401 / 403 / 5xx and network failures from the platform are normalised to upstream_unavailable (503) with Retry-After; the device simply backs off for the whole-second value.
Microphone permission declaration: the app target’s Info.plist must carry NSMicrophoneUsageDescription; without it, iOS terminates the process on the first microphone access. This repository is a SwiftPM package and an executableTarget has no Info.plist semantics — the example Examples/OrderAssistant/Info.plist is explicitly excluded in Package.swift; on iOS, when you build an app project in Xcode that depends on this package, set it as the app target’s Info.plist (or merge it key by key); on macOS, swift run embeds it with -Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker <path>. The repository deliberately avoids linkerSettings.unsafeFlags (SwiftPM rejects it when the package is used as a dependency).
Push wake-up: wire it yourself
Section titled “Push wake-up: wire it yourself”The SDK never bundles any push SDK (APNs / FCM / vendor channels are yours). The server’s webhook hook POSTs a minimal payload when a turn reaches a terminal state with no active subscriber; see v2 protocol and webhooks. After the app is woken by a push, it always goes through the existing three-level chain with zero new API: process still alive → automatic L1 reconnect on returning to the foreground; process dead → cold start with openSession(CreateSessionRequest(resumeSessionId: id)), i.e. L3.
Integration testing and building
Section titled “Integration testing and building”- App integration uses the public binary SPM package
tansr-ios-spm(see the iOS section of the quick start); theswift build/swift testin this section are for integration work by teams with source access. Do not mix the source SDK and the binary SDK in the same app dependency graph. - To work against the in-repo
examples/serve-demo:pnpm start/pnpm real(npm start/npm run realfor npm) are both real-model entry points; for a resident integration server start withDEMO_SERVE_ONLY=1andDEMO_PORT=8788(the source default port is 8799; there is no longer a public echo start command), and the standalone developer login servicepnpm logindefaults to127.0.0.1:8787; the demo auth ticket format isdemo1.<b64url(uid)>.<exp>.<hmac-sha256-hex>. The iOS simulator reaches the host machine directly via127.0.0.1. ExampleOrderAssistant:swift run OrderAssistant, orTANSR_DEMO_BASE_URL=http://127.0.0.1:8789 swift run OrderAssistantto override the service address; plain-text http is allowed viaNSAllowsArbitraryLoadsfor the demo only, production always uses TLS. - Building the logic layer on Windows / Linux:
swift build/swift test(Swift 6.3+) run the fullTansrCoresuite — golden-fixture comparison, contract table checks, the pump state machine, optimistic posting, the two-bridge matrix, the direct audio surface. Runningswift teston Windows requires adding the XCTest runtime DLL directory to PATH (<Swift>\Platforms\<ver>\Windows.platform\Developer\Library\XCTest-<ver>\usr\bin64). - If on Windows package loading fails immediately with
NSCocoaErrorDomain Code=257 "You don't have permission.", it is usually not an ACL problem: SwiftPM compilesPackage.swiftinto<package>-manifest.exeunder%TEMP%and launches it, and security software with active defence intercepts and quarantines it. Pick one of two remedies: add the Swift toolchain directory to the security software’s trust zone (the root fix), or create amklink /Jalias in a directory the security software allows and run from there. - macOS (Xcode 15+):
swift build && swift testruns everything includingTansrUI; for the iOS simulator / device, open this package in Xcode and run theOrderAssistantscheme.
Was this page helpful?
Thanks for your feedback.