Skip to content

Instantly share code, notes, and snippets.

@shootdaj
Created March 28, 2026 00:21
Show Gist options
  • Select an option

  • Save shootdaj/c9f5d5e3d0d5200e17d8d0ba82a81eea to your computer and use it in GitHub Desktop.

Select an option

Save shootdaj/c9f5d5e3d0d5200e17d8d0ba82a81eea to your computer and use it in GitHub Desktop.
Palate - Full Project Specification

Palate -- Complete Project Specification

Version: 1.0 Date: 2026-03-27 Status: Pre-development


Table of Contents

  1. Product Overview
  2. Architecture
  3. Tech Stack
  4. Authentication
  5. Database Schema
  6. API Endpoints
  7. Claude Agent Design
  8. Gmail Receipt Parser
  9. Mobile App Screens
  10. Notification System
  11. Location Intelligence
  12. Health Intelligence
  13. All 58 Requirements
  14. Phased Roadmap
  15. Known Pitfalls & Mitigations
  16. Cost Estimate
  17. Environment Setup

1. Product Overview

What This Is

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.

Core Value Proposition

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.

Who It's For

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.com with 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

How It Works

  1. 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.
  2. 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."
  3. Remember: The agent maintains a persistent conversation. It knows your taste profile, health sensitivities, favorite dishes, and blacklisted places. It never starts fresh.
  4. 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.
  5. 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?"
  6. Track health: Weekly digests of eating patterns. Correlates health complaints with specific foods over time. Builds a sensitivity profile ("cream sauces = indigestion").

2. Architecture

System Diagram

+-------------------------------+
|   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 Boundaries

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

Data Flow: Key Operations

Flow 1: New Grab Order Import (Automated)

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

Flow 2: Conversational Rating

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

Flow 3: Recommendation Request

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

Flow 4: Dine-In Detection

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

Flow 5: Offline Rating

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

3. Tech Stack

Core Mobile App

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.

Backend

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.

AI / Conversational Agent

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.

Database

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

Image Storage

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.

Push Notifications

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.

Voice Input

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.

Geolocation

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.

Gmail Integration

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.

Scheduling

Technology Version Purpose Why Chosen
Vercel Cron (built-in) Scheduled jobs Gmail sync fallback, notification queue processing, Gmail watch renewal, weekly health digest.

UI & Styling (Mobile)

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.

Dev Dependencies

Technology Version Purpose
drizzle-kit (latest) DB migration generation
@types/node (latest) Node.js type definitions
typescript 5.7+ TypeScript compiler

Full Dependency List

# 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/node

4. Authentication

Strategy: Google OAuth for Both App Login AND Gmail Access (One Flow)

The user authenticates with Google OAuth once. This single flow provides:

  1. App identity -- who the user is (Google account)
  2. Gmail API access -- permission to read Gmail (for receipt import)

There is no separate "app login" system. Google OAuth IS the login.

OAuth2 Flow

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 Lifecycle

// 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 Persistence

  • 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

Security

  • 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

5. Database Schema

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

Key Schema Design Decisions

  1. bookingId as dedup key on orders: Prevents importing the same Grab order twice. Uses ON CONFLICT DO NOTHING on insert.
  2. normalizedName on restaurants: Handles "FuelBite Thonglor" and "FuelBite Ekkamai" as the same brand. Lowercase, trimmed, stripped of branch suffixes.
  3. restaurantBranches separate from restaurants: A restaurant brand has many physical locations. Ratings roll up to the brand level.
  4. compacted flag on messages: Enables efficient conversation loading -- only fetch WHERE compacted = false. Avoids scanning full history.
  5. rawReceiptHtml preserved on orders: Allows re-parsing if the receipt parser improves later.
  6. JSONB for flexible fields: tags, foodSensitivities, flavorPreferences -- schema evolves without migrations.
  7. Separate healthNotes table: Health observations can correlate across multiple orders over time. Not just a field on the order.
  8. Separate ratings table + rating fields on orders: The orders table has the current rating for quick access. The ratings table tracks all rating events for historical analysis.
  9. isBackfill flag on orders: Historical imports from Gmail backfill never trigger nag notifications. This flag controls that behavior.
  10. notificationQueue table: Enables smart notification timing, snooze, batch unrated, and timezone-aware delivery.

6. API Endpoints

All routes are Next.js API routes deployed on Vercel. Base URL: https://palate-api.vercel.app

Authentication

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 } }

Agent (Chat)

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 }> }

Orders

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 } }

Ratings

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 }

Photos

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 }> }

Restaurants

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 } }

Gmail Sync

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
}

Health

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

Notifications

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 } }

