A single-turn "trip idea" agent: give it one natural-language travel request, get back a typed, structured suggestion (destinations, flights, accommodation, rough budget, caveats).
No UI, no persistence, no real travel APIs — see Design notes for why.
- Provider: OpenRouter (OpenAI-compatible chat completions API)
- Default model:
openai/gpt-oss-120b— free, reliable enough at returning JSON on request. - Override the model via
OPENROUTER_MODEL(any OpenRouter model slug) without touching code.
Why OpenRouter first: one API key gives access to many underlying models (Claude, Gemini,
GPT, open models), so swapping models is an env var change, not a code change. If usage
outgrows what's economical there, the same completeJSON interface in
src/lib/llm/openrouter.ts can be duplicated for a direct
Anthropic/Gemini SDK client behind the same shape.
npm install
cp .env.example .env.local
# edit .env.local and paste your OpenRouter key (https://openrouter.ai/keys)
npm run trip -- "Relaxing beach week in February under \$2000 for two, leaving from JFK. Want a nice hotel."That prints a JSON TripSuggestion to stdout.
| Var | Required | Default | Notes |
|---|---|---|---|
OPENROUTER_API_KEY |
yes | — | From https://openrouter.ai/keys |
OPENROUTER_MODEL |
no | openai/gpt-4o-mini |
Used for the agent's extraction + synthesis passes (the customer-facing path) |
OPENROUTER_JUDGE_MODEL |
no | falls back to OPENROUTER_MODEL |
Used only by the eval harness's LLM-as-judge step (npm run eval) — pick a stronger model here since judge reliability matters more than judge latency/cost |
- Missing
OPENROUTER_API_KEY: fails fast, before any network call, withConfiguration error: OPENROUTER_API_KEY is not set...and CLI exit code2. The API route returns HTTP500with the same message in the body. - Invalid/rejected key (401 from OpenRouter):
Configuration error: OpenRouter rejected the API key (401 Unauthorized)..., exit code2/ HTTP500. - Other request failures (network error, rate limit, non-2xx, malformed JSON from the
model after one retry):
Request error: ..., exit code3/ HTTP502.
Try it: run npm run trip -- "test" without setting up .env.local to see the missing-key
message live.
HTTP (Next.js API route):
npm run dev
curl -X POST http://localhost:3000/api/trip \
-H "Content-Type: application/json" \
-d '{"request": "Long weekend in Europe, flying from SFO. Walkable city, great food, boutique hotel under $300/night."}'Eval harness:
npm run evalRuns 7 cases through the agent and prints a per-case + aggregate report (see below).
Four tools in src/lib/tools/: searchDestinations, searchFlights,
searchHotels, and computeBudget. Flights and hotels take different inputs (origin/dates
vs. location/nights) and have different mock shapes, so merging them would just push the
branching inside one function instead of removing it. computeBudget is plain arithmetic —
it's deliberately not an LLM-callable tool, since totals are exactly the kind of thing that
shouldn't be left to a model to "remember" correctly.
Single-turn requests over a small, fixed tool universe don't benefit from a multi-step reason/act/observe loop — there's nothing for the model to discover by looping. Instead (src/lib/agent.ts):
- Extract — one LLM call turns free text into a typed
TripRequest(origin, budget, dates, vibe, exclusions, etc.), leaving anything unstated asnull/false/[]. - Tools — code deterministically calls
searchDestinations→searchFlights→searchHotels→computeBudgetfor each destination candidate. No model involved. - Synthesize — one LLM call picks the best candidate and writes the reasoning/caveats, given the tool data as context. It is explicitly instructed not to invent numbers.
- Assemble — the final response is built in code from step 2's data plus step 3's reasoning/caveats/confidence, then validated against the response schema. Prices and hotel names always come from the mock tools, never from model text, so a flaky model can't hallucinate a price into a structurally valid response.
Three-tier handling, enforced in code (not left to model judgment):
- Missing, non-blocking (e.g. no traveler count) → default applied (2 travelers, 5 days)
- a caveat stating the assumption.
- Missing, blocking for one tool (e.g. no origin city) → that field degrades to
null(no flight price) + a caveat asking for the missing info — never a guessed price. - Nearly no information (e.g.
"Cheap.") → still returns a valid structured response, butconfidence: "low"and a caveat saying the suggestion is a generic placeholder.
This is a single-turn tool (no follow-up question loop), so "ask" takes the form of a caveat in the response rather than a clarifying question back to the user.
eval/run-eval.ts runs 7 cases (the prompt's examples) through the agent and scores 3 dimensions:
- Schema validity (deterministic) — does the response parse against the Zod schema.
- Constraint adherence (deterministic, per-case) — case-specific checks, e.g. "flight price present when an origin was given," "destination excludes Santorini," "no flight cost when the user already has one booked."
- Groundedness (LLM-as-judge, 1 extra call per case) — does the response avoid inventing facts beyond the mock data, and does it caveat appropriately what wasn't provided.
Two deterministic + one judged dimension keeps the harness cheap and the deterministic checks give a hard signal; the judge call only covers the part deterministic checks can't reach (whether the prose is grounded).
The judge step can use a different (typically stronger) model than the agent's own
extraction/synthesis passes via OPENROUTER_JUDGE_MODEL — judge reliability matters more
than judge cost/latency, since it only runs in CI/local evals, not on every customer
request.
Dimensions considered but intentionally not scored for this prototype: latency/call-count (useful to observe, not a quality signal), output determinism across reruns (worth a manual spot-check, not worth automating at this scale), and caveat precision beyond "are there any caveats" (folded into the groundedness judge rather than given its own dimension).
No real travel APIs (mocked data only), no UI, no persistence/multi-turn state, no auth, no production concerns (deploy/cache/observability/retries). See the task brief for the full list — this prototype optimizes for a clear, typed, testable single-turn flow over completeness.