diff --git a/README.md b/README.md
index 6874276..f9441ce 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ one dark blue-green accent. No animations, no gradients.
## System overview
```
-apps/mobile Expo / React Native app (three views: Welcome, Chat, Paywall)
+apps/mobile Expo / React Native app (two views: Chat, Paywall)
services/api One Lambda backend (chat, usage, subscription verification)
infra AWS CDK stack (the entire cloud environment)
db/migrations SQL schema (two tables: users, usage)
@@ -22,16 +22,42 @@ it is not built.
### Request flow
-1. The app signs in via the **Cognito** hosted UI (Apple / Google / email)
- and receives a JWT.
-2. `POST /chat` goes through **API Gateway** (JWT authorizer) to the single
- **Lambda**.
-3. The Lambda checks the free-tier quota in **PostgreSQL** (Aurora
- Serverless v2). Over the limit and not subscribed → HTTP 402 → the app
- shows the paywall.
-4. Otherwise it calls the **OpenAI Responses API** with the Markdown
- knowledge base (`services/api/knowledge/`) as system instructions and
- returns the reply.
+1. The user lands directly in the chat — no registration before the first
+ question. Guests are identified by an app-generated device id
+ (`POST /guest/chat`); dynamic conversation starters come from
+ `GET /suggestions` (both public routes). The suggestions live in
+ `services/api/suggestions.json` and can be updated with a deploy — no
+ app release needed.
+2. Requests go through **API Gateway** to the single **Lambda**; signed-in
+ users use `POST /chat` with a **Cognito** JWT (Apple / Google / email via
+ the hosted UI).
+3. The Lambda calls the **OpenAI Responses API** with the Markdown knowledge
+ base (`services/api/knowledge/`) as system instructions. The model
+ returns structured output: a reply plus an `analysis_ready` flag.
+4. Sign-in happens at the paywall, since a purchase must attach to an
+ account. Usage counters live in **PostgreSQL** (Aurora Serverless v2).
+
+### The intelligent paywall
+
+The paywall is not a hardcoded message count. Free conversations run in
+_discovery mode_: the model asks relevant follow-up questions, names
+patterns, shows understanding, and builds toward an analysis — without
+delivering the full solution. When the problem is described, the information
+is sufficient, and a concrete action plan is ready, the model signals
+`analysis_ready`, writes a calm transition ("…I have a concrete strategy I
+would recommend. Continue with Premium to see the analysis and the
+recommended steps."), and the conversation pauses.
+
+The instructions explicitly forbid manufactured urgency, emotional pressure,
+fake readiness, and stopping mid-answer — Premium should feel like the
+natural continuation of an already valuable dialogue.
+
+On unlock, the app resends the transcript; premium mode then delivers the
+full analysis, recommended strategies and concrete exercises immediately,
+and the dialogue continues without restriction.
+
+A generous `MESSAGE_CAP` (default 200 per 30 days) exists purely as an
+abuse backstop for the free tier — it is not the paywall.
Conversations are never stored server-side; the client holds them in memory
and sends the running transcript with each request. The database stores the
@@ -51,9 +77,6 @@ provider differs per platform behind one `PaymentProvider` interface:
Plans: Monthly and Yearly. Nothing else.
-Free tier: `FREE_MESSAGE_LIMIT` messages (default 50 ≈ 5–10 conversations),
-resetting every `USAGE_RESET_DAYS` days.
-
## Getting started
```sh
@@ -100,12 +123,19 @@ GitHub Actions: `ci.yml` lints, type-checks and tests every PR;
## Security
-- All traffic over HTTPS; every API route requires a Cognito JWT.
+- All traffic over HTTPS. Account routes require a Cognito JWT; the two
+ public routes (`/suggestions`, `/guest/chat`) carry no account data and
+ are bounded by the free-tier message cap.
- Secrets live in AWS Secrets Manager only — no API keys in the client.
- The Lambda runs in private subnets; the database is not publicly reachable.
## Product philosophy
+Every free user should leave the app feeling that the system understood
+their situation, that a concrete analysis is ready, and that the next step
+is available in Premium — never that they were held back by an artificial
+interruption.
+
Every new feature must justify itself. The allowed AWS surface is
API Gateway, Lambda, Cognito, S3, Secrets Manager and CloudWatch — and V1
does not even need S3. No Redis, no Kubernetes, no Kafka, no Elasticsearch,
diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx
index a36cf65..9e2dbcc 100644
--- a/apps/mobile/App.tsx
+++ b/apps/mobile/App.tsx
@@ -1,29 +1,45 @@
-import { useEffect, useState } from 'react';
+import { useState } from 'react';
import { StatusBar } from 'expo-status-bar';
-import { getAccessToken } from './src/auth/session';
+import type { ChatMessage } from './src/api/client';
import { ChatScreen } from './src/screens/ChatScreen';
import { PaywallScreen } from './src/screens/PaywallScreen';
-import { WelcomeScreen } from './src/screens/WelcomeScreen';
-
-type View = 'loading' | 'welcome' | 'chat' | 'paywall';
/**
- * Three views, no navigation library. The backend decides when the paywall
- * appears (HTTP 402 on /chat).
+ * Two views, no navigation library. The user lands directly in the chat —
+ * no registration before the first question. The backend decides the paywall
+ * moment (the analysis is ready); after unlock the conversation continues
+ * with the full analysis delivered immediately.
*/
export default function App() {
- const [view, setView] = useState('loading');
-
- useEffect(() => {
- getAccessToken().then((token) => setView(token ? 'chat' : 'welcome'));
- }, []);
+ const [view, setView] = useState<'chat' | 'paywall'>('chat');
+ const [messages, setMessages] = useState([]);
+ const [paywalled, setPaywalled] = useState(false);
+ const [deliverAnalysis, setDeliverAnalysis] = useState(false);
return (
<>
- {view === 'welcome' && setView('chat')} />}
- {view === 'chat' && setView('paywall')} />}
- {view === 'paywall' && setView('chat')} />}
+ {view === 'chat' && (
+ setPaywalled(true)}
+ onContinueWithPremium={() => setView('paywall')}
+ deliverAnalysis={deliverAnalysis}
+ onAnalysisDelivered={() => setDeliverAnalysis(false)}
+ />
+ )}
+ {view === 'paywall' && (
+ {
+ setPaywalled(false);
+ setDeliverAnalysis(true);
+ setView('chat');
+ }}
+ onDismiss={() => setView('chat')}
+ />
+ )}
>
);
}
diff --git a/apps/mobile/src/api/client.ts b/apps/mobile/src/api/client.ts
index 308d894..6b63615 100644
--- a/apps/mobile/src/api/client.ts
+++ b/apps/mobile/src/api/client.ts
@@ -1,3 +1,5 @@
+import * as Crypto from 'expo-crypto';
+import * as SecureStore from 'expo-secure-store';
import { appConfig } from '../config';
import { getAccessToken } from '../auth/session';
@@ -16,23 +18,38 @@ export interface ChatMessage {
content: string;
}
+export interface ChatReply {
+ reply: string;
+ /** True when the backend judged the analysis ready — the paywall moment. */
+ paywall: boolean;
+}
+
export interface Me {
subscriptionStatus: 'free' | 'active';
messagesUsed: number;
- freeMessageLimit: number;
+ messageCap: number;
}
-async function request(path: string, init?: RequestInit): Promise {
- const token = await getAccessToken();
- if (!token) throw new ApiError(401, 'not_signed_in');
+const DEVICE_KEY = 'neurosemantics.device';
+/** Stable anonymous id so guests can chat before signing in. */
+async function getDeviceId(): Promise {
+ let id = await SecureStore.getItemAsync(DEVICE_KEY);
+ if (!id) {
+ id = Crypto.randomUUID();
+ await SecureStore.setItemAsync(DEVICE_KEY, id);
+ }
+ return id;
+}
+
+async function request(
+ path: string,
+ init: RequestInit = {},
+ headers: Record = {},
+): Promise {
const response = await fetch(`${appConfig.apiUrl}${path}`, {
...init,
- headers: {
- Authorization: `Bearer ${token}`,
- 'Content-Type': 'application/json',
- ...init?.headers,
- },
+ headers: { 'Content-Type': 'application/json', ...headers },
});
if (!response.ok) {
@@ -47,21 +64,39 @@ async function request(path: string, init?: RequestInit): Promise {
return (await response.json()) as T;
}
-export function fetchMe(): Promise {
- return request('/me');
+async function authHeader(): Promise> {
+ const token = await getAccessToken();
+ if (!token) throw new ApiError(401, 'not_signed_in');
+ return { Authorization: `Bearer ${token}` };
}
-export function sendChat(messages: ChatMessage[]): Promise<{ reply: string }> {
- return request<{ reply: string }>('/chat', {
- method: 'POST',
- body: JSON.stringify({ messages }),
- });
+export async function fetchSuggestions(): Promise {
+ const data = await request<{ suggestions: string[] }>('/suggestions');
+ return data.suggestions;
}
-export function verifyPurchase(body: {
+/** Signed-in users chat via /chat; guests via /guest/chat with a device id. */
+export async function sendChat(messages: ChatMessage[]): Promise {
+ const body = { method: 'POST', body: JSON.stringify({ messages }) };
+ const token = await getAccessToken();
+ if (token) {
+ return request('/chat', body, { Authorization: `Bearer ${token}` });
+ }
+ return request('/guest/chat', body, { 'X-Device-Id': await getDeviceId() });
+}
+
+export async function fetchMe(): Promise {
+ return request('/me', {}, await authHeader());
+}
+
+export async function verifyPurchase(body: {
platform: 'ios' | 'android';
productId: string;
receipt: string;
}): Promise<{ subscriptionStatus: 'free' | 'active' }> {
- return request('/subscription/verify', { method: 'POST', body: JSON.stringify(body) });
+ return request(
+ '/subscription/verify',
+ { method: 'POST', body: JSON.stringify(body) },
+ await authHeader(),
+ );
}
diff --git a/apps/mobile/src/screens/ChatScreen.tsx b/apps/mobile/src/screens/ChatScreen.tsx
index 714c4ec..f48a224 100644
--- a/apps/mobile/src/screens/ChatScreen.tsx
+++ b/apps/mobile/src/screens/ChatScreen.tsx
@@ -1,4 +1,4 @@
-import { useRef, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import {
FlatList,
KeyboardAvoidingView,
@@ -9,54 +9,104 @@ import {
TextInput,
View,
} from 'react-native';
-import { ApiError, sendChat, type ChatMessage } from '../api/client';
+import { fetchSuggestions, sendChat, type ChatMessage } from '../api/client';
import { colors, spacing, type } from '../theme';
interface Props {
- onLimitReached: () => void;
+ messages: ChatMessage[];
+ setMessages: (messages: ChatMessage[]) => void;
+ /** The analysis is ready behind Premium; input is replaced by the unlock button. */
+ paywalled: boolean;
+ onPaywallTriggered: () => void;
+ onContinueWithPremium: () => void;
+ /** Set right after unlock: fetch the promised analysis automatically. */
+ deliverAnalysis: boolean;
+ onAnalysisDelivered: () => void;
}
+const SUGGESTIONS_SHOWN = 4;
+
/**
* The whole product: one heading, the conversation, a text field and send.
- * Conversations live in memory only — nothing is stored.
+ * The user lands here directly — no registration before the first question.
+ * Conversations live in memory only; nothing is stored.
*/
-export function ChatScreen({ onLimitReached }: Props) {
- const [messages, setMessages] = useState([]);
+export function ChatScreen({
+ messages,
+ setMessages,
+ paywalled,
+ onPaywallTriggered,
+ onContinueWithPremium,
+ deliverAnalysis,
+ onAnalysisDelivered,
+}: Props) {
+ const [suggestions, setSuggestions] = useState([]);
const [draft, setDraft] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const listRef = useRef>(null);
- async function send() {
- const content = draft.trim();
- if (!content || busy) return;
- const next: ChatMessage[] = [...messages, { role: 'user', content }];
+ useEffect(() => {
+ fetchSuggestions()
+ .then((all) =>
+ setSuggestions([...all].sort(() => Math.random() - 0.5).slice(0, SUGGESTIONS_SHOWN)),
+ )
+ .catch(() => setSuggestions([]));
+ }, []);
+
+ // After unlocking Premium the transcript ends with the assistant's
+ // transition message; resend it so the backend delivers the full analysis.
+ useEffect(() => {
+ if (!deliverAnalysis || busy) return;
+ setBusy(true);
+ setError(null);
+ sendChat(messages)
+ .then(({ reply }) => {
+ setMessages([...messages, { role: 'assistant', content: reply }]);
+ onAnalysisDelivered();
+ })
+ .catch(() => setError('Something went wrong. Please try again.'))
+ .finally(() => setBusy(false));
+ }, [deliverAnalysis]);
+
+ async function send(content: string) {
+ const trimmed = content.trim();
+ if (!trimmed || busy || paywalled) return;
+ const next: ChatMessage[] = [...messages, { role: 'user', content: trimmed }];
setMessages(next);
setDraft('');
setBusy(true);
setError(null);
try {
- const { reply } = await sendChat(next);
+ const { reply, paywall } = await sendChat(next);
setMessages([...next, { role: 'assistant', content: reply }]);
- } catch (e) {
- if (e instanceof ApiError && e.status === 402) {
- onLimitReached();
- } else {
- setError('Something went wrong. Please try again.');
- setMessages(messages);
- setDraft(content);
- }
+ if (paywall) onPaywallTriggered();
+ } catch {
+ setError('Something went wrong. Please try again.');
+ setMessages(messages);
+ setDraft(trimmed);
} finally {
setBusy(false);
}
}
+ const showSuggestions = messages.length === 0 && suggestions.length > 0;
+
return (
NeuroSemantics AI
+ {showSuggestions ? (
+
+ {suggestions.map((s) => (
+ send(s)} disabled={busy}>
+ {s}
+
+ ))}
+
+ ) : null}
{error ? {error} : null}
-
-
-
- {busy ? '…' : 'Send'}
-
-
+ {paywalled ? (
+
+
+ Continue with Premium
+
+
+ ) : (
+
+
+ send(draft)}
+ disabled={!draft.trim() || busy}
+ >
+ {busy ? '…' : 'Send'}
+
+
+ )}
);
}
@@ -104,9 +162,19 @@ const styles = StyleSheet.create({
paddingTop: spacing.xxl,
paddingBottom: spacing.m,
},
+ suggestions: { paddingHorizontal: spacing.l, gap: spacing.s, marginBottom: spacing.m },
+ suggestion: {
+ backgroundColor: colors.surface,
+ borderColor: colors.border,
+ borderWidth: 1,
+ borderRadius: 10,
+ paddingVertical: spacing.s + 2,
+ paddingHorizontal: spacing.m,
+ },
+ suggestionText: { ...type.body, color: colors.accent },
list: { flex: 1 },
listContent: { paddingHorizontal: spacing.l, paddingBottom: spacing.m, gap: spacing.s },
- empty: { ...type.body, color: colors.textMuted, textAlign: 'center', marginTop: spacing.xxl },
+ empty: { ...type.body, color: colors.textMuted, textAlign: 'center', marginTop: spacing.xl },
message: { maxWidth: '85%', borderRadius: 12, padding: spacing.m },
user: { alignSelf: 'flex-end', backgroundColor: colors.accent },
assistant: {
@@ -144,4 +212,12 @@ const styles = StyleSheet.create({
},
sendDisabled: { opacity: 0.4 },
sendText: { ...type.heading, color: colors.accentText },
+ unlock: {
+ flex: 1,
+ backgroundColor: colors.accent,
+ borderRadius: 12,
+ paddingVertical: spacing.m,
+ alignItems: 'center',
+ },
+ unlockText: { ...type.heading, color: colors.accentText },
});
diff --git a/apps/mobile/src/screens/PaywallScreen.tsx b/apps/mobile/src/screens/PaywallScreen.tsx
index 6f9b420..b39d719 100644
--- a/apps/mobile/src/screens/PaywallScreen.tsx
+++ b/apps/mobile/src/screens/PaywallScreen.tsx
@@ -1,16 +1,40 @@
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
+import { getAccessToken, signIn, type Provider } from '../auth/session';
import { purchase, restore, type Plan } from '../purchases';
import { colors, spacing, type } from '../theme';
interface Props {
onSubscribed: () => void;
+ onDismiss: () => void;
}
-export function PaywallScreen({ onSubscribed }: Props) {
+/**
+ * Shown when the backend has a complete analysis ready. Guests sign in
+ * first (purchases must attach to an account), then choose a plan.
+ */
+export function PaywallScreen({ onSubscribed, onDismiss }: Props) {
+ const [signedIn, setSignedIn] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
+ useEffect(() => {
+ getAccessToken().then((token) => setSignedIn(token !== null));
+ }, []);
+
+ async function handleSignIn(provider: Provider) {
+ setBusy(true);
+ setError(null);
+ try {
+ await signIn(provider);
+ setSignedIn(true);
+ } catch {
+ setError('Sign-in did not complete. Please try again.');
+ } finally {
+ setBusy(false);
+ }
+ }
+
async function run(action: () => Promise<'free' | 'active'>) {
setBusy(true);
setError(null);
@@ -35,20 +59,52 @@ export function PaywallScreen({ onSubscribed }: Props) {
Continue your journey
- You have used your free conversations. Subscribe to continue.
+ Your analysis and recommended steps are ready. Premium unlocks them and lets the
+ conversation continue without limits.
- buy('monthly')} disabled={busy}>
- Monthly
-
- buy('yearly')} disabled={busy}>
- Yearly
-
- run(restore)} disabled={busy}>
- Restore Purchase
-
+ {signedIn === false ? (
+ <>
+ handleSignIn('apple')}
+ disabled={busy}
+ >
+ Continue with Apple
+
+ handleSignIn('google')}
+ disabled={busy}
+ >
+ Continue with Google
+
+ handleSignIn('email')}
+ disabled={busy}
+ >
+ Continue with Email
+
+ >
+ ) : signedIn === true ? (
+ <>
+ buy('monthly')} disabled={busy}>
+ Monthly
+
+ buy('yearly')} disabled={busy}>
+ Yearly
+
+ run(restore)} disabled={busy}>
+ Restore Purchase
+
+ >
+ ) : null}
{error ? {error} : null}
+
+ Not now
+
);
@@ -77,7 +133,17 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
primaryButtonText: { ...type.heading, color: colors.accentText },
- restore: { alignItems: 'center', paddingVertical: spacing.m },
- restoreText: { ...type.body, color: colors.accent },
+ secondaryButton: {
+ backgroundColor: colors.surface,
+ borderColor: colors.border,
+ borderWidth: 1,
+ paddingVertical: spacing.m,
+ borderRadius: 10,
+ alignItems: 'center',
+ },
+ secondaryButtonText: { ...type.heading },
+ textButton: { alignItems: 'center', paddingVertical: spacing.m },
+ textButtonText: { ...type.body, color: colors.accent },
+ dismissText: { ...type.caption },
error: { ...type.caption, color: '#8A3B2E', textAlign: 'center' },
});
diff --git a/apps/mobile/src/screens/WelcomeScreen.tsx b/apps/mobile/src/screens/WelcomeScreen.tsx
deleted file mode 100644
index 5ad6289..0000000
--- a/apps/mobile/src/screens/WelcomeScreen.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import { useState } from 'react';
-import { Pressable, StyleSheet, Text, View } from 'react-native';
-import { signIn, type Provider } from '../auth/session';
-import { colors, spacing, type } from '../theme';
-
-interface Props {
- onSignedIn: () => void;
-}
-
-export function WelcomeScreen({ onSignedIn }: Props) {
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState(null);
-
- async function handleSignIn(provider: Provider) {
- setBusy(true);
- setError(null);
- try {
- await signIn(provider);
- onSignedIn();
- } catch {
- setError('Sign-in did not complete. Please try again.');
- } finally {
- setBusy(false);
- }
- }
-
- return (
-
-
- NeuroSemantics AI
-
- A quiet conversation partner for neurosemantics and NLP.
-
-
-
- handleSignIn('apple')}
- disabled={busy}
- >
- Continue with Apple
-
- handleSignIn('google')}
- disabled={busy}
- >
- Continue with Google
-
- handleSignIn('email')}
- disabled={busy}
- >
- Continue with Email
-
- {error ? {error} : null}
- Start free. No card required.
-
-
- );
-}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: colors.background,
- paddingHorizontal: spacing.l,
- justifyContent: 'space-between',
- },
- top: { marginTop: spacing.xxl * 2 },
- title: { ...type.title, textAlign: 'center' },
- subtitle: {
- ...type.body,
- color: colors.textMuted,
- textAlign: 'center',
- marginTop: spacing.m,
- },
- actions: { marginBottom: spacing.xxl, gap: spacing.s },
- primaryButton: {
- backgroundColor: colors.accent,
- paddingVertical: spacing.m,
- borderRadius: 10,
- alignItems: 'center',
- },
- primaryButtonText: { ...type.heading, color: colors.accentText },
- secondaryButton: {
- backgroundColor: colors.surface,
- borderColor: colors.border,
- borderWidth: 1,
- paddingVertical: spacing.m,
- borderRadius: 10,
- alignItems: 'center',
- },
- secondaryButtonText: { ...type.heading },
- error: { ...type.caption, color: '#8A3B2E', textAlign: 'center', marginTop: spacing.s },
- footnote: { ...type.caption, textAlign: 'center', marginTop: spacing.m },
-});
diff --git a/infra/lib/neurosemantics-stack.ts b/infra/lib/neurosemantics-stack.ts
index 3cb368e..7f50ad1 100644
--- a/infra/lib/neurosemantics-stack.ts
+++ b/infra/lib/neurosemantics-stack.ts
@@ -1,5 +1,5 @@
import { CfnOutput, Duration, RemovalPolicy, Stack, type StackProps } from 'aws-cdk-lib';
-import { HttpApi, HttpMethod } from 'aws-cdk-lib/aws-apigatewayv2';
+import { HttpApi, HttpMethod, HttpNoneAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2';
import { HttpJwtAuthorizer } from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
import { HttpLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import * as cognito from 'aws-cdk-lib/aws-cognito';
@@ -141,7 +141,7 @@ export class NeuroSemanticsStack extends Stack {
DB_SECRET_ARN: db.secret?.secretArn ?? '',
DB_NAME,
KNOWLEDGE_DIR: 'knowledge',
- FREE_MESSAGE_LIMIT: '50',
+ MESSAGE_CAP: '200',
USAGE_RESET_DAYS: '30',
},
});
@@ -161,6 +161,21 @@ export class NeuroSemanticsStack extends Stack {
api.addRoutes({ path: '/chat', methods: [HttpMethod.POST], integration });
api.addRoutes({ path: '/subscription/verify', methods: [HttpMethod.POST], integration });
+ // Public routes: the user meets the chat before any registration.
+ const publicAuthorizer = new HttpNoneAuthorizer();
+ api.addRoutes({
+ path: '/suggestions',
+ methods: [HttpMethod.GET],
+ integration,
+ authorizer: publicAuthorizer,
+ });
+ api.addRoutes({
+ path: '/guest/chat',
+ methods: [HttpMethod.POST],
+ integration,
+ authorizer: publicAuthorizer,
+ });
+
new CfnOutput(this, 'ApiUrl', { value: api.apiEndpoint });
new CfnOutput(this, 'UserPoolId', { value: userPool.userPoolId });
new CfnOutput(this, 'UserPoolClientId', { value: userPoolClient.userPoolClientId });
diff --git a/services/api/src/chat.ts b/services/api/src/chat.ts
index 9404bc8..78be1f7 100644
--- a/services/api/src/chat.ts
+++ b/services/api/src/chat.ts
@@ -7,6 +7,14 @@ export interface ChatMessage {
content: string;
}
+export type ChatMode = 'free' | 'premium';
+
+export interface ChatResult {
+ reply: string;
+ /** Free mode only: the model judges a complete analysis is ready to present. */
+ analysisReady: boolean;
+}
+
const BASE_INSTRUCTIONS = `You are NeuroSemantics AI — a calm, precise conversation partner
specialized in neurosemantics and NLP (Neuro-Linguistic Programming).
@@ -19,16 +27,74 @@ Principles:
distress or a medical condition, recommend seeking professional help.
- Answer in the language the user writes in.`;
+const FREE_INSTRUCTIONS = `# Conversation mode: free tier (discovery)
+
+Work in discovery mode. In every reply you should:
+- ask relevant follow-up questions (one at a time),
+- identify and name patterns you notice,
+- show genuine understanding of the user's situation,
+- build toward a complete analysis.
+
+Do not yet present the full analysis, the recommended strategy, or concrete
+exercises.
+
+Set "analysis_ready" to true ONLY when all of the following are genuinely met:
+- the user has described their problem,
+- you have enough information to give a concrete, personal recommendation,
+- a specific action plan is ready to present.
+
+When analysis_ready is true, the reply must be a calm, natural transition —
+not an interruption mid-answer. Summarize at a high level what you have
+understood and that a concrete strategy is ready. Example of tone:
+"I think I'm starting to understand what lies behind this situation, and I
+can see some clear communication patterns. I also have a concrete strategy
+I would recommend for your specific situation. Continue with Premium to see
+the analysis and the recommended steps."
+
+Never:
+- manufacture urgency or emotional pressure to drive a purchase,
+- claim readiness or insight you do not have,
+- stop in the middle of answering a direct question,
+- mention Premium in any other situation.
+
+Otherwise, set analysis_ready to false.`;
+
+const PREMIUM_INSTRUCTIONS = `# Conversation mode: premium
+
+The user has full access. Deliver complete value:
+- when your analysis is ready, present it in full: the analysis, recommended
+ strategies, and concrete exercises,
+- if the conversation ends with your own message announcing that an analysis
+ is ready, the user has just unlocked Premium — deliver the full analysis
+ and the recommended steps now, without being asked again,
+- continue the dialogue without restriction.
+
+Always set "analysis_ready" to false; it is not used in this mode.`;
+
+const RESPONSE_SCHEMA = {
+ type: 'object',
+ properties: {
+ reply: { type: 'string' },
+ analysis_ready: { type: 'boolean' },
+ },
+ required: ['reply', 'analysis_ready'],
+ additionalProperties: false,
+} as const;
+
/**
* Calls the OpenAI Responses API with the knowledge base as system
* instructions and the conversation as input. Conversations are held by the
* client and passed through — nothing is persisted server-side.
+ *
+ * The model returns structured output so the backend — not a hardcoded
+ * message count — decides when the paywall moment has arrived.
*/
-export async function generateReply(messages: ChatMessage[]): Promise {
+export async function generateReply(messages: ChatMessage[], mode: ChatMode): Promise {
const appSecret = await getSecret(config.appSecretArn);
const apiKey = appSecret.OPENAI_API_KEY;
if (!apiKey) throw new Error('OPENAI_API_KEY missing from application secret');
+ const modeInstructions = mode === 'premium' ? PREMIUM_INSTRUCTIONS : FREE_INSTRUCTIONS;
const response = await fetch('https://api.openai.com/v1/responses', {
method: 'POST',
headers: {
@@ -37,8 +103,16 @@ export async function generateReply(messages: ChatMessage[]): Promise {
},
body: JSON.stringify({
model: config.openAiModel,
- instructions: `${BASE_INSTRUCTIONS}\n\n# Knowledge base\n\n${loadKnowledgeBase()}`,
+ instructions: `${BASE_INSTRUCTIONS}\n\n${modeInstructions}\n\n# Knowledge base\n\n${loadKnowledgeBase()}`,
input: messages.map((m) => ({ role: m.role, content: m.content })),
+ text: {
+ format: {
+ type: 'json_schema',
+ name: 'chat_turn',
+ strict: true,
+ schema: RESPONSE_SCHEMA,
+ },
+ },
}),
});
@@ -56,7 +130,11 @@ export async function generateReply(messages: ChatMessage[]): Promise {
.filter((part) => part.type === 'output_text')
.map((part) => part.text ?? '')
.join('');
-
if (!text) throw new Error('OpenAI response contained no output text');
- return text;
+
+ const parsed = JSON.parse(text) as { reply: string; analysis_ready: boolean };
+ return {
+ reply: parsed.reply,
+ analysisReady: mode === 'free' && parsed.analysis_ready === true,
+ };
}
diff --git a/services/api/src/config.ts b/services/api/src/config.ts
index d400135..9a8b489 100644
--- a/services/api/src/config.ts
+++ b/services/api/src/config.ts
@@ -3,8 +3,12 @@
* variables (set by the CDK stack) — secrets never live here.
*/
export const config = {
- /** Number of free messages before the paywall is shown (~5–10 conversations). */
- freeMessageLimit: Number(process.env.FREE_MESSAGE_LIMIT ?? '50'),
+ /**
+ * Abuse backstop, NOT the paywall. The paywall is intelligent: the model
+ * decides when an analysis is ready. This cap only bounds how many free
+ * messages a single user/device can send per reset window.
+ */
+ messageCap: Number(process.env.MESSAGE_CAP ?? '200'),
/** Free-tier usage resets after this many days. */
usageResetDays: Number(process.env.USAGE_RESET_DAYS ?? '30'),
openAiModel: process.env.OPENAI_MODEL ?? 'gpt-4.1-mini',
diff --git a/services/api/src/handler.ts b/services/api/src/handler.ts
index ea0d664..b6fa1bf 100644
--- a/services/api/src/handler.ts
+++ b/services/api/src/handler.ts
@@ -1,10 +1,11 @@
-import type { APIGatewayProxyEventV2WithJWTAuthorizer, APIGatewayProxyResultV2 } from 'aws-lambda';
+import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
import { generateReply, type ChatMessage } from './chat.js';
import { config } from './config.js';
import { getOrCreateUser, getUsage, incrementUsage, resetUsage, type UserRow } from './db.js';
import { verifyAndApplyPurchase } from './subscription/index.js';
import type { VerifyPurchaseRequest } from './subscription/types.js';
import { canSendMessage, shouldResetUsage } from './usage.js';
+import suggestionsFile from '../suggestions.json';
function json(statusCode: number, body: unknown): APIGatewayProxyResultV2 {
return {
@@ -20,10 +21,17 @@ interface Identity {
provider: string;
}
-function identityFromClaims(event: APIGatewayProxyEventV2WithJWTAuthorizer): Identity {
- const claims = event.requestContext.authorizer.jwt.claims;
+/** Reads the Cognito JWT claims on authorized routes; null on public routes. */
+function identityFromEvent(event: APIGatewayProxyEventV2): Identity | null {
+ const authorizer = (
+ event.requestContext as {
+ authorizer?: { jwt?: { claims?: Record } };
+ }
+ ).authorizer;
+ const claims = authorizer?.jwt?.claims;
+ if (!claims) return null;
const sub = String(claims.sub ?? '');
- if (!sub) throw new Error('JWT is missing a sub claim');
+ if (!sub) return null;
const email = String(claims.email ?? '');
// Cognito sets an `identities` claim for federated sign-ins (Apple/Google).
const identities = String(claims.identities ?? '');
@@ -49,29 +57,43 @@ async function handleMe(user: UserRow): Promise {
return json(200, {
subscriptionStatus: user.subscription_status,
messagesUsed: usage.messages_used,
- freeMessageLimit: config.freeMessageLimit,
+ messageCap: config.messageCap,
});
}
+/**
+ * The paywall is intelligent: no hardcoded message count. In free mode the
+ * model works in discovery (follow-up questions, patterns, understanding)
+ * and signals `analysisReady` when a concrete, valuable answer could be
+ * delivered — that is the paywall moment, returned as `paywall: true`.
+ * Premium mode delivers the full analysis, including immediately after
+ * unlock (the transcript then ends with the assistant's transition message).
+ */
async function handleChat(
user: UserRow,
body: string | undefined,
): Promise {
+ const premium = user.subscription_status === 'active';
const parsed = body ? (JSON.parse(body) as { messages?: ChatMessage[] }) : {};
const messages = parsed.messages ?? [];
const last = messages[messages.length - 1];
- if (!last || last.role !== 'user' || typeof last.content !== 'string' || !last.content.trim()) {
+ const validLast =
+ last &&
+ typeof last.content === 'string' &&
+ last.content.trim() &&
+ (last.role === 'user' || (premium && last.role === 'assistant'));
+ if (!validLast) {
return json(400, { error: 'messages must end with a non-empty user message' });
}
const usage = await currentUsage(user);
- if (!canSendMessage(usage.messages_used, user.subscription_status, config.freeMessageLimit)) {
- return json(402, { error: 'free_limit_reached' });
+ if (!canSendMessage(usage.messages_used, user.subscription_status, config.messageCap)) {
+ return json(402, { error: 'message_cap_reached' });
}
- const reply = await generateReply(messages);
+ const result = await generateReply(messages, premium ? 'premium' : 'free');
await incrementUsage(user.id);
- return json(200, { reply });
+ return json(200, { reply: result.reply, paywall: result.analysisReady });
}
async function handleVerifyPurchase(
@@ -90,14 +112,33 @@ async function handleVerifyPurchase(
return json(200, { subscriptionStatus: status });
}
-export async function handler(
- event: APIGatewayProxyEventV2WithJWTAuthorizer,
-): Promise {
+/** Guests chat before signing in, identified by an app-generated device id. */
+async function handleGuestChat(event: APIGatewayProxyEventV2): Promise {
+ const deviceId = event.headers?.['x-device-id'] ?? '';
+ if (!/^[0-9a-fA-F-]{8,64}$/.test(deviceId)) {
+ return json(400, { error: 'a valid X-Device-Id header is required' });
+ }
+ const user = await getOrCreateUser(`guest:${deviceId}`, '', 'guest');
+ return handleChat(user, event.body);
+}
+
+export async function handler(event: APIGatewayProxyEventV2): Promise {
try {
- const identity = identityFromClaims(event);
- const user = await getOrCreateUser(identity.sub, identity.email, identity.provider);
const route = `${event.requestContext.http.method} ${event.rawPath}`;
+ // Public routes (no sign-in before the first question).
+ if (route === 'GET /suggestions') {
+ return json(200, { suggestions: suggestionsFile.suggestions });
+ }
+ if (route === 'POST /guest/chat') {
+ return await handleGuestChat(event);
+ }
+
+ // Authorized routes (Cognito JWT, enforced by API Gateway).
+ const identity = identityFromEvent(event);
+ if (!identity) return json(401, { error: 'unauthorized' });
+ const user = await getOrCreateUser(identity.sub, identity.email, identity.provider);
+
switch (route) {
case 'GET /me':
return await handleMe(user);
diff --git a/services/api/suggestions.json b/services/api/suggestions.json
new file mode 100644
index 0000000..f3ff6ca
--- /dev/null
+++ b/services/api/suggestions.json
@@ -0,0 +1,14 @@
+{
+ "suggestions": [
+ "I need support before a difficult conversation.",
+ "Help me prepare for a salary negotiation.",
+ "I'm going on a first date.",
+ "Why do I get stuck in the same conflicts?",
+ "Help me understand my reactions.",
+ "How do I set clearer boundaries?",
+ "I want to become a better communicator.",
+ "Help me prepare for a job interview.",
+ "How do I build better self-confidence?",
+ "Help me make a difficult decision."
+ ]
+}