Amy
Recipes

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 start

Everything 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  You

Two notes on the design taste, because this is the part that takes willpower to keep:

  1. 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-200 instead of borders.

  2. Phase events instead of typing dots. While a turn is running, the backend emits phase events (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.

ToolWhat it isHow to get it
Bun ≥ 1.xThe package manager and runtime this repo usescurl -fsSL https://bun.sh/install | bash
Xcode ≥ 16iOS simulator + native build chainMac App Store; then xcode-select --install
EAS CLIBuilds and submits the iOS binarybun add -g eas-cli
A Clerk projectHosts the user database & sign-in UIFree dev project; copy the publishable key
The Amy repoThis recipe edits files in itYou 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.0

The 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.

LayerPickWhy
RuntimeExpo SDK 56 (RN 0.85, React 19.2)Latest stable as of May 2026; expo-router v6 ships in-box.
RoutingExpo Router v6File-based routing; native UITabBar on iOS for free; deep links and universal links handled.
AuthClerk Core 3 via @clerk/expoSame Clerk app the backend already trusts. apiKeyProvider: () => getToken() plugs into the Amy SDK with zero glue.
StylingNativeWind v4Tailwind for RN; supports CSS variables, dark mode, and a real className prop. Lets us share the docs-site palette token names verbatim.
AnimationsReanimated 3 + react-native-gesture-handlerLayout animations are what make the streaming phase list feel alive without a single timing function in app code.
Server stateTanStack Query v5The Amy SDK is request-response under the hood; Query handles caching, retry, and stale-while-revalidate. Streams bypass Query entirely.
UI stateZustandTiny store for chat draft text, sheet open/closed, theme override. Avoids the React Context circus.
Local cachereact-native-mmkvSync, fast, key-value. We mirror amy.data.sync() here so the Today tab opens instantly even offline.
Lists@shopify/flash-listThe chat list grows long; FlashList is the only RN list that survives 1000+ messages without dropping frames.
Imagesexpo-imageLazy, cached, animated; better defaults than <Image>.
Hapticsexpo-hapticsOne line per interaction. Required to make the app feel native.
Blurexpo-blurThe tab bar's frosted background.
Iconslucide-react-nativeStroke icons; consistent with the docs-site & CLI.
TypographyInter Variable + a serifUI in Inter, Amy's prose in a soft serif (Source Serif 4 or system serif).
Web viewexpo-web-browserFor the Terra OAuth widget.
File pickerexpo-document-pickerFor 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 default

Pick 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/expo@3.x (Core 3) ships support for Expo SDK 56 (RN 0.85); install the current @clerk/expo and its native components (AuthView, UserButton) work on this set. If you ever pin an older Clerk that warns about the peer range, run bunx expo install @clerk/expo to 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 --check

bunx 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 start

Press 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:ios builds your own bundle once, and from then on bun start reloads 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-tailwindcss

Reanimated 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-handler

2.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:

  1. nativewind/babel must come after babel-preset-expo.
  2. react-native-reanimated/plugin must be the last plugin in the list. Out-of-order plugins are the #1 reason useSharedValue returns undefined at 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-screen

We 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-store

The 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.xyz

apps/mobile/.env.example:

EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=
EXPO_PUBLIC_AMY_BASE_URL=https://amy.heyamy.xyz

Make 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 appWhat to set EXPO_PUBLIC_AMY_BASE_URL to
iOS Simulator on the same Mac as wrangler devhttp://localhost:8787
Android Emulator on the same Machttp://10.0.2.2:8787 (Android's loopback alias to the host)
A real iPhone / Android on the same Wi-Fihttp://<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:

  • tokenCache is 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 over expo-secure-store that handles Clerk's session JWT storage.
  • publishableKey is 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 .env is 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      # You

apps/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> inside tabBarBackground. 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 ClerkProvider warning. The most common cause is the publishable key being undefined — the .env file isn't loaded until you restart the Metro bundler, so kill bun start and 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: false is the recommended option when you also use @clerk/expo/native components (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 throw ClerkOfflineError in v3.1+ — previously it returned null. 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-query

apps/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-resolver

Update 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 calling useMe() only inside (app), where the layout has verified isSignedIn === 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_delta events.
  • On turn.completed, the pills + typewriter cross-fade out, and the canonical result.answer cross-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, and GET /v1/turns/:id will 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_delta is streamed to the typewriter. Per-agent reasoning isn't streamed at the token level — agents emit agent_start / agent_end pairs (and phase markers in between) for the "who's working" line. The user-visible answer comes only from synthesis_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.Text with FadeIn on 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 starti. Sign in. Ask "What's my average HRV this month?" or any other question your account has data for. You should see:

  1. Your message slide in on the right.
  2. An empty "Amy" header with a thin petrol rule, immediately.
  3. Phase pills fade in one at a time: Routing, Data Science, Validator: gating ds-avg-hrv-30d, etc.
  4. Once synthesis_delta events start arriving (after the agent pipeline finishes and synthesis begins), an italic strip materializes below the pills and fills with tokens.
  5. 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.tsx

expo-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-browser

apps/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-picker

apps/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 prebuild

react-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_summary is Record<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.


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,
  });
}

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:

  • selectionAsync for 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" | null

