Iterate (iOS)

The iterate mobile app: sign in, pick a project, chat with its agents — the phone equivalent of the dashboard's "new chat", against any deployment. Chat is the trunk; native features (voice — see PR #1605, whose plumbing this app shares — push, widgets) graft on later.

Run it on your phone#

Iterate uses its own development client so phone development exercises the same bundle identity, app scheme, Keychain, Face ID, and APNs entitlement as the native app. Expo Go is not a supported runtime.

Building for a physical iPhone requires an Expo login, a paid Apple Developer membership, and that phone's registered UDID:

pnpm --dir apps/mobile dlx eas-cli@21.0.1 login
pnpm --dir apps/mobile dlx eas-cli@21.0.1 device:create
pnpm --dir apps/mobile build:development:ios
pnpm --dir apps/mobile start

Use build:simulator:ios for an EAS simulator binary or build:preview:ios for a production-like internal build without the development launcher. EAS performs the native build in the cloud, so local Xcode is not required. After installing a development build, enable iOS Developer Mode and use the normal start command for Metro. Day-to-day JavaScript changes then hot-reload into that installed client.

When apps/mobile/package.json gains a native module, the already-installed client cannot load it from Metro. Build and install a new development client before testing that change. The repo workspace's native Markdown renderer is one such module; a client built before it landed will fail when opening chat or a Markdown preview. The note composer's camera-roll strip is another: it re-encodes the tapped photo through expo-image-manipulator, so on an older client the strip is simply absent and the + picker is the only way in.

This repository does not contain Apple or Expo credentials. The first signed physical-device development build completed through the linked @mishanustom/iterate EAS project on 2026-07-17; subsequent builds reuse its EAS-managed Apple credentials and registered devices. Install a build from its EAS dashboard link on a provisioned phone before starting Metro.

The development build is ad-hoc internal distribution: only provisioned devices can install it, and it contains the development launcher. preview is the production-like internal lane without that launcher. TestFlight and App Store releases use the production profile, store distribution signing, and a separate EAS Submit/App Store Connect step; a successful development build is not silently treated as a releasable binary.

Dev ↔ preview: two builds, one phone#

Both builds share the bundle ID, so installing one replaces the other — deliberate: the keychain survives, sign-in persists, and switching is just opening the other build's EAS install page and tapping Install. Bookmark both install pages (EAS builds list).

  • Dev: the development client + pnpm --dir apps/mobile start. Metro hot-reload; JS comes from your laptop.
  • Preview: standalone, JS bundled in, laptop off. It tracks main by itself: every merge, CI publishes the JS bundle to the EAS Update preview channel (.depot/workflows/mobile-eas-update.ymlscripts/ci/publish-mobile-update.ts) and the installed app pulls it on next launch. The drawer's Build info screen shows what's running — the channel it pulls from vs the channel this build was made for, the running branch/commit/message, and whether anything newer is published — and has a check/update button.

The runtime version uses the fingerprint policy: a merge that changes native code (new native module, Expo upgrade) produces updates old binaries ignore. Native builds are keyed on that fingerprint — one build per unique fingerprint, ever (ensureBuildForRuntime): a change that doesn't move it triggers no build, and a native-change PR's build is the one main reuses after merge. Old binaries can't hear about the new runtime through OTA (the update server filters by runtime before answering), so the app asks prd for the channel's CI-pushed snapshot and shows a "this channel's latest JS expects a different native build" banner with a Download button. The same merges are the ones that need a manual dev-client rebuild (build:development:ios), as above.

eas update publishes stamp src/build-info.json via scripts/write-build-info.mjs (EAS native builds run it through the eas-build-pre-install hook). Besides provenance, the stamp carries the bundle's expected backend (expectedBackendEnv — the PR's leased preview slot — plus a pr<N>+test@nustom.com test identity). The QR confirm screen is the ONE surface that acts on it: after a channel switch it compares the new bundle's expectation against the phone's server/sign-in and offers the fix as a default-checked checkbox on Continue (src/lib/expected-backend.ts); the sign-in screen and Build info only display it. Main and local bundles stamp it empty — no recommendation, phones default to prd. The checked-in file is an all-empty placeholder — don't commit a stamped one. Manual publish to the shared main channel (rare — see per-PR channels below): pnpm --dir apps/mobile update:preview.

Per-PR channels#

