A polished AI voice-notes app. Records audio, transcribes it, runs an LLM pass for summary + task extraction, gates power features behind a paywall, and reports errors. No application backend — all user data (notes, audio, tasks) lives locally on device. The only server-side code is a thin set of Expo Router API routes that proxy AI provider calls so secret keys never ship in the JS bundle. They're stateless and store nothing.
The build is split into independent stages so each one can be shipped on its own.
Tagline: Speak it. We'll write it down, summarize it, and pull out your tasks.
Core flows
- Sign in with Clerk (email + Apple/Google OAuth via the new native sign-in components).
- Tap record → speak → tap stop.
- App transcribes the audio via ElevenLabs Scribe.
- App runs an LLM pass to produce: title, 3-bullet summary, tasks (checklist), and tags.
- Note is saved locally and listed on the home screen.
- Free tier: 3 minutes/day, 1 summary/day. Pro (RevenueCat): unlimited minutes, smart summaries, export to Markdown / share sheet.
- Errors and slow transcriptions are reported to Sentry with user + session context.
Out of scope (intentionally): stateful backend (no DB, no user-data persistence on the server), multi-device sync of audio, collaboration, web app.
| Layer | Choice | Notes |
|---|---|---|
| App framework | Expo SDK 55 + Expo Router (already scaffolded) | File-based routing, typed routes |
| Language | TypeScript, React 19, RN 0.83 | |
| Auth | Clerk (@clerk/clerk-expo) |
New native sign-in components, OAuth (Apple, Google) |
| Audio recording | expo-audio |
Replaces deprecated expo-av |
| Server (key proxy) | Expo Router API routes + EAS Hosting | Stateless +api.ts handlers that proxy AI provider calls. No DB, no user data stored. Same repo, same deploy. |
| Transcription | ElevenLabs Scribe (/v1/speech-to-text) |
Default: proxied through /api/transcribe. If their SDK ships a safe public-key flow, swap to direct device call (TBD — see open questions). |
| LLM (summary + tasks) | OpenAI via the Vercel AI SDK (ai + @ai-sdk/openai) |
Runs server-side in /api/summarize+api.ts. generateObject against a Zod schema for structured output. Default model gpt-4o-mini. |
| Local DB | expo-sqlite + a thin Drizzle ORM layer | Typed queries, migrations |
| Object storage | expo-file-system |
Audio blobs in app sandbox |
| Paywall | RevenueCat (react-native-purchases + react-native-purchases-ui) |
Sandbox entitlement: pro |
| Observability | Sentry (@sentry/react-native) |
Errors, performance traces around record→transcribe→summarize |
| Styling | StyleSheet.create (RN built-in) |
Plus a small theme.ts for tokens (colors, spacing, type) |
| State | React Query for async, zustand for UI state |
Keep it small |
| Icons | expo-symbols (SF Symbols on iOS) |
Already a dep |
Configuration & secrets: all keys live in a top-level .env file (standard Expo workflow). Two clear tiers:
- Public, client-side keys —
EXPO_PUBLIC_*prefix. Inlined into the JS bundle at build time. Used by SDKs that are designed for in-bundle distribution (Clerk publishable key, RevenueCat SDK keys, Sentry DSN). - Private, server-only keys — no prefix. Read by Expo Router API routes via
process.env.*at request time. Never reach the device. Used for OpenAI, and (default) ElevenLabs.
.env is gitignored; .env.example ships in the repo with empty placeholders. EAS Hosting picks up the same env names from project secrets at deploy time.
| Var | Used by | Where it runs |
|---|---|---|
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY |
Clerk (Stage 2) | Client |
EXPO_PUBLIC_REVENUECAT_IOS_KEY / _ANDROID_KEY |
RevenueCat (Stage 7) | Client (RC public SDK keys) |
EXPO_PUBLIC_SENTRY_DSN |
Sentry (Stage 8) | Client |
EXPO_PUBLIC_API_BASE_URL |
Client → API routes | Client (points at EAS Hosting URL) |
OPENAI_API_KEY |
OpenAI / AI SDK (Stage 6) | Server only (/api/summarize) |
ELEVENLABS_API_KEY |
ElevenLabs (Stage 5, default) | Server only (/api/transcribe) |
CLERK_SECRET_KEY |
API-route auth check | Server only (@clerk/backend) |
SENTRY_AUTH_TOKEN |
Source-map upload at build time | Build-time only |
API-route auth: every API route requires a valid Clerk session token. The client sends Authorization: Bearer <token> (from useAuth().getToken()); the route verifies it with @clerk/backend before calling the upstream provider. Stops randos on the internet from burning the OpenAI key.
src/
app/ # expo-router
(auth)/ # unauthenticated stack
sign-in.tsx
sign-up.tsx
(app)/ # authenticated tabs
_layout.tsx # tab bar
index.tsx # Notes list
record.tsx # Record screen (modal-style)
settings.tsx
note/[id].tsx # Note detail
api/ # Expo Router API routes (server-only)
_utils/
auth.ts # verify Clerk Bearer token via @clerk/backend
transcribe+api.ts # POST audio → ElevenLabs Scribe
summarize+api.ts # POST transcript → OpenAI via AI SDK
_layout.tsx # ClerkProvider, RC, Sentry, QueryClient
components/
ui/ # buttons, cards, sheets
record/ # waveform, mic button, timer
notes/ # NoteCard, SummaryView, TaskList
paywall/ # PaywallSheet wrapper
features/
auth/ # clerk hooks/wrappers
audio/ # recorder service
transcription/ # client wrapper that POSTs audio to /api/transcribe
summarize/ # client wrapper that POSTs transcript to /api/summarize + shared zod schemas
notes/ # db schema, queries, sync
entitlements/ # RC hooks, gating helpers
telemetry/ # sentry init + spans
db/
schema.ts # drizzle schema
client.ts # sqlite + drizzle
migrations/
constants/
hooks/
// db/schema.ts (Drizzle)
notes {
id: text (uuid) pk
user_id: text // Clerk user id
title: text
created_at: int (ms)
updated_at: int (ms)
duration_ms: int
audio_uri: text // file:// in sandbox
transcript: text // raw STT output
summary: text | null // markdown
tags: text // json array
status: 'recorded' | 'transcribing' | 'summarizing' | 'ready' | 'error'
}
tasks {
id: text pk
note_id: text fk
text: text
done: int (0|1)
order: int
}Local only. Scoped by user_id so switching Clerk accounts gives you a clean list without wiping the DB.
Each stage is a shippable unit. The app should run end-to-end at the close of every stage.
Goal: clean slate to build on.
- Strip the Expo template (
explore.tsx, sample components). - Drop the existing
global.css— styling is plainStyleSheet.create. - Add a
src/constants/theme.tswith color, spacing, radius, and type tokens for both color schemes. - Add
zustand,@tanstack/react-query,drizzle-orm,expo-sqlite,zod. - Convert
app.json→app.config.ts. OnlyEXPO_PUBLIC_*keys reach the client (publishable Clerk key, RC SDK keys, Sentry DSN,EXPO_PUBLIC_API_BASE_URL). Server-only keys (OPENAI_API_KEY,ELEVENLABS_API_KEY,CLERK_SECRET_KEY) are read by API routes viaprocess.envat request time — never forwarded ontoextra, never reachable from the device. - Enable Expo Router server output: set
web.output: "server"(orexperiments.server: trueper the SDK 55 docs) inapp.config.tsso API routes are emitted at build time. - Add a stub
app/api/health+api.tsreturning{ ok: true }so we can verify the route runtime works on day one. - Commit
.env.examplewith the full key list from §2; add.envand.env.localto.gitignore(Expo already does this by default — verify). - Define
features/folder structure (empty stubs). - Replace README with a project overview.
Done when: bun start boots a blank tab layout with two empty tabs (Notes, Settings) styled from theme.ts via StyleSheet.
Goal: the visual skeleton for the rest of the app.
- Three-tab layout (Notes / Record / Settings) with a center FAB-style record tab.
- Empty states for Notes ("Your first recording is one tap away").
- Reusable primitives:
Button,Card,Sheet,Pressablewith haptics — each owns its ownStyleSheet, pulls values fromtheme.ts. - Light + dark mode via
useColorScheme, with auseTheme()hook returning the active token set. - Typography scale, spacing tokens, brand color all live in
theme.ts; no inline magic numbers in components.
Done when: the app is "demo-pretty" — you could screenshot it for the App Store with placeholder data.
Goal: real sign-in/sign-up, with Apple + Google OAuth, using Clerk's new native sign-in components.
- Install
@clerk/clerk-expo, wireClerkProviderin root_layout.tsxwithtokenCachefromexpo-secure-store. (auth)route group:sign-in.tsxandsign-up.tsxusing the new<SignIn />/<SignUp />native flows.- Apple OAuth via
expo-apple-authentication, Google viaexpo-auth-session. - Protect
(app)group with a redirect if!isSignedIn, use Expo Route guard - Settings screen: show user avatar, email, sign-out button using
useUser/useAuth. - Persist a
user_idforeign key on every DB row going forward.
Done when: sign in with Apple → land on empty Notes tab; sign out → kick back to sign-in.
Goal: notes live somewhere and persist across launches.
expo-sqlite+ Drizzle, schema from §4, generated migrations checked in.features/notes/exposesuseNotes(),useNote(id),createNote(),deleteNote(),updateNote()via React Query with optimistic updates.- Notes list with swipe-to-delete and pull-to-refresh.
- Note detail screen renders title, transcript (placeholder for now), and a tasks checklist.
- Seed script for dev (5 fake notes) behind
__DEV__.
Done when: you can tap "+ New (mock)" in Settings, see it appear instantly, kill the app, relaunch, and it's still there.
Goal: capture audio reliably and store it in the sandbox.
expo-audiorecorder with permissions request flow, including a friendly explainer screen.- Record screen: big mic button, live duration, animated waveform (Reanimated 4 worklet driven by metering).
- Pause/resume, cancel, save → writes to
${FileSystem.documentDirectory}recordings/<uuid>.m4a. - On save, insert a
notesrow withstatus: 'recorded',audio_uri,duration_ms. - Inline player on note detail screen.
Done when: record 10s, see it as a row on Notes with playable audio.
Goal: raw audio → text, with a state machine the user can see, secret keys staying on the server.
Pre-flight check: confirm whether ElevenLabs offers a safe public/scoped key for client-side STT (e.g. short-lived signed URLs or a browser-safe key like RC's). Two paths:
- Default (private key): route the call through
app/api/transcribe+api.ts. Client uploadsmultipart/form-datato/api/transcribe(with the user's Clerk Bearer token); the route verifies auth, then forwards toPOST https://api.elevenlabs.io/v1/speech-to-text(Scribe) usingprocess.env.ELEVENLABS_API_KEY. The route streams the response back so we don't buffer audio in memory twice. (See open question 6.) - If a safe public key exists: drop the proxy and call ElevenLabs directly from the device (latency win, one fewer hop). Keep the proxy code in a branch as the "production-shaped" reference.
Client work (same regardless of path):
features/transcription/client.ts: thin wrapper that takes a local audio URI, builds the multipart body, attaches the Clerk token, and POSTs toEXPO_PUBLIC_API_BASE_URL + '/api/transcribe'(or directly to ElevenLabs in the public-key variant).- After save in Stage 4, automatically transition the note
status: 'recorded' → 'transcribing' → 'ready'. - On the note detail screen: skeleton transcript while pending, real text when done, retry button on
error. - Pretty transcript view: paragraphs split on long pauses, timestamps in a side gutter (Scribe returns word-level timing).
- Free-tier minute counter (used in Stage 7's gating).
Done when: record → 5–10s later transcript appears in the note. Disconnect network mid-flight and the error → retry path works. Inspecting the JS bundle shows no ElevenLabs key.
Goal: the moment the app stops being a tape recorder. OpenAI key never leaves the server.
- Install
ai+@ai-sdk/openai+zod. - Shared schema (
features/summarize/schema.ts): ZodNoteSummarySchema = { title, summary: string[], tasks: { text: string }[], tags: string[] }. Imported by both the API route and the client so types match end to end. - Server (
app/api/summarize+api.ts):POSThandler. Verifies Clerk Bearer token via_utils/auth.ts.- Body:
{ transcript: string }. - Calls
generateObject({ model: openai('gpt-4o-mini'), schema: NoteSummarySchema, system, prompt: transcript })usingprocess.env.OPENAI_API_KEY. - Returns the validated object as JSON. AI SDK handles the structured-output coercion, so no hand-rolled JSON parsing on either side.
- Streaming variant (behind
?stream=1):streamObject+result.toTextStreamResponse()for a live-bullet UX.
- Client (
features/summarize/client.ts): tinyfetchwrapper that POSTs to/api/summarize, attaches the Clerk token, parses withNoteSummarySchema.parse()for defense in depth. - Trigger automatically on
status === 'ready'after transcription, OR manually on a free account via a "Summarize" button (gated by Stage 7). - Note detail layout becomes: header (title + tags) → summary bullets → tasks checklist (toggleable, persisted) → expandable raw transcript.
- Re-run summary action (with a confirmation if it would burn a free-tier credit).
Done when: new recording auto-produces a clean title, 3 bullets, and a checked-off-able task list within ~10s of stopping. Streaming variant works behind the flag.
Goal: monetize the smart features, with a real sandbox flow.
- Configure RC dashboard: products
pro_monthly,pro_yearly, entitlementpro, paywall configured in RC's no-code editor. react-native-purchasesinit with ClerkuserIdas the RC app user id (so entitlement follows the account).react-native-purchases-ui<RevenueCatUI.Paywall />presented as a modal sheet.- Gating helpers:
useIsPro(),<ProGate feature="summary">…</ProGate>. - Free tier limits: 3 min/day recording, 1 summary/day, no export. Counters live in SQLite, reset at local midnight.
- "Upgrade to Pro" entry points: Settings, Record screen when the cap is hit, Summary button when daily summary used.
- Restore purchases in Settings.
Done when: sandbox purchase flips useIsPro() to true, removes the limits, and persists across app restart and account sign-out/sign-in.
Goal: production-grade error + performance reporting.
@sentry/react-nativewith the Expo plugin, source maps uploaded via EAS hook (client).@sentry/node(or the Expo Router server integration if available) insideapp/api/_utils/sentry.tsso the API routes report too (server).- Set
usercontext from Clerk (id, hashed email — no PII sent in plaintext) on both sides; on the server the user is set per-request from the verified Clerk token. - Wrap the navigator with the Sentry Expo Router instrumentation.
- Custom spans across the wire:
- Client:
record.save,transcribe.client,summarize.client. - Server:
transcribe.server(forwarding to ElevenLabs) andsummarize.server(calling OpenAI via the AI SDK), with attributes (duration_ms,bytes,model,provider).
- Client:
- Propagate
sentry-trace/baggageheaders from client → API route so a single trace covers the full request. - Crash button in Settings (dev-only) to demo a stack trace; matching
/api/_debug/boom+api.ts(gated to__DEV__) to demo a server-side crash. - Profiling enabled at 10% sample on the client.
Done when: force a crash → it shows up in Sentry within seconds with user, route, and the right span breadcrumbs. The summarize and transcribe server spans connect to their client spans via the trace header, so a single timeline shows the full request.
Goal: the App-Store-quality wrap.
- Pro: export note as Markdown to Files, share sheet (transcript + summary + tasks).
- Search across notes (SQLite FTS5 if it fits the time budget, otherwise
LIKE). - Tag filter chips.
- Re-record / append to existing note.
- Onboarding carousel on first launch.
- App icon, splash, store screenshots (the Expo
screenshot-optimizationskill can drive this).
Done when: the app feels like something you'd pay for.
- Privacy copy on first record: explain audio leaves the device (ElevenLabs + OpenAI, via our own proxy), with links.
- Offline behavior: recording always works offline; transcription/summary queue and run on reconnect.
- Error UX: every async surface has loading / empty / error states. No silent failures.
- Accessibility: Dynamic Type, VoiceOver labels on the mic button and waveform, ≥44pt tap targets.
- API-route hygiene: every route verifies a Clerk session, sets a request-scoped Sentry user, and rate-limits per
userId(in-memory token bucket — fine for a demo). No request body is logged. - Testing posture: smoke
bun start+ iOS sim happy path at the end of every stage; for stages 5/6, alsocurlthe API route directly to prove auth rejects unsigned requests. No formal unit tests in scope unless time permits.
Stage 0 → 1 → 2 (Clerk) → 3 → 4 → 5 (ElevenLabs) → 6 (OpenAI / AI SDK) → 7 (RevenueCat) → 8 (Sentry) → 9
Stages 5 and 6 can swap; everything else is hard-ordered (paywall gating depends on the features it gates existing first; Sentry server spans depend on the API routes existing).
- Default OpenAI model for Stage 6 —
gpt-4o-minifor cost vs.gpt-4o/gpt-4.1for output quality? - App bundle id and Apple/Google OAuth client setup — needed before Clerk OAuth can be tested on a real device.
- RevenueCat product SKUs and the exact paywall design (template vs. custom).
- Sentry org/project + DSN. Server SDK choice (
@sentry/nodevs. an Expo-Router-specific integration if one ships in time). - Will there be any cross-device sync, even a token one (e.g. Pro entitlement only)? If yes, Clerk public metadata is enough; if no, drop the word "sync" from the marketing copy.
- ElevenLabs key model: confirm whether they offer a safe public/scoped key for client-side STT. If yes, Stage 5 can call directly from the device; if no, default to the
/api/transcribeproxy path. - API hosting: EAS Hosting (preferred — same repo, same deploy story) vs. a separate Vercel/Cloudflare deploy if EAS Hosting can't host Expo Router API routes. Affects the
EXPO_PUBLIC_API_BASE_URLvalue and the deploy story at the start of Stage 5.