Version: 1.0 Date: 2026-03-27 Status: Pre-development
- Product Overview
- Architecture
- Tech Stack
- Authentication
- Database Schema
- API Endpoints
- Claude Agent Design
- Gmail Receipt Parser
- Mobile App Screens
- Notification System
- Location Intelligence
- Health Intelligence
- All 58 Requirements
- Phased Roadmap
- Known Pitfalls & Mitigations
- Cost Estimate
- Environment Setup
Palate is a persistent conversational food agent. You talk to it about food -- rate a meal, complain about bad packaging, ask what to eat, mention that yesterday's order gave you indigestion two days later. The agent figures out what to do with everything you say: update ratings, add health notes, adjust recommendations, blacklist a place.
It auto-imports delivery orders from Gmail (Grab receipts) and detects dine-in visits via background geolocation + Google Places API. Built for someone who travels constantly and orders food daily but can never remember what was good.
Never forget a good meal, never reorder from a bad place. The app remembers so you don't have to.
The agent is the primary interface. The UI (photos, quick-tap ratings, leaderboard) exists for things that are faster to tap than say. But you should never HAVE to use the UI -- everything is doable through conversation.
A single user who:
- Is based in Bangkok but travels constantly across countries
- Orders food delivery (primarily Grab) almost daily
- Grab order history disappears after a couple months -- permanent history is a key motivator
- Has ~201 Grab receipts in Gmail going back months, all from
no-reply@grab.comwith subject "Your Grab E-Receipt" - Wants voice-first interaction -- typing is acceptable but speaking is preferred
- Values honest, direct recommendations -- not "here are 10 options", more like "get the green curry from that place you loved"
- This is a solo personal tool -- no multi-user, no social features
- Auto-import: Grab receipts arrive in Gmail. The system detects them via Gmail API watch + Vercel Cron, parses them (restaurant, items, prices, booking ID), and stores them in Postgres.
- Rate: ~30 minutes after a delivery, you get a push notification. Tap to rate (quick buttons or talk to the agent). Or rate anytime conversationally: "that green curry yesterday was amazing, 5 stars."
- Remember: The agent maintains a persistent conversation. It knows your taste profile, health sensitivities, favorite dishes, and blacklisted places. It never starts fresh.
- Recommend: Ask "what should I eat?" and the agent considers your location, taste profile, recent eating patterns, health notes, and blacklist to give personalized suggestions.
- Detect dine-in: Background geolocation + Google Places detects when you visit a restaurant. After you leave, the app prompts "Looks like you were at Sushi Masa. How was it?"
- Track health: Weekly digests of eating patterns. Correlates health complaints with specific foods over time. Builds a sensitivity profile ("cream sauces = indigestion").
+-------------------------------+
| React Native (Expo) App |
| iOS + Android |
| |
| +-------------------------+ | +--------------------+
| | Conversation UI | | | Anthropic API |
| | (Voice/Text/Buttons) | |<----->| (Claude Sonnet 4) |
| +-------------------------+ | | + Tool Use |
| | Local Storage (offline) | | | + Compaction |
| +-------------------------+ | +--------------------+
+-------------------------------+
|
v
+-------------------------------+ +--------------------+
| Next.js API Routes | | Cloudflare R2 |
| (Vercel Serverless) |<----->| (Image Storage) |
| | +--------------------+
| - Agent orchestration |
| - Gmail polling/webhook | +--------------------+
| - Push notification send | | Google Cloud |
| - Image upload presign |<----->| Pub/Sub |
| - Cron job handlers | | (Gmail push) |
+-------------------------------+ +--------------------+
|
v
+-------------------------------+
| Neon Postgres |
| (Serverless, Drizzle ORM) |
| |
| - Orders & items |
| - Ratings & notes |
| - Restaurants & branches |
| - Conversation history |
| - User profile & prefs |
| - Push subscriptions |
| - Gmail sync state |
+-------------------------------+
| Component | Responsibility | Communicates With |
|---|---|---|
| React Native App | UI rendering, voice/text input, background geolocation, push notification handling, photo capture, offline caching | API Routes via HTTPS |
| API Routes (Agent) | Claude orchestration, tool execution, conversation management, streaming responses | Anthropic API, Neon Postgres, R2 |
| API Routes (Ingest) | Gmail receipt parsing, order creation, notification dispatch | Gmail API, Neon Postgres, Expo Push |
| Neon Postgres | Persistent storage for all structured data | API Routes only (never direct client access) |
| Cloudflare R2 | Image blob storage (food photos) | API Routes (presigned upload), Client (read via public CDN URL) |
| Google Cloud Pub/Sub | Gmail new-email event delivery | Gmail API, Webhook API Route |
| Anthropic API | LLM inference, tool calling, compaction | Agent API Route |
| Expo Push | Push notification delivery to iOS/Android | API Routes |
Gmail receives Grab receipt
-> Pub/Sub pushes to /api/gmail/webhook
-> Handler fetches full email via Gmail API
-> Claude Haiku parses receipt into structured data
-> Order + items created in Postgres
-> Restaurant created/updated (with normalization)
-> Notification queued for ~30min later
-> Vercel Cron picks up queue, sends Expo Push notification
-> User taps notification -> app opens to order
-> User speaks/types rating -> Claude agent stores it
User says "that green curry yesterday was amazing, 5 stars"
-> React Native captures voice (Expo Speech) or text input
-> POST /api/agent with message
-> Load conversation history from DB (uncompacted messages)
-> Send to Claude with tools + system prompt + user context
-> Claude calls search_orders({ query: "green curry", date_range: "yesterday" })
-> Tool returns matching order
-> Claude calls create_rating({ order_id: "...", overall_rating: 5, note: "amazing" })
-> Rating stored in DB
-> Claude responds: "Rated your green curry from [restaurant] 5 stars!"
-> Response stored in conversation history
-> Streamed back to mobile app
User says "what should I eat? something spicy under 200 baht"
-> POST /api/agent
-> Claude calls get_user_profile() -> taste preferences + current city
-> Claude calls get_recommendations({ mood: "spicy", budget: 200 })
-> Tool queries DB: highly rated, spicy items, under 200 baht, in current city, not blacklisted
-> Claude synthesizes: "Get the tom yum from that place you loved on Sukhumvit.
You rated it 5 stars last month and it's 180 baht."
User enters a restaurant area (background geolocation detects significant location change)
-> App queries Google Places API for nearby restaurants
-> Match found: "Sushi Masa"
-> User stays for 30+ minutes then leaves
-> App sends push notification: "Looks like you were at Sushi Masa. How was it?"
-> User taps -> conversation opens with pre-filled context
-> User rates via voice/text/buttons
User has no connectivity
-> User taps quick-rate button on an order
-> Rating saved to local async storage queue
-> UI shows "saved offline, will sync"
-> Connectivity restored
-> App processes sync queue, POSTs to /api/agent
-> Postgres updated, conversation history updated
| Technology | Version | Purpose | Why Chosen |
|---|---|---|---|
| React Native | 0.76+ | Cross-platform mobile framework | Real background geolocation, reliable push notifications, native voice input. PWA cannot do background geolocation. |
| Expo | SDK 52+ | React Native tooling, managed workflow | Simplifies builds, OTA updates, push notifications (Expo Push), EAS Build for store submissions. |
| Expo Router | v4 | File-based navigation | Familiar Next.js-like routing for React Native. Deep linking support. |
| TypeScript | 5.7+ | Type safety | Non-negotiable for a project with this many integrations. Catches tool schema mismatches at compile time. |
Why React Native (Expo) over PWA: PWAs cannot do background geolocation (browser limitation -- Geolocation API stops when page is backgrounded, service workers do not have access to it). PWAs also have unreliable push notifications on iOS (only works when installed to home screen, iOS 16.4+). React Native with Expo provides real native capabilities: background location tracking, reliable push via Expo Push, native voice input, and native camera access.
| Technology | Version | Purpose | Why Chosen |
|---|---|---|---|
| Next.js | 16.2 | API routes, serverless backend | Latest stable (Mar 2026). App Router mature. Vercel-native deployment. 400% faster dev startup in 16.2. |
| Vercel | (hosting) | Serverless deployment | Zero-config deployment for Next.js. Free Hobby tier sufficient for single user. |
Why Next.js for backend only (not full-stack): The mobile app is React Native, so Next.js serves purely as the API layer. No SSR/SSG needed -- just API routes.
| Technology | Version | Purpose | Why Chosen |
|---|---|---|---|
| @anthropic-ai/sdk | ^0.80.0 | Claude API client, Messages API with tool use | The standard SDK, NOT the Agent SDK. |
Why NOT the Claude Agent SDK (@anthropic-ai/claude-agent-sdk): The Agent SDK wraps Claude Code -- it is designed for agents that read files, run shell commands, and edit code. It gives Claude control of the execution loop. Palate needs Claude to parse natural language into structured data (ratings, notes, searches) and call domain-specific tools (rate_order, search_restaurants, log_health_note). The standard Anthropic SDK with tool use gives full control over the agent loop, conversation history, and tool execution.
Why NOT Vercel AI SDK (@ai-sdk/anthropic): Adds an abstraction layer. The direct SDK gives full control over tool schemas, conversation management, and the compaction API. Vercel AI SDK is useful for multi-provider apps, but Palate is committed to Claude.
| Technology | Version | Purpose | Why Chosen |
|---|---|---|---|
| Neon Postgres | (serverless) | Primary database, all structured data | Multi-device sync. Serverless = scale-to-zero on free tier. Vercel-native integration. Full Postgres (JSONB, full-text search, potential PostGIS). |
| @neondatabase/serverless | ^1.0.2 | Neon serverless driver | Low-latency WebSocket connections from Vercel serverless functions. |
| Drizzle ORM | ^0.45.2 | Schema definition, queries, migrations | TypeScript-first, SQL-like API (7.4kb, zero deps). First-class Neon support. Schema-as-code with drizzle-kit migrations. |
| drizzle-kit | (latest) | Migration tooling | Generate and run migrations from schema changes. |
Why NOT SQLite: Project requires multi-device sync (phone + laptop). SQLite is local-only. Neon's free tier (100 CU-hours/month, 0.5GB storage) is generous.
Why NOT Supabase: Bundles unnecessary features (auth, realtime, storage). Neon is leaner -- just Postgres, done well.
Why NOT Prisma: Generates a heavy client (~3MB). Drizzle is 7.4KB. Prisma's query API abstracts too much SQL. Drizzle stays close to SQL, which matters for complex queries (leaderboards, pattern detection).
| Technology | Version | Purpose | Why Chosen |
|---|---|---|---|
| Cloudflare R2 | (service) | Food photo storage | 10GB free tier. ZERO egress fees. S3-compatible API. |
| @aws-sdk/client-s3 | ^3.x | R2 client (S3-compatible) | R2 uses S3 protocol. Standard AWS SDK works. |
| @aws-sdk/s3-request-presigner | ^3.x | Presigned upload URLs | Client uploads directly to R2 via presigned URL. |
Why NOT Vercel Blob: Charges for egress. A food journal with photos serves images frequently (browsing gallery, viewing past orders). R2's zero egress fees make it strictly better.
| Technology | Version | Purpose | Why Chosen |
|---|---|---|---|
| expo-notifications | (latest) | Push notification handling on device | Native push for iOS + Android. Reliable delivery. Background notification handling. |
| Expo Push API | (service) | Server-side push delivery | Free. Handles APNs (iOS) and FCM (Android) routing. No separate setup for each platform. |
Why Expo Push over web-push/FCM directly: Expo Push provides a unified API for both platforms. Single push token per device, single API call to send. Free for any volume a personal tool would need.
| Technology | Version | Purpose | Why Chosen |
|---|---|---|---|
| expo-speech (or React Native Voice) | (latest) | Native speech-to-text | Native speech recognition via OS APIs. Works reliably on both iOS and Android including background. |
Why native over Web Speech API: Web Speech API has quirky support on iOS (partial, events may not fire correctly in standalone PWA mode). Native speech recognition via Expo/React Native is reliable on both platforms.
| Technology | Version | Purpose | Why Chosen |
|---|---|---|---|
| expo-location | (latest) | Foreground + background geolocation | Native background location tracking. Significant location change monitoring without GPS battery drain. |
| Google Places API (New) | v1 | Nearby restaurant lookup from coordinates | Official "New" API (POST-based). Returns place names, addresses, types. |
| googleapis | ^171.4.0 | Google API client for Places + Gmail | Single client for all Google API interactions. |
| Technology | Version | Purpose | Why Chosen |
|---|---|---|---|
| googleapis | ^171.4.0 | Gmail API client | OAuth2 with gmail.readonly scope. users.messages.list + users.messages.get. |
| Google Cloud Pub/Sub | (service) | Real-time new email notifications | Gmail users.watch() pushes to Pub/Sub topic. Webhook receives notification. |
| Technology | Version | Purpose | Why Chosen |
|---|---|---|---|
| Vercel Cron | (built-in) | Scheduled jobs | Gmail sync fallback, notification queue processing, Gmail watch renewal, weekly health digest. |
| Technology | Version | Purpose | Why Chosen |
|---|---|---|---|
| NativeWind | v4 | Tailwind CSS for React Native | Familiar Tailwind utility classes in React Native. Consistent with web mental model. |
| React Native Reanimated | ^3.x | Animations | Smooth 60fps animations for chat messages, card transitions, rating interactions. |
| Lucide React Native | (latest) | Icons | Tree-shakeable. Consistent icon set. |
| Technology | Version | Purpose |
|---|---|---|
| drizzle-kit | (latest) | DB migration generation |
| @types/node | (latest) | Node.js type definitions |
| typescript | 5.7+ | TypeScript compiler |
# Mobile app (React Native / Expo)
npx create-expo-app palate-app --template tabs
cd palate-app
npx expo install expo-router expo-location expo-notifications expo-camera expo-image-picker expo-speech expo-secure-store expo-crypto
npm install nativewind react-native-reanimated lucide-react-native
npm install @react-native-async-storage/async-storage
# Backend (Next.js API routes)
npx create-next-app palate-api --typescript --app
cd palate-api
npm install @anthropic-ai/sdk
npm install drizzle-orm @neondatabase/serverless
npm install googleapis
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
npm install expo-server-sdk # For sending Expo Push notifications
npm install -D drizzle-kit @types/nodeThe user authenticates with Google OAuth once. This single flow provides:
- App identity -- who the user is (Google account)
- Gmail API access -- permission to read Gmail (for receipt import)
There is no separate "app login" system. Google OAuth IS the login.
1. User taps "Sign in with Google" in the React Native app
2. App opens Google OAuth consent screen via expo-auth-session
- Scopes requested: openid, email, profile, gmail.readonly
3. User grants permission (one-time consent)
4. Google returns authorization code to app via redirect
5. App sends code to backend: POST /api/auth/google/callback
6. Backend exchanges code for access_token + refresh_token
7. Backend stores tokens in Postgres (encrypted)
8. Backend creates a session token and returns it to the app
9. App stores session token in expo-secure-store
10. All subsequent API calls include session token in Authorization header
// Token storage in Postgres
interface GoogleTokens {
accessToken: string; // Expires in 1 hour
refreshToken: string; // Long-lived, stored encrypted
expiresAt: Date; // When access token expires
scopes: string[]; // Granted scopes
}
// Token refresh strategy:
// 1. Before any Gmail API call, check if accessToken is expired
// 2. If expired, use refreshToken to get a new accessToken
// 3. If refreshToken is revoked (401), trigger re-auth flow
// 4. Store the new tokens- Session token stored in
expo-secure-store(encrypted native keychain) - Token survives app restarts, phone reboots
- Backend validates session token on every request
- If session is invalid/expired, app shows login screen
- User should effectively never need to re-login unless they explicitly sign out or Google revokes the refresh token
- API keys (Anthropic, Google, R2) NEVER leave the backend
- Google refresh token stored encrypted in Postgres
- Session token is a random UUID mapped to the user in Postgres
- All API routes validate the session token before processing
All tables defined in Drizzle ORM format. Database is Neon Postgres (serverless).
import {
pgTable,
uuid,
text,
boolean,
integer,
doublePrecision,
timestamp,
jsonb,
index,
uniqueIndex,
} from 'drizzle-orm/pg-core';
// ============================================================
// USERS
// ============================================================
// Single-user app, but schema supports future multi-user
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
email: text('email').notNull().unique(),
name: text('name'),
googleId: text('google_id').notNull().unique(),
// Encrypted Google OAuth tokens for Gmail API
googleAccessToken: text('google_access_token'),
googleRefreshToken: text('google_refresh_token'),
googleTokenExpiresAt: timestamp('google_token_expires_at'),
// App session
sessionToken: text('session_token').unique(),
sessionExpiresAt: timestamp('session_expires_at'),
// Timezone and location
timezone: text('timezone').default('Asia/Bangkok'),
currentCity: text('current_city'),
currentCountry: text('current_country'),
lastLocationLat: doublePrecision('last_location_lat'),
lastLocationLng: doublePrecision('last_location_lng'),
lastLocationAt: timestamp('last_location_at'),
// Push notification token (Expo)
expoPushToken: text('expo_push_token'),
// Preferences
notificationsEnabled: boolean('notifications_enabled').default(true),
quietHoursStart: integer('quiet_hours_start').default(22), // 10pm
quietHoursEnd: integer('quiet_hours_end').default(8), // 8am
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
});
// ============================================================
// TASTE PROFILES
// ============================================================
// Evolves over time as the agent learns preferences
export const tasteProfiles = pgTable('taste_profiles', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id).notNull().unique(),
preferredCuisines: jsonb('preferred_cuisines').$type<string[]>().default([]),
avoidCuisines: jsonb('avoid_cuisines').$type<string[]>().default([]),
flavorPreferences: jsonb('flavor_preferences').$type<Record<string, number>>().default({}),
// e.g., { "spicy": 0.8, "sweet": 0.3, "sour": 0.6 }
foodSensitivities: jsonb('food_sensitivities')
.$type<{ food: string; reaction: string; confidence: number }[]>()
.default([]),
dietaryNotes: text('dietary_notes'), // Freeform, updated by agent
averageBudget: jsonb('average_budget')
.$type<Record<string, number>>() // { "THB": 250, "USD": 15 }
.default({}),
lastUpdated: timestamp('last_updated').defaultNow(),
});
// ============================================================
// RESTAURANTS
// ============================================================
// Canonical restaurant entity (brand level)
export const restaurants = pgTable('restaurants', {
id: uuid('id').defaultRandom().primaryKey(),
name: text('name').notNull(),
normalizedName: text('normalized_name').notNull(), // lowercase, trimmed, for matching
googlePlaceId: text('google_place_id'),
cuisine: text('cuisine'), // Inferred over time
isBlacklisted: boolean('is_blacklisted').default(false),
blacklistReason: text('blacklist_reason'),
averageRating: doublePrecision('average_rating'), // Auto-derived
ratingStdDev: doublePrecision('rating_std_dev'), // For consistency tracking
totalOrders: integer('total_orders').default(0),
instructionComplianceRate: doublePrecision('instruction_compliance_rate'),
// e.g., 0.6 means "ignores your notes 40% of the time"
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
}, (table) => [
index('idx_restaurants_normalized_name').on(table.normalizedName),
index('idx_restaurants_cuisine').on(table.cuisine),
]);
// ============================================================
// RESTAURANT BRANCHES
// ============================================================
// Physical locations of a restaurant brand
export const restaurantBranches = pgTable('restaurant_branches', {
id: uuid('id').defaultRandom().primaryKey(),
restaurantId: uuid('restaurant_id').references(() => restaurants.id).notNull(),
branchName: text('branch_name'), // "FuelBite Thonglor", "FuelBite Ekkamai"
city: text('city'),
country: text('country'),
latitude: doublePrecision('latitude'),
longitude: doublePrecision('longitude'),
googlePlaceId: text('google_place_id'),
createdAt: timestamp('created_at').defaultNow(),
}, (table) => [
index('idx_branches_restaurant').on(table.restaurantId),
index('idx_branches_city').on(table.city),
]);
// ============================================================
// ORDERS
// ============================================================
// One per Grab receipt or dine-in visit
export const orders = pgTable('orders', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id).notNull(),
restaurantId: uuid('restaurant_id').references(() => restaurants.id),
branchId: uuid('branch_id').references(() => restaurantBranches.id),
bookingId: text('booking_id').unique(), // Grab booking ID for dedup
orderType: text('order_type').notNull(), // 'grab_food' | 'grab_mart' | 'dine_in'
source: text('source').notNull(), // 'gmail_import' | 'manual' | 'geolocation'
totalAmount: doublePrecision('total_amount'),
currency: text('currency').default('THB'),
orderDate: timestamp('order_date').notNull(),
city: text('city'),
country: text('country'),
latitude: doublePrecision('latitude'),
longitude: doublePrecision('longitude'),
isForSomeoneElse: boolean('is_for_someone_else').default(false),
isGroupOrder: boolean('is_group_order').default(false),
// Rating fields
rating: doublePrecision('rating'), // Overall 1-5
wouldReorder: boolean('would_reorder'),
packagingRating: doublePrecision('packaging_rating'), // 1-5
arrivedHot: boolean('arrived_hot'),
instructionsFollowed: boolean('instructions_followed'),
ratedAt: timestamp('rated_at'),
note: text('note'), // Free-form note
tags: jsonb('tags').$type<string[]>().default([]), // 'hangover', 'date_night', etc.
timeOfDay: text('time_of_day'), // 'breakfast' | 'lunch' | 'dinner' | 'snack'
rawReceiptHtml: text('raw_receipt_html'), // Original receipt for re-parsing
isBackfill: boolean('is_backfill').default(false), // True for historical imports
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
}, (table) => [
index('idx_orders_user').on(table.userId),
index('idx_orders_restaurant').on(table.restaurantId),
index('idx_orders_date').on(table.orderDate),
index('idx_orders_city').on(table.city),
uniqueIndex('idx_orders_booking_id').on(table.bookingId),
]);
// ============================================================
// ORDER ITEMS
// ============================================================
// Individual dishes/items in an order
export const orderItems = pgTable('order_items', {
id: uuid('id').defaultRandom().primaryKey(),
orderId: uuid('order_id').references(() => orders.id).notNull(),
name: text('name').notNull(),
quantity: integer('quantity').default(1),
price: doublePrecision('price'),
currency: text('currency'),
rating: doublePrecision('rating'), // Per-dish rating (optional)
note: text('note'),
isFavorite: boolean('is_favorite').default(false),
category: text('category'), // 'fried', 'soup', 'rice', 'vegetable', etc.
createdAt: timestamp('created_at').defaultNow(),
}, (table) => [
index('idx_order_items_order').on(table.orderId),
index('idx_order_items_favorite').on(table.isFavorite),
]);
// ============================================================
// RATINGS
// ============================================================
// Separate ratings table for historical tracking
// (orders table has current rating; this tracks all rating events)
export const ratings = pgTable('ratings', {
id: uuid('id').defaultRandom().primaryKey(),
orderId: uuid('order_id').references(() => orders.id).notNull(),
orderItemId: uuid('order_item_id').references(() => orderItems.id),
userId: uuid('user_id').references(() => users.id).notNull(),
ratingType: text('rating_type').notNull(), // 'overall' | 'dish' | 'packaging' | 'value'
score: doublePrecision('score').notNull(), // 1-5
note: text('note'),
source: text('source').notNull(), // 'quick_tap' | 'conversation' | 'voice'
createdAt: timestamp('created_at').defaultNow(),
}, (table) => [
index('idx_ratings_order').on(table.orderId),
index('idx_ratings_user').on(table.userId),
]);
// ============================================================
// HEALTH NOTES
// ============================================================
export const healthNotes = pgTable('health_notes', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id).notNull(),
orderId: uuid('order_id').references(() => orders.id),
note: text('note').notNull(),
symptom: text('symptom'), // 'indigestion', 'bloating', etc.
severity: text('severity'), // 'mild' | 'moderate' | 'severe'
reportedAt: timestamp('reported_at').defaultNow(),
daysAfterOrder: integer('days_after_order'), // Auto-computed
}, (table) => [
index('idx_health_notes_user').on(table.userId),
index('idx_health_notes_order').on(table.orderId),
index('idx_health_notes_symptom').on(table.symptom),
]);
// ============================================================
// PHOTOS
// ============================================================
export const photos = pgTable('photos', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id).notNull(),
orderId: uuid('order_id').references(() => orders.id),
orderItemId: uuid('order_item_id').references(() => orderItems.id),
r2Key: text('r2_key').notNull(), // Cloudflare R2 object key
publicUrl: text('public_url').notNull(), // CDN URL for display
width: integer('width'),
height: integer('height'),
sizeBytes: integer('size_bytes'),
createdAt: timestamp('created_at').defaultNow(),
}, (table) => [
index('idx_photos_order').on(table.orderId),
index('idx_photos_user').on(table.userId),
]);
// ============================================================
// CONVERSATIONS
// ============================================================
// Conversation container (one per user for now)
export const conversations = pgTable('conversations', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id).notNull(),
title: text('title').default('Main'),
lastMessageAt: timestamp('last_message_at'),
messageCount: integer('message_count').default(0),
createdAt: timestamp('created_at').defaultNow(),
});
// ============================================================
// MESSAGES
// ============================================================
// Individual messages in a conversation
export const messages = pgTable('messages', {
id: uuid('id').defaultRandom().primaryKey(),
conversationId: uuid('conversation_id').references(() => conversations.id).notNull(),
role: text('role').notNull(), // 'user' | 'assistant'
content: jsonb('content').notNull(),
// Content is the full Anthropic content block array:
// [{ type: 'text', text: '...' }] for user/assistant text
// [{ type: 'tool_use', id: '...', name: '...', input: {...} }] for tool calls
// [{ type: 'tool_result', tool_use_id: '...', content: '...' }] for tool results
compacted: boolean('compacted').default(false), // True = before last compaction point
tokenCount: integer('token_count'), // Estimated for budget tracking
createdAt: timestamp('created_at').defaultNow(),
}, (table) => [
index('idx_messages_conversation').on(table.conversationId),
index('idx_messages_compacted').on(table.compacted),
]);
// ============================================================
// BLACKLIST
// ============================================================
export const blacklist = pgTable('blacklist', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id).notNull(),
restaurantId: uuid('restaurant_id').references(() => restaurants.id).notNull(),
reason: text('reason'),
autoSuggested: boolean('auto_suggested').default(false),
createdAt: timestamp('created_at').defaultNow(),
}, (table) => [
uniqueIndex('idx_blacklist_user_restaurant').on(table.userId, table.restaurantId),
]);
// ============================================================
// FAVORITES
// ============================================================
export const favorites = pgTable('favorites', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id).notNull(),
orderItemId: uuid('order_item_id').references(() => orderItems.id).notNull(),
restaurantId: uuid('restaurant_id').references(() => restaurants.id).notNull(),
note: text('note'), // "always order this"
createdAt: timestamp('created_at').defaultNow(),
}, (table) => [
index('idx_favorites_user').on(table.userId),
]);
// ============================================================
// NOTIFICATION QUEUE
// ============================================================
export const notificationQueue = pgTable('notification_queue', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id).notNull(),
orderId: uuid('order_id').references(() => orders.id),
type: text('type').notNull(), // 'rate_order' | 'rate_reminder' | 'dine_in_prompt' | 'health_digest' | 'welcome_back'
scheduledFor: timestamp('scheduled_for').notNull(),
sentAt: timestamp('sent_at'),
snoozedUntil: timestamp('snoozed_until'),
dismissed: boolean('dismissed').default(false),
payload: jsonb('payload'), // Extra data for the notification
createdAt: timestamp('created_at').defaultNow(),
}, (table) => [
index('idx_notification_queue_scheduled').on(table.scheduledFor),
index('idx_notification_queue_user').on(table.userId),
]);
// ============================================================
// GMAIL SYNC STATE
// ============================================================
export const gmailSyncState = pgTable('gmail_sync_state', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id).notNull().unique(),
historyId: text('history_id').notNull(), // Last processed historyId
watchExpiration: timestamp('watch_expiration'), // When watch() needs renewal
lastFullSync: timestamp('last_full_sync'),
lastWebhookAt: timestamp('last_webhook_at'),
totalImported: integer('total_imported').default(0),
updatedAt: timestamp('updated_at').defaultNow(),
});bookingIdas dedup key on orders: Prevents importing the same Grab order twice. UsesON CONFLICT DO NOTHINGon insert.normalizedNameon restaurants: Handles "FuelBite Thonglor" and "FuelBite Ekkamai" as the same brand. Lowercase, trimmed, stripped of branch suffixes.restaurantBranchesseparate fromrestaurants: A restaurant brand has many physical locations. Ratings roll up to the brand level.compactedflag on messages: Enables efficient conversation loading -- only fetchWHERE compacted = false. Avoids scanning full history.rawReceiptHtmlpreserved on orders: Allows re-parsing if the receipt parser improves later.- JSONB for flexible fields:
tags,foodSensitivities,flavorPreferences-- schema evolves without migrations. - Separate
healthNotestable: Health observations can correlate across multiple orders over time. Not just a field on the order. - Separate
ratingstable + rating fields onorders: Theorderstable has the current rating for quick access. Theratingstable tracks all rating events for historical analysis. isBackfillflag on orders: Historical imports from Gmail backfill never trigger nag notifications. This flag controls that behavior.notificationQueuetable: Enables smart notification timing, snooze, batch unrated, and timezone-aware delivery.
All routes are Next.js API routes deployed on Vercel. Base URL: https://palate-api.vercel.app
| Method | Path | Description | Auth Required |
|---|---|---|---|
| POST | /api/auth/google/callback |
Exchange Google OAuth code for session | No (this creates the session) |
| POST | /api/auth/logout |
Invalidate session | Yes |
| GET | /api/auth/me |
Get current user info | Yes |
// POST /api/auth/google/callback
// Request:
{ code: string; redirectUri: string }
// Response:
{ sessionToken: string; user: { id, email, name } }
// GET /api/auth/me
// Response:
{ user: { id, email, name, currentCity, timezone } }| Method | Path | Description | Auth Required |
|---|---|---|---|
| POST | /api/agent |
Send message to Claude agent (streaming) | Yes |
| GET | /api/agent/history |
Get conversation history | Yes |
// POST /api/agent
// Request:
{
message: string; // User's text (or voice transcript)
inputType: 'text' | 'voice'; // How the user provided input
}
// Response: Server-Sent Events (SSE) stream
// Each event is one of:
// data: { type: 'text_delta', text: string }
// data: { type: 'tool_use', tool: string, input: object }
// data: { type: 'tool_result', result: object }
// data: { type: 'message_complete', messageId: string }
// data: { type: 'error', error: string }
// GET /api/agent/history
// Query: ?limit=50&before=<messageId>
// Response:
{ messages: Array<{ id, role, content, createdAt }> }| Method | Path | Description | Auth Required |
|---|---|---|---|
| GET | /api/orders |
List orders (paginated, filterable) | Yes |
| GET | /api/orders/:id |
Get single order with items | Yes |
| PATCH | /api/orders/:id |
Update order metadata | Yes |
| DELETE | /api/orders/:id |
Delete an order | Yes |
// GET /api/orders
// Query: ?city=Bangkok&from=2026-01-01&to=2026-03-27&type=grab_food&rated=false&limit=20&offset=0
// Response:
{
orders: Array<{
id, restaurantName, orderType, totalAmount, currency,
orderDate, city, rating, wouldReorder, note, tags, items: []
}>;
total: number;
hasMore: boolean;
}
// PATCH /api/orders/:id
// Request (partial update):
{
note?: string;
tags?: string[];
timeOfDay?: string;
isForSomeoneElse?: boolean;
isGroupOrder?: boolean;
}
// Response:
{ order: { ...updatedOrder } }| Method | Path | Description | Auth Required |
|---|---|---|---|
| POST | /api/orders/:id/rate |
Quick-tap rate an order | Yes |
| POST | /api/orders/:id/items/:itemId/rate |
Rate a specific dish | Yes |
// POST /api/orders/:id/rate
// Request:
{
rating: number; // 1-5
wouldReorder?: boolean;
packagingRating?: number; // 1-5
arrivedHot?: boolean;
instructionsFollowed?: boolean;
note?: string;
}
// Response:
{ order: { ...updatedOrder }, ratingId: string }
// POST /api/orders/:id/items/:itemId/rate
// Request:
{
rating: number; // 1-5
note?: string;
isFavorite?: boolean;
}
// Response:
{ item: { ...updatedItem }, ratingId: string }| Method | Path | Description | Auth Required |
|---|---|---|---|
| POST | /api/photos/upload-url |
Get presigned R2 upload URL | Yes |
| POST | /api/photos |
Register uploaded photo in DB | Yes |
| GET | /api/photos |
List photos (filterable) | Yes |
| DELETE | /api/photos/:id |
Delete a photo | Yes |
// POST /api/photos/upload-url
// Request:
{ contentType: string; orderId?: string; orderItemId?: string }
// Response:
{ uploadUrl: string; r2Key: string; publicUrl: string }
// POST /api/photos
// Request:
{
r2Key: string;
publicUrl: string;
orderId?: string;
orderItemId?: string;
width?: number;
height?: number;
sizeBytes?: number;
}
// Response:
{ photo: { id, publicUrl, orderId, createdAt } }
// GET /api/photos
// Query: ?orderId=...&restaurantId=...&city=Bangkok&minRating=4&limit=20&offset=0
// Response:
{ photos: Array<{ id, publicUrl, orderId, restaurantName, rating, city, createdAt }> }| Method | Path | Description | Auth Required |
|---|---|---|---|
| GET | /api/restaurants |
List restaurants (leaderboard) | Yes |
| GET | /api/restaurants/:id |
Get restaurant details + stats | Yes |
| POST | /api/restaurants/:id/blacklist |
Add/remove from blacklist | Yes |
// GET /api/restaurants
// Query: ?city=Bangkok&cuisine=Thai&sortBy=rating&limit=20&offset=0
// Response:
{
restaurants: Array<{
id, name, cuisine, city, averageRating, ratingStdDev,
totalOrders, isBlacklisted, instructionComplianceRate
}>;
total: number;
}
// GET /api/restaurants/:id
// Response:
{
restaurant: { ...full restaurant data },
branches: [...],
recentOrders: [...],
favoriteItems: [...],
stats: {
averageRating, totalOrders, ratingTrend,
consistency, topItems, worstItems
}
}
// POST /api/restaurants/:id/blacklist
// Request:
{ action: 'add' | 'remove'; reason?: string }
// Response:
{ restaurant: { ...updated } }| Method | Path | Description | Auth Required |
|---|---|---|---|
| POST | /api/gmail/webhook |
Pub/Sub push notification handler | Verified via Pub/Sub signature |
| POST | /api/gmail/sync |
Manual trigger full Gmail sync | Yes |
| GET | /api/gmail/status |
Get sync status | Yes |
// POST /api/gmail/webhook
// Request: Google Pub/Sub message envelope
// Response: 200 OK (must respond quickly)
// POST /api/gmail/sync
// Response:
{ imported: number; skipped: number; errors: string[] }
// GET /api/gmail/status
// Response:
{
lastSync: string; // ISO timestamp
totalImported: number;
watchActive: boolean;
watchExpires: string; // ISO timestamp
}| Method | Path | Description | Auth Required |
|---|---|---|---|
| GET | /api/health/digest |
Get weekly eating pattern digest | Yes |
| GET | /api/health/sensitivities |
Get detected food sensitivities | Yes |
// GET /api/health/digest
// Query: ?weeks=1
// Response:
{
period: { from: string; to: string };
patterns: {
totalOrders: number;
categoryBreakdown: { fried: 5, soup: 2, rice: 3, vegetable: 1 };
averageRating: number;
healthNotes: Array<{ date, symptom, relatedOrder }>;
};
insights: string[]; // Claude-generated insights
}
// GET /api/health/sensitivities
// Response:
{
sensitivities: Array<{
food: string;
reaction: string;
confidence: number; // 0-1
occurrences: number;
}>
}| Method | Path | Description | Auth Required |
|---|---|---|---|
| POST | /api/notifications/register |
Register Expo push token | Yes |
| POST | /api/notifications/:id/snooze |
Snooze a notification | Yes |
| POST | /api/notifications/:id/dismiss |
Dismiss a notification | Yes |
| GET | /api/notifications/pending |
Get pending notifications | Yes |
// POST /api/notifications/register
// Request:
{ expoPushToken: string }
// Response:
{ success: true }
// POST /api/notifications/:id/snooze
// Request:
{ until?: string } // ISO timestamp, defaults to tomorrow
// Response:
{ notification: { ...updated, snoozedUntil } }| Method | Path | Description | Auth Required |
|---|---|---|---|
| POST | /api/location/update |
Update user's current location | Yes |
| POST | /api/location/check-restaurant |
Check if near a restaurant | Yes |
// POST /api/location/update
// Request:
{ latitude: number; longitude: number; city?: string; country?: string }
// Response:
{ updated: true; city: string; country: string }
// POST /api/location/check-restaurant
// Request:
{ latitude: number; longitude: number }
// Response:
{
nearbyRestaurants: Array<{
name: string;
googlePlaceId: string;
distance: number; // meters
knownRestaurantId?: string; // if we have it in our DB
}>
}| Method | Path | Schedule | Description |
|---|---|---|---|
| GET | /api/cron/gmail-sync |
Every 30 min | Fallback Gmail poll + process new receipts |
| GET | /api/cron/send-notifications |
Every 30 min | Process notification queue |
| GET | /api/cron/renew-gmail-watch |
Daily 6am UTC | Re-call gmail.users.watch() |
| GET | /api/cron/health-digest |
Weekly Sunday 9am | Generate weekly eating pattern summary |
All cron endpoints are secured with Vercel's CRON_SECRET Bearer token.
// vercel.json
{
"crons": [
{ "path": "/api/cron/gmail-sync", "schedule": "*/30 * * * *" },
{ "path": "/api/cron/send-notifications", "schedule": "*/30 * * * *" },
{ "path": "/api/cron/renew-gmail-watch", "schedule": "0 6 * * *" },
{ "path": "/api/cron/health-digest", "schedule": "0 9 * * 0" }
]
}Note: Vercel Hobby plan limits cron frequency to once per day. For more frequent scheduling (every 30 min), upgrade to Vercel Pro ($20/mo) or use a free external scheduler (GitHub Actions, cron-job.org) to call these endpoints.
const buildSystemPrompt = (userContext: UserContext): string => `
You are Palate, a personal food journal assistant. You have a direct, honest personality.
You remember everything about the user's food experiences and preferences.
## Your Capabilities
- Rate orders and individual dishes (create, update, correct)
- Search order history by restaurant, dish, date, city, rating
- Log health observations and track food sensitivities
- Blacklist restaurants and manage favorites
- Recommend restaurants and dishes based on preferences
- Track eating patterns and provide health insights
- Handle corrections ("actually that was lunch not dinner")
- Parse natural language input including venting ("that place was garbage")
## Current User Context
- Name: ${userContext.name}
- Current city: ${userContext.currentCity || 'Unknown'}
- Current country: ${userContext.currentCountry || 'Unknown'}
- Timezone: ${userContext.timezone}
- Preferred cuisines: ${userContext.preferredCuisines.join(', ') || 'Still learning'}
- Food sensitivities: ${userContext.foodSensitivities.map(s => `${s.food} -> ${s.reaction}`).join(', ') || 'None known'}
- Recent orders (last 7 days): ${userContext.recentOrdersSummary}
## Behavioral Rules
1. Parse sentiment from casual input. "That place was garbage" = low rating + blacklist suggestion.
2. Handle delayed feedback naturally. "That order from 2 days ago gave me indigestion" = find the order, add health note.
3. Never ask unnecessary clarifying questions. If the user says "5 stars", rate the most recent unrated order.
4. When recommending food, consider: location, taste profile, recent patterns, health sensitivities, blacklist.
5. Give direct recommendations, not lists of 10 options. "Get the green curry from that place you loved" is better than "Here are some options..."
6. For corrections, find the record and fix it. Don't ask "which order do you mean?" if there's only one plausible match.
7. When the user vents, acknowledge their frustration, then take action (low rating, blacklist suggestion).
8. Track time-of-day context. If it's 7pm and the user asks "what should I eat?", assume dinner.
9. Warn the user if they're about to interact with a blacklisted restaurant.
10. If you're not sure which order the user means, ask -- but make it conversational, not bureaucratic.
11. Never mention your tools by name. Don't say "I'll use the search_orders tool." Just do it.
12. Keep responses concise. This is a chat, not an essay.
`;All tools use strict: true for constrained decoding (Claude's tool calls are guaranteed to match the schema exactly).
const AGENT_TOOLS: Anthropic.Tool[] = [
// ---- SEARCH & RETRIEVAL ----
{
name: 'search_orders',
description: 'Search past orders by restaurant name, dish name, date range, city, rating, or any combination. Use this when the user asks about past orders, wants to find a specific meal, or you need to identify an order for rating/updating.',
input_schema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Free-text search query (restaurant name, dish name, description)'
},
filters: {
type: 'object',
properties: {
city: { type: 'string' },
country: { type: 'string' },
date_from: { type: 'string', description: 'ISO date string' },
date_to: { type: 'string', description: 'ISO date string' },
min_rating: { type: 'number', minimum: 1, maximum: 5 },
max_rating: { type: 'number', minimum: 1, maximum: 5 },
cuisine: { type: 'string' },
order_type: { type: 'string', enum: ['grab_food', 'grab_mart', 'dine_in'] },
is_rated: { type: 'boolean' },
tags: { type: 'array', items: { type: 'string' } },
restaurant_id: { type: 'string' }
}
},
limit: { type: 'number', default: 10 },
sort_by: {
type: 'string',
enum: ['date_desc', 'date_asc', 'rating_desc', 'rating_asc'],
default: 'date_desc'
}
},
required: ['query']
}
},
// ---- RATING ----
{
name: 'rate_order',
description: 'Rate an order overall or rate individual dishes within an order. Use when the user provides ratings through conversation.',
input_schema: {
type: 'object',
properties: {
order_id: { type: 'string', description: 'UUID of the order to rate' },
overall_rating: { type: 'number', minimum: 1, maximum: 5 },
would_reorder: { type: 'boolean' },
packaging_rating: { type: 'number', minimum: 1, maximum: 5 },
arrived_hot: { type: 'boolean' },
instructions_followed: { type: 'boolean' },
note: { type: 'string', description: 'Free-form note about the order' },
dish_ratings: {
type: 'array',
items: {
type: 'object',
properties: {
item_name: { type: 'string' },
rating: { type: 'number', minimum: 1, maximum: 5 },
note: { type: 'string' },
is_favorite: { type: 'boolean' }
},
required: ['item_name', 'rating']
}
},
tags: {
type: 'array',
items: { type: 'string' },
description: 'Mood/occasion tags like "hangover food", "date night", "quick lunch"'
},
is_for_someone_else: { type: 'boolean' }
},
required: ['order_id', 'overall_rating']
}
},
// ---- ORDER MANAGEMENT ----
{
name: 'update_order',
description: 'Update order metadata. Use for corrections like changing time of day, fixing notes, marking as group order, etc.',
input_schema: {
type: 'object',
properties: {
order_id: { type: 'string' },
updates: {
type: 'object',
properties: {
note: { type: 'string' },
tags: { type: 'array', items: { type: 'string' } },
time_of_day: { type: 'string', enum: ['breakfast', 'lunch', 'dinner', 'snack'] },
is_for_someone_else: { type: 'boolean' },
is_group_order: { type: 'boolean' }
}
}
},
required: ['order_id', 'updates']
}
},
// ---- HEALTH ----
{
name: 'log_health_note',
description: 'Log a health observation tied to an order or general. Use when the user reports symptoms like indigestion, bloating, allergic reactions related to food.',
input_schema: {
type: 'object',
properties: {
order_id: { type: 'string', description: 'UUID of related order, if identifiable' },
note: { type: 'string', description: 'Description of the health observation' },
symptom: {
type: 'string',
description: 'Categorized symptom',
enum: ['indigestion', 'bloating', 'nausea', 'heartburn', 'allergic_reaction', 'headache', 'fatigue', 'other']
},
severity: {
type: 'string',
enum: ['mild', 'moderate', 'severe']
}
},
required: ['note']
}
},
// ---- BLACKLIST ----
{
name: 'blacklist_restaurant',
description: 'Add or remove a restaurant from the blacklist. Use when the user explicitly wants to blacklist, or auto-suggest after repeated bad ratings.',
input_schema: {
type: 'object',
properties: {
restaurant_id: { type: 'string' },
action: { type: 'string', enum: ['add', 'remove'] },
reason: { type: 'string' }
},
required: ['restaurant_id', 'action']
}
},
// ---- RECOMMENDATIONS ----
{
name: 'get_recommendations',
description: 'Get food/restaurant recommendations based on user preferences, location, mood, and budget. Use when the user asks what to eat.',
input_schema: {
type: 'object',
properties: {
mood: { type: 'string', description: 'Current mood or craving (e.g., "spicy", "comfort food", "light")' },
cuisine: { type: 'string' },
budget: { type: 'number', description: 'Maximum price in local currency' },
exclude_recent_days: {
type: 'number',
default: 3,
description: 'Exclude restaurants ordered from in the last N days'
},
time_of_day: { type: 'string', enum: ['breakfast', 'lunch', 'dinner', 'snack'] },
tags: { type: 'array', items: { type: 'string' } }
}
}
},
// ---- CORRECTIONS ----
{
name: 'log_correction',
description: 'Record a correction to any order field. Use when the user says things like "actually that was lunch not dinner" or "I meant the other restaurant".',
input_schema: {
type: 'object',
properties: {
order_id: { type: 'string' },
field: { type: 'string', description: 'Which field to correct' },
old_value: { type: 'string', description: 'Previous value (for audit)' },
new_value: { type: 'string', description: 'Corrected value' },
reason: { type: 'string', description: 'Why the correction was made' }
},
required: ['order_id', 'field', 'new_value']
}
},
// ---- LEADERBOARD ----
{
name: 'get_leaderboard',
description: 'Get restaurant rankings/leaderboard. Filterable by city, cuisine, and sort criteria.',
input_schema: {
type: 'object',
properties: {
city: { type: 'string' },
cuisine: { type: 'string' },
sort_by: {
type: 'string',
enum: ['rating', 'consistency', 'order_count', 'value'],
default: 'rating'
},
limit: { type: 'number', default: 10 }
}
}
},
// ---- SURPRISE ME ----
{
name: 'surprise_me',
description: 'Pick a random well-rated restaurant the user has not ordered from recently. The fun option.',
input_schema: {
type: 'object',
properties: {
min_rating: { type: 'number', default: 3.5 },
exclude_recent_days: { type: 'number', default: 14 },
cuisine: { type: 'string' }
}
}
},
// ---- HISTORY & STATS ----
{
name: 'get_history',
description: 'Get order history summary and statistics. Use for "how many times have I ordered from X" or "what did I eat last week" type queries.',
input_schema: {
type: 'object',
properties: {
restaurant_id: { type: 'string' },
city: { type: 'string' },
days: { type: 'number', description: 'Look back N days' },
include_stats: { type: 'boolean', default: false }
}
}
},
// ---- EATING PATTERNS ----
{
name: 'get_eating_patterns',
description: 'Analyze recent eating patterns for health insights. Returns food category breakdown, frequency analysis, and health correlations.',
input_schema: {
type: 'object',
properties: {
days: { type: 'number', default: 7 },
include_health_correlations: { type: 'boolean', default: true }
}
}
},
// ---- USER PROFILE ----
{
name: 'update_user_profile',
description: 'Update the user taste profile or preferences. Use when you learn new information about what the user likes or dislikes.',
input_schema: {
type: 'object',
properties: {
preferred_cuisines: { type: 'array', items: { type: 'string' } },
avoid_cuisines: { type: 'array', items: { type: 'string' } },
food_sensitivities: {
type: 'array',
items: {
type: 'object',
properties: {
food: { type: 'string' },
reaction: { type: 'string' }
},
required: ['food', 'reaction']
}
},
dietary_notes: { type: 'string' }
}
}
}
];Compaction API (Anthropic beta compact-2026-01-12):
- Automatically summarizes older context when input tokens exceed a configurable threshold
- Creates a
compactionblock that replaces all prior messages with a summary - Supported on Claude Sonnet 4 and later models
- No extra charge for compaction itself; saves ~90% on post-compaction requests with caching
async function agentLoop(
conversationId: string,
userMessage: string,
userContext: UserContext
): Promise<AsyncGenerator<StreamEvent>> {
// 1. Load conversation history (uncompacted messages only)
const history = await db.select()
.from(messages)
.where(and(
eq(messages.conversationId, conversationId),
eq(messages.compacted, false)
))
.orderBy(messages.createdAt);
// 2. Append new user message
const newUserMsg = { role: 'user', content: [{ type: 'text', text: userMessage }] };
await db.insert(messages).values({
conversationId,
role: 'user',
content: newUserMsg.content,
});
const allMessages = [...history.map(m => ({ role: m.role, content: m.content })), newUserMsg];
// 3. Build system prompt with dynamic context
const systemPrompt = buildSystemPrompt(userContext);
// 4. Agent loop with compaction
while (true) {
const response = await anthropic.beta.messages.create({
betas: ['compact-2026-01-12'],
model: 'claude-sonnet-4-6',
max_tokens: 4096,
system: systemPrompt,
tools: AGENT_TOOLS,
messages: allMessages,
stream: true,
context_management: {
edits: [{
type: 'compact_20260112',
trigger: { type: 'input_tokens', value: 80000 }
}]
}
});
// Stream response tokens to client
let assistantContent = [];
for await (const event of response) {
if (event.type === 'content_block_delta') {
yield { type: 'text_delta', text: event.delta.text };
}
// Collect full content for storage
assistantContent.push(event);
}
// Store assistant response
await db.insert(messages).values({
conversationId,
role: 'assistant',
content: assistantContent,
});
// If stop_reason is 'end_turn', we're done
if (response.stop_reason === 'end_turn') break;
// Execute tool calls
const toolCalls = assistantContent.filter(b => b.type === 'tool_use');
const toolResults = await Promise.all(
toolCalls.map(async (toolCall) => {
const result = await executeToolLocally(toolCall.name, toolCall.input);
return {
type: 'tool_result',
tool_use_id: toolCall.id,
content: JSON.stringify(result)
};
})
);
allMessages.push({ role: 'assistant', content: assistantContent });
allMessages.push({ role: 'user', content: toolResults });
// Store tool results
await db.insert(messages).values({
conversationId,
role: 'user',
content: toolResults,
});
}
}Use Claude Haiku 3.5 for cheap internal operations and Sonnet 4 for the main conversational agent.
| Task | Model | Why |
|---|---|---|
| Conversational agent (user-facing) | Claude Sonnet 4 | Complex reasoning, multi-turn, tool selection |
| Receipt parsing | Claude Haiku 3.5 | Structured extraction, no reasoning needed |
| Food categorization | Claude Haiku 3.5 | Simple classification |
| Conversation summarization | Claude Sonnet 4 | Needs to preserve nuance |
| Health pattern analysis | Claude Sonnet 4 | Complex correlation |
Haiku is ~10x cheaper than Sonnet. Estimated split: 70% of Claude calls use Haiku, 30% use Sonnet.
Emails come from no-reply@grab.com with subject "Your Grab E-Receipt". The HTML body contains mixed Thai and English text with the following structure:
- Booking ID: Alphanumeric code (e.g.,
A-7849302618) - Date/Time: Order date and time (e.g.,
26 Mar 2026, 19:34) - Restaurant name: Often in Thai script or mixed Thai/English (e.g.,
FuelBite - Thongloror a Thai name) - Items: List of items with names (sometimes Thai), quantities, and prices in THB
- Subtotal / Total: Order total including delivery fee, sometimes with promo discounts
- Payment method: Credit card, GrabPay, etc.
- Order type: GrabFood or GrabMart (indicated by branding in the email)
Rather than fragile regex/DOM parsing on undocumented HTML, use Claude Haiku with forced tool use to extract structured data. This handles Thai/English mixed content, format changes, and edge cases gracefully.
import Anthropic from '@anthropic-ai/sdk';
const orderSchema = {
type: 'object',
properties: {
booking_id: { type: 'string', description: 'Grab booking/order ID' },
restaurant_name: { type: 'string', description: 'Restaurant name as shown' },
order_type: { type: 'string', enum: ['grab_food', 'grab_mart'] },
order_date: { type: 'string', description: 'ISO 8601 date-time string' },
currency: { type: 'string', default: 'THB' },
items: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
quantity: { type: 'number' },
price: { type: 'number' }
},
required: ['name', 'quantity', 'price']
}
},
subtotal: { type: 'number' },
delivery_fee: { type: 'number' },
discount: { type: 'number' },
total: { type: 'number' },
payment_method: { type: 'string' }
},
required: ['booking_id', 'restaurant_name', 'order_type', 'order_date', 'items', 'total']
};
async function parseGrabReceipt(rawHtml: string): Promise<ParsedOrder> {
// Strip HTML tags for cleaner input but preserve structure
const textContent = htmlToText(rawHtml);
const response = await anthropic.messages.create({
model: 'claude-haiku-4-5',
max_tokens: 1024,
tools: [{
name: 'create_order',
description: 'Extract order details from a Grab receipt email',
input_schema: orderSchema
}],
tool_choice: { type: 'tool', name: 'create_order' },
messages: [{
role: 'user',
content: `Parse this Grab receipt email and extract the order details. The receipt may contain Thai and English text mixed together. Extract all items with their prices.\n\n${textContent}`
}]
});
const toolUse = response.content.find(b => b.type === 'tool_use');
if (!toolUse) throw new Error('Failed to parse receipt');
return toolUse.input as ParsedOrder;
}async function importOrder(parsed: ParsedOrder, rawHtml: string): Promise<boolean> {
// Check for duplicate by booking_id
const existing = await db.select()
.from(orders)
.where(eq(orders.bookingId, parsed.booking_id))
.limit(1);
if (existing.length > 0) {
return false; // Already imported, skip
}
// ON CONFLICT DO NOTHING as extra safety
await db.insert(orders).values({
bookingId: parsed.booking_id,
// ... rest of fields
rawReceiptHtml: rawHtml,
}).onConflictDoNothing({ target: orders.bookingId });
return true;
}function normalizeRestaurantName(rawName: string): string {
return rawName
.toLowerCase()
.trim()
// Remove common branch suffixes
.replace(/\s*[-–—]\s*(sukhumvit|thonglor|ekkamai|silom|asoke|sathorn|ari|siam|on nut|bang na|phra khanong|ladprao|ratchada|rama\s*\d+|branch\s*\d*).*$/i, '')
// Remove parenthetical branch info
.replace(/\s*\([^)]*\)\s*$/, '')
// Collapse whitespace
.replace(/\s+/g, ' ')
.trim();
}
// "FuelBite - Thonglor" -> "fuelbite"
// "FuelBite (Ekkamai Branch)" -> "fuelbite"
// "Sushi Masa - Sukhumvit 33" -> "sushi masa"Additional normalization:
- Lowercase + trim whitespace
- Strip branch suffixes (common Bangkok area names)
- Google Places API canonical name as source of truth when available
- Fuzzy matching (Levenshtein distance) for similar names
- Store all name variants, display the canonical one
- Manual merge capability via agent conversation ("those two restaurants are the same place")
Every parsed field is validated before storage:
booking_id: Must be non-empty string. Reject if missing.restaurant_name: Must be non-empty. Reject if missing.order_date: Must be valid date. Reject if unparseable.items: Must have at least 1 item. Reject if empty.total: Must be positive number. Reject if zero or negative.
Failed parses are logged with the raw HTML for manual review. They never silently produce bad data.
The chat screen IS the app. It is the default screen and the primary way to interact with Palate.
Layout:
- Full-screen chat interface (message bubbles)
- Text input field at bottom with send button
- Microphone button for voice input (next to text field)
- Agent messages include inline action cards (rating cards, order summaries)
- Streaming text appears in real-time as Claude generates it
Behavior:
- On first open after an order import, the agent proactively says: "I see you just got food from [restaurant]. How was it?"
- Persistent conversation -- never shows a blank screen or "how can I help you?"
- Long-press on an agent message to copy text
- Tap on a mentioned restaurant to see its stats
- Tap on a mentioned order to see full details
Appears inline in chat or as a standalone card when tapping a notification.
Layout:
- Restaurant name + order date at top
- 5-star rating (tap to rate)
- "Would reorder?" toggle (thumbs up/down)
- Optional expandable section:
- Individual item ratings (each item from the order)
- Packaging rating (1-5)
- "Arrived hot?" toggle
- "Instructions followed?" toggle
- Free-form note text field
- Photo upload button (camera icon)
- Tags row (quick-tap pills: "hangover food", "date night", "quick lunch", custom)
- "Ordered for someone else" checkbox
- Submit button
Behavior:
- Minimum required: Overall rating (1-5). Everything else is optional.
- Rating the order also updates the agent's conversation context
- Can be triggered from push notification, order history, or agent suggestion
Scrollable list of past orders with filters.
Layout:
- Filter bar at top: City dropdown, Date range, Order type (Food/Mart/Dine-in), Rated/Unrated
- Each order card shows:
- Restaurant name + branch
- Date and time
- Items list (truncated)
- Total price + currency
- Rating stars (or "Unrated" badge)
- Photo thumbnail if available
- Pull-to-refresh
- Infinite scroll pagination
Behavior:
- Tap order to see full detail + rating card
- Swipe actions: Quick rate, Add to favorites
- Unrated orders have a prominent "Rate" button
Ranked list of restaurants.
Layout:
- Segmented control: By City / By Cuisine / All
- Sort dropdown: Rating, Consistency, Order Count, Value
- Each entry shows:
- Rank number
- Restaurant name
- Average rating (stars + number)
- Consistency indicator (stable/variable)
- Order count
- Instruction compliance rate
- Blacklisted badge (if applicable)
Behavior:
- Tap restaurant to see full stats, order history, photos
- Long-press to blacklist/unblacklist
- Search bar for quick lookup
Visual grid of food photos.
Layout:
- Grid view (3 columns) of food photos
- Filter tabs: All, By Restaurant, By City, By Rating (4+)
- Each photo shows restaurant name and rating as overlay
Behavior:
- Tap photo to see full-size with order details
- Swipe between photos in full-screen mode
- Share photo (system share sheet)
Layout:
- Account section: Google account info, logout
- Notifications: Enable/disable, quiet hours
- Gmail sync: Status, last sync time, manual sync button
- Data: Export all data (JSON), storage used
- About: Version, privacy info
Navigation:
- Bottom tab bar with 4 tabs:
- Chat (primary, center, largest)
- Orders (history list)
- Leaderboard
- Profile/Settings
- Photo gallery accessible from Orders and Leaderboard screens
// Mobile app: Register for push notifications
import * as Notifications from 'expo-notifications';
async function registerForPushNotifications() {
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
return null; // User denied
}
const token = (await Notifications.getExpoPushTokenAsync()).data;
// Send token to backend
await fetch('/api/notifications/register', {
method: 'POST',
headers: { Authorization: `Bearer ${sessionToken}` },
body: JSON.stringify({ expoPushToken: token })
});
return token;
}// Backend: Send push notification via Expo Push API
import { Expo } from 'expo-server-sdk';
const expo = new Expo();
async function sendPushNotification(
pushToken: string,
title: string,
body: string,
data?: Record<string, unknown>
) {
if (!Expo.isExpoPushToken(pushToken)) {
console.error('Invalid Expo push token:', pushToken);
return;
}
await expo.sendPushNotificationsAsync([{
to: pushToken,
sound: 'default',
title,
body,
data: data || {},
}]);
}async function shouldSendNotification(userId: string): Promise<boolean> {
const user = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (!user[0] || !user[0].notificationsEnabled) return false;
const timezone = user[0].timezone || 'Asia/Bangkok';
const now = new Date();
const localHour = getLocalHour(now, timezone);
// Quiet hours check (default 10pm - 8am)
const quietStart = user[0].quietHoursStart ?? 22;
const quietEnd = user[0].quietHoursEnd ?? 8;
if (quietStart > quietEnd) {
// Wraps around midnight (e.g., 22 - 8)
if (localHour >= quietStart || localHour < quietEnd) return false;
} else {
if (localHour >= quietStart && localHour < quietEnd) return false;
}
return true;
}
function getLocalHour(date: Date, timezone: string): number {
return parseInt(
new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
hour12: false,
timeZone: timezone
}).format(date)
);
}// Snooze: Reschedule for tomorrow at noon local time
async function snoozeNotification(notificationId: string, userId: string) {
const user = await getUser(userId);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
// Set to noon in user's timezone
const snoozedUntil = setLocalHour(tomorrow, 12, user.timezone);
await db.update(notificationQueue)
.set({ snoozedUntil })
.where(eq(notificationQueue.id, notificationId));
}
// Batch: If 3+ unrated orders exist, send one batch notification
async function processNotificationBatch(userId: string) {
const pending = await db.select()
.from(notificationQueue)
.where(and(
eq(notificationQueue.userId, userId),
eq(notificationQueue.type, 'rate_order'),
isNull(notificationQueue.sentAt),
isNull(notificationQueue.snoozedUntil),
eq(notificationQueue.dismissed, false)
));
if (pending.length === 0) return;
if (pending.length >= 3) {
// Batch notification
await sendPushNotification(
user.expoPushToken,
'Rate your recent orders',
`You have ${pending.length} unrated orders. Tap to catch up!`,
{ type: 'batch_rate', orderIds: pending.map(p => p.orderId) }
);
} else {
// Individual notifications
for (const notif of pending) {
const order = await getOrder(notif.orderId);
await sendPushNotification(
user.expoPushToken,
`How was ${order.restaurantName}?`,
'Tap to rate your order',
{ type: 'rate_order', orderId: order.id }
);
}
}
// Mark all as sent
await db.update(notificationQueue)
.set({ sentAt: new Date() })
.where(inArray(notificationQueue.id, pending.map(p => p.id)));
}- Gmail webhook receives new Grab receipt notification
- Parse receipt, create order in DB
- Insert into
notificationQueuewithscheduledFor = order_date + 30 minutes - Next cron run picks up the notification
- Check
shouldSendNotification()(timezone, quiet hours) - If OK, send: "How was your order from [restaurant]?"
- User taps -> app opens to the order's rating card
- Background geolocation detects user at a restaurant location for 30+ minutes
- User leaves the restaurant area
- App creates a dine-in order stub in DB
- Insert into
notificationQueuewithscheduledFor = now + 10 minutes - Push notification: "Looks like you were at Sushi Masa. How was it?"
- User taps -> agent conversation opens with context: "I noticed you visited Sushi Masa. Want to log how it was?"
Orders imported from Gmail history (backfill) have isBackfill = true. The notification queue logic explicitly skips these:
// When importing from Gmail backfill
if (isBackfillImport) {
await db.insert(orders).values({ ...orderData, isBackfill: true });
// DO NOT insert into notificationQueue
}Using expo-location for native background location tracking:
import * as Location from 'expo-location';
// Request background location permission
async function setupBackgroundLocation() {
const { status: foreground } = await Location.requestForegroundPermissionsAsync();
if (foreground !== 'granted') return false;
const { status: background } = await Location.requestBackgroundPermissionsAsync();
if (background !== 'granted') return false;
// Start monitoring significant location changes (battery-efficient)
await Location.startLocationUpdatesAsync('RESTAURANT_DETECTION', {
accuracy: Location.Accuracy.Balanced, // WiFi/cell, not GPS
timeInterval: 60000, // Check every 60 seconds max
distanceInterval: 100, // Only update if moved 100m
deferredUpdatesInterval: 300000, // Batch updates every 5 min in background
showsBackgroundLocationIndicator: true, // iOS: show blue status bar
foregroundService: { // Android: required for background
notificationTitle: 'Palate',
notificationBody: 'Detecting restaurant visits',
},
});
}async function checkNearbyRestaurants(
latitude: number,
longitude: number
): Promise<NearbyRestaurant[]> {
// Use Google Places API (New) - POST-based
const response = await fetch(
'https://places.googleapis.com/v1/places:searchNearby',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': process.env.GOOGLE_PLACES_API_KEY!,
'X-Goog-FieldMask': 'places.displayName,places.id,places.types,places.location'
},
body: JSON.stringify({
includedTypes: ['restaurant', 'cafe', 'meal_delivery', 'meal_takeaway'],
maxResultCount: 5,
locationRestriction: {
circle: {
center: { latitude, longitude },
radius: 50.0 // 50 meters
}
}
})
}
);
const data = await response.json();
return data.places || [];
}1. Background location update fires (user moved 100m+)
2. TaskManager handler runs:
a. Query Google Places for nearby restaurants within 50m
b. If match found, record "entered restaurant zone" with timestamp
c. Start tracking dwell time
3. Subsequent location update fires (still near restaurant):
a. User has been within 50m for 30+ minutes
b. Record this as a "likely dine-in visit"
4. Location update shows user has left the area (100m+ away):
a. Create dine-in order stub in DB
b. Queue notification: "Looks like you were at [restaurant]. How was it?"
5. User taps notification:
a. App opens to conversation with context
b. Agent asks about the visit
c. User rates via conversation or quick-tap
- Use
Accuracy.Balanced(WiFi/cell triangulation, NOT GPS) - Distance interval: 100m (skip updates for small movements)
- Deferred updates in background: batch every 5 minutes
- Cache Places API results aggressively -- if user hasn't moved 100m+, don't re-query
- Rate limit Places API: max 1 query per 5 minutes per location
async function detectCity(latitude: number, longitude: number): Promise<{ city: string; country: string }> {
// Use reverse geocoding
const [result] = await Location.reverseGeocodeAsync({ latitude, longitude });
return {
city: result?.city || result?.subregion || 'Unknown',
country: result?.country || 'Unknown'
};
}async function checkWelcomeBack(userId: string, currentCity: string): Promise<WelcomeBackData | null> {
const user = await getUser(userId);
// Did the city change?
if (user.currentCity === currentCity) return null;
// Have we been here before?
const previousVisits = await db.select()
.from(orders)
.where(and(
eq(orders.userId, userId),
eq(orders.city, currentCity)
))
.orderBy(desc(orders.orderDate))
.limit(5);
if (previousVisits.length === 0) {
// First time in this city
await updateUserCity(userId, currentCity);
return null;
}
// Returning to a known city!
await updateUserCity(userId, currentCity);
return {
city: currentCity,
lastVisit: previousVisits[0].orderDate,
topRestaurants: await getTopRestaurantsInCity(userId, currentCity, 3),
totalOrders: previousVisits.length,
};
// Agent message: "Welcome back to Bangkok! Last time you loved [restaurant] (rated 5 stars)."
}- Store original currency and amount on every order. Never convert.
- Compare prices only within the same currency.
- Display prices with currency symbol: "฿250", "$15", "€12"
- The agent understands currency context: "best value in Bangkok" only compares THB orders.
- No exchange rate API needed. Cross-currency comparison is explicitly out of scope.
Generated by a weekly cron job (Sunday 9am). Uses Claude Sonnet to analyze patterns and generate human-readable insights.
async function generateWeeklyDigest(userId: string): Promise<string> {
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
// Gather data
const recentOrders = await db.select()
.from(orders)
.innerJoin(orderItems, eq(orders.id, orderItems.orderId))
.where(and(
eq(orders.userId, userId),
gte(orders.orderDate, weekAgo)
));
const healthNotes = await db.select()
.from(healthNotes)
.where(and(
eq(healthNotes.userId, userId),
gte(healthNotes.reportedAt, weekAgo)
));
// Categorize items
const categories = categorizeItems(recentOrders);
// e.g., { fried: 5, soup: 2, rice: 3, vegetable: 1, noodle: 4 }
// Generate digest with Claude
const digest = await anthropic.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 512,
system: 'You are a health-conscious food analyst. Generate a brief, friendly weekly eating digest. Be direct but not preachy. Note patterns, flag concerns, celebrate good choices.',
messages: [{
role: 'user',
content: `Weekly eating data:
Orders: ${recentOrders.length}
Food categories: ${JSON.stringify(categories)}
Health complaints: ${JSON.stringify(healthNotes)}
Generate a 3-5 sentence digest.`
}]
});
return digest.content[0].text;
// Example output: "You ate 12 meals this week. Fried food 5 of 7 days -- that's up
// from last week. You had vegetables only once. Two indigestion reports, both after
// cream-based dishes. Consider mixing in a soup or salad day."
}async function detectCorrelations(userId: string): Promise<Correlation[]> {
// Get all health notes with their associated orders
const notes = await db.select({
symptom: healthNotes.symptom,
itemName: orderItems.name,
itemCategory: orderItems.category,
restaurantName: restaurants.name,
})
.from(healthNotes)
.innerJoin(orders, eq(healthNotes.orderId, orders.id))
.innerJoin(orderItems, eq(orders.id, orderItems.orderId))
.innerJoin(restaurants, eq(orders.restaurantId, restaurants.id))
.where(eq(healthNotes.userId, userId));
// Count co-occurrences: symptom + food item/category
const correlations = new Map<string, { count: number; total: number }>();
for (const note of notes) {
const key = `${note.symptom}:${note.itemCategory || note.itemName}`;
const existing = correlations.get(key) || { count: 0, total: 0 };
existing.count++;
correlations.set(key, existing);
}
// Only return correlations with 3+ occurrences (statistical minimum)
return Array.from(correlations.entries())
.filter(([, v]) => v.count >= 3)
.map(([key, v]) => {
const [symptom, food] = key.split(':');
return {
symptom,
food,
occurrences: v.count,
confidence: Math.min(v.count / 10, 1.0) // Confidence grows with data
};
});
}The taste profile's foodSensitivities array is updated automatically when correlations are detected:
// After detecting correlations, update the user's sensitivity profile
async function updateSensitivityProfile(userId: string) {
const correlations = await detectCorrelations(userId);
const sensitivities = correlations
.filter(c => c.confidence >= 0.3) // At least 30% confidence
.map(c => ({
food: c.food,
reaction: c.symptom,
confidence: c.confidence
}));
await db.update(tasteProfiles)
.set({ foodSensitivities: sensitivities, lastUpdated: new Date() })
.where(eq(tasteProfiles.userId, userId));
}When the get_recommendations tool runs, it checks:
- Recent eating patterns (last 7 days of food categories)
- Known food sensitivities
- Recent health complaints
The system prompt includes these as context, so Claude naturally factors them in:
User has had fried food 5 of the last 7 days.
Known sensitivity: cream sauces -> indigestion (confidence: 0.7, 4 occurrences)
Last health complaint: bloating after curry yesterday.
Claude then incorporates this into recommendations without being preachy -- it just avoids suggesting cream-based dishes and might mention "maybe something lighter today?"
Items are categorized (not calorie-counted) into broad groups:
| Category | Examples |
|---|---|
| fried | Fried chicken, tempura, french fries |
| soup | Tom yum, ramen, pho |
| rice | Fried rice, khao pad, biryani |
| noodle | Pad thai, spaghetti, udon |
| vegetable | Salad, stir-fried vegetables |
| protein | Grilled chicken, steak, fish |
| bread | Burger, sandwich, pizza |
| dessert | Ice cream, cake, mochi |
Claude Haiku categorizes items during import. This is approximate and that's fine -- the goal is "you haven't had vegetables in 4 days", not calorie tracking.
| ID | Description | Phase |
|---|---|---|
| AUTH-01 | User can log in with Google OAuth (single sign-on -- same Google account grants app access + Gmail permission) | Phase 1 |
| AUTH-02 | User session persists across app restarts (token stored securely on device) | Phase 1 |
| AUTH-03 | Gmail API access is authorized during login flow (user grants gmail.readonly scope once) | Phase 1 |
| ID | Description | Phase |
|---|---|---|
| IMPORT-01 | System auto-imports Grab Food/Mart receipts from Gmail via OAuth2 | Phase 1 |
| IMPORT-02 | System parses receipts: restaurant name, items, prices, total, date, booking ID, order type (Food vs Mart) | Phase 1 |
| IMPORT-03 | System detects and skips duplicate orders (never imports same order twice) | Phase 1 |
| IMPORT-04 | System normalizes restaurant names across branches (FuelBite Thonglor = FuelBite Ekkamai = same brand) | Phase 1 |
| IMPORT-05 | System distinguishes GrabFood vs GrabMart orders (meals vs groceries tracked separately) | Phase 1 |
| IMPORT-06 | System tags every order with location (city + coordinates) | Phase 1 |
| IMPORT-07 | System detects group orders so value ratings aren't penalized for multi-person totals | Phase 1 |
| ID | Description | Phase |
|---|---|---|
| AGENT-01 | User can interact via persistent conversation -- Claude remembers all prior messages, never starts fresh | Phase 2 |
| AGENT-02 | Agent routes any input to the right action (rate, update, search, log health issue, blacklist, correct, etc.) | Phase 2 |
| AGENT-03 | User can provide delayed feedback ("that order from 2 days ago gave me indigestion") and agent updates the correct order retroactively | Phase 2 |
| AGENT-04 | User can correct mistakes ("actually that was lunch not dinner") and agent fixes the record | Phase 2 |
| AGENT-05 | Agent parses venting as input ("that place was garbage" -> low rating + blacklist suggestion) | Phase 2 |
| AGENT-06 | Agent builds and maintains a user taste profile from ratings, notes, and conversations -- passed to every interaction | Phase 2 |
| AGENT-07 | Agent provides conversational recommendations -- "what should I eat?" starts a dialogue, narrows by mood/craving/budget | Phase 2 |
| AGENT-08 | User can search via natural language ("best pizza", "something spicy and soupy under 300 baht", "that place on Thonglor with the good gyro") | Phase 2 |
| ID | Description | Phase |
|---|---|---|
| RATE-01 | User can rate orders via quick-tap buttons (taste 1-5, packaging, would-reorder) | Phase 2 |
| RATE-02 | User can rate via text input -- Claude parses natural language into structured ratings + notes | Phase 2 |
| RATE-03 | User can rate via voice input -- native React Native speech recognition, Claude parses the transcript | Phase 2 |
| RATE-04 | Default rating is order-level, expandable to per-dish with one tap | Phase 2 |
| RATE-05 | Progressive rating -- minimum is taste + would-reorder, more detail (packaging, instructions, portions, value, arrived-hot) optional | Phase 2 |
| RATE-06 | Free-form notes are first-class data -- used in recommendations and search, not just decorative | Phase 2 |
| RATE-07 | User can upload food photos attached to any order or dish | Phase 2 |
| RATE-08 | User can flag specific dishes as favorites ("always order this") | Phase 2 |
| RATE-09 | User can flag "ordered for someone else" so it doesn't skew taste profile | Phase 2 |
| ID | Description | Phase |
|---|---|---|
| NOTIF-01 | System sends push notification ~30min after new order detected (post-delivery timing) | Phase 3 |
| NOTIF-02 | Smart notification timing -- respects timezone, doesn't buzz at 3am, adjusts for travel | Phase 3 |
| NOTIF-03 | User can snooze ("rate later") -- reminds next day | Phase 3 |
| NOTIF-04 | System batches multiple unrated orders into single notification if backlogged | Phase 3 |
| NOTIF-05 | Old backfill orders (Gmail history) never trigger nag notifications | Phase 3 |
| ID | Description | Phase |
|---|---|---|
| LOC-01 | System knows what city/country user is in, scopes recommendations to local restaurants | Phase 3 |
| LOC-02 | Background geolocation detects when user visits a restaurant (via Google Places API cross-reference) | Phase 3 |
| LOC-03 | Post-visit prompt -- "Looks like you were at Sushi Masa. How was it?" after user leaves a restaurant | Phase 3 |
| LOC-04 | "Welcome back" -- resurfaces user's history when returning to a previously visited city | Phase 3 |
| LOC-05 | Multi-currency support -- handles THB, USD, EUR etc. for value comparisons across cities | Phase 1 |
| LOC-06 | Multi-language restaurant names -- Thai, Japanese, Korean script handled properly | Phase 1 |
| ID | Description | Phase |
|---|---|---|
| REC-01 | Conversational recommendations -- Claude considers location, taste profile, recent patterns, health notes, blacklist | Phase 4 |
| REC-02 | Craving-based search -- "something spicy and soupy under 300 baht" | Phase 4 |
| REC-03 | Restaurant leaderboard -- by city, by cuisine, by rating | Phase 4 |
| REC-04 | Blacklist -- auto-suggested after repeated bad ratings, or manual add. Warns if about to order from blacklisted place. | Phase 4 |
| REC-05 | Favorites -- flag specific dishes to always reorder | Phase 4 |
| REC-06 | "Surprise me" -- random pick from well-rated places user hasn't ordered in a while | Phase 4 |
| REC-07 | Auto-derived restaurant scores -- weighted average across all visits | Phase 4 |
| REC-08 | Consistency tracking -- does quality vary or is it reliable? | Phase 4 |
| REC-09 | Instruction compliance rate -- "this place ignores your notes 60% of the time" | Phase 4 |
| REC-10 | Time-of-day awareness -- lunch spot vs dinner spot tracked separately | Phase 4 |
| REC-11 | Mood/occasion tags -- "hangover food", "date night", "quick work lunch" | Phase 4 |
| ID | Description | Phase |
|---|---|---|
| HEALTH-01 | Weekly digest of eating patterns ("fried food 5 of 7 days") | Phase 4 |
| HEALTH-02 | Pattern detection -- correlate health complaints with specific foods/restaurants over time | Phase 4 |
| HEALTH-03 | Proactive health nudges -- when recommending food, factor in recent eating patterns | Phase 4 |
| HEALTH-04 | Food sensitivity profile -- built from health notes ("cream sauces = indigestion") | Phase 4 |
| HEALTH-05 | Nutritional awareness -- infer food categories from order items ("you haven't had vegetables in 4 days") | Phase 4 |
| ID | Description | Phase |
|---|---|---|
| MEDIA-01 | Photo gallery -- browse food photos by restaurant, rating, city | Phase 4 |
| MEDIA-02 | Multi-device sync -- works on phone and laptop | Phase 2 |
| MEDIA-03 | Offline support -- rate offline, sync when back online | Phase 2 |
| MEDIA-04 | Full data export capability -- user owns all data | Phase 2 |
| Category | Count |
|---|---|
| Authentication | 3 |
| Data Import | 7 |
| Agent Core | 8 |
| Rating & Input | 9 |
| Notifications | 5 |
| Location & Travel | 6 |
| Recommendations & Discovery | 11 |
| Health Intelligence | 5 |
| Media & Data | 4 |
| Total | 58 |
Goal: User logs in with Google, authorizes Gmail, and has a complete deduplicated order history imported with parsed restaurant names, items, prices, and location tags.
Depends on: Nothing (first phase)
Requirements: AUTH-01, AUTH-02, AUTH-03, IMPORT-01, IMPORT-02, IMPORT-03, IMPORT-04, IMPORT-05, IMPORT-06, IMPORT-07, LOC-05, LOC-06 (12 requirements)
What gets built:
- Neon Postgres database with full Drizzle schema + migrations
- Google OAuth login flow (expo-auth-session -> backend token exchange)
- Gmail API integration (OAuth2, gmail.readonly scope)
- Receipt parser (Claude Haiku with forced tool use)
- Gmail backfill: import all ~201 historical Grab receipts
- Gmail watch + Pub/Sub webhook for ongoing real-time receipt detection
- Vercel Cron for Gmail watch renewal + fallback sync
- Restaurant name normalization
- Duplicate detection (booking_id)
- GrabFood vs GrabMart distinction
- Multi-currency storage
- Multi-language restaurant name handling (UTF-8, no normalization of non-Latin scripts)
Success Criteria:
- User can log in with Google OAuth and the session persists across app restarts
- Gmail API access is authorized during login -- user grants permission once and all ~201 Grab receipts are imported automatically
- Each imported order shows restaurant name, individual items with prices, total, date, booking ID, and whether it is GrabFood or GrabMart
- Duplicate receipts are never imported twice, even when the sync runs repeatedly
- Restaurant names are normalized across branches (e.g., "FuelBite Thonglor" and "FuelBite Ekkamai" are linked as the same brand)
- Orders display prices in their original currency and restaurant names render correctly in Thai, Japanese, and Korean script
Goal: User can talk to the agent about any meal -- rate it, correct it, search history, vent about bad food -- through voice, text, or quick-tap buttons, and it all persists across devices.
Depends on: Phase 1
Requirements: AGENT-01 through AGENT-08, RATE-01 through RATE-09, MEDIA-02, MEDIA-03, MEDIA-04 (20 requirements)
What gets built:
- Claude agent with system prompt, 12 tool definitions, agentic loop
- Streaming chat endpoint (SSE)
- Conversation history storage + compaction API integration
- Chat UI screen (primary interface)
- Quick-tap rating card UI
- Voice input (native speech recognition)
- Text input parsing
- Photo upload flow (client compression -> presigned R2 upload -> DB registration)
- Order history / browse screen
- Taste profile building and injection into system prompt
- Offline rating queue + sync
- Data export endpoint
Success Criteria:
- User can open the app, type or speak naturally ("curry was great, rice was soggy, 4 stars"), and the agent creates structured ratings and notes on the correct order
- User can say "that order from 2 days ago gave me indigestion" and the agent retroactively updates the right order with health notes
- User can quick-tap a rating (taste 1-5, would-reorder) on any unrated order without typing a word
- Conversation history persists across sessions -- the agent never asks "what can I help you with?" as if starting fresh
- User can rate and take notes offline on their phone, and changes sync when connectivity returns; the same data is accessible from a second device
Goal: The app proactively reaches out after deliveries and restaurant visits, respects the user's timezone and travel schedule, and knows what city they are in.
Depends on: Phase 2
Requirements: NOTIF-01 through NOTIF-05, LOC-01 through LOC-04 (9 requirements)
What gets built:
- Expo Push notification registration and delivery
- Notification queue processing (Vercel Cron)
- Smart timing logic (timezone-aware, quiet hours, travel adjustment)
- Snooze and batch unrated logic
- Backfill order suppression (no nag notifications for old imports)
- Background geolocation setup (expo-location)
- Google Places API integration for restaurant detection
- Dine-in detection flow (enter -> dwell -> leave -> prompt)
- City detection + city change detection
- "Welcome back" logic for returning to known cities
Success Criteria:
- User receives a push notification ~30 minutes after a new Grab order is detected, prompting them to rate it -- and never gets buzzed at 3am regardless of timezone changes
- User can snooze a rating notification and get reminded the next day; if multiple orders pile up unrated, they arrive as a single batch notification
- After dining in at a restaurant, the app detects the visit via background geolocation and prompts "Looks like you were at Sushi Masa. How was it?"
- When the user arrives in a previously visited city, the app surfaces their history ("Welcome back to Tokyo -- last time you loved Tsukiji Ramen")
- Old backfill orders imported from Gmail history never trigger rating nag notifications
Goal: The agent leverages accumulated order history and ratings to give personalized recommendations, surface health patterns, and let the user browse their food memories visually.
Depends on: Phase 3
Requirements: REC-01 through REC-11, HEALTH-01 through HEALTH-05, MEDIA-01 (17 requirements)
What gets built:
- Full recommendation engine (Claude + tools + DB queries)
- Craving-based search ("something spicy and soupy under 300 baht")
- Restaurant leaderboard UI with filters and sorting
- Blacklist management with auto-suggestion and warnings
- Favorites management
- "Surprise me" feature
- Auto-derived restaurant scores (weighted average, std dev)
- Consistency tracking per restaurant
- Instruction compliance rate tracking
- Time-of-day awareness in recommendations
- Mood/occasion tagging system
- Weekly health digest generation (Vercel Cron + Claude Sonnet)
- Food-health correlation detection
- Food sensitivity profile auto-building
- Proactive health nudges in recommendations
- Nutritional category inference (Claude Haiku on import)
- Photo gallery UI with filters
Success Criteria:
- User asks "what should I eat?" and gets a recommendation informed by their location, taste profile, recent eating patterns, and blacklist -- not a generic list
- User can view a restaurant leaderboard filtered by city and cuisine, with auto-derived scores, consistency ratings, and instruction compliance rates
- User receives a weekly digest of eating patterns ("fried food 5 of 7 days") and the agent factors health notes into future recommendations ("you said cream sauces give you indigestion -- skipping that option")
- User can browse a photo gallery of their food organized by restaurant, rating, and city
- User can tag orders with mood/occasion ("hangover food", "date night") and use those tags in future searches and recommendations
Phase 1: Data Pipeline + Auth
|
v
Phase 2: Agent + Rating
|
v
Phase 3: Notifications + Location
|
v
Phase 4: Intelligence + Discovery
Each phase depends on the previous one. The ordering rationale:
- Phase 1 before 2: You need data before you can build tools that operate on data
- Phase 2 before 3: A working agent is more valuable than notifications with nothing to notify about
- Phase 3 before 4: Location context enriches recommendations. Notifications drive engagement.
- Phase 4 last: Requires the most accumulated data. Benefits from all prior infrastructure. Recommendations with insufficient data produce garbage.
What goes wrong: You send ALL messages to Claude on every request. Works fine for 20 messages. At 500 messages, you spend $0.50 per request on input tokens and hit context limits.
Why it happens: The Anthropic Messages API is stateless -- you must send conversation history yourself. The naive approach is "send everything."
Prevention: Use the Anthropic Compaction API (compact-2026-01-12 beta). Set trigger at 80K tokens (40% of 200K context window). Store a compacted flag on messages. Only load uncompacted messages per request. Conversation runs indefinitely without cost explosion.
Detection: Monitor input token counts per request. If they grow linearly with conversation length, compaction is not working.
What goes wrong: Grab changes their email template. All new imports break silently -- wrong data gets stored or imports fail.
Prevention:
- Use Claude Haiku as the parser (not regex/DOM) -- LLMs adapt to format changes
- Store raw email HTML alongside parsed data (allows re-parsing later)
- Validate every extracted field. If booking_id is missing, reject the import
- Log parsing failures visibly. Never swallow errors.
- Compare parsed order count vs email count. Divergence = silent failures.
What goes wrong: Access token expires (1hr). Refresh token gets revoked (password change, 6-month inactivity). Gmail watch silently stops. No new orders imported. User doesn't notice for weeks.
Prevention:
- Token refresh with proper error handling. On 401, trigger re-auth flow.
- Gmail watch expires after 7 days. Renew via daily cron (safe to renew daily).
- Monitor: if no new emails processed in 3 days and user is still ordering, alert.
- Store
historyIdfrom each watch notification. On renewal, use storedhistoryIdto catch missed emails. - Show "Last Gmail sync" timestamp in Settings screen. If stale, user knows something is wrong.
What goes wrong: Sophisticated recommendation engine on launch with 0 rated orders. Feature is useless and untestable.
Prevention: Recommendations are Phase 4, after data has been accumulating through Phases 1-3. Require minimum thresholds: "Not enough rated orders for recommendations yet" is a valid response. The backfill of ~201 Gmail receipts provides orders, but they still need ratings.
Prevention: Build normalization early (Phase 1). Lowercase + strip branch suffixes + Google Places canonical name as source of truth + fuzzy matching (Levenshtein) + manual merge via conversation.
Prevention: Store ALL timestamps in UTC in Postgres. Detect timezone client-side via Intl.DateTimeFormat().resolvedOptions().timeZone. Convert for display only. Check user's current timezone before sending notifications.
What goes wrong: Claude calls rate_order with a non-existent order_id.
Prevention: Validate all tool parameters server-side. If order_id doesn't exist in DB, return a clear error to Claude so it can self-correct. Use strict: true on all tool schemas.
What goes wrong: Polling every 60s during a 2-hour dinner = 120 requests = $3.84 for one meal.
Prevention: Cache Places results aggressively. If user hasn't moved 100m+, don't re-query. Rate limit to max 1 Places query per 5 minutes. Use distance threshold to skip queries. Monthly cost should stay well within Google's $200 free credit.
What goes wrong: Claude API call + tool execution + DB queries exceed Vercel's 10s timeout (Hobby plan).
Prevention: Use streaming -- Vercel Hobby allows 60s for streaming functions (vs 10s non-streaming). Keep tool execution fast with indexed DB queries. Run expensive operations (health digest, summarization) in cron jobs, not the chat path.
Prevention: Idempotent import handler. Check booking_id before inserting. Use ON CONFLICT DO NOTHING on the insert query.
Prevention: Don't convert. Store original currency and amount. Compare only within same currency. Cross-currency comparison is an anti-feature.
| Phase | Likely Pitfall | Mitigation |
|---|---|---|
| Phase 1: Data Pipeline | Receipt HTML format changes | Claude Haiku parser + store raw HTML + validate all fields |
| Phase 2: Agent + Rating | Context window bloat | Compaction API from day one. Monitor token counts. |
| Phase 3: Notifications | Timezone bugs during travel | UTC storage, client-side conversion, timezone-aware delivery |
| Phase 3: Location | Places API cost | Aggressive caching, 100m distance threshold, 5-min rate limit |
| Phase 4: Recommendations | Insufficient data | Minimum data thresholds. "Not enough data yet" is valid. |
| Phase 4: Health | False correlations with small data | Require 3+ occurrences minimum. Show confidence levels. |
Monthly cost breakdown for single-user personal use:
| Service | Free Tier | Expected Usage | Monthly Cost |
|---|---|---|---|
| Vercel (Hobby) | 100GB bandwidth, 100hr serverless | Low traffic, single user | $0 |
| Neon Postgres (Free) | 100 CU-hrs, 0.5GB storage | ~200 orders/month, conversation history | $0 |
| Cloudflare R2 (Free) | 10GB storage, 0 egress | ~50 photos/month at 300KB = 15MB/month | $0 |
| Anthropic API | Pay-per-use | ~5-10 messages/day (Haiku + Sonnet mix) | ~$2-5 |
| Google Cloud Pub/Sub (Free) | 10GB/month | ~30 email notifications/month | $0 |
| Google Places API | $200/month credit | ~50 lookups/month at $0.032/req = ~$1.60 | $0 (within credit) |
| Expo Push | Free | Unlimited for personal use | $0 |
| Expo EAS Build | Free tier | Occasional builds | $0 |
| Total | ~$2-5/month |
Anthropic API (~$2-5/month):
- Sonnet 4 (conversational agent): ~5-10 user messages/day. With compaction, average input ~10K tokens/request. ~$0.10-0.20/day.
- Haiku 3.5 (receipt parsing): ~1 receipt/day. ~$0.001/parse. Negligible.
- Haiku 3.5 (food categorization): Runs on import. Negligible.
- Sonnet 4 (weekly digest): 1x/week. ~$0.02/week. Negligible.
Total Anthropic: ~$3-6/month realistically. Could be $1-2 with aggressive prompt caching.
| At 1 user (v1) | At 10 users (v2) |
|---|---|
| Neon free tier (0.5GB) sufficient for years | Neon Launch ($19/mo) |
| Anthropic $2-5/month | $20-50/month |
| R2 free tier (10GB) | Still free (50K photos) |
| Vercel Hobby (free) | Vercel Pro ($20/mo) for better cron |
# ============================================================
# Anthropic AI
# ============================================================
# Get from: https://console.anthropic.com/account/keys
ANTHROPIC_API_KEY=sk-ant-...
# ============================================================
# Neon Postgres
# ============================================================
# Get from: https://console.neon.tech -> Project -> Connection Details
DATABASE_URL=postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/palate?sslmode=require
# ============================================================
# Google OAuth2 (App Login + Gmail API)
# ============================================================
# Get from: https://console.cloud.google.com -> APIs & Services -> Credentials
# Create an OAuth 2.0 Client ID (type: Web application for backend, iOS/Android for mobile)
GOOGLE_CLIENT_ID=xxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-...
GOOGLE_REDIRECT_URI=https://palate-api.vercel.app/api/auth/google/callback
# For Expo mobile app:
EXPO_GOOGLE_CLIENT_ID=xxxx.apps.googleusercontent.com
# ============================================================
# Google Places API
# ============================================================
# Get from: https://console.cloud.google.com -> APIs & Services -> Credentials -> API Key
# Restrict to Places API (New) only
GOOGLE_PLACES_API_KEY=AIzaSy...
# ============================================================
# Cloudflare R2 (Image Storage)
# ============================================================
# Get from: https://dash.cloudflare.com -> R2 -> Manage R2 API Tokens
R2_ACCOUNT_ID=your-cloudflare-account-id
R2_ACCESS_KEY_ID=your-r2-access-key
R2_SECRET_ACCESS_KEY=your-r2-secret-key
R2_BUCKET_NAME=palate-photos
R2_PUBLIC_URL=https://pub-xxx.r2.dev
# R2 endpoint for S3 SDK:
R2_ENDPOINT=https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com
# ============================================================
# Expo Push Notifications
# ============================================================
# No server-side env var needed -- Expo Push tokens come from the mobile app
# The expo-server-sdk package sends directly to Expo's push service
# ============================================================
# Vercel Cron
# ============================================================
# Auto-provided by Vercel in the function environment
# CRON_SECRET is used to verify cron requests
CRON_SECRET=auto-provided-by-vercel
# ============================================================
# Encryption Key (for Google tokens at rest)
# ============================================================
# Generate with: openssl rand -hex 32
TOKEN_ENCRYPTION_KEY=your-64-char-hex-string- Go to https://console.cloud.google.com
- Create a new project named "Palate"
- Enable these APIs:
- Gmail API
- Google Places API (New)
- Cloud Pub/Sub API
- Create OAuth 2.0 credentials:
- Go to APIs & Services -> Credentials -> Create Credentials -> OAuth Client ID
- Application type: Web application (for backend)
- Authorized redirect URIs:
https://palate-api.vercel.app/api/auth/google/callback - Create another credential for mobile app (iOS/Android type)
- Configure OAuth consent screen:
- User type: External (or Internal if using Workspace)
- Scopes:
openid,email,profile,gmail.readonly - Test users: Add your email
- Set up Pub/Sub for Gmail:
- Go to Pub/Sub -> Create Topic:
palate-gmail-notifications - Create Push Subscription pointing to:
https://palate-api.vercel.app/api/gmail/webhook - Grant
gmail-api-push@system.gserviceaccount.comthe Publisher role on the topic
- Go to Pub/Sub -> Create Topic:
- Go to https://console.neon.tech
- Create a new project: "Palate"
- Choose region closest to your Vercel deployment (us-east-2 recommended)
- Copy the connection string to
DATABASE_URL - Run migrations:
npx drizzle-kit push
- Go to https://dash.cloudflare.com
- Navigate to R2 -> Create Bucket: "palate-photos"
- Enable public access on the bucket (food photos are not sensitive)
- Create R2 API token with Object Read & Write permissions
- Copy credentials to env vars
- Go to https://console.anthropic.com
- Create an API key
- Set a usage limit alert at $10/month (safety net)
- Copy the key to
ANTHROPIC_API_KEY
- Go to https://vercel.com
- Import the backend repository
- Add all environment variables in Project Settings -> Environment Variables
- Deploy
- Configure custom domain if desired
- Install EAS CLI:
npm install -g eas-cli - Log in:
eas login - Configure:
eas build:configure - For iOS: Requires Apple Developer account ($99/yr) for push notifications
- For Android: Configure Firebase project for FCM (Expo handles this via EAS)
- Build:
eas build --platform all
This specification was synthesized from PROJECT.md, REQUIREMENTS.md, ROADMAP.md, and research documents (SUMMARY.md, ARCHITECTURE.md, STACK.md, FEATURES.md, PITFALLS.md) on 2026-03-27.