# Project export: TripSync

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: Group trip planning, automated: everyone shares preferences via a link, an agent browses real flights/hotels/activities, then ranks destinations against what the group wants.
- Devpost: https://devpost.com/software/tripsync-5q81zg
- GitHub: https://github.com/deionchaudhary1/TripSync
- Team: 1 GitHub contributor(s) — Deion Chaudhary (3 commits)

## Devpost submission (written by the team)

### Inspiration

Every group trip we've planned hits the same wall: a group chat with eight people throwing out preferences, someone volunteering to "look into flights," and three days later nothing's booked. We wanted an agent that could actually do that comparison work — check real prices, not just suggest a vibe.

### What it does

TripSync collects trip preferences from a group via a shareable link — budget, vibe, dates, must-haves. Once enough people respond, the organizer hits "Generate Trip Plan" and the agent takes over: it reconciles everyone's input, brainstorms candidate destinations, browses real flight prices, hotel rates, and local activities for each, then scores and ranks them against the group's stated preferences. Output: one report with ranked destination cards and a plain-English "why this fits" for each.

### How we built it

A small Express server handles live group input — shareable trip codes, QR links, real-time response tracking. That triggers a four-stage pipeline: aggregate group responses into one preference set, brainstorm candidates (pure LLM reasoning), research each via real Browserbase sessions (flights, hotels, activities), then score and rank against the group's actual preferences. Every reasoning step uses forced tool calls for structured output instead of prompting for JSON — far more reliable across a multi-step chain. Each stage also runs standalone for isolated testing.

### Challenges we ran into

Google Flights and Booking.com are dense, frequently-changing pages — getting reliable extraction took real iteration, plus fallbacks (Skyscanner) so one site's quirks couldn't sink a destination's results. Keeping multi-device form submissions race-condition-free took some care, solved by Node's single-threaded handling as long as nothing awaits between reading and writing state.

### Accomplishments we're proud of

Our activities step originally pulled a full Wikivoyage page through Stagehand's extractor every run — ~70k tokens to find a "things to do" list. We rewrote it to grab only the relevant sections via a lightweight DOM script first, cutting that to ~2.6k tokens (~27x), with a fallback to full extraction if a page's structure ever changes. Small fix, big difference for a pipeline meant to run repeatedly.

### What we learned

The biggest lever for cost and reliability was separating "the agent should reason here" from "we already know where this data lives" — Claude reasons where it adds value (reconciling preferences, scoring tradeoffs), and gets pre-mapped extraction everywhere else.

### What's next

Two-way Google Calendar sync for the winning destination, group voting on final picks instead of one organizer deciding, and more research sources for larger or pickier groups.

## README (from the GitHub repository)

# TripSync

A group trip planner agent built for CalHacks AI Hackathon (Browserbase track).

Describe a group's trip preferences — budget, dates, vibe, must-haves, deal-breakers — and
TripSync:

