This is the technical source of truth. If code you’re generating disagrees with this file, the file wins — stop and reconcile rather than improvising.
┌────────────────────────────────────────────────────────┐
│ FRONTEND (client app) │
│ Expo / React Native + TypeScript │
│ Screens (Expo Router) → Components (gluestack-ui) │
│ → lib/ (all external calls go here) │
└───────────────┬───────────────────────┬─────────────────┘
│ │
┌──────────▼─────────┐ ┌─────────▼──────────┐
│ Supabase │ │ RevenueCat SDK │
│ (Postgres + Auth │ │ (subscriptions, │
│ + Row Level │ │ ads, Paywalls v2) │
│ Security) │ └─────────────────────┘
│ + 1 Edge Function │
│ (LLM proxy) │ ┌─────────────────────┐
└──────────┬───────────┘ │ OneSignal SDK │
│ │ (daily push) │
│ └─────────────────────┘
┌──────────▼───────────┐
│ LLM API │
│ (roleplay + feedback)│
└────────────────────────┘
There is no custom backend server. “Backend” in this project means: Supabase (managed Postgres + Auth + Row Level Security) plus exactly one Supabase Edge Function that proxies LLM calls so the LLM API key never ships inside the client bundle. RevenueCat and OneSignal are called directly from the client via their official SDKs — that is the correct, intended integration pattern for both, not a shortcut.
| Concern | Decision | Why |
|---|---|---|
| Framework | Expo (managed workflow) | Fastest iteration for AI coding agents; one codebase for iOS + Android; no native build needed until OneSignal is added (see §8) |
| Language | TypeScript, strict: true |
Non-negotiable — catches schema/contract mismatches before runtime |
| Navigation | Expo Router (file-based) | Screens map directly to files in app/, which is easier for an agent to reason about than manually configured React Navigation |
| UI components | gluestack-ui v3+ | Not a traditional npm component library — it’s a CLI that copy-pastes component source into your repo (components/ui/). See §4 for exact setup. This means: after running npx gluestack-ui add [component], the component’s real prop signature is sitting in your own repo — read that file, don’t guess its props. |
| Styling | NativeWind (Tailwind syntax for RN) | Installed as part of the gluestack-ui init flow |
| Server state (Supabase data) | TanStack Query (React Query) | Handles loading/error/caching for session history and feedback without hand-rolled useEffect + useState fetch logic in every screen |
| Local/UI state | React useState / useReducer only |
Do not add Redux, Zustand, or any global state library — the app’s state surface is small enough that a global store adds complexity without benefit |
| Chat UI | react-native-gifted-chat |
Purpose-built for exactly the Roleplay screen — see 03-DESIGN-SYSTEM-AND-SCREENS.md §5 for wiring details |
| Data access rule | Components never call Supabase, the LLM, RevenueCat, or OneSignal directly. Every external call goes through a function in lib/. |
Keeps components testable and keeps every external contract in one place per source-of-truth map |
Supabase is the entire backend. Concretely:
Resolved (Sep 2026): the Supabase client must use a persistent auth storage adapter. createClient() with no storage option falls back to an in-memory adapter on React Native (confirmed via @supabase/auth-js’s supportsLocalStorage()), meaning every app launch creates a brand-new anonymous user and all prior sessions/history become permanently unreachable. Install @react-native-async-storage/async-storage (npx expo install @react-native-async-storage/async-storage) and configure the client explicitly:
createClient(url, anonKey, {
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
});
This is not optional — without it, user_id-scoped RLS effectively resets on every launch.
user_id matches their own auth uid. This is not optional; do not ship a table without an RLS policy.llm-proxy): the only server-side compute in this project. Its entire job is: receive a request from the client, attach the LLM API key (stored as a Supabase secret, never in client code), forward to the LLM API, return the response. This exists solely so the LLM API key is never bundled into the app. See §11 for its exact contract.Why not skip the Edge Function and call the LLM directly from the client? You can, if you’re extremely time-constrained — it will work for a hackathon demo. But any API key shipped in a client bundle can be extracted from the compiled app, and this is the kind of shortcut a technically-literate judge might notice. The Edge Function is a ~20-line proxy; budget an hour for it, not a day.
RevenueCat: no backend involvement needed. The client SDK (react-native-purchases) talks to RevenueCat’s servers directly and RevenueCat validates receipts with Apple/Google itself.
OneSignal: no backend involvement needed for the daily nudge (it’s a dashboard-scheduled campaign, not something your code triggers per-user).
Run these in order. Do not substitute package names — these are verified against current registries.
# Core scaffold
npx create-expo-app@latest preprounds --template blank-typescript
cd preprounds
# Routing
npx expo install expo-router react-native-safe-area-context react-native-screens expo-linking expo-constants expo-status-bar
# UI system — this CLI configures NativeWind, Tailwind config, and a components/ui folder for you
npx gluestack-ui init
# then, per screen, add only the components you actually use, e.g.:
npx gluestack-ui add button
npx gluestack-ui add card
npx gluestack-ui add input
npx gluestack-ui add badge
npx gluestack-ui add spinner
# Server state
npm install @tanstack/react-query
# Chat UI (Roleplay screen)
npm install react-native-gifted-chat
# Supabase client
npm install @supabase/supabase-js
# RevenueCat
npm install react-native-purchases react-native-purchases-ui
# OneSignal — requires a config plugin AND the SDK package
npx expo install onesignal-expo-plugin
npm install react-native-onesignal
Important gotcha to flag, not guess around: react-native-onesignal is a native module. It will not run inside Expo Go. Sequence your build so OneSignal is the last thing you add (per the build order in AGENTS.md), and once it’s added you must switch to an EAS development build (eas build --profile development) to keep testing. Do not attempt to “make OneSignal work in Expo Go” — it structurally cannot; this is documented OneSignal/Expo behavior, not a bug to debug around.
If you want additional official guidance while integrating OneSignal specifically, OneSignal publishes an AI-agent-oriented setup reference at https://raw.githubusercontent.com/OneSignal/sdk-ai-prompts/main/docs/react-native-expo/ai-prompt.md — fetch and follow it for the OneSignal step specifically rather than improvising config plugin settings.
preprounds/ # project root — also where the 6 spec files
# and AGENTS.md/CLAUDE.md live (see note below)
00-START-HERE.md
01-PRD.md
02-ARCHITECTURE-AND-DATA.md
03-DESIGN-SYSTEM-AND-SCREENS.md
04-SCENARIO-PROMPTS.md
AGENTS.md # the real one — see shadowing note below
CLAUDE.md # duplicate of AGENTS.md, per its own stated convention
EXPO-SDK-NOTES.md # Expo's own generated guidance, preserved under this name
app/ # Expo Router — one file per screen
index.tsx # required by Expo Router for the `/` route. Step 1: a
# placeholder linking to both onboarding and tabs. Step 3
# replaces it with the real redirect logic (US-1 AC5:
# returning user → Home, first run → onboarding).
(onboarding)/
role.tsx
unit.tsx
notifications.tsx
(tabs)/
_layout.tsx # bottom tab bar: Home / History / Settings
home.tsx
history.tsx
settings.tsx
scenario/
[id]/
prebrief.tsx
roleplay.tsx
feedback.tsx
paywall.tsx
_layout.tsx # root layout: providers (QueryClient, GluestackUIProvider, RevenueCat init)
components/
ui/ # gluestack-generated — do not hand-edit prop APIs, only styling
ScenarioCard.tsx
ConfidenceTrend.tsx
FeedbackCard.tsx
EmptyState.tsx
ErrorState.tsx
LoadingState.tsx
lib/
supabase.ts # Supabase client init + typed table helpers
revenuecat.ts # init, entitlement check, presentPaywallIfNeeded wrapper
onesignal.ts # init, permission request, campaign opt-in/out
llm/
llmClient.ts # thin client calling the `llm-proxy` Edge Function
roleplayPrompts.ts # imports from 04-SCENARIO-PROMPTS.md content
feedbackPrompt.ts
hooks/
useScenarioSession.ts # React Query hooks wrapping lib/supabase.ts calls
useFeedbackReport.ts
useSubscriptionStatus.ts
constants/
theme.ts # colors/typography/spacing tokens, see design doc
scenarios.ts # the 4 scenario definitions (metadata + prompt reference)
supabase/
functions/
llm-proxy/
index.ts
migrations/
0001_init.sql # schema from §10, including RLS policies
app.config.ts # Expo config — OneSignal plugin registered here
.env.local # see §6 — never commit this file
Resolved (Sep 2026): AGENTS.md/CLAUDE.md shadowing. create-expo-app generates its own AGENTS.md (Expo SDK version guidance) and CLAUDE.md (an @AGENTS.md import of that file) at the project root — the same path this project’s real AGENTS.md needs to occupy now that the spec files live inside preprounds/ per the tree above. Resolution: Expo’s generated AGENTS.md is renamed to EXPO-SDK-NOTES.md rather than deleted (its version-specific guidance is genuinely useful), this project’s real AGENTS.md takes the now-free root path, and CLAUDE.md is overwritten with a duplicate of the real AGENTS.md — consistent with the convention AGENTS.md itself already states (“duplicate this file as CLAUDE.md… if your tool looks for a specific filename”), rather than pointing at Expo’s renamed file. Nothing from Expo’s original guidance is lost; it’s one file away under its new name if ever needed.
Use these exact names everywhere — do not rename in one file and not another.
EXPO_PUBLIC_SUPABASE_URL=
EXPO_PUBLIC_SUPABASE_ANON_KEY=
EXPO_PUBLIC_REVENUECAT_API_KEY_IOS=
EXPO_PUBLIC_REVENUECAT_API_KEY_ANDROID=
EXPO_PUBLIC_ONESIGNAL_APP_ID=
Server-side only (set as Supabase Edge Function secrets, never in .env.local, never prefixed EXPO_PUBLIC_):
LLM_API_KEY=
LLM_MODEL= # e.g. current Gemini Flash-Lite GA model ID — check
# Google AI Studio at build time, do not hard-code
# a version that may already be superseded
Resolved (Sep 2026 addendum, revised): provider is Google Gemini — see §11.0 for the full request/response shape, auth header, and why Flash-Lite specifically.
Anything prefixed EXPO_PUBLIC_ is bundled into the client and is not a secret — that’s why the LLM key is deliberately excluded from that prefix and lives only inside the Edge Function’s environment.
Every hook in lib/hooks/ returns the same shape (this is what TanStack Query gives you natively — don’t reinvent it):
{ data, isLoading, isError, error, refetch }
Every screen that consumes one of these hooks must render exactly one of three states — never leave a fourth “silently nothing” case:
isLoading → the screen’s Loading state (see 03-DESIGN-SYSTEM-AND-SCREENS.md §5)isError → the screen’s Error state, with a retry action wired to refetchdata present → the real contentreact-native-onesignal. From this point on, npx expo start + Expo Go will not show OneSignal behavior — use eas build --profile development and install that build on a device/simulator instead.AGENTS.md reflects this.user_id in any write — always derive it from the authenticated session server-side (Supabase RLS policies enforce this automatically when written correctly; see §10 policy examples).lib/supabase.ts or a types/db.ts)export type UserRole = 'charge_nurse' | 'aspiring' | 'nurse_manager';
export type UnitType = 'med_surg' | 'ed' | 'icu' | 'other';
export type Difficulty = 'easy' | 'realistic' | 'intense';
export type ScenarioId =
| 'feedback_after_error'
| 'saying_no_overtime'
| 'boundary_with_peer'
| 'deescalate_conflict';
export interface UserProfile {
id: string; // matches Supabase auth.uid()
role: UserRole;
unit_type: UnitType;
notification_time: string | null; // 'HH:MM', null if notifications declined
created_at: string;
}
export interface TranscriptTurn {
role: 'user' | 'persona';
content: string;
timestamp: string;
}
export interface ScenarioSession {
id: string;
user_id: string;
scenario_id: ScenarioId;
difficulty: Difficulty;
transcript: TranscriptTurn[];
started_at: string;
ended_at: string | null;
}
export interface FeedbackItem {
quote?: string;
why_it_worked?: string;
moment?: string;
original?: string;
suggested_phrase?: string;
}
export interface FeedbackReport {
id: string;
session_id: string;
what_landed: FeedbackItem[];
try_instead: FeedbackItem[];
confidence_score: number; // 1-5
one_line_summary: string;
created_at: string;
}
export interface SubscriptionStatus {
user_id: string;
entitlement_active: boolean;
free_sessions_used: number;
bonus_sessions_granted: number;
last_synced: string;
}
supabase/migrations/0001_init.sql)create table user_profiles (
id uuid primary key references auth.users(id) on delete cascade,
role text not null check (role in ('charge_nurse','aspiring','nurse_manager')),
unit_type text not null check (unit_type in ('med_surg','ed','icu','other')),
notification_time text,
created_at timestamptz not null default now()
);
alter table user_profiles enable row level security;
create policy "users manage their own profile"
on user_profiles for all
using (auth.uid() = id)
with check (auth.uid() = id);
create table scenario_sessions (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
scenario_id text not null check (scenario_id in
('feedback_after_error','saying_no_overtime','boundary_with_peer','deescalate_conflict')),
difficulty text not null check (difficulty in ('easy','realistic','intense')),
transcript jsonb not null default '[]',
started_at timestamptz not null default now(),
ended_at timestamptz
);
alter table scenario_sessions enable row level security;
create policy "users manage their own sessions"
on scenario_sessions for all
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
create table feedback_reports (
id uuid primary key default gen_random_uuid(),
session_id uuid not null references scenario_sessions(id) on delete cascade,
what_landed jsonb not null default '[]',
try_instead jsonb not null default '[]',
confidence_score int not null check (confidence_score between 1 and 5),
one_line_summary text not null,
created_at timestamptz not null default now()
);
alter table feedback_reports enable row level security;
create policy "users manage feedback for their own sessions"
on feedback_reports for all
using (
session_id in (select id from scenario_sessions where user_id = auth.uid())
)
with check (
session_id in (select id from scenario_sessions where user_id = auth.uid())
);
create table subscription_status (
user_id uuid primary key references auth.users(id) on delete cascade,
entitlement_active boolean not null default false,
free_sessions_used int not null default 0,
bonus_sessions_granted int not null default 0,
last_synced timestamptz not null default now()
);
alter table subscription_status enable row level security;
create policy "users manage their own subscription status"
on subscription_status for all
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
Three gaps were flagged during a Claude Code build session and resolved here rather than guessed. This subsection is the answer; §11.1/§11.2 below reflect it.
Provider: Google Gemini, generateContent endpoint. Model choice deliberately not hard-pinned — Gemini’s Flash-Lite line has shipped several versions in a short span; confirm the current GA Flash-Lite model ID in Google AI Studio at build time and set it as LLM_MODEL (see §6). Chosen for cost: Flash-Lite pricing runs roughly $0.10–0.30 per million input tokens, well under Haiku’s $1.00, and Google’s Flash/Flash-Lite tier carries a recurring free quota (rate-limited) that Anthropic’s API does not offer — meaningful for an unfunded build.
Base URL: https://generativelanguage.googleapis.com/v1beta/models/<LLM_MODEL>:generateContent
Headers: x-goog-api-key: <LLM_API_KEY>
content-type: application/json
Do not put the API key in the URL as a ?key= query param even though Gemini’s docs show that as an option — headers only, per this project’s security conventions (§9).
TranscriptTurn.role maps to Gemini’s contents[].role: 'user' → 'user', 'persona' → 'model' (not 'assistant' — that’s the Anthropic/OpenAI convention, easy to carry over by mistake). The scenario system prompt is sent via the top-level systemInstruction: { parts: [{ text: ... }] } field, never folded into contents.
Schema constraints and server-side retry (added Sep 2026). responseSchema must constrain emptiness and range, not just field names: minItems: 1 on what_landed and try_instead, minLength: 1 on every required string, and minimum: 1 / maximum: 5 on confidence_score. Without these the schema and parseFeedbackResponse disagreed — Gemini returned try_instead: [], legal under the schema and rejected by the validator, which failed 5 of 8 calls on a real transcript and surfaced as a 502 over an otherwise good debrief. Every validator rejection condition needs a matching schema constraint; adding one without the other reopens the gap. The Edge Function also now retries generation once on a validation failure, which is separate from and inside the client’s retry-once on 502 (PRD US-4 AC2) — a flaky completion self-heals before reaching the client.
For the feedback call (§11.2), use Gemini’s native structured output: set generationConfig.responseMimeType: "application/json" and generationConfig.responseSchema to a JSON Schema matching FeedbackResponse exactly. This is Gemini’s equivalent of forced tool-use — it constrains the model’s output at generation time rather than merely requesting JSON via prompt text, which is what reduces how often the retry-once path in PRD US-4 AC2 actually triggers. FEEDBACK_SYSTEM_PROMPT in 04-SCENARIO-PROMPTS.md is unchanged and still sent as systemInstruction; the response schema is an added enforcement layer, not a replacement.
Routing: both request types below carry a required action discriminator. llm-proxy/index.ts does switch (body.action) to route to the roleplay-turn handler or the feedback handler.
session_ended sentinel: the literal token [[SCENE_COMPLETE]]. The Edge Function composes the actual systemInstruction text sent to the API as the verbatim scenario prompt from ROLEPLAY_SYSTEM_PROMPTS[scenario_id] (unedited, per 04-SCENARIO-PROMPTS.md) followed by a second, fixed, scenario-agnostic instruction owned by this file, not by 04-SCENARIO-PROMPTS.md:
SCENE CONTINUITY
This is a practice conversation, not a single exchange. Stay in character
for the whole scene and make the trainee work for the outcome.
- Do not resolve, agree, or soften just because the trainee said one
reasonable thing. A single acknowledgement is not a resolution.
- Hold your position until the trainee has actually addressed it — named
your specific concern, answered a follow-up, or put a concrete proposal
on the table.
- Concede in increments. Real people give partial ground before they give
all of it.
- Each time you push back, use a new angle: a follow-up question, a
practical objection, a consequence you are worried about. Never repeat an
objection you have already made.
- Do not be artificially difficult either. If the trainee has genuinely done
the work, let the scene land.
ENDING THE SCENE
End only when the conversation has reached a real resolution: the trainee
has heard your concern, addressed it specifically, and you have both arrived
at a concrete next step. A vague intention ("I'll look into it", "let me see
what I can do") is not a concrete next step. Most scenes need several
exchanges to get there.
If, and only if, that has happened, end your reply with the token
[[SCENE_COMPLETE]] alone on its own final line. Otherwise, do not include
this token.
Minimum-turn floor (added Sep 2026). The sentinel alone proved unreliable:
measured against the deployed function, the model declared resolution at user
turn 2 (boundary_with_peer) and turn 3 (saying_no_overtime,
deescalate_conflict) — far below the 4–8 exchanges a practice session needs.
The Edge Function therefore suppresses session_ended until the trainee has
taken at least MIN_USER_TURNS_BEFORE_END[scenario_id] turns, counted from
transcript_so_far. The model can still end a scene any time at or after that
floor; it can no longer end one before it. The client’s 10-turn cap remains the
upper backstop, so a scene now resolves within a bounded window rather than
whenever the model first feels agreeable.
On response, the Edge Function checks the completion text for [[SCENE_COMPLETE]]; if present, strips it (and the trailing newline) before setting reply, and sets session_ended: true. If absent, session_ended: false. The client’s 10-turn cap (PRD US-3 AC3) remains the authoritative fallback regardless of what the model signals — this sentinel only enables ending a scene earlier than the cap when resolution genuinely lands sooner.
Opening-line kickoff turn — corrected (Sep 2026, supersedes the “no contents history” line in §11.1). Gemini’s generateContent rejects a request with systemInstruction but an empty contents array (HTTP 400) — systemInstruction alone is not a valid request on this provider. The fix follows the same pattern as the sentinel: a fixed, scenario-agnostic control token, owned by this file, never added to the verbatim prompts in 04-SCENARIO-PROMPTS.md.
On a call with transcript_so_far: [], the Edge Function sends contents as exactly one turn:
contents: [{ role: 'user', parts: [{ text: '[[BEGIN_SCENE]]' }] }]
And appends this instruction to the composed systemInstruction, alongside the sentinel instruction from above:
The user's first message will be the literal token [[BEGIN_SCENE]].
This is a stage direction, not something to respond to directly. On
seeing it, begin the scene in character, per your behavior rules
above, with your opening line.
This kickoff turn is call-scoped only. It is not a TranscriptTurn, must never be added to ScenarioSession.transcript, must never be returned to the client, and must never be sent again on subsequent turns. Once the client receives the persona’s real opening line back as reply, its own locally-held transcript for that session begins with that line as a persona turn — the [[BEGIN_SCENE]] token has no further existence past this one request.
Provider-swap note: if you ever swap providers again, only this §11.0 and the callLlm() implementation inside llm-proxy/index.ts should need to change — §11.1/§11.2’s request/response TypeScript shapes are provider-agnostic by design and should not change.
Feedback transcript representation — resolved (Sep 2026). The feedback call (§11.2) is not a conversation — the model isn’t taking a turn, it’s analyzing a finished document — so it must not be sent as multi-turn chat contents. Doing so ties correctness to how the original scene happened to end, which is exactly the failure Claude Code isolated. Fix: render the entire transcript as a single user turn.
function renderTranscriptForFeedback(transcript: TranscriptTurn[]): string {
const body = transcript
.map(turn => `${turn.role === 'user' ? 'Trainee' : 'Colleague'}: ${turn.content}`)
.join('\n');
return `Here is the finished practice session transcript to review:\n\n${body}`;
}
For the feedback call only, contents is exactly:
contents: [{ role: 'user', parts: [{ text: renderTranscriptForFeedback(transcript) }] }]
This always ends on role: 'user' by construction — one turn, always that role — so Gemini’s trailing-role requirement is satisfied unconditionally, independent of how the roleplay itself concluded. Timestamps are intentionally omitted from the rendered text; they’re irrelevant to the analysis. FEEDBACK_SYSTEM_PROMPT in 04-SCENARIO-PROMPTS.md is unchanged and still sent as systemInstruction — its existing “Analyze ONLY what the trainee (the ‘user’ turns) actually said” instruction already maps correctly onto the Trainee:/Colleague: labels above, no edit needed there.
This applies to §11.2 only. §11.1’s roleplay-turn call is unaffected and keeps sending contents as real multi-turn chat history, per §11.0’s kickoff-turn section above.
llm-proxy (roleplay turn)Request (client → Edge Function):
interface RoleplayTurnRequest {
action: 'roleplay_turn';
scenario_id: ScenarioId;
difficulty: Difficulty;
transcript_so_far: TranscriptTurn[]; // see note below re: empty array
}
Response:
interface RoleplayTurnResponse {
reply: string; // the persona's next line, with any sentinel stripped
session_ended: boolean; // true only if [[SCENE_COMPLETE]] was present, per §11.0
}
Resolved (addendum): difficulty is pass-through, not behavior-altering. There is no user-facing difficulty selector anywhere in this project — 01-PRD.md and 03-DESIGN-SYSTEM-AND-SCREENS.md both only ever show difficulty as a read-only badge sourced from that scenario’s fixed ScenarioMeta.defaultDifficulty (04-SCENARIO-PROMPTS.md). The four verbatim personas are each already written and calibrated at their own assigned default difficulty (three at realistic, boundary_with_peer at intense) — there is no neutral baseline to modify from, so layering a difficulty modifier on top would double up rather than adjust. The Edge Function’s job for this field is exactly what was already implemented: validate it against the Difficulty enum (§10) and persist it to scenario_sessions.difficulty for the record — it must not branch model behavior on it. If a real difficulty selector is wanted later, that’s a new feature requiring new scenario content, not a v1 gap.
Resolved (addendum, corrected Sep 2026): an empty transcript_so_far array is valid and means “give me the scene’s opening line.” Every verbatim persona prompt in 04-SCENARIO-PROMPTS.md includes explicit opening behavior (“Start slightly defensive…”, “Open with real pressure…”, “Open dismissively…”, “Open venting…”) — the personas are written to speak first, so the contract must allow a call with no prior user message. Do not reject transcript_so_far: []. Do not send an empty contents array either — Gemini rejects systemInstruction alone with HTTP 400. Instead, use the [[BEGIN_SCENE]] kickoff-turn mechanism defined in §11.0, and return the model’s resulting opening line as reply. The client’s Roleplay screen (03-DESIGN-SYSTEM-AND-SCREENS.md §5.4) makes exactly one such call automatically on screen load, before the user has typed anything, and renders the result as the first message in the thread.
The Edge Function’s job: look up the correct system prompt for scenario_id (from 04-SCENARIO-PROMPTS.md, mirrored server-side), compose it with the sentinel instruction per §11.0, forward transcript_so_far as conversation history (empty on the opening call, per above), call the LLM API using the server-side LLM_API_KEY, return reply. Always enforce the 10-turn cap client-side as well, regardless of session_ended.
llm-proxy (feedback generation)Request:
interface FeedbackRequest {
action: 'generate_feedback';
scenario_id: ScenarioId;
transcript: TranscriptTurn[]; // the complete, finished transcript
}
Response — must match this shape exactly, validate before rendering (PRD US-4 AC2):
interface FeedbackResponse {
what_landed: { quote: string; why_it_worked: string }[]; // 1-2 items
try_instead: { moment: string; original?: string; suggested_phrase: string }[]; // 1-2 items
confidence_score: number; // integer 1-5
one_line_summary: string;
}
The exact system prompt enforcing this schema is in 04-SCENARIO-PROMPTS.md — use it verbatim, do not paraphrase the JSON-schema instructions.
Resolved (Sep 2026): transcript is sent to Gemini as a single rendered user turn, not as chat-shaped contents. See §11.0’s “Feedback transcript representation” for the exact rendering function and rationale — this is what fixed the 400 that occurred whenever a finished transcript happened to end on a persona turn.
Error contract, for the client’s retry logic (PRD US-4 AC2), corrected to match implementation: HTTP 502 from this endpoint means the function could not produce a valid FeedbackResponse for any reason — Gemini’s response failed schema validation, or the upstream call to Gemini itself failed (network error, non-2xx, timeout). Both collapse to the same client action, so they are deliberately not distinguished by status code: retry the call once (US-4 AC2), and if it 502s again, show the Feedback screen’s Error state. Any other status (e.g. a routing/validation 400, an auth failure) is not retried — those indicate a request problem, not a transient one, and should go straight to the Error state.
proPurchases.configure({ apiKey }) once at app root, using platform-specific key from §6Purchases.getCustomerInfo() → customerInfo.entitlements.active['pro']RevenueCatUI.presentPaywallIfNeeded({ requiredEntitlementIdentifier: 'pro' })OneSignal.initialize(process.env.EXPO_PUBLIC_ONESIGNAL_APP_ID)