The 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 --latest

EAS 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 the expo-router deep-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 present

Then 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:

  1. publishableKey is undefined. Check .env is loaded; kill and re-run bun start after editing it.
  2. tokenCache isn't passed to <ClerkProvider>. Without it, sign-in succeeds but the session disappears on next launch.
  3. 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 -c

Reanimated: "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 symlink

If 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

On this page

What you'll buildPrerequisitesThe stack — and why each pieceStep 1 — Scaffold the app inside the monorepo1.1 — Make apps/ a workspace root1.2 — Generate the Expo project1.3 — Wire the workspace dependencies1.4 — Configure Metro for the monorepo1.5 — Confirm the scaffoldStep 2 — NativeWind v4 + design tokens2.1 — Install2.2 — Tailwind config2.3 — Global styles2.4 — Babel + Metro transforms2.5 — TypeScript typings2.6 — Fonts2.7 — Smoke testStep 3 — Wire Clerk3.1 — Install3.2 — EnvironmentPointing at a local backend3.3 — Root layout: ClerkProvider3.4 — Split routes by auth state3.5 — The tab bar (native iOS tabs)3.6 — Gating UI inside a screen with <Show>3.7 — Confirm sign-in worksStep 4 — Drop in @amy/sdk + TanStack Query4.1 — The useAmy() hook4.2 — TanStack Query provider4.3 — Path aliases4.4 — Quick test: useMe()Step 5 — Build the chat with streaming phases5.1 — Message types5.2 — The useChat() hook5.3 — Bubble components5.4 — The composer5.5 — The Ask screen5.6 — Verify the streaming feelStep 6 — Sources and Labs6.1 — The Sources screen6.2 — The Labs screen6.3 — The You hubStep 7 — The Today tab7.1 — Install MMKV7.2 — The cache helper7.3 — The useDataSync() hook7.4 — The Today screenStep 8 — The Trends tab8.1 — The list query8.2 — The Trends screen8.3 — The turn detail screenStep 9 — Polish9.1 — Haptics on every intent9.2 — Blur on the tab bar9.3 — Reanimated entering / exiting9.4 — Dark mode9.5 — Keyboard avoidance9.6 — Status bar9.7 — Splash screen9.8 — IconShip to TestFlightLong turns and the lock screen1. Keep the screen on while a turn is streaming2. Re-fetch on resume3. Push notification on turn.completedCommon problems and fixesModule not found: @clerk/clerk-expo<SignedIn> and <SignedOut> not exported from @clerk/expogetToken() throws ClerkOfflineError after 15sconcurrency_limit_exceeded on sendcode: "invalid_token" on every callnetwork_error / timeoutMMKV crashes in Expo GoAuthView is blank / freezes on first sign-inNativeWind classes don't applyReanimated: "Reanimated 2 failed to create a worklet"Metro can't find @amy/sdkMismatched React / RN versions across workspacesStream stops after ~30 seconds"Idempotency key mismatch"Phase pills don't animate in"TextInput becomes laggy in the chat"Tab bar covers the last messageexpo prebuild keeps re-runningApp immediately signs out on cold startWhat you've builtWhere to next