1. **Brainstorms** 2-3 candidate destinations with Claude (pure reasoning, no browsing).
2. **Researches** each candidate live in a real browser via [Browserbase](https://browserbase.com) +
   [Stagehand](https://stagehand.dev):
   - Flight price range from Google Flights (falls back to Skyscanner if extraction fails)
   - Lodging price range from Booking.com
   - Top activities/things to do from Wikivoyage
3. **Scores** each destination against the original preferences (budget fit, vibe match,
   deal-breaker violations) using Claude with structured tool-use output.
4. **Renders** a visual HTML report with ranked destination cards.

## Setup

```bash
npm install
cp .env.example .env   # fill in your keys
```

Required environment variables (`.env`, gitignored):

```
ANTHROPIC_API_KEY=
BROWSERBASE_API_KEY=
BROWSERBASE_PROJECT_ID=
```

## Running

Run the full pipeline (edit the example preferences in `src/main.ts` to change the trip):

```bash
npx tsx src/main.ts
```

Output is written to `output/results.html` — open it in a browser to see the ranked destination
cards.

### Running each stage in isolation

Each stage is independently testable:

```bash
npx tsx src/brainstorm.ts   # Step 1: destination brainstorm
npx tsx src/research.ts     # Step 2: Browserbase + Stagehand research for one destination
npx tsx src/score.ts        # Step 3: scoring against preferences
```

## Architecture

| File | Purpose |
|---|---|
| `src/types.ts` | Shared types for preferences, candidates, research, and scores |
| `src/brainstorm.ts` | Step 1 — Claude tool-use call to suggest candidate destinations |
| `src/research.ts` | Step 2 — Stagehand browser automation for flights, lodging, activities |
| `src/score.ts` | Step 3 — Claude tool-use call to score and rank candidates |
| `src/render.ts` | Step 4 — renders the ranked results as a static HTML report |
| `src/main.ts` | CLI orchestrator that runs all four steps end to end |

## Step 0: live group-preference form

Before the pipeline above runs, the organizer collects each group member's preferences through
a live multi-device form. Express server + in-memory store (no database needed for a single
demo event), plain HTML/JS frontend (mobile-friendly).

```bash
npm run server
```

This prints both a `localhost` URL and a LAN URL (e.g. `http://192.168.x.x:3000`) — use the LAN
URL on phones.

Flow:

1. Organizer opens `http://localhost:3000`, clicks **Create Trip** → gets a short code (e.g. `A4EJ6`)
   and a shareable link `/trip/A4EJ6`.
2. Each group member opens that link on their own phone and submits name, budget, vibe (multi-select),
   dates (or "flexible"), and free-text notes.
3. Organizer opens `/trip/A4EJ6/results` to watch responses arrive live (polls every 3s).
4. Once ≥2 people have responded, the organizer enters a **departure city + airport code** for
   the group and clicks **Generate Trip Plan**. This triggers the full pipeline on the server:
   - `src/aggregate.ts` synthesizes all respondents into one `TripPreferences` object (Claude
     tool-use call — reconciles budgets, dates, and vibes, and pulls must-haves/deal-breakers out
     of free-text notes)
   - then Steps 1-4 (`brainstorm.ts` → `research.ts` → `score.ts` → `render.ts`) run exactly as
     described above
5. The results page polls `/api/trips/:tripId/plan` for status and shows a **View Trip Plan**
   button once done. The finished report is also saved to `output/<tripId>.html`.

This takes **1-2 minutes per destination** (real browser research via Browserbase), so 3
candidates can take 3-5 minutes total — the UI shows a "generating" state throughout.

### Testing the multi-device flow locally

1. Make sure your laptop and phone(s) are on the **same WiFi network**.
2. Run `npm run server` and note the `Network:` URL it prints (e.g. `http://192.168.86.29:3000`).
3. On your laptop, open that URL and click **Create Trip** to get a code like `A4EJ6`.
4. On each phone's browser, go to `http://192.168.86.29:3000/trip/A4EJ6` and submit the form.
5. Watch `http://192.168.86.29:3000/trip/A4EJ6/results` on your laptop update within ~3 seconds
   of each submission.

If a phone can't reach the laptop, check that your Mac's firewall isn't blocking incoming
connections to Node, and that the phone isn't on a "guest"/isolated WiFi network (some
routers/venues block device-to-device traffic on guest networks — use a personal hotspot as a
fallback).


## Detected evidence (automated analysis)

Indexed codebase: 21 recognized source files, 128 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- Express (technology) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- TypeScript (language) — detected in the code

## Codebase structure (from repository index)

### Files (26 of 26)

```
.env.example
.gitignore
LICENSE
package.json
public/form.html
public/form.js
public/home.html
public/home.js
public/qrcode.js
public/results.html
public/results.js
public/styles.css
README.md
src/aggregate.ts
src/brainstorm.ts
src/main.ts
src/render.ts
src/research.ts
src/score.ts
src/server/app.ts
src/server/ids.ts
src/server/index.ts
src/server/pipeline.ts
src/server/store.ts
src/types.ts
tsconfig.json
```

### Dependencies

- package.json: @anthropic-ai/sdk@^0.105.0, @browserbasehq/sdk@^2.14.1, @browserbasehq/stagehand@^3.6.0, @types/express@^5.0.6, @types/node@^26.0.0, dotenv@^17.4.2, express@^5.2.1, tsx@^4.22.4, typescript@^6.0.3, zod@^4.4.3

### Recent commits (newest first)

- done ?
- proto
- Initial commit

## Key source files (fetched from GitHub, selected and truncated for size)

### package.json

```
{
  "name": "tripsync",
  "version": "1.0.0",
  "description": "ai hacks 2026",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "server": "tsx src/server/index.ts"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/deionchaudhary1/TripSync.git"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "bugs": {
    "url": "https://github.com/deionchaudhary1/TripSync/issues"
  },
  "homepage": "https://github.com/deionchaudhary1/TripSync#readme",
  "type": "module",
  "dependencies": {
    "@anthropic-ai/sdk": "^0.105.0",
    "@browserbasehq/sdk": "^2.14.1",
    "@browserbasehq/stagehand": "^3.6.0",
    "dotenv": "^17.4.2",
    "express": "^5.2.1",
    "zod": "^4.4.3"
  },
  "devDependencies": {
    "@types/express": "^5.0.6",
    "@types/node": "^26.0.0",
    "tsx": "^4.22.4",
    "typescript": "^6.0.3"
  }
}

```

### src/main.ts

```typescript
import "dotenv/config";
import { mkdir } from "node:fs/promises";
import { brainstormDestinations } from "./brainstorm.js";
import { researchDestination } from "./research.js";
import { scoreDestinations } from "./score.js";
import { writeResultsHtml } from "./render.js";
import type { DestinationResearch, TripPreferences } from "./types.js";

async function main() {
  const prefs: TripPreferences = {
    groupSize: 4,
    budgetPerPerson: 1500,
    originCity: "San Francisco",
    originAirportCode: "SFO",
    startDate: "2026-09-10",
    endDate: "2026-09-17",
    vibe: ["beach", "good food", "relaxed nightlife"],
    mustHaves: ["direct or 1-stop flights", "walkable downtown"],
    dealBreakers: ["no cold weather"],
  };

  console.log("Step 1: brainstorming candidate destinations...");
  const candidates = await brainstormDestinations(prefs);
  console.log(
    `  -> ${candidates.map((c) => c.name).join(", ")}`,
  );

  console.log("Step 2: researching flights, lodging, and activities (this opens real browser sessions)...");
  const research: DestinationResearch[] = [];
  for (const candidate of candidates) {
    console.log(`  -> researching ${candidate.name}...`);
    const result = await researchDestination(candidate, prefs);
    research.push(result);
  }

  console.log("Step 3: scoring destinations against preferences...");
  const scores = await scoreDestinations(research, prefs);
  console.log(
    `  -> ranked: ${scores.map((s) => `${s.name} (${s.overallScore.toFixed(1)})`).join(", ")}`,
  );

  console.log("Step 4: rendering HTML output...");
  await mkdir("output", { recursive: true });
  const outPath = "output/results.html";
  await writeResultsHtml(outPath, prefs, research, scores);
  console.log(`  -> wrote ${outPath}`);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

```

### src/server/index.ts

```typescript
import { networkInterfaces } from "node:os";
import { createApp } from "./app.js";

const PORT = Number(process.env.PORT) || 3000;

function getLanIp(): string | undefined {
  for (const ifaces of Object.values(networkInterfaces())) {
    for (const iface of ifaces ?? []) {
      if (iface.family === "IPv4" && !iface.internal) {
        return iface.address;
      }
    }
  }
  return undefined;
}

const app = createApp();

app.listen(PORT, "0.0.0.0", () => {
  const lanIp = getLanIp();
  console.log(`TripSync server running on:`);
  console.log(`  Local:   http://localhost:${PORT}`);
  if (lanIp) {
    console.log(`  Network: http://${lanIp}:${PORT}  <- use this on phones (same WiFi)`);
  } else {
    console.log(`  Network: could not detect a LAN IP — run \`ifconfig\` (mac/linux) or \`ipconfig\` (windows) to find it`);
  }
});