The preview channel is main-only; publishing PR work to it would be last-write-wins chaos. PRs touching apps/mobile/** get their own channel named after the branch: CI (.depot/workflows/mobile-pr-preview.ymlscripts/ci/publish-mobile-pr-preview.ts) publishes on every push and maintains a PR-body section with two tappable QR codes. Either one lands you on the PR — pick by what your phone already has:

  • OTA — an iterate://preview-channel/<channel> deep link that points the installed app at the PR's channel (confirm screen, then fetch + reload). Needs a binary whose runtime already matches. The QR encodes the raw scheme URL so the camera opens the app directly, no browser hop.
  • Full install — the channel-stable interstitial mobile.iterate.com/install/<channel>, which resolves the channel's expected native build at scan time from the CI-pushed snapshot (so a QR printed three pushes ago still lands on the right build), installs it in place via an OS-served itms-services manifest (the EAS build page stays linked for details, and as the fallback while a build compiles), and keeps an Open in app tap for the post-install channel switch.

Builds are shared across channels — one per runtime fingerprint, all plain preview profile. A JS-only PR triggers no build (its install QR resolves to main's binary for the same runtime); a native-change PR triggers the one build its runtime needs, ~15–20 minutes, and the section says "build still running" until then. Installing a binary lands on the binary's own channel, which is why the interstitial sequences install-then-Open-in-app: the first boot of a new binary clears any channel override, so the switch must come after the install.

The fingerprint heuristic still decides which QR is expanded, but getting it wrong costs a scan rather than leaving you on main.

A channel switch persists across restarts; get back with Build info → Reset to default channel. Installing a native build overpowers that persistence: the first boot of a new binary force-clears any pre-existing channel override (with a notice), so the build you installed is the build you run (resetChannelOverrideForNewInstall in src/lib/build-state.ts).

Lifecycle: closing a PR deletes its channel, update branch, QR assets, and channel-status snapshot, and swaps the PR body's QR section for an honest placeholder (.depot/workflows/mobile-pr-preview-cleanup.yml). On a MERGE, the main publish then writes main's QR section into that same body — and when the merge changed the fingerprint with no pre-built PR build to reuse, a follow-up job waits out the fresh main build and upgrades the install link once it's installable (scripts/ci/refresh-mobile-main-qr.ts). The merged PR is the on-ramp back onto main; no hunting commit comments.

Channel discovery is the PR bodies' QR sections — deliberately no in-app channel list, because listing channels needs the EAS API and we don't ship EXPO_TOKEN to the deployment. What the platform DOES know is the CI-pushed per-channel snapshot (packages/shared/src/mobile-channel-status.tsPUT mobile.iterate.com/channel-status/<channel>, admin bearer): each publish records the channel's runtime and expected native build; the install interstitial (/install/<channel>) and the app's staleness banner read it back tokenlessly.

The web surface is the app's own worker — apps/mobile/website/, mobile.iterate.com, zero-framework, prd-only (kernel vs userland: none of it lives in apps/os, which keeps 301s for /m/* QRs already printed). The domain also carries the apple-app-site-association, so https://mobile.iterate.com/preview-channel/<channel> is a universal link: binaries carrying the applinks:mobile.iterate.com entitlement (app.json ios.associatedDomains) open the app directly from the PR body's tap link; older binaries fall back to the web interstitial bounce.

Run and test it in a browser#

Expo Web renders the same Expo Router screens through React Native Web, so UI work does not need a phone, Xcode, an iOS simulator, or a new native build:

pnpm --dir apps/mobile start:web

For a repeatable 390×844 Chromium test, run:

pnpm spec --project=mobile

Playwright starts and stops its own Expo Web server, checks the signed-out server-picker interaction, and exits. The root pnpm spec command runs this alongside the web project; pnpm spec --project=web runs only the dashboard specs.

This is a fast browser-test lane, not an iOS emulator: platform-native behavior such as the in-app OAuth handoff, Keychain, Face ID, and push notifications still needs the Iterate development build. Authenticated project/chat fixtures are follow-up work; this first lane stays deterministic and credential-free at the signed-out entry point.

Pointing it at a deployment#

The sign-in screen has an editable server field with at most two one-tap presets: Production (os.iterate.com, default) and the running bundle's expected backend when it names a preview slot. Anything else — another preview slot, a captun tunnel, a teammate's box — gets typed in.

  • Local dev from the phone: the phone can't see localhost, so publish your dev server through captun — CAPTUN_TUNNEL_NAME=<name> pnpm dev — and use https://<name>.tunnels.iterate.com as the server (docs/dev-environments.md "Tunnels and webhooks").
  • Auth is OAuth code + PKCE against the deployment's auth worker (discovered via RFC 9728 from the OS host) with dynamic client registration; refresh tokens live in the keychain. Zero-org users get funneled through org/project creation inside the sign-in browser (the project scope does this) — the app has no org UI on purpose.

How chat works#

One capnweb WebSocket to <server>/api (authenticate({type:"bearer"}) — owned by the shared iterate/sdk/itx/react keeper and typed from its public contract). A chat is an agent stream: "new chat" mints /agents/mobile/<timestamp> and the first message() call creates it (same lazy-seeding contract as the dashboard). The chat list is the unfiltered /agents catalogue, so web/Slack-started chats open and continue here too. The thread screen renders only visible messages plus a "working…" row derived from in-flight activity (src/lib/chat.ts); useStreamConnection pushes live events into the same TanStack Query cache as the initial read (src/lib/use-live-events.ts). Assistant messages are rendered as selectable Markdown; user messages remain literal text.

Editing repositories#

/repos is the first project destination in the drawer. It lists the repos exposed by project.repos, with /repos/config first, and opens a native file workspace backed by Repo.listFiles(), readFile(), and commitFiles(). Edits, new files, and deletes stay in a local working tree until they are committed together. If the remote head changes while local edits exist, commit is blocked until the user deliberately reloads.

Markdown files have Preview and Source modes. Preview and assistant chat use react-native-enriched-markdown; Source uses the bundled CodeMirror editor and is canonical. The rich Markdown input is intentionally not used because it cannot losslessly represent every repo Markdown block construct.

Approving held requests#

The Approvals screen (per project, from the chat list's header) is a human- in-the-loop approver for egress requests a project's hold rules park — the same protocol iterate approve (packages/iterate/) and Jonas's Secure Enclave menu-bar app (PR #1868) speak. "Enroll this device" generates a real P-256 keypair (@noble/curves — Hermes has no WebCrypto) and stores the private half in the Keychain behind Face ID (expo-secure-store's requireAuthentication); it's the same "software" key kind packages/iterate/src/approval-keys.ts already uses for CI/non-Mac machines, not a fake — every grant is a real signature the platform verifies, just without Secure Enclave hardware isolation. See tasks/mobile-native-followups.md for the remaining gap and what closing it needs (these capabilities require the native development build).

Running examples#

The Examples screen (per project, from the chat list's header) lists every catalogue example that's runnable against a project itx — the same catalogue that powers the web REPL's Examples panel (apps/os/src/itx/examples.ts), filtered to context: "project" entries whose runtimes includes "run-script". Tap Run and it executes via capabilityHost.runScript — no local JS eval on the phone, the same server-side script isolate agents use — and shows the JSON result inline. Exists so testing a platform feature never needs a laptop CLI step first: every mobile feature here is built by agents, so it needs to be fully testable from the phone alone. The runner shipped in PR #2059.

Verification#

Lane What it proves
pnpm --dir apps/mobile test Pure logic: chat reducer, merge, path conventions (runs in root CI)
pnpm spec --project=mobile Real Expo Router + React Native Web behavior at a phone-sized viewport and one visible interaction; no Xcode/native build. A browser has no camera roll, so the note composer's strip reads a fixture library the spec injects (src/lib/recent-photos.ts)
doppler run --config dev -- pnpm --dir apps/mobile test:e2e Live round-trip through iterate/node: bearer auth → new mobile chat → real agent reply → live connection. Point it at a preview by switching the Doppler config. Needs pnpm dev running for the dev config.
npx expo export / npx expo prebuild The bundle builds; app config is sane
Iterate development build on a phone Native integration: the in-app browser OAuth hop, Keychain/Face ID, APNs enrollment, and device-specific behavior

Layout#

Path What
src/lib/build-state-core.ts Pure: which channel am I on, is this build watched, what does an update check mean
src/lib/build-state.ts Expo/react-query binding for the above: channel override, install guard, update actions
src/lib/session.ts The app-global "am I signed in, where, and as whom?"
src/lib/itx.ts Mobile deployment/OAuth binding for the shared iterate/sdk/itx/react keeper
src/lib/auth.ts Issuer discovery, dynamic registration, PKCE, rotation-safe token refresh
src/lib/chat.ts Pure: stream events → bubbles + working flag; agent path conventions
src/lib/use-live-events.ts Initial stream reads + shared subscription hook feeding the TanStack Query cache
src/lib/repo-working-tree.ts Local source-preserving edits and explicit batch commit state
src/lib/approver-core.ts Pure P-256 keygen/sign (Expo-free, e2e-able) — the phone's "software" approval key
src/lib/approver.ts Face-ID-gated Keychain storage binding for approver-core.ts
src/lib/approvals.ts Egress-approval protocol: grant/reject/reconcile, ported from the CLI's approve-core.ts
src/lib/examples.ts Filters the shared itx example catalogue to phone-runnable entries
src/lib/recent-photos.ts The note composer's camera-roll strip: permission, recent assets, tap → JPEG attachment
src/app/ expo-router screens: sign-in → projects → chat list → thread, approvals, examples

pnpm typecheck / pnpm test run in root CI; nothing native does.

Was this page helpful?