Location

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
  }>
}

Cron Endpoints

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.


7. Claude Agent Design

System Prompt Template

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.
`;

Tool Definitions

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' }
      }
    }
  }
];

Conversation Management Strategy

Compaction API (Anthropic beta compact-2026-01-12):

  • Automatically summarizes older context when input tokens exceed a configurable threshold
  • Creates a compaction block 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,
    });
  }
}

Cost Optimization: Model Routing

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.


8. Gmail Receipt Parser

Grab Receipt Email Format

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 - Thonglor or 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)

Parsing Strategy: Claude Haiku as Parser

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

Deduplication Logic

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

Restaurant Name Normalization

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:

  1. Lowercase + trim whitespace
  2. Strip branch suffixes (common Bangkok area names)
  3. Google Places API canonical name as source of truth when available
  4. Fuzzy matching (Levenshtein distance) for similar names
  5. Store all name variants, display the canonical one
  6. Manual merge capability via agent conversation ("those two restaurants are the same place")

Validation

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.


9. Mobile App Screens

Screen 1: Chat Screen (Primary Interface)

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

Screen 2: Rating Card (Quick-Tap)

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

Screen 3: Order History / Browse

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

Screen 4: Restaurant Leaderboard

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

Screen 5: Photo Gallery

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)

Screen 6: Settings

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:
    1. Chat (primary, center, largest)
    2. Orders (history list)
    3. Leaderboard
    4. Profile/Settings
  • Photo gallery accessible from Orders and Leaderboard screens

10. Notification System

Expo Push Setup

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

Smart Timing Logic

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 / Batch Logic

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

Post-Delivery Prompt Flow

  1. Gmail webhook receives new Grab receipt notification
  2. Parse receipt, create order in DB
  3. Insert into notificationQueue with scheduledFor = order_date + 30 minutes
  4. Next cron run picks up the notification
  5. Check shouldSendNotification() (timezone, quiet hours)
  6. If OK, send: "How was your order from [restaurant]?"
  7. User taps -> app opens to the order's rating card

Post-Dine-In Prompt Flow

  1. Background geolocation detects user at a restaurant location for 30+ minutes
  2. User leaves the restaurant area
  3. App creates a dine-in order stub in DB
  4. Insert into notificationQueue with scheduledFor = now + 10 minutes
  5. Push notification: "Looks like you were at Sushi Masa. How was it?"
  6. User taps -> agent conversation opens with context: "I noticed you visited Sushi Masa. Want to log how it was?"

Old Backfill Orders

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
}

11. Location Intelligence

Background Geolocation (React Native)

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

Google Places API for Nearby Restaurant Lookup

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 || [];
}

Dine-In Detection Flow

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

Battery Optimization

  • 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

City Awareness

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'
  };
}

"Welcome Back" Logic

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)."
}

Multi-Currency Handling

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

12. Health Intelligence

Weekly Eating Pattern Digest

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."
}

Food-Health Correlation Detection

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

Sensitivity Profile Building

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

Proactive Nudges in Recommendations

When the get_recommendations tool runs, it checks:

  1. Recent eating patterns (last 7 days of food categories)
  2. Known food sensitivities
  3. 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?"

Nutritional Category Inference

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.


13. All 58 Requirements

Authentication (3 requirements)

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

Data Import (7 requirements)

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

Agent Core (8 requirements)

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

Rating & Input (9 requirements)

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

Notifications (5 requirements)

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

Location & Travel (6 requirements)

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

Recommendations & Discovery (11 requirements)

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

Health Intelligence (5 requirements)

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

Media & Data (4 requirements)

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

Total: 58 Requirements

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

14. Phased Roadmap

Phase 1: Data Pipeline + Auth

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:

  1. User can log in with Google OAuth and the session persists across app restarts
  2. Gmail API access is authorized during login -- user grants permission once and all ~201 Grab receipts are imported automatically
  3. Each imported order shows restaurant name, individual items with prices, total, date, booking ID, and whether it is GrabFood or GrabMart
  4. Duplicate receipts are never imported twice, even when the sync runs repeatedly
  5. Restaurant names are normalized across branches (e.g., "FuelBite Thonglor" and "FuelBite Ekkamai" are linked as the same brand)
  6. Orders display prices in their original currency and restaurant names render correctly in Thai, Japanese, and Korean script

Phase 2: Agent + Rating

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:

  1. 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
  2. User can say "that order from 2 days ago gave me indigestion" and the agent retroactively updates the right order with health notes
  3. User can quick-tap a rating (taste 1-5, would-reorder) on any unrated order without typing a word
  4. Conversation history persists across sessions -- the agent never asks "what can I help you with?" as if starting fresh
  5. 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

Phase 3: Notifications + Location

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:

  1. 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
  2. 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
  3. 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?"
  4. When the user arrives in a previously visited city, the app surfaces their history ("Welcome back to Tokyo -- last time you loved Tsukiji Ramen")
  5. Old backfill orders imported from Gmail history never trigger rating nag notifications

Phase 4: Intelligence + Discovery

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:

  1. 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
  2. User can view a restaurant leaderboard filtered by city and cuisine, with auto-derived scores, consistency ratings, and instruction compliance rates
  3. 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")
  4. User can browse a photo gallery of their food organized by restaurant, rating, and city
  5. User can tag orders with mood/occasion ("hangover food", "date night") and use those tags in future searches and recommendations

Phase Dependency Graph

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.

15. Known Pitfalls & Mitigations

Critical Pitfalls

1. Context Window Bloat -- Sending Full History to Claude Every Request

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.

2. Gmail Receipt Parsing Fragility

What goes wrong: Grab changes their email template. All new imports break silently -- wrong data gets stored or imports fail.

Prevention:

  1. Use Claude Haiku as the parser (not regex/DOM) -- LLMs adapt to format changes
  2. Store raw email HTML alongside parsed data (allows re-parsing later)
  3. Validate every extracted field. If booking_id is missing, reject the import
  4. Log parsing failures visibly. Never swallow errors.
  5. Compare parsed order count vs email count. Divergence = silent failures.

3. OAuth2 Token Expiry Breaking Gmail Watch

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:

  1. Token refresh with proper error handling. On 401, trigger re-auth flow.
  2. Gmail watch expires after 7 days. Renew via daily cron (safe to renew daily).
  3. Monitor: if no new emails processed in 3 days and user is still ordering, alert.
  4. Store historyId from each watch notification. On renewal, use stored historyId to catch missed emails.
  5. Show "Last Gmail sync" timestamp in Settings screen. If stale, user knows something is wrong.

4. Building Recommendations Before Having Data

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.

Moderate Pitfalls

5. Mixed Thai/English Restaurant Name Matching

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.

6. Timezone Handling Across Countries

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.

7. Claude Hallucinating Tool Parameters

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.

8. Google Places API Cost Surprise

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.

9. Vercel Serverless Function Timeout

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.

10. Duplicate Order Import on Pub/Sub Retry

Prevention: Idempotent import handler. Check booking_id before inserting. Use ON CONFLICT DO NOTHING on the insert query.

11. Currency Conversion Complexity

Prevention: Don't convert. Store original currency and amount. Compare only within same currency. Cross-currency comparison is an anti-feature.

Phase-Specific Warnings

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.

16. Cost Estimate

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

Cost Breakdown by Component

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.

Scaling Notes

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

17. Environment Setup

Environment Variables

# ============================================================
# 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

External Service Setup Instructions

1. Google Cloud Project

  1. Go to https://console.cloud.google.com
  2. Create a new project named "Palate"
  3. Enable these APIs:
    • Gmail API
    • Google Places API (New)
    • Cloud Pub/Sub API
  4. 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)
  5. Configure OAuth consent screen:
    • User type: External (or Internal if using Workspace)
    • Scopes: openid, email, profile, gmail.readonly
    • Test users: Add your email
  6. 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.com the Publisher role on the topic

2. Neon Postgres

  1. Go to https://console.neon.tech
  2. Create a new project: "Palate"
  3. Choose region closest to your Vercel deployment (us-east-2 recommended)
  4. Copy the connection string to DATABASE_URL
  5. Run migrations: npx drizzle-kit push

3. Cloudflare R2

  1. Go to https://dash.cloudflare.com
  2. Navigate to R2 -> Create Bucket: "palate-photos"
  3. Enable public access on the bucket (food photos are not sensitive)
  4. Create R2 API token with Object Read & Write permissions
  5. Copy credentials to env vars

4. Anthropic API

  1. Go to https://console.anthropic.com
  2. Create an API key
  3. Set a usage limit alert at $10/month (safety net)
  4. Copy the key to ANTHROPIC_API_KEY

5. Vercel

  1. Go to https://vercel.com
  2. Import the backend repository
  3. Add all environment variables in Project Settings -> Environment Variables
  4. Deploy
  5. Configure custom domain if desired

6. Expo EAS

  1. Install EAS CLI: npm install -g eas-cli
  2. Log in: eas login
  3. Configure: eas build:configure
  4. For iOS: Requires Apple Developer account ($99/yr) for push notifications
  5. For Android: Configure Firebase project for FCM (Expo handles this via EAS)
  6. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment