Build a mobile app
A production-quality iOS-first React Native app for the Amy backend — Expo SDK 56, Expo Router v6 native tabs, Clerk Core 3 (@clerk/expo), NativeWind v4, Reanimated 3, TanStack Query v5, @amy/sdk as a workspace import. Words-first, journal-like — not a dashboard.
Goal. A polished, words-first mobile app for Amy that looks nothing like a typical AI chatbot. Petrol-blue accents on a paper-warm canvas. Amy's bubbles read like a journal entry; your messages are bare. While Amy is thinking, the phase events from the backend stream in live with layout transitions — the same "watch Amy think" feel the CLI nails, ported to a phone.
Time. ~2 hours end-to-end. Cost. $0 to ship to TestFlight (Apple Dev account aside). The Amy backend is already live at
https://amy.heyamy.xyz— you don't deploy anything new.
This recipe is the canonical, opinionated reference for the Amy
mobile app. It's the one we build internally, and it's the one we
recommend you copy. Everything in it has been chosen on purpose:
package picks, file layout, animations, error mapping. Where there's
a fork — pnpm vs bun, MMKV vs SecureStore, native tabs vs custom
— it tells you which path we took and why.
This is also a monorepo recipe. The app lives inside this repo
at apps/mobile/, alongside the backend it talks to. That means
@amy/sdk and @amy/contracts are imported as workspace:*
packages — not from npm. Type changes in the backend show up as
TypeScript errors in the app on save.
If you want the 30-second tour without the rest of the recipe:
# from the repo root
bun create expo@latest apps/mobile --template default
# add "apps/*" to root package.json workspaces
# add @amy/sdk + @amy/contracts as workspace:* deps in apps/mobile/package.json
bun install
cd apps/mobile && bun startEverything after the scaffold is in the steps below.
What you'll build
A four-tab iOS app. Drop a screenshot into your imagination as you read; the visual language is intentional.
┌─────────────────────────┐ Tab 1 — Today
│ Today │
│ │ Big serif title. Below it, a quiet,
│ You slept 7h 38m. │ journal-style paragraph from
│ Recovery is 71. │ amy.data.sync(): the things Amy
│ Restful, but you've │ would mention if you asked. No
│ been here before. │ sparklines, no rings, no leaderboard.
│ │ Just words on paper-warm linen.
│ [ Ask Amy → ] │
│ │
│ │
│ ──────────────────── │ Thin rules instead of borders.
└─────────────────────────┘
Today Ask Trends You Native iOS tabs (UITabBar).
┌─────────────────────────┐ Tab 2 — Ask
│ Ask Amy ⌘ │
│ │ The heart of the app. Amy's bubbles
│ How is my HRV │ are journal-like: serif heading,
│ trending? │ petrol-blue rule, body text. Your
│ │ messages are right-aligned, no
│ Your 30-day average │ chrome, just text.
│ is up 4.2 ms from │
│ the prior month… │ While Amy thinks, phase events fade
│ │ in: "Routing → Data Science →
│ ──────────────────── │ Validator". No spinner. No typing
│ [ ▌ Ask something ] │ dots. The CLI's feel, in a phone.
└─────────────────────────┘
Today Ask Trends You
┌─────────────────────────┐ Tab 3 — Trends
│ Trends │
│ │ A scroll of past turns, newest
│ Today · $0.13 · 2m │ first. Preview of the question;
│ How is my HRV… │ cost and duration on the side.
│ │ Tap one to re-read the answer.
│ Yesterday · $0.09 · 1m │
│ What does my… │
└─────────────────────────┘
Today Ask Trends You
┌─────────────────────────┐ Tab 4 — You
│ You │
│ │ Profile, sources, labs, sign-out.
│ ●● Yatendra Kumar │ Clerk's <UserButton /> from
│ yatendra@… │ @clerk/expo/native gives you the
│ │ avatar + menu for free.
│ Wearables │
│ WHOOP ● connected │
│ [ + Connect another ] │
│ │
│ Labs │
│ 2 reports parsed │
│ [ + Upload a report ] │
│ │
│ [ Sign out ] │
└─────────────────────────┘
Today Ask Trends YouTwo notes on the design taste, because this is the part that takes willpower to keep:
-
Words first. Amy's job is to read your body's signals and tell you a clear story. The UI should look like a place where a clear story would live — Substack, Notion, a paper notebook — not a B2B analytics dashboard. We use serif type inside Amy's messages, and a UI sans (Inter) for chrome. No cards with shadows. No metric tiles. Thin rules in
--amy-ink-200instead of borders. -
Phase events instead of typing dots. While a turn is running, the backend emits
phaseevents (Routing,Data Science,Validator: gating claim …). We render them live, with Reanimated layout transitions, so the user is watching Amy reason rather than watching a fake "•••". This is the single highest-ROI piece of polish in the whole app, and it's what makes the experience feel like the CLI.
The petrol-blue accent (#2c5878 / oklch(0.48 0.105 230)) is the
same one docs.heyamy.xyz uses. We carry
it into the mobile app as design tokens so the brand is consistent
across surfaces.
Prerequisites
You need a handful of things installed. Most of you will already have most of them.
| Tool | What it is | How to get it |
|---|---|---|
| Bun ≥ 1.x | The package manager and runtime this repo uses | curl -fsSL https://bun.sh/install | bash |
| Xcode ≥ 16 | iOS simulator + native build chain | Mac App Store; then xcode-select --install |
| EAS CLI | Builds and submits the iOS binary | bun add -g eas-cli |
| A Clerk project | Hosts the user database & sign-in UI | Free dev project; copy the publishable key |
| The Amy repo | This recipe edits files in it | You already have it open |
| An Apple Developer account (optional) | Required only for TestFlight | $99/yr at developer.apple.com — skip if you just want simulator |
You do not need:
- Android Studio. Expo SDK 56 builds Android out of the box; we just don't target Android in this recipe because every minute spent on multi-platform pixel-tuning is a minute not spent on the chat experience. The same code runs there.
- The Amy backend running locally. Use the production URL.
- Any new credentials beyond your existing Clerk publishable key.
Check you're set up:
bun --version # ≥ 1.0
xcrun --version # any
eas --version # ≥ 14.0The stack — and why each piece
Every dependency here has been weighed. The recipe is opinionated on purpose; the wrong default in a mobile app is brutally expensive to walk back later.
| Layer | Pick | Why |
|---|---|---|
| Runtime | Expo SDK 56 (RN 0.85, React 19.2) | Latest stable as of May 2026; expo-router v6 ships in-box. |
| Routing | Expo Router v6 | File-based routing; native UITabBar on iOS for free; deep links and universal links handled. |
| Auth | Clerk Core 3 via @clerk/expo | Same Clerk app the backend already trusts. apiKeyProvider: () => getToken() plugs into the Amy SDK with zero glue. |
| Styling | NativeWind v4 | Tailwind for RN; supports CSS variables, dark mode, and a real className prop. Lets us share the docs-site palette token names verbatim. |
| Animations | Reanimated 3 + react-native-gesture-handler | Layout animations are what make the streaming phase list feel alive without a single timing function in app code. |
| Server state | TanStack Query v5 | The Amy SDK is request-response under the hood; Query handles caching, retry, and stale-while-revalidate. Streams bypass Query entirely. |
| UI state | Zustand | Tiny store for chat draft text, sheet open/closed, theme override. Avoids the React Context circus. |
| Local cache | react-native-mmkv | Sync, fast, key-value. We mirror amy.data.sync() here so the Today tab opens instantly even offline. |
| Lists | @shopify/flash-list | The chat list grows long; FlashList is the only RN list that survives 1000+ messages without dropping frames. |
| Images | expo-image | Lazy, cached, animated; better defaults than <Image>. |
| Haptics | expo-haptics | One line per interaction. Required to make the app feel native. |
| Blur | expo-blur | The tab bar's frosted background. |
| Icons | lucide-react-native | Stroke icons; consistent with the docs-site & CLI. |
| Typography | Inter Variable + a serif | UI in Inter, Amy's prose in a soft serif (Source Serif 4 or system serif). |
| Web view | expo-web-browser | For the Terra OAuth widget. |
| File picker | expo-document-picker | For lab uploads. |
| SDK | @amy/sdk (workspace) | The same SDK the docs walk through. apiKeyProvider + Clerk = zero token plumbing. |
| Contracts | @amy/contracts (workspace) | Source of truth for Turn, StreamEvent, error codes. Imported directly for typings. |
A few notes on what's deliberately absent:
- No Redux / MobX / Jotai. Server state is TanStack Query's job, ephemeral UI state is Zustand's. Nothing else needs a global store.
- No React Navigation directly. Expo Router wraps it; we don't reach past the wrapper.
- No socket / EventSource library. The SDK iterator already does
SSE over
fetch+ a ReadableStream — works in RN without polyfills. - No Sentry / analytics in v1. Add them when you have real users.
Step 1 — Scaffold the app inside the monorepo
The app lives at apps/mobile/ in this repo, so the SDK and
contracts are workspace imports, not npm packages. This is a
deliberate departure from the typical Expo tutorial, which assumes
a fresh standalone project — and it's the single best decision
you'll make. The backend, SDK, and app share one TypeScript graph;
when you rename a field in @amy/contracts, the app fails to
typecheck on the next save.
1.1 — Make apps/ a workspace root
The repo's root package.json currently has:
{
"workspaces": ["packages/*", "cloud"]
}Add "apps/*" to it:
{
"workspaces": ["packages/*", "cloud", "apps/*"]
}That's the only change you need to the root. Bun will pick up
anything under apps/ on the next bun install.
1.2 — Generate the Expo project
From the repo root:
bun create expo@latest apps/mobile --template defaultPick the default template when prompted. It scaffolds an Expo Router v6 project with TypeScript, a sample tab layout, and a working iOS + Android build.
Once it's done, you should have:
amy_health_assistant/
├── apps/
│ └── mobile/
│ ├── app/
│ │ ├── _layout.tsx
│ │ ├── (tabs)/
│ │ │ ├── _layout.tsx
│ │ │ ├── index.tsx
│ │ │ └── explore.tsx
│ │ └── +not-found.tsx
│ ├── assets/
│ ├── components/
│ ├── constants/
│ ├── package.json
│ ├── tsconfig.json
│ └── app.json
├── packages/
│ ├── contracts/
│ └── sdk-ts/ # @amy/sdk
├── cloud/
└── package.json # ← workspaces now includes apps/*Delete the parts of the template you won't use. The default project ships with a parallax header, a HelloWave animation, and demo content — all of which you'll replace.
cd apps/mobile
rm -rf app/explore.tsx components/HelloWave* components/Parallax* components/ThemedText.tsx components/ThemedView.tsx constants
rm -rf assets/images/{partial-react-logo.png,react-logo*}We'll rebuild a smaller components/ directory as we go.
1.3 — Wire the workspace dependencies
Open apps/mobile/package.json. Add @amy/sdk and
@amy/contracts to dependencies. The full file should look
something like:
{
"name": "amy-mobile",
"version": "0.1.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"ios": "expo run:ios",
"android": "expo run:android",
"typecheck": "tsc --noEmit",
"lint": "expo lint"
},
"dependencies": {
"@amy/contracts": "workspace:*",
"@amy/sdk": "workspace:*",
"expo": "~56.0.0",
"expo-router": "~6.0.0",
"react": "19.2.0",
"react-native": "0.85.0"
},
"devDependencies": {
"@types/react": "~19.2.0",
"typescript": "~5.7.0"
}
}On the SDK 56 / Clerk peer range.
@clerk/[email protected](Core 3) ships support for Expo SDK 56 (RN 0.85); install the current@clerk/expoand its native components (AuthView,UserButton) work on this set. If you ever pin an older Clerk that warns about the peer range, runbunx expo install @clerk/expoto pull the matching version rather than downgrading Expo.
We're keeping the version specifiers loose; let bunx expo install
align them to the SDK 56 stable set. From the repo root:
bun install
cd apps/mobile && bunx expo install --checkbunx expo install --check is the safety net: it reads expo from
package.json, looks up the version matrix for that SDK, and warns
about every direct dep that doesn't match. Run it any time you bump
expo or add a new RN package.
Bun resolves workspace:* to the in-repo versions and hoists
everything to the root node_modules. You should see the SDK
linked at apps/mobile/node_modules/@amy/sdk as a symlink into
packages/sdk-ts/.
1.4 — Configure Metro for the monorepo
Expo's Metro can find workspace packages, but it needs to be told
where the root is. Create apps/mobile/metro.config.js:
// apps/mobile/metro.config.js
const { getDefaultConfig } = require("expo/metro-config");
const path = require("node:path");
const projectRoot = __dirname;
const monorepoRoot = path.resolve(projectRoot, "../..");
const config = getDefaultConfig(projectRoot);
// 1. Watch the whole monorepo so changes to @amy/sdk hot-reload.
config.watchFolders = [monorepoRoot];
// 2. Let Metro resolve packages from both app-level and root node_modules.
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(monorepoRoot, "node_modules"),
];
// 3. Force a single copy of React/RN even if a workspace package
// has its own.
config.resolver.disableHierarchicalLookup = true;
// 4. Bun's workspaces use symlinks. Metro needs this enabled to
// follow them when resolving @amy/sdk → packages/sdk-ts/.
config.resolver.unstable_enableSymlinks = true;
module.exports = config;That's it for Metro. Two lines matter: watchFolders makes edits in
packages/sdk-ts/src/ propagate to the running app within the
second, and unstable_enableSymlinks is what lets Metro follow the
Bun-workspace symlink — without it you'll get Cannot find module @amy/sdk
at Metro start.
1.5 — Confirm the scaffold
From the repo root:
cd apps/mobile
bun startPress i to open the iOS simulator. You should see the default
Expo tab template render — two tabs, a sample home screen. That's
your starting line. Everything below replaces this default UI.
Why we don't use Expo Go. Expo Go is great for hello-world, but several packages we use —
react-native-mmkv,@clerk/expo/native, the AuthView, and a Reanimated 4 release if you migrate later — don't work inside Go because they ship native code. We use a custom dev client instead:expo run:iosbuilds your own bundle once, and from then onbun startreloads JS into it. One-time cost: ~3 minutes the first build. Everything else stays as fast as Expo Go.
Step 2 — NativeWind v4 + design tokens
NativeWind brings Tailwind's className to React Native. Version
4 is what we want — it supports CSS variables, dark mode via the
dark: prefix, and arbitrary values, all backed by a Metro
transform that has no runtime cost.
We use it as much for the token system as for the utility
classes. The petrol palette and ink scale live in CSS variables,
matched bit-for-bit to docs.heyamy.xyz, so the brand is
consistent across surfaces.
2.1 — Install
cd apps/mobile
bun add nativewind react-native-css-interop
bun add -d tailwindcss@^3.4 prettier-plugin-tailwindcssReanimated and Gesture Handler come next (we'll need them for layout animations and the chat composer):
bunx expo install react-native-reanimated react-native-gesture-handler2.2 — Tailwind config
apps/mobile/tailwind.config.ts:
import type { Config } from "tailwindcss";
export default {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
presets: [require("nativewind/preset")],
theme: {
extend: {
colors: {
// Petrol blue — same scale as docs.heyamy.xyz. Hex
// approximations of the docs-site oklch tokens, because
// RN's color parser doesn't speak oklch reliably yet.
petrol: {
50: "#f1f6fa",
100: "#e0ecf4",
200: "#c2d8e7",
300: "#9bbed4",
400: "#5b85b3",
500: "#3f6f9a",
600: "#2c5878",
700: "#1c3f5a",
800: "#152e44",
900: "#0e2030",
},
// Ink — neutrals warm enough to read like paper, cold
// enough to stay legible at 100% saturation in code.
ink: {
50: "#fafaf8",
100: "#f1f1ed",
200: "#e5e5df",
300: "#cfcfc6",
400: "#a5a59a",
500: "#76766c",
600: "#5c5c52",
700: "#3c3c34",
800: "#222220",
900: "#16161a",
950: "#0c0c0f",
},
// The canvas. Slightly warm linen.
paper: {
DEFAULT: "#f7f4ef",
dark: "#15161a",
},
},
fontFamily: {
sans: ["Inter"],
serif: ["SourceSerif4", "Georgia", "serif"],
mono: ["IBMPlexMono", "monospace"],
},
fontSize: {
// A small set, intentionally. The journal voice needs few
// sizes; too many sizes is the dashboard smell.
"xs": ["12px", { lineHeight: "16px" }],
"sm": ["14px", { lineHeight: "20px" }],
"base": ["16px", { lineHeight: "24px" }],
"lg": ["18px", { lineHeight: "28px" }],
"xl": ["22px", { lineHeight: "32px" }],
"2xl": ["28px", { lineHeight: "36px" }],
"3xl": ["34px", { lineHeight: "42px" }],
},
},
},
plugins: [],
} satisfies Config;2.3 — Global styles
apps/mobile/global.css:
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
/* Light theme — paper. */
:root {
--color-bg: 245 244 239; /* paper */
--color-fg: 22 22 26; /* ink-900 */
--color-fg-muted: 118 118 108; /* ink-500 */
--color-rule: 229 229 223; /* ink-200 */
--color-accent: 44 88 120; /* petrol-600 */
--color-accent-soft: 224 236 244; /* petrol-100 */
--color-amy-bubble: 253 251 246;
--color-user-bubble: 44 88 120;
}
/* Dark theme — late evening. */
@media (prefers-color-scheme: dark) {
:root {
--color-bg: 21 22 26;
--color-fg: 241 241 237;
--color-fg-muted: 165 165 154;
--color-rule: 60 60 52;
--color-accent: 155 190 212; /* petrol-300 */
--color-accent-soft: 28 63 90; /* petrol-700 */
--color-amy-bubble: 34 34 32;
--color-user-bubble: 155 190 212;
}
}
}The CSS variables expose the tokens as bg-[rgb(var(--color-bg))]
classes — useful for one-off tweaks. Most components will reach for
the named palette (bg-paper, text-ink-900).
2.4 — Babel + Metro transforms
apps/mobile/babel.config.js:
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
plugins: [
// Must be the LAST plugin.
"react-native-reanimated/plugin",
],
};
};Two non-obvious rules:
nativewind/babelmust come afterbabel-preset-expo.react-native-reanimated/pluginmust be the last plugin in the list. Out-of-order plugins are the #1 reasonuseSharedValuereturnsundefinedat runtime.
Update apps/mobile/metro.config.js so NativeWind sees global.css:
// apps/mobile/metro.config.js
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const path = require("node:path");
const projectRoot = __dirname;
const monorepoRoot = path.resolve(projectRoot, "../..");
const config = getDefaultConfig(projectRoot);
config.watchFolders = [monorepoRoot];
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(monorepoRoot, "node_modules"),
];
config.resolver.disableHierarchicalLookup = true;
module.exports = withNativeWind(config, { input: "./global.css" });2.5 — TypeScript typings
Create apps/mobile/nativewind-env.d.ts:
/// <reference types="nativewind/types" />This makes className valid on every native element, including
custom components.
2.6 — Fonts
bunx expo install expo-font @expo-google-fonts/inter @expo-google-fonts/source-serif-4 expo-splash-screenWe register the fonts in the root layout (next step), with
SplashScreen.preventAutoHideAsync() so the splash holds until
Inter is loaded.
2.7 — Smoke test
Edit apps/mobile/app/(tabs)/index.tsx to a single styled view:
import { View, Text } from "react-native";
export default function Home() {
return (
<View className="flex-1 items-center justify-center bg-paper">
<Text className="font-serif text-3xl text-ink-900">Hello, Amy.</Text>
<Text className="mt-2 text-base text-ink-500">
Words first. Paper warm. Petrol blue.
</Text>
</View>
);
}Run bun start and press i. If the text renders in Inter (the
serif may fall back to system serif until step 2.6 lands fonts)
on a warm off-white background, your token pipeline is wired.
Step 3 — Wire Clerk
Auth is the most consequential piece of code in this app, and
it's the one that's changed the most recently. Clerk shipped
Core 3 / @clerk/expo v3.1 in March 2026, which renamed the
package, added native components, replaced <SignedIn>/<SignedOut>
with <Show when="…">, and reworked the token cache module path.
Everything in older blog posts is wrong on at least one of these.
The short version: use @clerk/expo (no -clerk- in the middle),
import tokenCache from @clerk/expo/token-cache, render the
sign-in screen as <AuthView mode="signInOrUp" /> from
@clerk/expo/native, and gate routes with <Show when="signed-in">.
3.1 — Install
cd apps/mobile
bunx expo install @clerk/expo expo-secure-storeThe package is @clerk/expo, not @clerk/clerk-expo. The
older package still exists on npm but is deprecated and missing
the native components.
3.2 — Environment
apps/mobile/.env:
# Clerk publishable key from your dev project at https://dashboard.clerk.com.
# Must come from the same Clerk instance as the backend's CLERK_SECRET_KEY
# — otherwise the backend verifies your tokens against the wrong JWKS and
# every request returns 401 invalid_token.
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_…
# Production Amy backend.
EXPO_PUBLIC_AMY_BASE_URL=https://amy.heyamy.xyzapps/mobile/.env.example:
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=
EXPO_PUBLIC_AMY_BASE_URL=https://amy.heyamy.xyzMake sure .env is in .gitignore. The publishable key is fine to
commit in principle (it's a public credential), but keeping it out
of the repo means each developer points at their own Clerk dev
project.
Pointing at a local backend
When you're hacking on cloud/ and want the app to hit wrangler dev
instead of prod:
| Where you're running the app | What to set EXPO_PUBLIC_AMY_BASE_URL to |
|---|---|
iOS Simulator on the same Mac as wrangler dev | http://localhost:8787 |
| Android Emulator on the same Mac | http://10.0.2.2:8787 (Android's loopback alias to the host) |
| A real iPhone / Android on the same Wi-Fi | http://<your-mac-LAN-ip>:8787 — find it with ipconfig getifaddr en0 on macOS |
The physical-device case is the one that bites. localhost on your
phone is the phone, not your Mac — you have to use the Mac's LAN IP
(e.g. 192.168.1.42) and make sure wrangler dev --ip 0.0.0.0 so the
Worker listens on all interfaces, not just loopback. iOS also blocks
plain http:// by default; for dev, add an
NSAppTransportSecurity exception in app.json:
{
"expo": {
"ios": {
"infoPlist": {
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoadsInWebContent": true,
"NSExceptionDomains": {
"192.168.1.42": { "NSExceptionAllowsInsecureHTTPLoads": true }
}
}
}
}
}
}Re-run bunx expo prebuild --clean after editing app.json so iOS
picks up the Info.plist change. Strip the exception before submitting
to TestFlight.
3.3 — Root layout: ClerkProvider
Replace apps/mobile/app/_layout.tsx:
// apps/mobile/app/_layout.tsx
import "../global.css";
import { useEffect } from "react";
import { useFonts } from "expo-font";
import { Inter_400Regular, Inter_500Medium, Inter_600SemiBold } from "@expo-google-fonts/inter";
import { SourceSerif4_400Regular, SourceSerif4_600SemiBold } from "@expo-google-fonts/source-serif-4";
import * as SplashScreen from "expo-splash-screen";
import { Slot } from "expo-router";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { StatusBar } from "expo-status-bar";
import { ClerkProvider } from "@clerk/expo";
import { tokenCache } from "@clerk/expo/token-cache";
import { QueryProvider } from "@/lib/query";
SplashScreen.preventAutoHideAsync();
const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY;
if (!publishableKey) {
throw new Error(
"EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY is missing from .env. " +
"Copy it from your Clerk dashboard at https://dashboard.clerk.com.",
);
}
export default function RootLayout() {
const [fontsLoaded] = useFonts({
Inter: Inter_400Regular,
InterMedium: Inter_500Medium,
InterSemiBold: Inter_600SemiBold,
SourceSerif4: SourceSerif4_400Regular,
SourceSerif4SemiBold: SourceSerif4_600SemiBold,
});
useEffect(() => {
if (fontsLoaded) SplashScreen.hideAsync();
}, [fontsLoaded]);
if (!fontsLoaded) return null;
return (
<ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
<QueryProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<StatusBar style="auto" />
<Slot />
</GestureHandlerRootView>
</QueryProvider>
</ClerkProvider>
);
}A few things worth noticing in this file:
tokenCacheis imported from@clerk/expo/token-cache— its own entry point. Importing it from the root used to work, no longer does in 3.1+. It's a thin wrapper overexpo-secure-storethat handles Clerk's session JWT storage.publishableKeyis now required. In older Clerk versions you could omit it and the provider would read it from env. In Core 3 you must pass it explicitly; the throw above gives a useful error if.envis misconfigured.SplashScreen.preventAutoHideAsync()runs at module-load time, before React mounts. We then hide the splash inside the effect once Inter and Source Serif have loaded. Without this, the splash flashes off and you see system fonts for half a second.<QueryProvider>is in step 4 — leave it commented if TypeScript complains for now.
3.4 — Split routes by auth state
Expo Router's group syntax ((name)) lets us colocate signed-in vs
signed-out routes. Create:
app/
├── _layout.tsx # ClerkProvider, fonts, providers
├── (auth)/ # Visible only when signed-out
│ ├── _layout.tsx
│ └── sign-in.tsx
└── (app)/ # Visible only when signed-in
├── _layout.tsx
└── (tabs)/
├── _layout.tsx
├── index.tsx # Today
├── ask.tsx # Ask
├── trends.tsx # Trends
└── you.tsx # Youapps/mobile/app/(auth)/_layout.tsx:
import { useAuth } from "@clerk/expo";
import { Redirect, Stack } from "expo-router";
export default function AuthLayout() {
const { isSignedIn, isLoaded } = useAuth();
if (!isLoaded) return null;
if (isSignedIn) return <Redirect href="/" />;
return <Stack screenOptions={{ headerShown: false }} />;
}apps/mobile/app/(auth)/sign-in.tsx:
import { View } from "react-native";
import { AuthView } from "@clerk/expo/native";
export default function SignIn() {
return (
<View className="flex-1 bg-paper">
<AuthView mode="signInOrUp" />
</View>
);
}That's the whole sign-in screen. AuthView is the native sign-in
component Clerk ships in 3.1 — it handles email + OAuth, password
reset, OTP, MFA, the lot. Don't roll your own form; you'll
ship a worse one and lose a year of edge-case coverage Clerk has
already paid for.
apps/mobile/app/(app)/_layout.tsx:
import { useAuth } from "@clerk/expo";
import { Redirect, Stack } from "expo-router";
export default function AppLayout() {
const { isSignedIn, isLoaded } = useAuth();
if (!isLoaded) return null;
if (!isSignedIn) return <Redirect href="/(auth)/sign-in" />;
return <Stack screenOptions={{ headerShown: false }} />;
}3.5 — The tab bar (native iOS tabs)
apps/mobile/app/(app)/(tabs)/_layout.tsx:
import { Tabs } from "expo-router";
import { BlurView } from "expo-blur";
import { Platform } from "react-native";
import { Calendar, MessageCircle, Activity, User } from "lucide-react-native";
export default function TabsLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: "#2c5878",
tabBarInactiveTintColor: "#76766c",
tabBarLabelStyle: { fontFamily: "InterMedium", fontSize: 11 },
tabBarStyle:
Platform.OS === "ios"
? { position: "absolute", borderTopWidth: 0 }
: { backgroundColor: "#f7f4ef" },
tabBarBackground: Platform.OS === "ios"
? () => <BlurView tint="light" intensity={80} style={{ flex: 1 }} />
: undefined,
}}
>
<Tabs.Screen
name="index"
options={{ title: "Today", tabBarIcon: ({ color }) => <Calendar size={22} color={color} /> }}
/>
<Tabs.Screen
name="ask"
options={{ title: "Ask", tabBarIcon: ({ color }) => <MessageCircle size={22} color={color} /> }}
/>
<Tabs.Screen
name="trends"
options={{ title: "Trends", tabBarIcon: ({ color }) => <Activity size={22} color={color} /> }}
/>
<Tabs.Screen
name="you"
options={{ title: "You", tabBarIcon: ({ color }) => <User size={22} color={color} /> }}
/>
</Tabs>
);
}A few details:
<Tabs>in Expo Router v6 renders native UITabBar on iOS by default. You don't need a special import; the framework detects it and switches behavior.- The frosted-glass effect comes from rendering an
<BlurView>insidetabBarBackground. On iOS this gets you the same translucent bar Mail and Messages use. - The active tint is the petrol-600 we defined in tokens (hex form here because the tab bar config can't reach into Tailwind).
3.6 — Gating UI inside a screen with <Show>
<Show when="signed-in"> and <Show when="signed-out"> are the
Core 3 replacement for <SignedIn> / <SignedOut>. Use them when
you want to flip a single piece of content in place rather than
redirect.
import { Show } from "@clerk/expo";
import { Text, View } from "react-native";
export default function MaybeBanner() {
return (
<View>
<Show when="signed-out">
<Text>Sign in to ask Amy.</Text>
</Show>
<Show when="signed-in">
<Text>Welcome back.</Text>
</Show>
</View>
);
}If you have an existing app on the old API, npx @clerk/upgrade
does most of the rewrite for you.
3.7 — Confirm sign-in works
Reload the simulator. You should land on the AuthView sign-in screen. Sign in with a Clerk test email. The app should redirect to the tabs (which are still stubs, but they should render).
If you see a blank screen on first run: check the console for a
ClerkProviderwarning. The most common cause is the publishable key being undefined — the.envfile isn't loaded until you restart the Metro bundler, so killbun startand re-run it after editing.env.
Step 4 — Drop in @amy/sdk + TanStack Query
The SDK is already a workspace dep from Step 1. What we need to do in this step is make it Clerk-aware and wrap it in TanStack Query so the data layer is uniform across screens.
4.1 — The useAmy() hook
apps/mobile/lib/amy.ts:
import { useMemo } from "react";
import { useAuth } from "@clerk/expo";
import { Amy } from "@amy/sdk";
const BASE_URL =
process.env.EXPO_PUBLIC_AMY_BASE_URL ?? "https://amy.heyamy.xyz";
/**
* The Amy client, bound to the current Clerk session. The SDK
* calls `apiKeyProvider` once per request, which forwards to
* Clerk's `getToken()` — Clerk caches and refreshes the JWT under
* the hood, so we get a fresh token without writing any caching
* code of our own.
*
* Memoized on `getToken` so the client reference is stable across
* renders. TanStack Query keys can use it safely.
*/
export function useAmy(): Amy {
const { getToken } = useAuth({ treatPendingAsSignedOut: false });
return useMemo(
() =>
new Amy({
baseUrl: BASE_URL,
apiKeyProvider: async () => {
try {
// Returning null/undefined throws inside the SDK with
// a useful message — treat that as a Clerk session
// expiry and let the AppLayout redirect to /sign-in.
return await getToken();
} catch (err) {
// ClerkOfflineError (3.1+) — throws after ~15s when
// the network is unreachable. Bubble it up; the chat
// hook will map it to a "you're offline" toast.
throw err;
}
},
}),
[getToken],
);
}Two non-obvious bits:
treatPendingAsSignedOut: falseis the recommended option when you also use@clerk/expo/nativecomponents (Clerk's docs mention this explicitly). It keeps the native SDK and the React hook in sync during the pending state right after sign-in.getToken()can throwClerkOfflineErrorin v3.1+ — previously it returnednull. Don't swallow the error here; the chat hook needs to know the difference between "offline" and "your session expired."
4.2 — TanStack Query provider
cd apps/mobile
bun add @tanstack/react-queryapps/mobile/lib/query.tsx:
import { useState } from "react";
import { QueryClient, QueryClientProvider, focusManager } from "@tanstack/react-query";
import { AppState, type AppStateStatus, Platform } from "react-native";
import { useEffect } from "react";
import { AmyApiError } from "@amy/sdk";
function onAppStateChange(status: AppStateStatus) {
// Web has its own focus event; on RN we drive Query's focusManager.
if (Platform.OS !== "web") {
focusManager.setFocused(status === "active");
}
}
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 5 * 60_000,
retry: (failureCount, error) => {
if (error instanceof AmyApiError) {
if (error.code === "invalid_token") return false;
if (error.code === "forbidden") return false;
if (error.code === "not_found") return false;
}
return failureCount < 2;
},
},
mutations: {
retry: false,
},
},
}),
);
useEffect(() => {
const sub = AppState.addEventListener("change", onAppStateChange);
return () => sub.remove();
}, []);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}The retry policy mirrors what we want at the UI level: don't retry auth failures (the user has to sign in again); do retry network blips up to twice.
4.3 — Path aliases
To write @/lib/amy instead of ../../lib/amy, edit
apps/mobile/tsconfig.json:
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./*"]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts"
]
}Add the matching Babel resolver:
bun add -d babel-plugin-module-resolverUpdate babel.config.js:
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
plugins: [
[
"module-resolver",
{
alias: { "@": "./" },
extensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
},
],
"react-native-reanimated/plugin", // must be last
],
};
};4.4 — Quick test: useMe()
apps/mobile/lib/queries.ts:
import { useQuery } from "@tanstack/react-query";
import { useAmy } from "@/lib/amy";
export function useMe() {
const amy = useAmy();
return useQuery({
queryKey: ["me"],
queryFn: () => amy.me.get(),
});
}Drop a temporary read in the You tab to confirm it round-trips:
// apps/mobile/app/(app)/(tabs)/you.tsx — temporary
import { Text, View } from "react-native";
import { useMe } from "@/lib/queries";
export default function You() {
const me = useMe();
return (
<View className="flex-1 bg-paper p-6">
<Text className="text-ink-900">{me.data?.email ?? "loading…"}</Text>
</View>
);
}Reload. If your Clerk email shows up under "You", the SDK + Clerk + Query chain is wired and you can move on.
If you see
Amy: apiKeyProvider returned null/undefined: the Clerk session isn't loaded yet. The AppLayout already gates this — make sure you're callinguseMe()only inside(app), where the layout has verifiedisSignedIn === true.
Step 5 — Build the chat with streaming phases
This is the screen that defines the app. Everything else is a supporting cast member. We're going to spend more space on it than on any other step.
The shape of what we're building:
- A FlashList of bubbles. User on the right, Amy on the left.
- Amy's bubbles are journal-like: serif heading "Amy" with a petrol rule under it, then body text.
- While a turn is running, Amy's currently-streaming bubble
renders a stack of phase pills ("Routing", "Data Science",
"Validator: gating ds-avg-rhr-30d") that fade in with Reanimated
layout animations, plus an italicized typewriter strip below
fed by
synthesis_deltaevents. - On
turn.completed, the pills + typewriter cross-fade out, and the canonicalresult.answercross-fades in with a subtle scale (0.98 → 1). - The composer at the bottom is the petrol-bordered input. While a turn is streaming it's disabled and shows a Stop button instead of Send.
5.1 — Message types
apps/mobile/lib/chat-types.ts:
import type { TurnResult } from "@amy/contracts";
export type Phase = {
/** Stable id used by Reanimated for layout transitions. */
key: string;
agent: string;
phase: string;
detail?: string;
};
export type UserMessage = {
role: "user";
id: string;
content: string;
createdAt: number;
};
export type AmyMessage = {
role: "amy";
id: string;
/** Set while the turn is running. Cleared on completion. */
streaming?: {
turnId: string;
phases: Phase[];
typewriter: string;
};
/** Final answer. Always set if `streaming` is undefined. */
result?: TurnResult;
/** Set if the turn failed. */
error?: { code?: string; message: string };
createdAt: number;
};
export type ChatMessage = UserMessage | AmyMessage;5.2 — The useChat() hook
This is the workhorse. It owns the message array, kicks off turns, subscribes to the stream, and translates events into UI state.
apps/mobile/lib/use-chat.ts:
import { useCallback, useRef, useState, useEffect } from "react";
import * as Haptics from "expo-haptics";
import { AmyApiError, type StreamEvent } from "@amy/sdk";
import { useAmy } from "@/lib/amy";
import type { AmyMessage, ChatMessage, Phase, UserMessage } from "@/lib/chat-types";
function uid() {
return Math.random().toString(36).slice(2, 10);
}
export function useChat() {
const amy = useAmy();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [pending, setPending] = useState(false);
const abortRef = useRef<AbortController | null>(null);
// Cancel any live stream when the screen unmounts.
useEffect(() => () => abortRef.current?.abort(), []);
const stop = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setPending(false);
}, []);
const send = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed || pending) return;
const userMessage: UserMessage = {
role: "user",
id: uid(),
content: trimmed,
createdAt: Date.now(),
};
const placeholder: AmyMessage = {
role: "amy",
id: uid(),
streaming: { turnId: "", phases: [], typewriter: "" },
createdAt: Date.now(),
};
setMessages((prev) => [...prev, userMessage, placeholder]);
setPending(true);
Haptics.selectionAsync().catch(() => {});
const controller = new AbortController();
abortRef.current = controller;
try {
const turn = await amy.turns.create({
messages: [
{ role: "user", content: trimmed },
],
});
// Patch the placeholder with the turn id so the UI can show
// it under "request id" if needed.
setMessages((prev) =>
prev.map((m) =>
m.id === placeholder.id && m.role === "amy" && m.streaming
? { ...m, streaming: { ...m.streaming, turnId: turn.id } }
: m,
),
);
for await (const ev of amy.turns.stream(turn.id, { signal: controller.signal })) {
applyEvent(placeholder.id, ev, setMessages);
if (ev.type === "turn.completed" || ev.type === "turn.failed") break;
}
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
} catch (err) {
const code =
err instanceof AmyApiError ? err.code : "network_error";
const message =
err instanceof Error ? err.message : "Something went wrong.";
setMessages((prev) =>
prev.map((m) =>
m.id === placeholder.id && m.role === "amy"
? { ...m, streaming: undefined, error: { code, message } }
: m,
),
);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
} finally {
setPending(false);
abortRef.current = null;
}
},
[amy, pending],
);
return { messages, pending, send, stop };
}
function applyEvent(
messageId: string,
ev: StreamEvent,
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>,
) {
setMessages((prev) =>
prev.map((m) => {
if (m.id !== messageId || m.role !== "amy") return m;
const s = m.streaming;
if (!s) return m;
switch (ev.type) {
case "phase": {
const data = ev.data as { agent: string; phase: string; detail?: string };
const key = `${data.agent}:${data.phase}:${s.phases.length}`;
const phase: Phase = {
key,
agent: data.agent,
phase: data.phase,
detail: data.detail,
};
return { ...m, streaming: { ...s, phases: [...s.phases, phase] } };
}
case "synthesis_delta": {
// The token stream of the user-facing answer (step 8 of the
// pipeline). Per-agent reasoning is *not* streamed at the
// token level — agents emit `agent_start` / `agent_end`
// pairs instead. See concepts/streaming for the catalog.
const data = ev.data as { text: string };
return {
...m,
streaming: { ...s, typewriter: s.typewriter + data.text },
};
}
case "turn.completed": {
const data = ev.data as { result: AmyMessage["result"] };
return { ...m, streaming: undefined, result: data.result };
}
case "turn.failed": {
const data = ev.data as { error?: { code?: string; message?: string } };
return {
...m,
streaming: undefined,
error: {
code: data.error?.code,
message: data.error?.message ?? "Turn failed.",
},
};
}
default:
return m;
}
}),
);
}A few decisions worth marking:
- AbortController on send + on unmount. The SDK's iterator
accepts
AbortSignal; we wire one per send, cancel it on stop or unmount. Aborting only closes the SSE connection client-side — the turn keeps running on the server, andGET /v1/turns/:idwill still return the result. That's by design. - Single placeholder per send. We append the user message and
the Amy placeholder in one
setMessages, so React schedules a single render with both bubbles already in place. The streaming state then mutates inside the placeholder. - Only
synthesis_deltais streamed to the typewriter. Per-agent reasoning isn't streamed at the token level — agents emitagent_start/agent_endpairs (andphasemarkers in between) for the "who's working" line. The user-visible answer comes only fromsynthesis_delta.
5.3 — Bubble components
apps/mobile/components/Bubble.tsx:
import { View, Text } from "react-native";
import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
import type { ChatMessage } from "@/lib/chat-types";
export function Bubble({ message }: { message: ChatMessage }) {
if (message.role === "user") return <UserBubble message={message} />;
return <AmyBubble message={message} />;
}
function UserBubble({ message }: { message: Extract<ChatMessage, { role: "user" }> }) {
return (
<Animated.View
entering={FadeIn.duration(180)}
layout={LinearTransition.duration(200)}
className="self-end mb-6 ml-12"
>
<Text className="text-base text-petrol-600 text-right">
{message.content}
</Text>
</Animated.View>
);
}
function AmyBubble({ message }: { message: Extract<ChatMessage, { role: "amy" }> }) {
return (
<Animated.View
entering={FadeIn.duration(220)}
layout={LinearTransition.duration(220)}
className="self-stretch mb-8"
>
<View className="mb-2 flex-row items-center">
<Text className="font-serif text-base text-ink-900">Amy</Text>
<View className="ml-3 h-px flex-1 bg-petrol-200" />
</View>
{message.streaming ? (
<StreamingBody streaming={message.streaming} />
) : message.error ? (
<ErrorBody error={message.error} />
) : message.result ? (
<Animated.Text
entering={FadeIn.duration(280)}
className="font-serif text-lg leading-7 text-ink-900"
>
{message.result.answer}
</Animated.Text>
) : null}
</Animated.View>
);
}
function StreamingBody({ streaming }: { streaming: NonNullable<Extract<ChatMessage, { role: "amy" }>["streaming"]> }) {
return (
<View>
{streaming.phases.map((p) => (
<Animated.View
key={p.key}
entering={FadeIn.duration(220)}
exiting={FadeOut.duration(160)}
layout={LinearTransition.duration(220)}
className="mb-1 flex-row items-center"
>
<View className="mr-2 h-1 w-1 rounded-full bg-petrol-500" />
<Text className="text-sm text-ink-500">
<Text className="text-ink-700">{labelFor(p.agent)}</Text>
{p.phase ? ` · ${p.phase}` : ""}
{p.detail ? ` · ${p.detail}` : ""}
</Text>
</Animated.View>
))}
{streaming.typewriter.length > 0 && (
<Animated.Text
entering={FadeIn.duration(120)}
className="mt-3 font-serif text-lg italic leading-7 text-ink-700"
>
{streaming.typewriter}
</Animated.Text>
)}
</View>
);
}
function ErrorBody({ error }: { error: { code?: string; message: string } }) {
return (
<View className="rounded-lg bg-rose-50 px-3 py-2">
<Text className="text-sm text-rose-700">{error.message}</Text>
{error.code ? <Text className="mt-1 text-xs text-rose-500">{error.code}</Text> : null}
</View>
);
}
function labelFor(agent: string) {
switch (agent) {
case "orchestrator": return "Routing";
case "data_science": return "Data Science";
case "domain_expert": return "Domain Expert";
case "health_coach": return "Health Coach";
case "investigator": return "Investigator";
case "validator": return "Validator";
case "synthesis": return "Synthesis";
default: return agent;
}
}The animations here are the whole show:
entering={FadeIn.duration(220)}on each phase pill makes it fade in without a config in app code.layout={LinearTransition.duration(220)}on the AmyBubble's outer wrapper means when phases pile in, the bubble grows smoothly downward — no jank.Animated.TextwithFadeInon the final answer gives the cross-fade that swaps phases for prose.
Reanimated 3 does all the heavy lifting on the UI thread.
5.4 — The composer
apps/mobile/components/Composer.tsx:
import { useState } from "react";
import { Pressable, TextInput, View } from "react-native";
import { Send, Square } from "lucide-react-native";
export function Composer({
onSend,
onStop,
pending,
}: {
onSend: (text: string) => void;
onStop: () => void;
pending: boolean;
}) {
const [text, setText] = useState("");
return (
<View className="border-t border-ink-200 bg-paper px-4 py-3">
<View className="flex-row items-end gap-2">
<TextInput
value={text}
onChangeText={setText}
editable={!pending}
placeholder="Ask Amy…"
placeholderTextColor="#a5a59a"
multiline
className="flex-1 rounded-2xl border border-ink-200 bg-white px-4 py-3 text-base text-ink-900"
style={{ maxHeight: 140 }}
/>
{pending ? (
<Pressable
onPress={onStop}
className="h-11 w-11 items-center justify-center rounded-full bg-ink-900"
>
<Square size={16} color="#f7f4ef" fill="#f7f4ef" />
</Pressable>
) : (
<Pressable
disabled={!text.trim()}
onPress={() => {
onSend(text);
setText("");
}}
className={`h-11 w-11 items-center justify-center rounded-full ${text.trim() ? "bg-petrol-600" : "bg-ink-300"}`}
>
<Send size={16} color="#f7f4ef" />
</Pressable>
)}
</View>
</View>
);
}We avoid Tailwind's disabled: variant because RN doesn't support
it consistently across components. Conditional className strings
are fine here — short enough, transparent enough.
5.5 — The Ask screen
apps/mobile/app/(app)/(tabs)/ask.tsx:
import { useCallback, useEffect, useRef } from "react";
import { KeyboardAvoidingView, Platform, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { FlashList, type FlashListRef } from "@shopify/flash-list";
import { useChat } from "@/lib/use-chat";
import { Bubble } from "@/components/Bubble";
import { Composer } from "@/components/Composer";
import type { ChatMessage } from "@/lib/chat-types";
export default function Ask() {
const { messages, pending, send, stop } = useChat();
const listRef = useRef<FlashListRef<ChatMessage> | null>(null);
useEffect(() => {
// Pin to the bottom whenever a new message lands.
if (messages.length > 0) {
listRef.current?.scrollToEnd({ animated: true });
}
}, [messages.length]);
const renderItem = useCallback(
({ item }: { item: ChatMessage }) => <Bubble message={item} />,
[],
);
return (
<SafeAreaView edges={["top"]} className="flex-1 bg-paper">
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
className="flex-1"
keyboardVerticalOffset={Platform.OS === "ios" ? 0 : 0}
>
<View className="flex-1">
<FlashList
ref={listRef}
data={messages}
keyExtractor={(m) => m.id}
renderItem={renderItem}
contentContainerStyle={{ padding: 20, paddingBottom: 24 }}
ListEmptyComponent={<Empty />}
/>
</View>
<Composer onSend={send} onStop={stop} pending={pending} />
</KeyboardAvoidingView>
</SafeAreaView>
);
}
function Empty() {
return (
<View className="mt-24 items-center">
<View className="px-8">
<View className="mb-4 h-px w-12 self-center bg-petrol-300" />
<View>
<Bubble
message={{
role: "amy",
id: "intro",
createdAt: 0,
result: {
answer:
"Hi. I'm reading from your wearables and labs. Ask me about a number you saw, or how something has trended this month. I'll show the work as I go.",
fact_sheet: [],
agents_used: [],
cost_usd: 0,
duration_ms: 0,
},
}}
/>
</View>
</View>
</View>
);
}We use FlashList because chat lists grow long; the default
FlatList drops frames after ~200 items. FlashList recycles cells
and is happy at 10k+.
5.6 — Verify the streaming feel
bun start → i. Sign in. Ask "What's my average HRV this
month?" or any other question your account has data for. You
should see:
- Your message slide in on the right.
- An empty "Amy" header with a thin petrol rule, immediately.
- Phase pills fade in one at a time:
Routing,Data Science,Validator: gating ds-avg-hrv-30d, etc. - Once
synthesis_deltaevents start arriving (after the agent pipeline finishes and synthesis begins), an italic strip materializes below the pills and fills with tokens. - On
turn.completed, the phase pills + typewriter fade out and the canonical answer fades + scales in.
If any of those don't happen, the most common culprit is the
Reanimated babel plugin — make sure it's the last entry in
babel.config.js's plugins array, and that you've fully
restarted Metro (bun start -c).
Step 6 — Sources and Labs
Both screens live under the "You" tab. We model them as their own routes so they push from the You hub instead of crowding it.
The Expo Router file layout:
app/(app)/(tabs)/
├── you.tsx # the hub
└── you/ # subroutes pushed from the hub
├── sources.tsx
└── labs.tsxexpo-router lets a tab also be a stack — the (tabs)/you.tsx
file is the entry, and you/*.tsx files are stack pushes from
it. Wire that with a layout file:
apps/mobile/app/(app)/(tabs)/you/_layout.tsx:
import { Stack } from "expo-router";
export default function YouStack() {
return <Stack screenOptions={{ headerShown: true }} />;
}6.1 — The Sources screen
bunx expo install expo-web-browserapps/mobile/lib/queries.ts — add:
export function useSources() {
const amy = useAmy();
return useQuery({
queryKey: ["sources"],
queryFn: () => amy.sources.list().then((r) => r.data),
});
}apps/mobile/app/(app)/(tabs)/you/sources.tsx:
import { useState } from "react";
import { ActivityIndicator, Alert, Pressable, ScrollView, Text, View } from "react-native";
import * as WebBrowser from "expo-web-browser";
import * as Haptics from "expo-haptics";
import { useQueryClient } from "@tanstack/react-query";
import { Plus, Watch } from "lucide-react-native";
import { useAmy } from "@/lib/amy";
import { useSources } from "@/lib/queries";
export default function Sources() {
const amy = useAmy();
const qc = useQueryClient();
const sources = useSources();
const [connecting, setConnecting] = useState(false);
const connect = async (provider?: string) => {
setConnecting(true);
Haptics.selectionAsync().catch(() => {});
try {
const { widget_url } = await amy.sources.terra.connect({
provider,
redirect_url: "amy-mobile://terra-callback",
});
const result = await WebBrowser.openAuthSessionAsync(
widget_url,
"amy-mobile://terra-callback",
);
if (result.type === "success" || result.type === "dismiss") {
await qc.invalidateQueries({ queryKey: ["sources"] });
await qc.invalidateQueries({ queryKey: ["me"] });
}
} catch (err) {
Alert.alert("Couldn't connect", (err as Error).message);
} finally {
setConnecting(false);
}
};
return (
<ScrollView className="flex-1 bg-paper">
<View className="px-6 pt-4 pb-2">
<Text className="font-serif text-3xl text-ink-900">Wearables</Text>
<Text className="mt-1 text-base text-ink-500">
Amy reads from these in real time. Connect or disconnect any time.
</Text>
</View>
<View className="mx-6 mt-6 border-t border-ink-200">
{sources.isLoading ? (
<View className="py-8 items-center">
<ActivityIndicator />
</View>
) : sources.data && sources.data.length > 0 ? (
sources.data.map((s) => (
<View
key={s.id}
className="flex-row items-center justify-between border-b border-ink-200 py-4"
>
<View className="flex-row items-center gap-3">
<Watch size={18} color="#2c5878" />
<View>
<Text className="text-base text-ink-900 capitalize">{s.provider}</Text>
<Text className="text-xs text-ink-500">
{s.status === "active" ? "Connected" : s.status}
{s.last_sync_at
? ` · last sync ${relative(s.last_sync_at)}`
: ""}
</Text>
</View>
</View>
<Pressable
onPress={() => disconnect(amy, qc, s.id)}
className="px-3 py-1"
>
<Text className="text-sm text-ink-500">Remove</Text>
</Pressable>
</View>
))
) : (
<Text className="py-6 text-ink-500">No wearables connected yet.</Text>
)}
</View>
<Pressable
disabled={connecting}
onPress={() => connect()}
className="mx-6 mt-8 flex-row items-center justify-center rounded-full bg-petrol-600 px-6 py-3.5"
>
<Plus size={16} color="#f7f4ef" />
<Text className="ml-2 text-base font-medium text-paper">
{connecting ? "Opening Terra…" : "Connect a wearable"}
</Text>
</Pressable>
</ScrollView>
);
}
async function disconnect(
amy: ReturnType<typeof useAmy>,
qc: ReturnType<typeof useQueryClient>,
id: string,
) {
await amy.sources.disconnect(id);
await qc.invalidateQueries({ queryKey: ["sources"] });
await qc.invalidateQueries({ queryKey: ["me"] });
}
function relative(iso: string) {
const ms = Date.now() - new Date(iso).getTime();
const m = Math.floor(ms / 60_000);
if (m < 1) return "moments ago";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
return `${d}d ago`;
}A note on the deep link: amy-mobile://terra-callback is the URL
Terra redirects back to when the user finishes connecting their
wearable. You need to declare the scheme in app.json:
{
"expo": {
"name": "Amy",
"slug": "amy-mobile",
"scheme": "amy-mobile"
}
}Reload after editing app.json for the scheme to take effect.
6.2 — The Labs screen
bunx expo install expo-document-pickerapps/mobile/lib/queries.ts — add:
export function useLabs() {
const amy = useAmy();
return useQuery({
queryKey: ["labs"],
queryFn: () => amy.labs.list().then((r) => r.uploads),
});
}apps/mobile/app/(app)/(tabs)/you/labs.tsx:
import { useState } from "react";
import { ActivityIndicator, Alert, Pressable, ScrollView, Text, View } from "react-native";
import * as DocumentPicker from "expo-document-picker";
import * as FileSystem from "expo-file-system";
import * as Haptics from "expo-haptics";
import { useQueryClient } from "@tanstack/react-query";
import { FileText, Plus } from "lucide-react-native";
import { useAmy } from "@/lib/amy";
import { useLabs } from "@/lib/queries";
export default function Labs() {
const amy = useAmy();
const qc = useQueryClient();
const labs = useLabs();
const [uploading, setUploading] = useState(false);
const upload = async () => {
setUploading(true);
Haptics.selectionAsync().catch(() => {});
try {
const picked = await DocumentPicker.getDocumentAsync({
// Backend accepts PDF + PNG + JPEG today; HEIC is not in the
// cloud's accepted-MIME list, so we either filter it out in the
// picker or convert before upload. Filter is simpler.
type: ["application/pdf", "image/jpeg", "image/png"],
copyToCacheDirectory: true,
multiple: false,
});
if (picked.canceled) return;
const asset = picked.assets[0];
// `await fetch(uri).then(r => r.blob())` works on iOS Simulator
// but is unreliable for large files on physical devices (OOMs at
// ~10 MB) and silently drops EXIF on HEIC. Read the file directly
// through expo-file-system instead.
const base64 = await FileSystem.readAsStringAsync(asset.uri, {
encoding: FileSystem.EncodingType.Base64,
});
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
const file = new Blob([bytes], {
type: asset.mimeType ?? "application/octet-stream",
});
await amy.labs.upload({ file, filename: asset.name });
await qc.invalidateQueries({ queryKey: ["labs"] });
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
} catch (err) {
Alert.alert("Upload failed", (err as Error).message);
} finally {
setUploading(false);
}
};
return (
<ScrollView className="flex-1 bg-paper">
<View className="px-6 pt-4 pb-2">
<Text className="font-serif text-3xl text-ink-900">Labs</Text>
<Text className="mt-1 text-base text-ink-500">
PDF or photo of a bloodwork panel. Amy parses biomarkers in ~30 seconds.
</Text>
</View>
<View className="mx-6 mt-6 border-t border-ink-200">
{labs.isLoading ? (
<View className="py-8 items-center">
<ActivityIndicator />
</View>
) : labs.data && labs.data.length > 0 ? (
labs.data.map((l) => (
<View
key={l.id}
className="flex-row items-center justify-between border-b border-ink-200 py-4"
>
<View className="flex-row items-center gap-3">
<FileText size={18} color="#2c5878" />
<View>
<Text className="text-base text-ink-900">{l.id}</Text>
<Text className="text-xs text-ink-500">
{l.parsed_at ? "Parsed" : l.terra_status}
{l.uploaded_at ? ` · uploaded ${shortDate(l.uploaded_at)}` : ""}
</Text>
</View>
</View>
</View>
))
) : (
<Text className="py-6 text-ink-500">No labs uploaded yet.</Text>
)}
</View>
<Pressable
disabled={uploading}
onPress={upload}
className="mx-6 mt-8 flex-row items-center justify-center rounded-full bg-petrol-600 px-6 py-3.5"
>
<Plus size={16} color="#f7f4ef" />
<Text className="ml-2 text-base font-medium text-paper">
{uploading ? "Uploading…" : "Upload a lab report"}
</Text>
</Pressable>
</ScrollView>
);
}
function shortDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}6.3 — The You hub
apps/mobile/app/(app)/(tabs)/you.tsx:
import { Pressable, ScrollView, Text, View } from "react-native";
import { router } from "expo-router";
import { ChevronRight, FileText, LogOut, Watch } from "lucide-react-native";
import { UserButton } from "@clerk/expo/native";
import { useClerk } from "@clerk/expo";
import { useMe, useSources, useLabs } from "@/lib/queries";
export default function You() {
const me = useMe();
const sources = useSources();
const labs = useLabs();
const { signOut } = useClerk();
return (
<ScrollView className="flex-1 bg-paper">
<View className="flex-row items-center px-6 pt-6 pb-4">
<UserButton style={{ width: 44, height: 44 }} />
<View className="ml-3 flex-1">
<Text className="font-serif text-xl text-ink-900">
{me.data?.email ?? "…"}
</Text>
<Text className="text-sm text-ink-500">
{me.data?.env ? `env: ${me.data.env}` : ""}
</Text>
</View>
</View>
<Section title="Wearables">
<Row
icon={<Watch size={18} color="#2c5878" />}
label="Connected sources"
value={sources.data ? `${sources.data.length}` : "…"}
onPress={() => router.push("/(app)/(tabs)/you/sources")}
/>
</Section>
<Section title="Labs">
<Row
icon={<FileText size={18} color="#2c5878" />}
label="Uploaded reports"
value={labs.data ? `${labs.data.length}` : "…"}
onPress={() => router.push("/(app)/(tabs)/you/labs")}
/>
</Section>
<Section title="Account">
<Row
icon={<LogOut size={18} color="#76766c" />}
label="Sign out"
onPress={async () => {
await signOut();
}}
/>
</Section>
</ScrollView>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<View className="px-6 mt-6">
<Text className="text-xs uppercase tracking-widest text-ink-500">{title}</Text>
<View className="mt-2 border-t border-ink-200">{children}</View>
</View>
);
}
function Row({
icon,
label,
value,
onPress,
}: {
icon: React.ReactNode;
label: string;
value?: string;
onPress?: () => void;
}) {
return (
<Pressable
onPress={onPress}
className="flex-row items-center justify-between border-b border-ink-200 py-3.5"
>
<View className="flex-row items-center gap-3">
{icon}
<Text className="text-base text-ink-900">{label}</Text>
</View>
<View className="flex-row items-center gap-2">
{value ? <Text className="text-sm text-ink-500">{value}</Text> : null}
<ChevronRight size={16} color="#a5a59a" />
</View>
</Pressable>
);
}UserButton from @clerk/expo/native gives us a circular avatar
that opens Clerk's user-profile sheet on tap — same UX Clerk
ships in its own marketing app. We don't need to build a profile
screen at all.
Step 7 — The Today tab
This is the screen that opens when the app cold-starts. It needs
to be instant, even offline, which is why we mirror
amy.data.sync() into MMKV and read from it on mount.
7.1 — Install MMKV
cd apps/mobile
bun add react-native-mmkv
bunx expo prebuildreact-native-mmkv ships native code, so it needs a custom dev
client — which is what expo prebuild generates. Run
expo run:ios once to build the new client; from then on
bun start reloads JS into it.
7.2 — The cache helper
apps/mobile/lib/cache.ts:
import { MMKV } from "react-native-mmkv";
import type { DataSyncResponse } from "@amy/sdk";
export const storage = new MMKV({ id: "amy" });
const KEYS = {
dataSync: "data_sync_v1",
dataSyncSince: "data_sync_since_v1",
} as const;
export function readCachedSync(): DataSyncResponse | null {
const raw = storage.getString(KEYS.dataSync);
if (!raw) return null;
try {
return JSON.parse(raw) as DataSyncResponse;
} catch {
return null;
}
}
export function writeCachedSync(data: DataSyncResponse) {
storage.set(KEYS.dataSync, JSON.stringify(data));
storage.set(KEYS.dataSyncSince, data.now);
}
export function readSyncSince(): string | null {
return storage.getString(KEYS.dataSyncSince) ?? null;
}7.3 — The useDataSync() hook
apps/mobile/lib/queries.ts — add:
import { readCachedSync, readSyncSince, writeCachedSync } from "@/lib/cache";
export function useDataSync() {
const amy = useAmy();
return useQuery({
queryKey: ["data_sync"],
queryFn: async () => {
const since = readSyncSince() ?? undefined;
const fresh = await amy.data.sync({ since });
writeCachedSync(fresh);
return fresh;
},
initialData: () => readCachedSync() ?? undefined,
staleTime: 60_000,
});
}The query reads from MMKV as initialData, so the Today tab
shows yesterday's numbers immediately while the fresh sync is in
flight. On success it writes back. If there's no cache, the tab
renders a quiet skeleton.
7.4 — The Today screen
apps/mobile/app/(app)/(tabs)/index.tsx:
import { useMemo } from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
import { router } from "expo-router";
import { useDataSync } from "@/lib/queries";
export default function Today() {
const sync = useDataSync();
const summary = useMemo(() => buildSummary(sync.data), [sync.data]);
return (
<ScrollView className="flex-1 bg-paper" contentContainerStyle={{ paddingBottom: 64 }}>
<View className="px-6 pt-12 pb-6">
<Text className="text-xs uppercase tracking-widest text-ink-500">
{today()}
</Text>
<Text className="mt-2 font-serif text-3xl leading-9 text-ink-900">
{summary.greeting}
</Text>
</View>
<View className="mx-6 border-t border-ink-200" />
<View className="px-6 pt-6">
{summary.lines.map((line, i) => (
<Text
key={i}
className="mb-4 font-serif text-lg leading-7 text-ink-900"
>
{line}
</Text>
))}
{sync.isLoading && summary.lines.length === 0 ? (
<Text className="text-base text-ink-500">Reading your data…</Text>
) : null}
</View>
<View className="mx-6 mt-8 border-t border-ink-200" />
<View className="mx-6 mt-8">
<Pressable
onPress={() => router.push("/(app)/(tabs)/ask")}
className="flex-row items-center justify-center rounded-full bg-petrol-600 px-6 py-3.5"
>
<Text className="text-base font-medium text-paper">Ask Amy →</Text>
</Pressable>
</View>
</ScrollView>
);
}
function today() {
return new Date().toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
});
}
function buildSummary(data: ReturnType<typeof useDataSync>["data"]) {
if (!data) return { greeting: "Good morning.", lines: [] };
const lines: string[] = [];
const lastDay = data.daily_summary[data.daily_summary.length - 1] as
| { recovery_score?: number; sleep_duration_minutes?: number; hrv_avg?: number }
| undefined;
if (lastDay?.sleep_duration_minutes) {
const h = Math.floor(lastDay.sleep_duration_minutes / 60);
const m = lastDay.sleep_duration_minutes % 60;
lines.push(`You slept ${h}h ${m}m last night.`);
}
if (typeof lastDay?.recovery_score === "number") {
lines.push(
lastDay.recovery_score >= 67
? `Recovery is ${Math.round(lastDay.recovery_score)} — strong. Push if you want.`
: lastDay.recovery_score >= 34
? `Recovery is ${Math.round(lastDay.recovery_score)} — middle of the road. Take it easy.`
: `Recovery is ${Math.round(lastDay.recovery_score)} — low. Rest day.`,
);
}
if (typeof lastDay?.hrv_avg === "number") {
lines.push(`HRV averaged ${Math.round(lastDay.hrv_avg)} ms.`);
}
return {
greeting:
lines.length > 0
? "Here's where you are."
: "Connect a wearable to start.",
lines,
};
}Two things worth pointing out:
- The summary is a function of the data, not a fixed template.
We re-derive it on every render via
useMemo; there's no side-effecting state. If Amy ships a new field tomorrow, you add a line here, not a migration. daily_summaryisRecord<string, unknown>[]in the SDK types because the schema is denormalized server-side. We cast to a narrow shape only at the call site, which keeps the contract honest while letting the UI be ergonomic.
Why the home screen isn't a dashboard. The temptation here is to pile on rings, sparklines, streaks. Don't. The whole point of Amy is that you ask a question and get a story. The home screen is the on-ramp to the story, not a competing surface. Three sentences > twelve metric tiles.
Step 8 — The Trends tab
A reverse-chronological list of every turn you've ever asked. Tapping one reads back the full answer. Useful for memory, useful for "what did Amy say about my last lab?"
8.1 — The list query
apps/mobile/lib/queries.ts:
export function useTurns(opts: { limit?: number } = {}) {
const amy = useAmy();
return useQuery({
queryKey: ["turns", opts.limit ?? 50],
queryFn: () =>
amy.turns.list({ limit: opts.limit ?? 50 }).then((r) => r.data),
});
}
export function useTurn(id: string | undefined) {
const amy = useAmy();
return useQuery({
queryKey: ["turn", id],
queryFn: () => amy.turns.retrieve(id!),
enabled: !!id,
});
}8.2 — The Trends screen
apps/mobile/app/(app)/(tabs)/trends.tsx:
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { FlashList } from "@shopify/flash-list";
import { router } from "expo-router";
import { useTurns } from "@/lib/queries";
export default function Trends() {
const turns = useTurns({ limit: 50 });
return (
<SafeAreaView edges={["top"]} className="flex-1 bg-paper">
<View className="px-6 pt-6 pb-4">
<Text className="font-serif text-3xl text-ink-900">Trends</Text>
<Text className="mt-1 text-base text-ink-500">
Every conversation, newest first.
</Text>
</View>
{turns.isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator />
</View>
) : (
<FlashList
data={turns.data ?? []}
keyExtractor={(t) => t.id}
renderItem={({ item }) => (
<Pressable
onPress={() => router.push(`/(app)/turn/${item.id}`)}
className="border-b border-ink-200 px-6 py-4"
>
<View className="flex-row items-center justify-between">
<Text className="text-xs uppercase tracking-widest text-ink-500">
{shortDate(item.created_at)}
</Text>
<Text className="text-xs text-ink-500">
{typeof item.duration_ms === "number"
? `${Math.round(item.duration_ms / 1000)}s`
: ""}
{typeof item.cost_usd === "number"
? ` · $${item.cost_usd.toFixed(2)}`
: ""}
</Text>
</View>
<Text className="mt-1.5 font-serif text-lg text-ink-900">
{item.user_message_preview}
</Text>
</Pressable>
)}
/>
)}
</SafeAreaView>
);
}
function shortDate(iso: string) {
const d = new Date(iso);
const ms = Date.now() - d.getTime();
if (ms < 24 * 3600_000) return "Today";
if (ms < 48 * 3600_000) return "Yesterday";
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}8.3 — The turn detail screen
apps/mobile/app/(app)/turn/[id].tsx:
import { ActivityIndicator, ScrollView, Text, View } from "react-native";
import { useLocalSearchParams, Stack } from "expo-router";
import { useTurn } from "@/lib/queries";
export default function TurnDetail() {
const { id } = useLocalSearchParams<{ id: string }>();
const turn = useTurn(id);
return (
<ScrollView className="flex-1 bg-paper" contentContainerStyle={{ paddingBottom: 64 }}>
<Stack.Screen options={{ title: "" }} />
{turn.isLoading ? (
<View className="py-12 items-center">
<ActivityIndicator />
</View>
) : turn.data ? (
<>
<View className="px-6 pt-6 pb-3">
<Text className="text-xs uppercase tracking-widest text-ink-500">
You asked
</Text>
<Text className="mt-2 font-serif text-2xl leading-8 text-ink-900">
{turn.data.messages.find((m) => m.role === "user")?.content}
</Text>
</View>
<View className="mx-6 border-t border-ink-200" />
<View className="px-6 pt-6">
<View className="mb-3 flex-row items-center">
<Text className="font-serif text-base text-ink-900">Amy</Text>
<View className="ml-3 h-px flex-1 bg-petrol-200" />
</View>
<Text className="font-serif text-lg leading-7 text-ink-900">
{turn.data.result?.answer ?? turn.data.error?.message ?? ""}
</Text>
</View>
{turn.data.result && turn.data.result.fact_sheet.length > 0 ? (
<View className="mx-6 mt-10">
<Text className="text-xs uppercase tracking-widest text-ink-500">
Receipts
</Text>
<View className="mt-2 border-t border-ink-200">
{turn.data.result.fact_sheet.map((c) => (
<View key={c.key} className="border-b border-ink-200 py-3">
<Text className="text-sm text-ink-900">
{c.value}
{c.unit ? ` ${c.unit}` : ""}
</Text>
<Text className="mt-0.5 text-xs text-ink-500">
{c.key}
{typeof c.n === "number" ? ` · n=${c.n}` : ""}
{c.window ? ` · ${c.window}` : ""}
</Text>
</View>
))}
</View>
</View>
) : null}
</>
) : (
<Text className="px-6 py-10 text-ink-500">Couldn't load the turn.</Text>
)}
</ScrollView>
);
}The "Receipts" section is what makes a turn re-readable. We're
showing the validated fact sheet that backed the answer — the same
provenance trail the CLI exposes via --show-fact-sheet. It's
small in the UI but communicates a lot: Amy's claims have numbers
behind them.
Step 9 — Polish
The chat works. The data loads. Now we cross the gap between "works on the simulator" and "feels like Amy."
9.1 — Haptics on every intent
We already wired Haptics.selectionAsync() in the chat send path.
Add it on the Today CTA and the Connect button:
import * as Haptics from "expo-haptics";
<Pressable
onPress={() => {
Haptics.selectionAsync();
router.push("/(app)/(tabs)/ask");
}}
…
>
Ask Amy →
</Pressable>Two heuristics:
selectionAsyncfor navigations and intentful taps (Send, Connect, Upload).notificationAsync(Success)for completions (turn done, upload parsed).notificationAsync(Error)for failures (turn failed, upload failed).
Never on scrolls, hovers, or default actions.
9.2 — Blur on the tab bar
We did this in Step 3.5, but the missing piece is the content inset. Because the tab bar is now translucent and positioned absolutely, FlashList content needs bottom padding so the last message isn't hidden:
<FlashList
data={messages}
contentContainerStyle={{ padding: 20, paddingBottom: 110 }}
…
/>The 110 is rough — it equals the tab bar height plus the safe-area
bottom. If you want precision, use
useBottomTabBarHeight() from @react-navigation/bottom-tabs.
9.3 — Reanimated entering / exiting
We've already used FadeIn, FadeOut, and LinearTransition. A
last layer that pays for itself: when the very first message in a
new chat appears, slide it up rather than fading in:
import Animated, { FadeInUp } from "react-native-reanimated";
<Animated.View entering={FadeInUp.duration(280).springify().damping(18)}>
…
</Animated.View>springify().damping(18) gives a subtle settle — not a bounce.
9.4 — Dark mode
The CSS variables in global.css already flip on
@media (prefers-color-scheme: dark). To force a theme manually
(useful for screenshots, otherwise leave it to the OS):
import { useColorScheme } from "react-native";
const scheme = useColorScheme(); // "light" | "dark" | nullThe dark: Tailwind variant works in NativeWind v4. Use it
sparingly — the whole point of CSS variables is that you write the
colors once. Reach for dark: only when you need a different
component in dark mode (e.g. swap an icon).
9.5 — Keyboard avoidance
KeyboardAvoidingView is already in the Ask screen. Set
keyboardVerticalOffset to the header height if you re-enable
headers. If the composer ever sits behind the keyboard, that's
the knob to turn.
9.6 — Status bar
<StatusBar style="auto" /> from expo-status-bar follows the
system. The Ask screen reads better with light-content over
dark backgrounds — but since we use a paper canvas, auto is
correct everywhere.
9.7 — Splash screen
apps/mobile/app.json:
{
"expo": {
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#f7f4ef"
}
}
}./assets/splash.png is a 1242×2436 PNG with the Amy mark
centered, on #f7f4ef. Quiet, not loud.
9.8 — Icon
{
"expo": {
"icon": "./assets/icon.png"
}
}./assets/icon.png is a 1024×1024 PNG. Match the docs-site
brand mark: rounded square in petrol-600, lowercase "a" in
paper-50.
Ship to TestFlight
Builds happen on Expo's cloud via EAS. From apps/mobile/:
# Once per project.
eas init
eas build:configure
# Build a TestFlight binary.
eas build --platform ios --profile production
# When the build finishes (~10 min on a free tier), submit it.
eas submit --platform ios --latestEAS handles signing, App Store Connect upload, and version bumping. You need an Apple Developer account ($99/yr) and an App Store Connect listing — but no Xcode build pipeline.
The first build takes ~10 min; subsequent builds use cache and are ~4 min. Submitting to TestFlight takes another ~15 min of review-bot processing on Apple's side before the build shows up for testers.
To roll out a JS-only change without a new binary, use EAS Update:
eas update --branch production --message "Tighten phase pill animation"That's how 90% of changes ship. Native code changes require a new build.
Long turns and the lock screen
Amy turns run 30 seconds to 7 minutes. iOS and Android both stop running your app's JS shortly after the screen locks or the user switches apps — Apple gives you about 30 seconds of grace before the SSE socket gets torn down. The turn keeps running on the server, but your streaming UI dies silently on the device.
Three strategies, pick based on how complete you want this to feel:
1. Keep the screen on while a turn is streaming
Smallest hammer. Tell iOS not to sleep while a turn is in flight.
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
// In the chat hook, around the stream loop:
await activateKeepAwakeAsync("amy-streaming");
try {
for await (const ev of amy.turns.stream(turn.id)) { /* … */ }
} finally {
deactivateKeepAwake("amy-streaming");
}This won't help if the user manually backgrounds the app — for that you need strategy 2 or 3. Battery cost is real; only activate during an actual stream, not "while the chat tab is open."
2. Re-fetch on resume
If the app comes back to the foreground mid-turn, throw away the stream you lost and just fetch the canonical result:
import { AppState } from "react-native";
useEffect(() => {
const sub = AppState.addEventListener("change", async (state) => {
if (state !== "active") return;
for (const turnId of inflightTurnIds()) {
const turn = await amy.turns.retrieve(turnId);
if (turn.status === "completed") commitResult(turnId, turn.result);
if (turn.status === "failed") commitError(turnId, turn.error);
// If still running, optionally re-open the SSE stream with
// Last-Event-Id and resume.
}
});
return () => sub.remove();
}, [amy]);GET /v1/turns/:id is cheap (one D1 row). The chat UI snaps from
"streaming…" to the final answer without any of the intermediate
typewriter feel, which is acceptable for the resume-from-background
case.
3. Push notification on turn.completed
The completionist's path. The backend can push when a turn completes, your app handles the notification with a deep link back to the chat screen and re-fetches the result.
Status — backend support is on the roadmap, not shipped. The v1 backend does not deliver outgoing webhooks or push notifications; see Concepts: Webhooks for the current state ("outgoing webhooks are a designed seam, not built"). Until then, rely on strategies 1 and 2. When push lands, this section will get a worked example with
expo-notifications, the APNs key flow, and theexpo-routerdeep-link route to open the in-progress turn.
In the meantime, do not rely on the SSE stream surviving backgrounding. If you demo the app to someone and they swipe over to Messages mid-answer, expect the typewriter to freeze — and make sure strategy 2 is wired so the answer reappears on resume.
Common problems and fixes
Module not found: @clerk/clerk-expo
You followed an older tutorial. The package was renamed to
@clerk/expo in March 2026. Reinstall:
bunx expo install @clerk/expo
bun remove @clerk/clerk-expo # if presentThen search the codebase for @clerk/clerk-expo and rewrite the
imports.
<SignedIn> and <SignedOut> not exported from @clerk/expo
Same root cause as above — Clerk Core 3 unified them into <Show when="…">. Rewrite:
// Before
<SignedIn><Foo /></SignedIn>
<SignedOut><Bar /></SignedOut>
// After
<Show when="signed-in"><Foo /></Show>
<Show when="signed-out"><Bar /></Show>The auto-migrator npx @clerk/upgrade does most of this for you.
getToken() throws ClerkOfflineError after 15s
This is intentional behavior in Clerk 3.1+: when the device is
offline, getToken() waits ~15s for a network response and then
throws ClerkOfflineError. Previously it returned null.
Handle it in the SDK's apiKeyProvider:
new Amy({
apiKeyProvider: async () => {
try {
return await getToken();
} catch (err) {
// Surface "you're offline" in the UI rather than swallowing.
throw err;
}
},
});Map it in the chat hook:
catch (err) {
if (err instanceof Error && err.name === "ClerkOfflineError") {
setMessages((prev) => /* show offline banner on the placeholder */);
return;
}
throw err;
}concurrency_limit_exceeded on send
Each user can have at most 20 turns in flight at once. If you fire
the 21st POST /v1/turns, the SDK throws AmyApiError with
code === "concurrency_limit_exceeded". Render a soft toast and
let it resolve:
if (err instanceof AmyApiError && err.code === "concurrency_limit_exceeded") {
toast("Amy's still thinking about your last question. One moment…");
return;
}The fix is to either wait for a turn to finish, or design the UI
so the user can't send while a turn is streaming (we already
disable the input in Composer).
code: "invalid_token" on every call
The Clerk session has expired or been invalidated server-side. Kick the user back to sign-in:
import { useClerk } from "@clerk/expo";
const { signOut } = useClerk();
if (err instanceof AmyApiError && err.code === "invalid_token") {
await signOut();
}The AppLayout's redirect will pick up the change and route to
/sign-in.
network_error / timeout
Transient. Show a retry button on the failed message:
{message.error?.code === "network_error" || message.error?.code === "timeout" ? (
<Pressable onPress={() => send(originalText)}>
<Text>Retry</Text>
</Pressable>
) : null}(Stash originalText per failed message in the message object so
retry has the question.)
MMKV crashes in Expo Go
Right — MMKV ships native code; it doesn't work in Expo Go. Run
expo run:ios once to build a custom dev client, then
bun start to reload JS into it. Expo Go is fine for hello-world
but not for this stack.
AuthView is blank / freezes on first sign-in
Three likely causes:
publishableKeyisundefined. Check.envis loaded; kill and re-runbun startafter editing it.tokenCacheisn't passed to<ClerkProvider>. Without it, sign-in succeeds but the session disappears on next launch.- You're using
@clerk/clerk-expo(the old package). AuthView doesn't exist there.
NativeWind classes don't apply
The Babel and Metro configs both need to be wired (Step 2.4). The
most common ordering mistake: nativewind/babel must come
after babel-preset-expo, and the Reanimated plugin must be
last. After fixing, clear the cache:
bun start -cReanimated: "Reanimated 2 failed to create a worklet"
Same root cause — Babel plugin order. The Reanimated plugin must be the last entry. Also: never enable Hermes off (we ship with Hermes by default in Expo SDK 56).
Metro can't find @amy/sdk
The workspace symlink didn't resolve. From the repo root:
bun install
ls apps/mobile/node_modules/@amy/sdk # should be a symlinkIf it's missing, you forgot to add "apps/*" to the workspaces
glob (Step 1.1). Add it and re-install.
Mismatched React / RN versions across workspaces
Bun hoists by default, which is what we want — one copy of React.
If you ever see two copies (react-native renderer mismatch),
add resolutions to the root package.json:
{
"resolutions": {
"react": "19.2.0",
"react-native": "0.85.0"
}
}Stream stops after ~30 seconds
The SDK does not auto-reconnect in v1 — when a stream drops it
surfaces a single error and stops. Reconnecting is your job: track the
last ev.id you saw and pass it back as lastEventId on retry, with
your own backoff (the server replays from there for up to 1 hour after
completion).
let lastId: string | number | null = null;
for await (const ev of amy.turns.stream(turn.id, {
lastEventId: lastId,
signal: controller.signal,
})) {
lastId = ev.id ?? lastId; // remember it so a retry can resume
console.log("ev", ev.type, ev.id);
}See Recipe: Stream events for the full reconnect-with-backoff loop.
"Idempotency key mismatch"
You retried a POST /v1/turns with the same idempotency key but
a different body. The SDK auto-generates a UUIDv4 per request, so
you shouldn't hit this unless you're passing your own
idempotencyKey. If you are, vary it per attempt.
Phase pills don't animate in
Reanimated layout transitions need <Animated.View>, not plain
<View>. Make sure each pill is wrapped in Animated.View with
layout={LinearTransition.duration(...)}. Also: LinearTransition
needs the parent to be Animated.View too — that's why the bubble
itself is animated.
"TextInput becomes laggy in the chat"
You're re-rendering the entire FlashList on every keystroke. Make
sure the composer's text state lives inside Composer, not
in Ask. Same logic for any heavy parent.
Tab bar covers the last message
Set paddingBottom: 110 on the FlashList's
contentContainerStyle. Or use useBottomTabBarHeight() for the
exact value.
expo prebuild keeps re-running
By default, expo prebuild --clean regenerates the iOS/Android
folders. We treat those as gitignored generated files. If you
want to lock native customizations, prebuild once and commit the
result — but you lose the upgrade ergonomics.
App immediately signs out on cold start
tokenCache isn't wired. Re-check Step 3.3 — the
<ClerkProvider tokenCache={tokenCache}> line is the one that
makes the session survive a relaunch.
What you've built
┌──────────────────────────────────────────────────────────┐
│ apps/mobile/ │
│ ├ Expo SDK 56 · React Native 0.85 · TypeScript │
│ ├ Expo Router v6 (native tabs) │
│ ├ Clerk Core 3 (@clerk/expo + AuthView + UserButton) │
│ ├ NativeWind v4 · Reanimated 3 · TanStack Query v5 │
│ ├ Zustand · MMKV · FlashList · expo-blur · haptics │
│ └ @amy/sdk · @amy/contracts (workspace:*) │
└────────────────────────────┬─────────────────────────────┘
│ HTTPS · Bearer Clerk JWT
▼
┌──────────────────────────────────────────────────────────┐
│ amy.heyamy.xyz — multi-agent backend on Cloudflare │
│ (the same one the CLI talks to) │
└──────────────────────────────────────────────────────────┘The mobile app is a thin, polished surface over the Amy backend. Same as the CLI, same as the web app. The agent doesn't run on the phone; the phone shows what the agent thinks, faithfully and live.
That decoupling — three surfaces, one typed contract, one set of agents — is the architecture decision that lets us ship a polished mobile experience in two hours instead of two months.
Where to next
- The full event catalog and resume protocol: Recipe · Stream events.
- Architecture and the contract the three surfaces share: Architecture.
- The CLI this mobile app pairs with: Concepts · Turns.
- Adding a wearable Terra doesn't support yet: Recipe · Add a new adapter.
- The TypeScript SDK reference: SDK · TypeScript.
Upload a lab report
Goal: let a user upload a PDF (or phone photo) of their bloodwork, wait for it to be OCR'd and parsed into structured biomarkers, and surface "out of range" markers in your UI.
Build a web app
A working web client for Amy. Next.js or Vite, the same API as the CLI, streaming SSE wired in so the user watches Amy think in real time.