```

### src/server/app.ts

```typescript
import express, { type Request, type Response } from "express";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { addRespondent, createTrip, getTrip, type Budget } from "./store.js";
import { runPipelineInBackground } from "./pipeline.js";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PUBLIC_DIR = path.join(__dirname, "..", "..", "public");

const VALID_BUDGETS: Budget[] = ["$", "$$", "$$$"];
const VALID_VIBES = [
  "relaxing",
  "adventurous",
  "foodie",
  "nightlife",
  "nature",
  "culture",
];

function isValidDateString(s: unknown): s is string {
  return typeof s === "string" && /^\d{4}-\d{2}-\d{2}$/.test(s);
}

export function createApp() {
  const app = express();
  app.use(express.json());
  app.use(express.static(PUBLIC_DIR));

  // --- Page routes (serve static HTML shells; client JS reads the trip id from the URL) ---

  app.get("/", (_req: Request, res: Response) => {
    res.sendFile(path.join(PUBLIC_DIR, "home.html"));
  });

  app.get("/trip/:tripId", (_req: Request, res: Response) => {
    res.sendFile(path.join(PUBLIC_DIR, "form.html"));
  });

  app.get("/trip/:tripId/results", (_req: Request, res: Response) => {
    res.sendFile(path.join(PUBLIC_DIR, "results.html"));
  });

  app.get("/trip/:tripId/plan", (req: Request, res: Response) => {
    const trip = getTrip(req.params.tripId as string);
    if (!trip || trip.plan.status !== "done" || !trip.plan.html) {
      res.status(404).send("Plan not ready yet.");
      return;
    }
    res.set("Content-Type", "text/html").send(trip.plan.html);
  });

  // --- API routes ---

  app.post("/api/trips", (_req: Request, res: Response) => {
    const trip = createTrip();
    res.status(201).json({ tripId: trip.id });
  });

  app.get("/api/trips/:tripId", (req: Request, res: Response) => {
    const trip = getTrip(req.params.tripId as string);
    if (!trip) {
      res.status(404).json({ error: "Trip not found" });
      return;
    }
    res.json({
      tripId: trip.id,
      count: trip.respondents.length,
      respondents: trip.respondents.map((r) => ({
        name: r.name,
        budget: r.budget,
        vibe: r.vibe,
        submittedAt: r.submittedAt,
      })),
    });
  });

  app.post("/api/trips/:tripId/responses", (req: Request, res: Response) => {
    const trip = getTrip(req.params.tripId as string);
    if (!trip) {
      res.status(404).json({ error: "Trip not found" });
      return;
    }

    const body = req.body ?? {};
    const errors: string[] = [];

    const name = typeof body.name === "string" ? body.name.trim() : "";
    if (!name) errors.push("name is required");

    const budget = body.budget;
    if (!VALID_BUDGETS.includes(budget)) errors.push("budget must be one of $, $$, $$$");

    const vibe = Array.isArray(body.vibe)
      ? body.vibe.filter((v: unknown) => typeof v === "string" && VALID_VIBES.includes(v))
      : [];
    if (vibe.length === 0) errors.push("at least one vibe must be selected");

    const flexible = body.dates?.flexible === true;
    let dates: { flexible: boolean; startDate?: string; endDate?: string };
    if (flexible) {
      dates = { flexible: true };
    } else {
      const startDate = body.dates?.startDate;
      const endDate = body.dates?.endDate;
      if (!isValidDateString(startDate) || !isValidDateString(endDate)) {
        errors.push("dates.startDate and dates.endDate are required (YYYY-MM-DD) unless flexible is true");
        dates = { flexible: false };
      } else {
        dates = { flexible: false, startDate, endDate };
      }
    }

    const notes = typeof body.notes === "string" ? body.notes.trim() : "";

    if (errors.length > 0) {
      res.status(400).json({ error: "Invalid submission", details: errors });
      return;
    }

    const respondent = addRespondent(trip.id, { name, budget, vibe, dates, notes });
    res.status(201).json({ respondent });
  });

  app.get("/api/trips/:tripId/export", (req: Request, res: Response) => {
    const trip = getTrip(req.params.tripId as string);
    if (!trip) {
      res.status(404).json({ error: "Trip not found" });
      return;
    }

    res.json({
      trip_id: trip.id,
      respondents: trip.respondents.map((r) => ({
        name: r.name,
        budget: r.budget,
        vibe: r.vibe,
        dates: r.dates,
        notes: r.notes,
      })),
    });
  });

  app.post("/api/trips/:tripId/generate", (req: Request, res: Response) => {
    const trip = getTrip(req.params.tripId as string);
    if (!trip) {
      res.status(404).json({ error: "Trip not found" });
      return;
    }
    if (trip.respondents.length < 2) {
      res.status(400).json({ error: "Need at least 2 respondents to generate a plan" });
      return;
    }
    if (trip.plan.status === "running") {
      res.status(409).json({ error: "Plan generation already in progress" });
      return;
    }

    const originCity = typeof req.body?.originCity === "string" ? req.body.originCity.trim() : "";
    const originAirportCode =
      typeof req.body?.originAirportCode === "string" ? req.body.originAirportCode.trim().toUpperCase() : "";

    if (!originCity || !originAirportCode) {
      res.status(400).json({ error: "originCity and originAirportCode are required" });
      return;
    }

    runPipelineInBackground(trip.id, originCity, originAirportCode);
    res.status(202).json({ status: "running" });
  });

  app.get("/api/trips/:tripId/plan", (req: Request, res: Response) => {
    const trip = getTrip(req.params.tripId as string);
    if (!trip) {
      res.status(404).json({ error: "Trip not found" });
      return;
    }
    res.json({
      status: trip.plan.status,
      error: trip.plan.error,
      startedAt: trip.plan.startedAt,
      finishedAt: trip.plan.finishedAt,
    });
  });

  return app;
}

```

### public/home.html

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>TripSync — Start a Trip</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%E2%9C%88%EF%B8%8F%3C/text%3E%3C/svg%3E" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
  <div class="container">
    <div class="brand"><span class="logo">✈️</span><h1>TripSync</h1></div>
    <p class="sub">Create a trip session, then share the link with your group.</p>
    <button class="primary" id="create-btn">🚀 Create Trip</button>
    <div class="error" id="error"></div>

    <div id="created" style="display:none">
      <h2>🔗 Share this with your group</h2>
      <div class="code-display" id="trip-code"></div>
      <div class="qr-wrap" id="qr-code"></div>
      <div class="link-box" id="trip-link"></div>
      <button class="primary" id="results-btn">📊 Go to Results View</button>
    </div>
  </div>

  <script src="/qrcode.js"></script>
  <script src="/home.js"></script>
</body>
</html>

```

### src/types.ts

```typescript
export interface TripPreferences {
  groupSize: number;
  budgetPerPerson: number;
  originCity: string;
  originAirportCode: string; // primary departure airport IATA code, e.g. "SFO"
  startDate: string; // YYYY-MM-DD
  endDate: string; // YYYY-MM-DD
  vibe: string[]; // e.g. ["beach", "nightlife", "relaxed"]
  mustHaves: string[]; // e.g. ["direct flights", "walkable"]
  dealBreakers: string[]; // e.g. ["no cold weather", "no long layovers"]
}

export interface DestinationCandidate {
  name: string; // e.g. "Lisbon, Portugal"
  airportCode: string; // primary airport IATA code, e.g. "LIS"
  pitch: string; // 1-2 sentence reasoning for why this fits
}

export interface PriceResearch {
  flightPriceRangeUSD: { low: number; high: number } | null;
  lodgingPriceRangeUSDPerNight: { low: number; high: number } | null;
  notes: string; // freeform notes on what was found / fallback used
  sourceUrls: string[];
}

export interface ActivityResearch {
  activities: string[]; // top activities/things to do matching vibe
  sourceUrls: string[];
}

export interface DestinationResearch {
  candidate: DestinationCandidate;
  price: PriceResearch;
  activities: ActivityResearch;
}

export interface DestinationScore {
  name: string;
  budgetFitScore: number; // 0-10
  vibeMatchScore: number; // 0-10
  dealBreakerViolations: string[];
  overallScore: number; // 0-10
  whyItFits: string;
  whyItDoesnt: string;
}

```

### public/home.js

```javascript
const createBtn = document.getElementById("create-btn");
const errorEl = document.getElementById("error");
const createdEl = document.getElementById("created");
const tripCodeEl = document.getElementById("trip-code");
const tripLinkEl = document.getElementById("trip-link");
const qrCodeEl = document.getElementById("qr-code");
const resultsBtn = document.getElementById("results-btn");

let currentTripId = null;

createBtn.addEventListener("click", async () => {
  errorEl.style.display = "none";
  createBtn.disabled = true;
  try {
    const res = await fetch("/api/trips", { method: "POST" });
    if (!res.ok) throw new Error("Failed to create trip");
    const data = await res.json();
    currentTripId = data.tripId;

    const formUrl = `${window.location.origin}/trip/${currentTripId}`;
    tripCodeEl.textContent = currentTripId;
    tripLinkEl.textContent = formUrl;

    const qr = qrcode(0, "M");
    qr.addData(formUrl);
    qr.make();
    qrCodeEl.innerHTML = qr.createSvgTag({ cellSize: 6, margin: 4 });

    if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") {
      errorEl.textContent =
        "Heads up: this QR code points at localhost, which phones can't reach. Open this page using the Network URL printed in your terminal instead.";
      errorEl.style.display = "block";
    }

    createdEl.style.display = "block";
    createBtn.style.display = "none";
  } catch (err) {
    errorEl.textContent = "Could not create trip. Please try again.";
    errorEl.style.display = "block";
  } finally {
    createBtn.disabled = false;
  }
});

resultsBtn.addEventListener("click", () => {
  if (currentTripId) {
    window.location.href = `/trip/${currentTripId}/results`;
  }
});

```

