An AI-powered travel agent that operates entirely through WhatsApp. It plans end-to-end trips, searches flights/hotels/restaurants/experiences, books via API or deep links, remembers preferences across conversations with confidence-weighted memory, validates trip plans, sends proactive notifications, and handles payments through Stripe.
Built with Claude (Anthropic), Fastify, PostgreSQL + pgvector, BullMQ, Twilio, Duffel, and Stripe.
- Features
- Architecture
- Project Structure
- Prerequisites
- Quick Start
- Environment Variables
- Database Setup
- Deployment
- API Endpoints
- How It Works
- External Services
- Testing
- Contributing
- License
- WhatsApp-native — Users interact entirely through WhatsApp with interactive buttons and list messages
- AI trip planning — Claude generates multi-day itineraries grounded in real-world research (Google Places, Brave Search, weather data)
- Flight search & booking — Duffel API integration for searching and booking flights across 300+ airlines
- Payment processing — Stripe Checkout for flight payments with per-booking service fees
- Three-tier context windowing — Recent messages kept verbatim, mid-range messages condensed, old messages summarized by Haiku to stay within 80K token budget
- Trip state injection — Active trip plan appended at the END of context on every turn, preventing "lost in the middle" drift in long planning sessions
- Stable tool set — All 15 tools registered on every Claude call for consistent tool selection accuracy
- Concise tool results — Search results condensed to top 3-5 with key fields before entering context
- Confidence-weighted preferences — Explicit preferences scored at 0.7, inferred at 0.4, with confirmation bumps (+0.2) and decay over time
- Contradiction detection — Detects when a new preference contradicts an existing one (e.g., vegetarian user asking for steakhouse) and prompts natural clarification
- Semantic memory — pgvector embeddings for contextual recall with similarity + recency + confidence reranking
- Confidence-weighted prompting — High-confidence preferences stated as fact, medium as observed tendencies, low as tentative
- Smart new user detection — Automatically enters onboarding mode for users with fewer than 4 high-confidence preferences
- Value-first approach — Gives genuinely useful travel insights before extracting preferences
- Natural extraction — Max 2 questions per message, weaved into conversation naturally
- Invisible transition — Seamlessly switches to regular prompt once enough preferences are learned
- Logistics checking — Validates travel times between consecutive activities, flags overlaps
- Budget checking — Flags when total costs exceed stated budget by more than 15%
- Pace checking — Ensures activity count matches user's pace preference (packed/balanced/relaxed)
- Venue verification — Google Places API lookup to catch closed or non-existent venues
- Auto-fix — Automatically shifts overlapping activities; flags unfixable issues for Claude to address
- Smart deep links — Pre-filled booking links for Marriott, Hilton, Hyatt, Airbnb, Booking.com, OpenTable, Resy, GetYourGuide, Viator, Skyscanner, Google Flights, and Kayak
- Booking tracking — Deep-link bookings tracked as
link_sent, updated touser_confirmedwhen user reports back - Booking confirmation tool — Claude can record user-confirmed bookings with reference numbers
- Browser automation — Available behind feature flag for hotel/restaurant/experience booking on real websites
- Price alerts — Price drop and increase notifications for watched bookings
- Trip countdowns — Reminders at T-7, T-3, and T-1 days before trip start
- Abandoned plan follow-up — Re-engages users after 48+ hours of silence on an active plan
- Opt-out — Every notification includes "Reply STOP to turn off alerts"
- Itinerary page — Mobile-optimized HTML page with day-by-day timeline, booking CTAs, budget breakdown, and map links (
GET /trip/:tripId) - Travel DNA profile — Shows all learned preferences grouped by category with confidence badges (
GET /profile/:userId) - Comparison page — Side-by-side hotel/flight comparison with recommended badge and booking buttons (
GET /compare/:comparisonId) - Secure links — Short-lived, unguessable tokens stored in Redis
- Retry with backoff — All external API calls (Claude, Duffel, Google, Brave, Twilio) wrapped with exponential backoff retry
- Circuit breaker — 3 failures in 5 minutes trips the circuit, blocking requests for 2 minutes to prevent cascading failures
- Dead letter queue — Permanently failed conversation jobs send an apology message to the user
- Graceful degradation — Search failures return helpful messages instead of errors; Claude handles missing data naturally
- Unsupported media handling — Audio/video messages get a friendly "type that out for me" response
- Intent classification — Layered system: fast regex for trivial intents, Haiku for complex classification, Redis-cached results
- Itinerary modification — Natural language plan edits ("swap day 3 dinner for sushi")
- PDF itineraries — Generates formatted PDFs with booking references
- Event discovery — Ticketmaster integration for finding concerts, festivals, and events during travel dates
- Structured logging — Per-turn logging of intent, tools called, token usage, onboarding state, and response time
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ WhatsApp │────▶│ Fastify │────▶│ BullMQ │
│ (Twilio) │◀────│ Server │ │ Job Queues │
└──────────────┘ └──────────────┘ └──────────────────┘
│ │
┌──────┴──────┐ ┌──────┴──────┐
│ PostgreSQL │ │ Redis │
│ + pgvector │ │ (cache + │
│ (Drizzle) │ │ queues) │
└─────────────┘ └─────────────┘
│
┌─────────────┼─────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────────┐
│ Claude │ │ Duffel │ │ Stripe │
│ (AI/LLM) │ │ (Flights)│ │ (Payments) │
└──────────┘ └──────────┘ └──────────────┘
| Layer | Technology |
|---|---|
| Runtime | Node.js 20+ / TypeScript |
| HTTP Server | Fastify 5 |
| Database | PostgreSQL 16 + pgvector (via Drizzle ORM) |
| Queue / Cache | Redis + BullMQ + ioredis |
| AI | Anthropic Claude (Sonnet for conversation, Haiku for background tasks) |
| Embeddings | Voyage AI (voyage-large-2, 1536-d vectors) |
| Twilio Business API + Content API (interactive messages) | |
| Flights | Duffel API |
| Payments | Stripe Checkout Sessions |
| Object Storage | Cloudflare R2 (S3-compatible) |
| Search | Google Maps Platform, Brave Search |
| Events | Ticketmaster Discovery API |
src/
├── ai/ # LLM layer
│ ├── client.ts # Anthropic SDK singleton
│ ├── tools.ts # 15 tool definitions exposed to Claude
│ └── prompts/ # System, planning, booking, extraction prompts
│ ├── system.ts # Main system prompt with confidence-weighted profiles
│ ├── extraction.ts # Preference extraction with contradiction detection
│ ├── planning.ts # Trip planning prompt
│ └── booking.ts # Booking flow prompt
│
├── config/
│ ├── env.ts # Zod-validated environment variables
│ └── constants.ts # Context tiers, models, FSM states, queue config
│
├── db/
│ ├── schema.ts # Drizzle table definitions
│ ├── client.ts # Postgres + Drizzle singleton
│ └── migrate.ts # Startup migration runner
│
├── jobs/
│ ├── queue.ts # BullMQ queue definitions + workers + dead letter handling
│ ├── scheduler.ts # Cron jobs (price checks, memory decay, notifications)
│ └── workers/ # Job processors (planning, booking, memory, pricing)
│
├── routes/
│ ├── health.ts # GET /health, /health/detailed
│ ├── whatsapp.ts # POST /webhook/whatsapp (Twilio)
│ ├── booking.ts # Booking session management
│ ├── payments.ts # POST /webhook/stripe + success/cancel pages
│ ├── web.ts # Web companion pages (itinerary, profile, comparison)
│ └── dev.ts # Dev-only chat endpoint (non-production)
│
├── services/
│ ├── conversation/ # Core conversation engine
│ │ ├── engine.ts # Three-tier context + Claude tool loop + onboarding detection
│ │ ├── context.ts # Three-tier context windowing + trip state injection
│ │ ├── state-machine.ts # Conversation FSM
│ │ ├── tool-executor.ts # Routes Claude tool calls with retry/fallback
│ │ ├── intent.ts # Intent classification (regex + Haiku)
│ │ └── clarifier.ts # Missing-info detection for planning
│ │
│ ├── booking/ # Booking orchestration
│ │ ├── orchestrator.ts # Browser session setup + execution
│ │ ├── session.ts # Session lifecycle management
│ │ ├── search-cache.ts # Redis-backed search result cache
│ │ └── providers/ # Site-specific automation + Duffel flights
│ │
│ ├── planning/ # Trip planning
│ │ ├── planner.ts # Plan generation orchestration
│ │ ├── validator.ts # Post-generation validation (logistics, budget, pace, venues)
│ │ ├── modifier.ts # AI-powered plan modifications
│ │ ├── research.ts # Destination research (Brave Search)
│ │ ├── pdf.ts # PDF itinerary generation
│ │ └── pricing.ts # Price comparison + drop detection
│ │
│ ├── memory/ # User preference memory
│ │ ├── store.ts # Preference CRUD with contradiction detection + confidence scoring
│ │ ├── recall.ts # Semantic memory retrieval + reranking
│ │ ├── embeddings.ts # Voyage AI vector embeddings
│ │ ├── extractor.ts # Extract preferences + detect contradictions from conversation
│ │ └── profile.ts # Build complete user profile
│ │
│ ├── notifications/ # Proactive outbound messaging
│ │ └── service.ts # Price alerts, trip countdowns, abandoned plan follow-ups
│ │
│ ├── payments/ # Stripe integration
│ │ ├── stripe.ts # Checkout session creation
│ │ └── webhook.ts # Payment completion → booking trigger
│ │
│ ├── search/ # Search providers
│ │ ├── flights.ts # Duffel HTTP API
│ │ ├── hotels.ts # Google Places hotels
│ │ ├── restaurants.ts # Google Places restaurants
│ │ └── experiences.ts # Google Places experiences
│ │
│ ├── tools/ # Utility tools for Claude
│ │ ├── maps.ts # Google Maps Places + Directions
│ │ ├── weather.ts # OpenWeatherMap + Brave fallback
│ │ ├── web-search.ts # Brave Search API
│ │ └── events.ts # Ticketmaster + Brave fallback
│ │
│ ├── whatsapp/ # WhatsApp messaging
│ │ ├── handler.ts # Incoming message processing + unsupported media handling
│ │ ├── sender.ts # Twilio message sending with retry
│ │ ├── templates.ts # Interactive message templates
│ │ └── formatter.ts # Message formatting for WhatsApp limits
│ │
│ ├── storage/
│ │ └── r2.ts # Cloudflare R2 uploads (screenshots, PDFs)
│ │
│ └── rate-limiter.ts # Rate limiting for Claude + browser sessions
│
├── templates/ # Server-rendered HTML pages
│ ├── itinerary.ts # Mobile-optimized trip itinerary page
│ ├── profile.ts # Travel DNA profile page
│ ├── comparison.ts # Side-by-side option comparison page
│ └── live-view.html # Live booking view (browser automation)
│
├── types/ # Shared TypeScript types
├── utils/
│ ├── errors.ts # Retry wrapper, circuit breaker, error categories
│ ├── deeplink.ts # Deep link generators (12 providers)
│ ├── logger.ts # Pino structured logging with PII redaction
│ ├── redis.ts # Shared Redis client
│ ├── correlation.ts # Request correlation IDs
│ ├── phone.ts # Phone number formatting
│ ├── date.ts # Date utilities
│ └── currency.ts # Currency formatting
│
└── index.ts # Application entry point
drizzle/ # SQL migration files
- Node.js 20+
- PostgreSQL 16+ with the
pgvectorextension - Redis 6+
# Clone the repository
git clone https://github.com/ASR4/destinx.git
cd destinx
# Install dependencies
npm install
# Configure environment
cp .env.example .env
# Edit .env with your API keys (see Environment Variables below)
# Run database migrations
npm run db:migrate
# Start development server
npm run dev| Command | Description |
|---|---|
npm run dev |
Start dev server with hot reload (loads .env automatically) |
npm run build |
Compile TypeScript + copy templates and migrations |
npm start |
Run compiled output (production) |
npm test |
Run tests with Vitest |
npm run test:watch |
Run tests in watch mode |
npm run db:generate |
Generate migration files from schema changes |
npm run db:migrate |
Apply pending migrations |
npm run db:push |
Push schema directly (dev convenience) |
npm run db:studio |
Open Drizzle Studio (visual DB explorer) |
Copy .env.example to .env and fill in your keys. Here's what each one does:
| Variable | Description |
|---|---|
DATABASE_URL |
PostgreSQL connection string |
REDIS_URL |
Redis connection string |
ANTHROPIC_API_KEY |
Anthropic API key for Claude |
TWILIO_ACCOUNT_SID |
Twilio Account SID |
TWILIO_AUTH_TOKEN |
Twilio Auth Token |
TWILIO_WHATSAPP_NUMBER |
Twilio WhatsApp sandbox or Business number |
| Variable | Description | Required? |
|---|---|---|
GOOGLE_MAPS_API_KEY |
Google Maps Platform (Places, Directions, venue verification) | Recommended |
DUFFEL_API_KEY |
Duffel flight search & booking | Recommended |
BRAVE_SEARCH_API_KEY |
Brave Search (web search, weather fallback, research) | Recommended |
VOYAGE_API_KEY |
Voyage AI embeddings for semantic memory | Optional |
| Variable | Description | Required? |
|---|---|---|
STRIPE_SECRET_KEY |
Stripe secret key (test or live) | Optional |
STRIPE_WEBHOOK_SECRET |
Stripe webhook signing secret | With Stripe |
STRIPE_SERVICE_FEE_CENTS |
Service fee per booking in cents (default: 1500) | With Stripe |
FORCE_STRIPE_FLOW |
Set true to test Stripe with Duffel test keys |
Optional |
| Variable | Description | Required? |
|---|---|---|
ENABLE_BROWSER_AUTOMATION |
Set true to enable browser booking (default: false) |
Optional |
BROWSERBASE_API_KEY |
Browserbase API key for cloud browsers | Optional |
BROWSERBASE_PROJECT_ID |
Browserbase project ID | With Browserbase |
| Variable | Description | Required? |
|---|---|---|
CLOUDFLARE_R2_ACCESS_KEY |
R2 access key for screenshots/PDFs | Optional |
CLOUDFLARE_R2_SECRET_KEY |
R2 secret key | With R2 |
CLOUDFLARE_R2_BUCKET |
R2 bucket name | With R2 |
CLOUDFLARE_R2_ENDPOINT |
R2 S3-compatible endpoint | With R2 |
TICKETMASTER_API_KEY |
Ticketmaster Discovery API for events | Optional |
OPENWEATHERMAP_API_KEY |
OpenWeatherMap 3.0 (Brave Search is fallback) | Optional |
| Variable | Description | Default |
|---|---|---|
PORT |
Server port | 3000 |
APP_URL |
Public URL (used for web pages, Stripe redirects) | http://localhost:3000 |
HEALTH_CHECK_API_KEY |
Protects /health/detailed endpoint |
None |
LOG_LEVEL |
Pino log level | info |
The application uses PostgreSQL with the pgvector extension for semantic memory. Migrations run automatically at startup via Drizzle ORM.
Tables:
users— Phone number, name, active/opt-out statususer_preferences— Key/value preferences with confidence scores, contradiction tracking, and decayuser_memory_embeddings— 1536-dimensional vector embeddings for semantic recalltrips— Destinations, dates, JSON itinerary plans, budgetconversations— Conversation state, context, and cached conversation summariesmessages— Full message history including tool calls/resultsbookings— Booking records with provider, status (planned/link_sent/user_confirmed/booked), Stripe session, payment statusautomation_scripts— Browser automation step tracking and success rates
- Connect your GitHub repo to Railway
- Add PostgreSQL and Redis services
- Set environment variables in the Railway dashboard
- Set
APP_URLto your Railway public domain - Railway auto-deploys on push to
main
The build runs tsc and copies templates/migrations. The start command runs node dist/index.js, which automatically executes pending database migrations.
- Go to Twilio Console → Messaging → Try it out → Send a WhatsApp message
- In the sandbox settings, set:
- When a message comes in:
https://YOUR-DOMAIN/webhook/whatsapp(POST) - Status callback URL:
https://YOUR-DOMAIN/webhook/whatsapp(POST)
- When a message comes in:
- Join the sandbox by sending the join code from your phone
- Go to Stripe Dashboard → Webhooks → Add endpoint
- URL:
https://YOUR-DOMAIN/webhook/stripe - Events:
checkout.session.completed - Copy the signing secret to
STRIPE_WEBHOOK_SECRET
| Method | Path | Description |
|---|---|---|
GET |
/health |
Basic health check (always 200) |
GET |
/health/detailed |
Detailed health with dependency status (requires API key) |
POST |
/webhook/whatsapp |
Twilio WhatsApp incoming messages |
GET |
/webhook/whatsapp |
Twilio webhook verification |
POST |
/webhook/stripe |
Stripe payment webhook |
GET |
/payment/success |
Post-payment success page |
GET |
/payment/cancel |
Post-payment cancel page |
POST |
/booking/start |
Start a browser booking session |
GET |
/booking/live/:sessionId |
Live booking view (embedded browser) |
POST |
/booking/live/:sessionId/cancel |
Cancel a booking session |
GET |
/trip/:tripId |
Web companion: full trip itinerary page |
GET |
/profile/:userId |
Web companion: Travel DNA profile page |
GET |
/compare/:comparisonId |
Web companion: side-by-side comparison page |
POST |
/dev/chat |
Dev-only: test conversation without WhatsApp |
GET |
/dev/conversations/:userId |
Dev-only: view conversation history |
- User sends a WhatsApp message
- Twilio webhook hits
/webhook/whatsapp - Message is queued via BullMQ for async processing
- Intent classifier determines the user's goal (regex fast-path → Haiku LLM)
- Three-tier context window is assembled: recent messages verbatim, mid-range condensed, old messages summarized
- Active trip state is injected at the END of context to prevent drift
- Claude receives the message with all 15 tools and the user's confidence-weighted profile
- Claude may call tools (search flights, check weather, create trip plan, book flight, etc.)
- Tool results are fed back to Claude in a loop (up to 10 iterations)
- Final response is sent back via WhatsApp (with interactive buttons when appropriate)
- Preferences are extracted asynchronously via Haiku and stored with confidence scores
- User asks to search flights → Claude calls
search_flights→ Duffel API - Results are cached in Redis with a short
searchId(prevents Claude from hallucinating long IDs) - User picks a flight → Claude collects passenger details → calls
book_flight - With Stripe: Creates a Checkout Session → user pays → webhook triggers Duffel booking
- Without Stripe: Books directly via Duffel API
- Confirmation sent via WhatsApp
- Claude calls
initiate_bookingwith venue details - System generates pre-filled deep links for multiple providers (Marriott, Booking.com, OpenTable, etc.)
- Links are sent to the user via WhatsApp with provider labels
- Booking is tracked as
link_sentin the database - When user confirms they booked, Claude calls
confirm_bookingto update the record - Browser automation path available behind
ENABLE_BROWSER_AUTOMATION=trueflag
- Preferences are extracted from every conversation by Claude Haiku (background task)
- Stored with confidence scores: explicit = 0.7, inferred = 0.4, with bumps for confirmation
- Contradictions detected and flagged — agent asks naturally instead of silently overwriting
- Confidence decays by 0.1 for preferences not referenced in 6+ months
- Semantic embeddings enable recall of relevant memories with similarity + recency + confidence reranking
- Profile injected into system prompt with confidence-weighted language
- Price monitoring runs every 6 hours for active bookings
- Trip countdown reminders sent at T-7, T-3, and T-1 days
- Abandoned plan follow-ups sent after 48+ hours of silence
- All notifications include opt-out footer
After generating an itinerary, the validator checks:
- Logistics: travel time between consecutive activities, scheduling overlaps
- Budget: total costs vs. stated budget (flags >15% overage)
- Pace: activity count vs. user preference (packed/balanced/relaxed)
- Venues: Google Places API verification for named restaurants/hotels/experiences
- Auto-fixable issues (time overlaps) are corrected automatically; others are flagged for Claude
# Run all tests
npm test
# Run specific test suites
npx vitest run src/__tests__/deep-links.test.ts
npx vitest run src/__tests__/state-machine.test.ts
npx vitest run src/__tests__/unit/search-cache.test.tsThe /dev/chat endpoint lets you test the full conversation loop without WhatsApp:
# Start a conversation
curl -X POST http://localhost:3000/dev/chat \
-H 'Content-Type: application/json' \
-d '{"message": "Plan a trip to Tokyo in October for 2 people"}'
# Continue with the same user (default phone: +15550001234)
curl -X POST http://localhost:3000/dev/chat \
-H 'Content-Type: application/json' \
-d '{"message": "Budget is around $4000, we love food and history"}'
# Test with a different user
curl -X POST http://localhost:3000/dev/chat \
-H 'Content-Type: application/json' \
-d '{"phone": "+15550009999", "message": "Hi there!"}'After creating a trip, test the web pages:
# Get a trip ID from the database
psql $DATABASE_URL -c "SELECT id, destination FROM trips ORDER BY created_at DESC LIMIT 1;"
# View in browser
open "http://localhost:3000/trip/<TRIP_ID>"
open "http://localhost:3000/profile/<USER_ID>"# Check preference confidence scoring
psql $DATABASE_URL -c "SELECT category, key, value, confidence, source FROM user_preferences ORDER BY created_at DESC LIMIT 10;"
# Check booking tracking
psql $DATABASE_URL -c "SELECT type, provider, status, booking_reference FROM bookings ORDER BY created_at DESC LIMIT 5;"
# Check conversation summaries
psql $DATABASE_URL -c "SELECT id, context->>'conversationSummary' as summary FROM conversations WHERE context->>'conversationSummary' IS NOT NULL LIMIT 3;"| Service | Free Tier | Sign Up |
|---|---|---|
| Anthropic | Pay-as-you-go | Required |
| Twilio | Free trial credits | Required |
| Duffel | Free test mode | Recommended |
| Google Maps Platform | $200/month free | Recommended |
| Brave Search | 2,000 queries/month free | Recommended |
| Stripe | No monthly fee (2.9% + 30¢ per transaction) | Optional |
| Cloudflare R2 | 10GB free, zero egress | Optional |
| Voyage AI | Free tier available | Optional |
| Ticketmaster | 5,000 requests/day free | Optional |
Contributions are welcome! Here's how to get started:
- Fork the repository
- Create a branch for your feature (
git checkout -b feature/my-feature) - Make your changes and ensure they build (
npm run build) - Run tests (
npm test) - Commit with a descriptive message
- Open a Pull Request
- Testing — Integration and unit test coverage (see
src/__tests__/) - WhatsApp templates — Twilio Content API template registration for interactive buttons/lists
- Multi-currency — Currency conversion and display for international users
- Localization — Multi-language support for WhatsApp messages
- Observability — Prometheus metrics, Sentry integration
- Use
npm run devfor hot-reload development - Use
/dev/chatendpoint to test without WhatsApp (non-production only) - Use
npm run db:studioto inspect the database visually - Duffel test keys only work with "Duffel Airways" (fictional airline) — real airline bookings require a live key
- Browser automation is behind
ENABLE_BROWSER_AUTOMATION=true— without it, the system uses deep links
MIT