Skip to article
HB CodeDocsGet the app

HandbookiOS app

iOS: packages, launch, and the relay connection

How the iPhone app is split into Swift packages, why launch waits for the Keychain, how its Network.framework socket registers, times out, recovers, and filters live events, and how it reads agent limits.

Checked against the source on 5 October 2026

On this page

Three Swift packages and a thin app target

The Xcode project has two targets: the HB Code app and a VibeUITests bundle that runs hosted inside it. The app target holds three Swift files, the SwiftUI entry point, the app delegate, and a delegate extension for timeline interest. It links Core and VibeUI directly, and its delegate calls Core for push, the background, and recovery. Everything else lives in three local Swift packages built with Swift 6.3 for iOS 18.

Target or packageWhat it holdsDepends on
App targetThree Swift files: the SwiftUI entry point, the app delegate for launch, push, and the background, and its timeline interest extension.Core and VibeUI.
VibeUIEvery screen, the UIKit chat collection, the code and document viewers, Review Mode, and the bundled mermaid.min.js.Core, VibeUIFormatting, Runestone with 37 Tree-sitter grammars, swift-syntax, swift-cmark, MarkdownUI, and SwiftMath.
CoreOne library target built from Sources/Core and Sources/Security: the relay client, sign-in and tokens, the RPC command router, data services, StoreKit, push, voice, and the Keychain wrappers. A CoreTests target tests it.KeychainAccess.
VibeUIFormattingMarkdown text helpers: the path classifier, the inline reference linkifier, and the ordered list normalizer.Nothing.
VibeUITestsThe test bundle, hosted inside the app.Core, VibeUI, VibeUIFormatting, Runestone, and MarkdownUI.

Core cannot import VibeUI, so the relay client, sign-in, and data services build and test without a single screen.

Path from repository rootStart here for
mobile/ios/App/Launch, push registration, and the background and foreground transitions.
mobile/ios/Core/Sources/Core/Connectivity/The relay socket, registration, recovery epochs, and the timeline interest lease.
mobile/ios/Core/Sources/Core/Data/Manager/Ingress/Relay event intake before the main actor.
mobile/ios/VibeUI/Sources/VibeUI/Features/Workspace/Chat/Chat hosts, the collection view, the outbox, and chat previews.
mobile/ios/VibeUI/Sources/VibeUI/Features/Workspace/ReviewMode/Review Mode capture, transcription, and video composition.

Launch waits for the Keychain

A locked phone can read the device ID after its first unlock, but not the app JWT
A locked phone can read the device ID after its first unlock, but not the app JWTFour moments in a row, not to scale: a restart, the first unlock, locking the phone, and unlocking it again. The device ID can’t be read until the first unlock and stays readable after it, even while the phone is locked. The app JWT and the resume tokens can be read only while the phone is unlocked. A launch before the first unlock waits on the loading screen until protected data becomes available. A launch while the phone is locked reads the device ID and starts Core, keeps the saved sign-in because the JWT can’t be read, and checks it again after the unlock.
restart
first unlock
locked
unlocked
device IDthe phone’s identity
app JWTand resume tokens
applaunch, sign-in
unreadable
readable, also while locked
unreadable
readable
unreadable
readable
launch waits: no device ID, loading screen stays
device ID reads, Core starts, sign-in checked
launch while locked: Core starts, JWT unreadable, sign-in kept
unlock: sign-in checked again
  • Device IDAfterFirstUnlockThisDeviceOnly

    The phone’s identity for the device list and the relay. It never syncs to iCloud. The platform keeps this class readable while the phone is locked, but not before the first unlock after a restart.

  • App JWT and resume tokensWhenUnlockedThisDeviceOnly

    The default for the app’s Keychain items. The platform makes this class readable only while the phone is unlocked.

  • LaunchprotectedDataDidBecomeAvailable

    The app delegate and AppView read the device ID off the main thread. While it can’t be read, Core doesn’t start and AppView keeps its loading screen. Both try again when protected data becomes available or the app returns to the foreground.

  • Sign-in checkcheckStoredToken

    An app JWT that can’t be read because the phone is locked keeps the saved sign-in. The check runs again when protected data becomes available or the app becomes active.

The device ID is the phone’s identity for the device list and the relay, so nothing that talks to the network starts without it. It is a Keychain item with AfterFirstUnlockThisDeviceOnly accessibility that never syncs to iCloud. The app JWT, its expiry, and the relay resume tokens use the default for the app’s items, WhenUnlockedThisDeviceOnly.

The app delegate reads the device ID on a background task before it initializes Core, and AppView resolves the identity off the main thread as well. During a background launch before the first unlock, AppView stays on the loading screen and tries again when the system posts protectedDataDidBecomeAvailable. While the phone is locked, the platform keeps the device ID readable but not the app JWT, so a launch in that state keeps the saved sign-in and checks it again once protected data is available or the app becomes active. An item that cannot be read at all offers Reset Device Identity.

Sign-in needs no callback URL. ASWebAuthenticationSession opens /auth/auth0/initiate-login with callbackURLScheme set to nil, and the app polls /auth0/poll-status every 2 seconds, up to 60 times, before it exchanges the code with its PKCE verifier and finalizes the login. One refresh of the app JWT runs at a time. Its timer fires 300 seconds before expiry and never sooner than 5 seconds from now, and a 401 or 403 from the refresh endpoint ends the session.

The selected region is stored in UserDefaults. Its base URL, https://api-us.hbcode.ai or https://api-eu.hbcode.ai, drives both the REST client and the /ws/device-link socket. iOS persists the selected region before it publishes navigation state and binds each registration attempt to the origin it used.

A WebSocket on Network.framework

The relay socket is an NWConnection with NWProtocolWebSocket: version 13, automatic replies to pings, and a 32 MiB maximum message size. The parameters allow expensive and constrained paths and use the handover multipath service, so the connection can move between Wi-Fi and cellular. Only wss URLs are accepted. The upgrade request carries the app JWT as a bearer token, X-Device-ID and X-Token-Binding with the device ID, X-Client-Type: mobile, and X-Target-Desktop-Device-ID for the selected desktop.

Every socket gets a transport generation. The state object that installs a socket also performs sends that are bound to a generation, under the same lock, so a send prepared for an earlier socket can never reach its replacement. State callbacks from a connection that is no longer current are ignored.

After the upgrade the client sends register with the device ID, the device name, relayProtocolVersion 1.7, the target desktop, and the relay session ID and resume token when it has them. The handshake times out after 10 seconds, and a send that fails for a transient reason retries after min(0.25 × attempt, 1.5) seconds. Once registered, the client sends its own heartbeat message every 5 seconds, and a watchdog fails the connection after 15 seconds without inbound traffic.

Resume tokens are stored in the Keychain per phone and desktop pair, under the key <device ID>::<desktop ID>. A serial store gives each relay client an ownership generation for its key and rejects writes from a client that no longer owns it, so a stale client cannot overwrite a newer token. An invalidResume error makes the client register again without the token after 0.2 seconds. Three invalid resumes within a rolling 30 seconds stop the loop with invalidResumeLoop.

Requests, timeouts, and trace context

RpcMethod lists 69 methods: 10 in the system namespace, 23 for files, 15 for sessions, and 21 for runs. 24 of them are mutations that require an idempotency key. The client refuses an unknown method or a keyless mutation before anything is sent, and the outbound encoder throws when it meets a snake_case key anywhere in a payload. Pending calls are keyed by request ID together with an ownership token. A second in-flight request with the same ID is refused, and the cleanup of an old call cannot remove a newer one.

Default timeoutMethods
8 secondsfiles.readContent.
90 secondsFile trees, run list and get, Codex usage limits and auth profiles, Claude accounts, the Codex profile switch and the Claude account switch, agent usage history, voice vocabulary and context, and history state.
60 secondsSession and project lists, session get and update, project and session reorder, missing-workspace recovery, reconciliation, project skills, workspaces, existing sessions, cancel and delete, diffs, command output, web page Markdown, binary and media reads, and attachment uploads.
30 secondsEvery other method.
115, 60, and 20 secondsChat policy: the initial timeline, an older page, and the outbox acknowledgement.
60 secondsChat policy: run.chatOutboxStopAndSendNow.

Every request carries a W3C traceparent, and a response is rejected unless its trace ID matches the request’s. A timeout fails that call and reconnects the socket generation that sent it. When that socket was already replaced, the timeout touches nothing else.

Recovery epochs, network changes, and the background

The relay and the phone give a dropped desktop the same 90-second deadline
The relay and the phone give a dropped desktop the same 90-second deadlineIn this example the desktop’s connection to the relay closes at 0 seconds. The relay keeps the desktop’s place for 90 seconds, tells the phone it is reconnecting along with that deadline, and answers every ping for it with -32011. The phone opens a recovery epoch that ends at the same moment and pings on a backoff: four tries in about 2 seconds, then one every 2.5 to 5 seconds. At 30 seconds the desktop registers again, the relay sends online, the phone pings at once, and the desktop’s own answer makes it connected. Had the desktop stayed away, at 90 seconds the relay would send disconnected and the phone would show it offline.
Desktopexample: away 30 s
Relayreconnect grace
Phonerecovery epoch
not connected
answers pings with -32011
grace would run on
pings on a backoff
epoch would run on
reconnecting, deadline in 90 s
registers again
online
ping answered by the desktop
connected
4 tries in about 2 s, then one every 2.5 to 5 s
triesfirst 6 s, zoomed
disconnected
offline
0 s
15 s
30 s
45 s
60 s
75 s
90 s
  • Recovery epochdesktopReconnectGraceSeconds = 90

    One per desktop. It ends at the reconnectDeadline the relay sent, or without one 90 seconds after the phone noticed the loss. Silence, an RPC timeout, or a lost path inside the epoch keep that deadline.

  • Probesystem.ping

    Before each retry the phone waits a random time between half and all of a ceiling that doubles from 0.25 s and stops at 5 s, never past the deadline. A try without a socket opens one first, and it registers with its resume token.

  • Reconnect graceDESKTOP_RECONNECT_GRACE_SECS = 90

    When a desktop’s socket closes, the relay sends reconnecting with a reconnectDeadline and answers requests for that desktop with -32011. It sends online when the desktop registers again and disconnected when the deadline passes.

  • OutcomecanSendDesktopRPC

    The desktop counts as connected only after it answers system.ping itself. The phone shows it offline at the deadline, at once on disconnected, or when the probe stops for lack of a network path.

Recovery runs in epochs, one per desktop, each with a deadline. Inside an epoch the app probes the desktop with system.ping on a backoff, and when the relay sends online it pings at once. A desktop that has not answered by the deadline, or that the relay reports disconnected, is shown as temporarily offline. So is one whose probe stops because the phone has no network path.

NWPathMonitor reports lost paths and interface switches. A lost path closes every connected relay, fails its pending calls, and opens an epoch. A switch between Wi-Fi and cellular closes the old relay and fails in-flight calls before recovery opens exactly one replacement, because a socket that migrated can keep accepting sends without ever answering a registration. The switch discards the current epoch, so the replacement gets a new 90-second window.

When the app resigns active it suspends timeline interest. When it enters the background it disconnects every relay inside a GracefulDisconnect background task, unless desktop-backed media is playing, which the audio background mode allows. Back in the foreground, recovery for the active desktop starts 1.5 seconds later, and an existing socket is kept only when a ping answers within 3 seconds. A silent desktop_online push starts the same recovery right away.

Event intake before the main actor

A waiting snapshot can be replaced, and the lease is checked when it is delivered
A waiting snapshot can be replaced, and the lease is checked when it is deliveredAn example of five relay events within 260 milliseconds while you switch chats. Snapshot T1 for chat T is delivered at once. T2 arrives during the 100-millisecond pause after that delivery and waits until T3 replaces it. A run:status-changed event doesn’t wait and is delivered before T3. You open chat S at 105 milliseconds, so when T3 is delivered at 150 milliseconds its session holds no lease, and the main actor drops it before decoding. S1 for chat S arrives later, holds the lease, and is decoded and applied.
arrivesnetwork queue
waitsRelayEventIngress
leaseinterest coordinator
deliveredmain actor
lease: chat T
lease: chat S
you open chat S
T1
T2
T3
S1
run:status-changed
doesn’t wait
replaced by T3
T1 shown
T3 dropped: no lease, never decoded
S1 shown
100 ms pause
0 ms
100 ms
200 ms
300 ms
  • Snapshot slotchat:timeline-updated · event: blocks

    One waiting slot per session, run, and thread, and a newer snapshot replaces the one waiting. Past 128 slots the oldest is dropped. Snapshots leave one at a time, with a 100 ms pause after each delivery.

  • Control eventsrun:status-changed · device-status

    Every other relay event waits in arrival order and is delivered without a pause, so it can pass a waiting snapshot.

  • Lease checkWorkspaceTimelineRelayEventAuthorization

    Runs on the main actor when a timeline event is delivered and drops the event before decoding when its desktop and session hold no current lease. It runs again right before the view model changes.

  • Overload32 MiB · 64 MiB · 256 events

    An event over 32 MiB, more than 64 MiB waiting, or more than 256 waiting control events drops the rest of that subscription generation. The phone then subscribes again and starts desktop recovery.

RelayEventIngress admits relay events before any work reaches the main actor. Control events keep their arrival order and never wait behind snapshots. Block snapshots from chat:timeline-updated are best-effort UI state. A newer snapshot for the same session, run, and thread replaces one that is still waiting, and snapshots leave one at a time. The lease is checked when a snapshot is delivered on the main actor, before its payload is decoded, so content for a chat you left never reaches preparation.

Timeline content reaches the phone only while a chat is visible. Each visible chat view holds a claim, and the interest coordinator keeps one lease per desktop, for the base session of the newest claim. Claims keep the handoff between a parent chat and a subagent viewer safe while both views briefly exist, and a claim counts only for the foreground generation in which its view last confirmed visibility, so coming back to the foreground cannot revive a view that is gone. The lease is sent as chat:timeline-interest with the appSessionId and an active flag, renewed every 20 seconds, and issued again after the relay registers. A failed delivery is tried up to 4 times, starting at 0.25 seconds with at most 2 seconds between attempts and 20 percent jitter.

Relay eventGoes to
device-status, device-unlinkedDesktop presence with the recovery epoch, and the removal of a desktop that was unlinked.
project-directory-updatedThe project directory of that desktop.
session-created, session-updated, session-deleted, session-snapshot, history-state-changedThe session service.
chat:timeline-updatedThe visible chat, after the lease check. The same event also updates the run summaries.
chat:outbox-updatedA chatOutboxUpdated notification that the open chat observes.
run:created, run:deleted, run:list-invalidated, run:status-changed, run:tokens-updated, run:cost-updated, run:finalized, run:stream-progress, run:error-detailsThe run summaries in JobsDataService.

The activity indicator of a session comes from the run summaries. A chat:timeline-updated event with the event started or blocks marks its run as running. The events completed, failed, and aborted mark it as completed, failed, and canceled and set the end time. An event without run metadata is dropped with a warning, and one without a run ID changes nothing.

Agent limits and account switching

The two limit tiles share one 60-second refresh but not their reads
The two limit tiles share one 60-second refresh but not their readsAn example with three moments, not to scale. When you open the projects list, both tiles show “Checking…”. The Codex tile reads limits and profiles in parallel, and the Claude tile reads the account list. Every 60 seconds the refresh runs: Codex reads only the limits, and Claude reads the accounts again. Then you switch the Codex profile. The phone sends one keyed switch request and afterwards both Codex reads again, so the tile shows the limits of the other profile. The Claude tile is not touched. Below, a desktop with no active Codex profile shows “Not set up”, and the refresh then reads only the profile list.
You open the projects list
Every 60 s: the refresh
You switch the Codex profile
Codex tileprojects list
Desktop
Claude tileprojects list
Checking…
5h 76% left · Weekly 58% left
5h 100% left · Weekly 91% left
Checking…
5h 40% left · Weekly 74% left
limits and profiles, in parallel
limits only
one keyed switch request
then both reads again
account list
account list again
With no active Codex profile the tile shows “Not set up”, and the refresh reads only the profile list until one is active.
  • Codex readssystem.codexUsageLimits · system.codexAuthProfiles

    A full load sends both in parallel. A profile list without an active profile means “Not set up”, and the failed limits read stays hidden.

  • Claude readsystem.claudeAccounts

    One read returns every account with its usage windows. A failed read keeps the last accounts, and the accounts of another desktop are cleared before the load.

  • RefreshlimitRefreshIntervalSeconds = 60

    Runs while the projects list is on screen. Codex reads only the limits again. Claude reads the account list again.

  • Switchsystem.activateCodexAuthProfile · system.activateClaudeAccount

    One keyed request for each tap, with a new UUID in the key. Only one switch runs at a time. The answer is the new list. Codex then loads again because the limits belong to the active subscription.

The projects list shows one tile for each agent, and Settings has an Agents section with the same two status lines. A tap opens the usage screen of that agent. Accounts and profiles live on the desktop. The phone reads them through the relay and can only pick the active one. People add an account and sign in on the desktop.

Tile textWhen the Codex tile shows itWhen the Claude tile shows it
Checking…A load runs and no limits are known.The first read of this desktop has not answered.
Not set upThe profile list has no active profile, and the limits are cleared.The account list is empty and the read did not fail.
UnavailableNo limit window is known after the load.The account list is empty and the read failed.
Signed outThe Codex tile never shows it.No account is active, or the active account is signed out.
Usage rowsThe primary and the secondary window of the active profile, named from their length, 5h and Weekly.The 5h window and the weekly window of all models of the active account.
Could not read usageThe Codex tile never shows it.The active account has no usage windows and an error message.
Plan name, or No plan limitsThe Codex tile never shows it.The active account has no usage windows and no error.

A usage row shows the percent that is left, which is 100 minus usedPercent, clamped to 0 through 100. The row turns to the warning color at 25 percent or less and to the destructive color at 10 percent or less. The Claude usage screen also lists the weekly windows of single models, which the tile leaves out.

On the Codex usage screen a tap on a profile that is authenticated and not active sends system.activateCodexAuthProfile. On the Claude usage screen a tap on an account that is signed in and not active sends system.activateClaudeAccount, and that account runs the next Claude turn. Both calls are keyed mutations. The key is the method, the ID, and a new UUID, so each tap is its own mutation. A view model runs one switch at a time. A failed Codex switch sets the failed load state, and the tile keeps the limits it already has. A failed Claude switch keeps the accounts and stores the error.

Each usage screen also reads system.agentUsageHistory for its agent. The weekly line is drawn from weeklySamples. When a reset time lies between two samples and the later sample is lower, the line gets two extra points: the old value one second before the reset and zero at the reset. The tokens chart always spans the last 30 local days and exists only for Codex, because dailyTokens is null for Claude. A failed read keeps history that is already on screen.