### public/results.html

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>TripSync — Results</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%E2%9C%88%EF%B8%8F%3C/text%3E%3C/svg%3E" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
  <div class="container">
    <div class="brand"><span class="logo">✈️</span><h1>Trip Results</h1></div>
    <p class="sub" id="trip-label">Loading...</p>

    <div class="step-indicator" id="step-indicator">
      <div class="step active" data-step="collect"><span class="dot">1</span>Collect</div>
      <div class="connector"></div>
      <div class="step" data-step="generate"><span class="dot">2</span>Generate</div>
      <div class="connector"></div>
      <div class="step" data-step="done"><span class="dot">3</span>Done</div>
    </div>

    <div class="count-banner" id="count-banner">👥 0 submitted</div>

    <div class="respondent-list" id="respondent-list"></div>

    <div id="generate-form">
      <label for="origin-city">✈️ Departure city (whole group)</label>
      <input type="text" id="origin-city" placeholder="e.g. San Francisco" />
      <label for="origin-airport">🛫 Departure airport code</label>
      <input type="text" id="origin-airport" placeholder="e.g. SFO" maxlength="3" style="text-transform:uppercase" />

      <button class="primary" id="generate-btn" disabled>Generate Trip Plan (need at least 2)</button>
      <div class="error" id="generate-error"></div>
    </div>

    <div id="generating-status" style="display:none">
      <div class="count-banner pulsing">⏳ Generating trip plan...</div>
      <p class="sub" style="text-align:center">This runs real browser research for each destination — usually takes 1-2 minutes.</p>
    </div>

    <div id="plan-ready" style="display:none">
      <button class="primary" id="view-plan-btn">🎉 View Trip Plan</button>
    </div>
  </div>

  <script src="/results.js"></script>
</body>
</html>

```

### public/form.html

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>TripSync — Your Preferences</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%E2%9C%88%EF%B8%8F%3C/text%3E%3C/svg%3E" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
  <div class="container" id="form-container">
    <div class="brand"><span class="logo">✈️</span><h1>Trip Preferences</h1></div>
    <p class="sub" id="trip-label">Loading trip...</p>

    <form id="pref-form">
      <label for="name">👋 Your name</label>
      <input type="text" id="name" autocomplete="name" required />

      <label>💰 Budget</label>
      <div class="chip-group" id="budget-group">
        <div class="chip" data-value="$">$</div>
        <div class="chip" data-value="$$">$$</div>
        <div class="chip" data-value="$$$">$$$</div>
      </div>

      <label>🎉 Vibe (pick as many as you like)</label>
      <div class="chip-group" id="vibe-group">
        <div class="chip" data-value="relaxing">🧘 Relaxing</div>
        <div class="chip" data-value="adventurous">🧗 Adventurous</div>
        <div class="chip" data-value="foodie">🍜 Foodie</div>
        <div class="chip" data-value="nightlife">🌃 Nightlife</div>
        <div class="chip" data-value="nature">🌲 Nature</div>
        <div class="chip" data-value="culture">🏛️ Culture</div>
      </div>

      <label>📅 Dates</label>
      <div class="date-row">
        <div>
          <label for="start-date">Start</label>
          <input type="date" id="start-date" />
        </div>
        <div>
          <label for="end-date">End</label>
          <input type="date" id="end-date" />
        </div>
      </div>
      <label class="flex-toggle">
        <input type="checkbox" id="flexible-toggle" />
        🤷 I'm flexible on dates
      </label>

      <label for="notes">📝 Anything else? (deal-breakers, must-haves)</label>
      <textarea id="notes" placeholder="e.g. no long layovers, need a pool, vegetarian-friendly food..."></textarea>

      <div class="error" id="error"></div>
      <button type="submit" class="primary" id="submit-btn">Submit</button>
    </form>

    <div class="success" id="success" style="display:none">
      <div class="checkmark">✅</div>
      <h2>Thanks, you're in!</h2>
      <p class="sub">Your preferences have been submitted.</p>
    </div>
  </div>

  <script src="/form.js"></script>
</body>
</html>

```

