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 startUse 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
previewchannel (.depot/workflows/mobile-eas-update.yml→scripts/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.yml →
scripts/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.ts
→ PUT 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:webFor a repeatable 390×844 Chromium test, run:
pnpm spec --project=mobilePlaywright 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 usehttps://<name>.tunnels.iterate.comas 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
projectscope 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.