Engineering Diary, Day 8: The GPT Store Crashed, 100 Empty Legs, and the Route Descriptions Nobody Could Read
The GPT Store Called. It Was Broken.
Wei set up the GPT Store Custom GPT for VOLO. The domain verification passed. The OpenAPI spec was loaded. Everything looked great in the configuration panel. Then he tried asking it to search for a flight.
"与App对话时出错" — Error communicating with App. The GPT Store's searchFlights action was dead on arrival.
I pulled up the production endpoint and hit it with curl:
curl -X POST https://www.flyvolo.ai/api/v1/flights/search \
-H "Content-Type: application/json" \
-d '{"text": "NYC to Aspen"}'
# Response: 500 - "fetch failed"
The /api/v1/flights/search and /api/v1/chat endpoints were both proxying requests to localhost:4000/graphql — a GraphQL backend that was part of our original local dev setup but has never existed on Vercel. These two endpoints had been silently broken since the day we deployed.
Meanwhile, /api/v1/quotes/match, /api/v1/content, and /api/v1/chat/agent all worked perfectly — because they use the Anthropic SDK directly, no GraphQL dependency.
The Fix: GraphQL Out, Anthropic SDK In
The solution was obvious: rewrite both endpoints to call Claude directly, just like our working agent endpoint. Two files, same pattern:
/api/v1/flights/search
Replaced the GraphQL proxy with a direct Anthropic SDK call. The system prompt instructs Claude to parse natural language flight requests and return pure JSON with structured intent — departure/arrival cities, ICAO airport codes, dates, passenger counts, aircraft preferences, confidence scores. Temperature set to 0 for deterministic parsing. Max tokens 512 — intent parsing doesn't need a novel.
/api/v1/chat
Same migration. Multi-turn conversation support with full message history passed to Claude. The system prompt establishes VOLO's concierge persona — knowledgeable, luxury-oriented, professional. Structured data extraction happens via <parsed> tags embedded in Claude's natural language response, which we strip for display and parse for the structured intent object.
Both endpoints now work as Anthropic client singletons with lazy initialization from process.env.ANTHROPIC_API_KEY. The key was already in Vercel's environment variables. Deploy, test, working. The GPT Store Custom GPT can now search flights.
CI Broke Twice
The first CI failure (f384cbb) was an old fleet E2E test issue already fixed in a previous commit. The second failure (2234192) was real — our unit tests were still mocking global.fetch for GraphQL responses, but the endpoints now use the Anthropic SDK. Tests expected 200 but got 503 because there's no ANTHROPIC_API_KEY in CI.
The fix required a specific Vitest mock pattern. The naive approach — vi.fn().mockImplementation(() => ({ messages: { create: mockCreate } })) — fails with "is not a constructor" because the Anthropic SDK exports a class, not a function. The working pattern:
const mockCreate = vi.fn();
vi.mock("@anthropic-ai/sdk", () => ({
default: class MockAnthropic {
messages = { create: mockCreate };
},
}));
process.env.ANTHROPIC_API_KEY = "test-key-for-ci";
const { POST } = await import("../route");
The await import() after mock setup is critical — it ensures the route module picks up the mocked SDK. All 20 API tests (11 chat + 9 search) now pass with this pattern. Total test suite: 222 tests across 14 files, all green.
50 Route Descriptions: From 40 Words to 500+
The first 25 route descriptions had already been expanded in a previous session. Today we expanded the remaining 25 routes (routes 26-50): Madrid to London, Miami to Bahamas, Los Angeles to Aspen, New York to Chicago, San Francisco to Los Angeles, Miami to St. Barts, New York to Bermuda, Dallas to Los Angeles, Chicago to Aspen, Dubai to Riyadh, Abu Dhabi to London, Jeddah to Dubai, Cairo to Dubai, Dubai to Maldives, Cape Town to Johannesburg, Nairobi to Cape Town, Dubai to Seychelles, Paris to New York, Sydney to Auckland, London to Singapore, Hong Kong to Sydney, Tokyo to Los Angeles, Singapore to Sydney, Moscow to Dubai, and Sao Paulo to Miami.
Each description now follows a six-section structure in bilingual HTML:
- Route Overview — Who flies this route and why, business context, travel patterns
- Airport & FBO Analysis — Primary business aviation airports, FBO operators, ground handling, customs processing times
- Aircraft Recommendations — Which categories work for the distance, range considerations, pricing brackets
- Seasonal Pricing Intelligence — Peak demand periods, events that drive surges, optimal booking windows
- Ground Experience — Ultra-luxury hotels, helicopter transfers, ground transport options
- Booking Tips — Practical advice: lead time, empty leg opportunities, fuel stop alternatives
The popular-routes.ts file grew from 2,533 lines to 3,583 lines. All 50 routes now have substantive, SEO-rich content. Then we discovered the rendering bug — more on that below.
SEO Power Move: Price Structured Data
Google's Rich Results now support Product schema with pricing. We added a new RoutePriceJsonLd component that outputs per-category pricing for every route page:
- Product schema with
AggregateOfferwrapping all aircraft categories - Each category (Light Jet Charter, Midsize Jet Charter, Heavy Jet Charter, Ultra Long Range Jet Charter) gets its own
OfferwithPriceSpecification lowPrice= minimum across all categories,highPrice= maximum- Includes
aggregateRating,review,seller,shippingDetails, andmerchantReturnPolicyfor Rich Results eligibility
This means Google can now show "$18,000 - $95,000" pricing directly in search results for queries like "private jet Beijing to Shanghai price".
100 Empty Leg Routes
Wei's directive: "10条精选空腿航线,太少了,起码100条" (10 featured empty leg routes is too few, at least 100).
We had 50 routes in our database. The solution: generate 50 reverse routes. Beijing → Shanghai becomes Shanghai → Beijing. This is actually how empty legs work — the aircraft flew one direction with passengers and needs to return empty. So every A→B route naturally has a B→A empty leg opportunity. 50 + 50 = 100.
The page now groups routes by region:
- Asia-Pacific — Beijing, Shanghai, Tokyo, Seoul, Hong Kong, Singapore, Sydney, Auckland, Mumbai, Bangkok, Bali, Sanya, Taipei
- Europe — London, Paris, Nice, Geneva, Rome, Milan, Mykonos, Zurich, Ibiza, Edinburgh, Madrid, Moscow
- Americas — New York, Miami, Los Angeles, Las Vegas, Aspen, Chicago, San Francisco, Dallas, Bermuda, St. Barts, Bahamas, Sao Paulo
- Middle East & Africa — Dubai, Riyadh, Abu Dhabi, Jeddah, Cairo, Maldives, Cape Town, Johannesburg, Nairobi, Seychelles
- Intercontinental — Cross-continent routes (New York to London, Paris to New York, Tokyo to Los Angeles, etc.)
Each route card shows: cities, flight time, aircraft category, distance, original price (struck through), discounted empty leg price (50-75% off), and a savings badge. Forward routes link to their detail page; reverse routes link to the inquiry form. Deterministic hash-based discounting ensures stable prices across renders.
The entire page generates 100 Offer schema JSON-LD entries for Google — each with priceCurrency, price, availability: LimitedAvailability, validFrom/validThrough (90-day rolling window), and itemOffered referencing the charter service.
The Duplicate Nav Problem
Wei spotted it immediately: "一级菜单有空腿,services里面也有空腿" (Empty Legs appears both as a top-level nav item and inside the Services dropdown). Two clicks leading to the same page.
Fix: removed the top-level "Empty Legs" nav item, kept it under Services where it logically belongs, and updated the Services dropdown link from /services/empty-legs to /empty-legs (our new 100-route SEO page). One line added, five lines removed.
The Bug That Made 500 Words Unreadable
The last issue of the day was embarrassing. Wei sent a screenshot of the Paris to New York route page. The description — all 500+ words of carefully crafted HTML — was rendering as raw text. Every <p> tag, every <h3>, every <ul> was displaying as visible markup instead of formatted content.
The cause: line 124 of RouteContent.tsx.
// Before (broken):
<p className="text-platinum text-lg">{desc}</p>
// After (fixed):
<div dangerouslySetInnerHTML={{ __html: descHtml }} />
React's JSX escapes HTML by default. When the descriptions were 40 words of plain text, this worked fine. When we expanded them to 500+ words of semantic HTML with headings, paragraphs, lists, and blockquotes, the raw tags became visible.
The fix was two-part: (1) extract the first 220 characters of plain text from the first paragraph for the hero summary, and (2) add a new "Route Guide" section below the pricing cards that renders the full HTML description with dangerouslySetInnerHTML and proper Tailwind prose styling.
We also fixed the breadcrumb/navigation overlap — the breadcrumbs had padding-top: 20px but the fixed navbar is about 80px tall, so the breadcrumbs were hidden behind it. Changed to padding-top: 100px.
What Shipped Today
| Change | Scope | Impact |
|---|---|---|
| GPT Store API fix | 2 route files rewritten | searchFlights + chatWithConcierge now work |
| CI test fix | 2 test files, 20 tests | Anthropic SDK mock pattern, 222 tests green |
| Route descriptions (25-50) | +1,050 lines in popular-routes.ts | All 50 routes have 500+ word bilingual HTML |
| RoutePriceJsonLd | New component in JsonLd.tsx | Google rich results with per-category pricing |
| Empty legs page (100 routes) | New page, 800+ lines | 5 region groups, 100 Offer schema entries |
| Nav dedup | 1 file, -5 +1 lines | Single Empty Legs entry under Services |
| HTML rendering fix | RouteContent.tsx | Route descriptions render as formatted HTML |
| Breadcrumb overlap fix | 1 line change | Breadcrumbs visible below fixed navbar |
Commits
2234192— feat: expand all 50 route descriptions + fix GPT Store API endpoints855d9a8— feat: SEO — empty legs page, route price JSON-LD, fix CI tests1344f94— feat: expand empty legs page from 10 to 100+ routes with region grouping7536c06— fix: merge duplicate Empty Legs nav — keep under Servicesb56d9a9— fix: render route descriptions as HTML + fix breadcrumb/nav overlap
Reflections
Today's session was a case study in the gap between "it compiles" and "it works in production." The GPT Store API was syntactically correct and locally functional — it just depended on a backend that only exists in development. The route descriptions were beautifully written — they just rendered as raw HTML tags. The empty legs page had great data — it just showed 10 routes when 100 were needed.
The lesson is that production readiness is about integration, not implementation. Every feature needs to be tested end-to-end in the actual deployment environment, viewed in the actual browser, and evaluated by the person who will actually judge it (the CEO). Build → deploy → verify → iterate. Skip any step and you ship invisible bugs.
Day 8 total: 5 commits, 8 features shipped, 222 tests passing, 100 empty leg routes with structured data, and a GPT Store Custom GPT that finally works.
Stay Informed
Empty leg deals, new routes, and aviation insights — delivered to your inbox.