### src/brainstorm.ts

```typescript
import Anthropic from "@anthropic-ai/sdk";
import "dotenv/config";
import type { DestinationCandidate, TripPreferences } from "./types.js";

const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

const SUBMIT_CANDIDATES_TOOL: Anthropic.Tool = {
  name: "submit_candidates",
  description:
    "Submit the list of candidate destinations for the group trip.",
  input_schema: {
    type: "object",
    properties: {
      candidates: {
        type: "array",
        minItems: 2,
        maxItems: 3,
        items: {
          type: "object",
          properties: {
            name: {
              type: "string",
              description: "City and country, e.g. 'Lisbon, Portugal'",
            },
            airportCode: {
              type: "string",
              description: "Primary airport IATA code nearest the destination, e.g. 'LIS'",
            },
            pitch: {
              type: "string",
              description:
                "1-2 sentence reasoning for why this destination fits the group's stated preferences",
            },
          },
          required: ["name", "airportCode", "pitch"],
        },
      },
    },
    required: ["candidates"],
  },
};

export async function brainstormDestinations(
  prefs: TripPreferences,
): Promise<DestinationCandidate[]> {
  const prompt = `You are a travel-planning assistant helping a group pick a trip destination.

Group preferences:
- Group size: ${prefs.groupSize}
- Budget per person (total trip, excluding flights unless noted): $${prefs.budgetPerPerson}
- Departing from: ${prefs.originCity}
- Dates: ${prefs.startDate} to ${prefs.endDate}
- Vibe / style: ${prefs.vibe.join(", ")}
- Must-haves: ${prefs.mustHaves.join(", ") || "none stated"}
- Deal-breakers: ${prefs.dealBreakers.join(", ") || "none stated"}

Suggest 2-3 candidate destinations that best fit these preferences. Consider
flight feasibility from the origin city, seasonal weather for the given dates,
and the stated vibe. Avoid destinations that clearly violate a deal-breaker.
Call the submit_candidates tool with your answer.`;

  const response = await anthropic.messages.create({
    model: "claude-sonnet-4-6",
    max_tokens: 1024,
    tools: [SUBMIT_CANDIDATES_TOOL],
    tool_choice: { type: "tool", name: "submit_candidates" },
    messages: [{ role: "user", content: prompt }],
  });

  const toolUse = response.content.find(
    (block): block is Anthropic.ToolUseBlock => block.type === "tool_use",
  );

  if (!toolUse) {
    throw new Error("Claude did not return a submit_candidates tool call");
  }

  const { candidates } = toolUse.input as { candidates: DestinationCandidate[] };
  return candidates;
}

// Standalone test: `npx tsx src/brainstorm.ts`
if (import.meta.url === `file://${process.argv[1]}`) {
  const examplePrefs: TripPreferences = {
    groupSize: 4,
    budgetPerPerson: 1500,
    originCity: "San Francisco",
    originAirportCode: "SFO",
    startDate: "2026-09-10",
    endDate: "2026-09-17",
    vibe: ["beach", "good food", "relaxed nightlife"],
    mustHaves: ["direct or 1-stop flights", "walkable downtown"],
    dealBreakers: ["no cold weather"],
  };

  brainstormDestinations(examplePrefs)
    .then((candidates) => {
      console.log(JSON.stringify(candidates, null, 2));
    })
    .catch((err) => {
      console.error(err);
      process.exit(1);
    });
}

```

[10 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]