The prompts used to build this app with Claude and Cursor — the original idea in plain English, then the full technical spec.
Build a web app for discovering movies and TV shows I'll love. TikTok doesn't ask you what you like — it watches how you react and figures it out. I want something like that for movies. Show me a title; I rate with half-star red (seen) or blue (unseen interest) stars, and you reveal how close your prediction was. Over many rounds the AI should get better at knowing my taste. The real goal isn't rating films I've already seen — it's finding films I haven't seen but will love. The global watchlist is the actual product. Rating seen films is the training signal to get there. Each round: poster or trailer, director, cast, plot, Rotten Tomatoes when available. If I've seen it, one tap on the red stars submits. If I haven't, I tap "Not yet", then blue stars for interest — high stars add to my watchlist, low stars mean not interested. I can hit Next to skip without saving. Dismissed titles stay excluded from future picks. After I submit, load the next title automatically. Show accuracy in a chart — rolling window over recent decisions, not a lifetime average. When something lands on my watchlist, look up streaming services (US) when possible. Pick an LLM (DeepSeek, Claude, GPT-4o) if I have keys, or bring my own — any OpenAI-compatible endpoint (base URL + model + key), including a local model. Recommendations should be based on content similarity as judged by the model, not collaborative filtering. Multiple taste channels with per-channel prefetch queues — same title can have different ratings per channel. Each channel sets its own format (movies / TV / both), genres, era, language, and which streaming services it should be on (Netflix, Amazon Prime, Apple TV+, Disney+, HBO Max, Hulu, Paramount+, Peacock). A starter pack can merge example channels without wiping my data, including a per-service channel for each major streamer. One special "Coming Soon" channel skips the AI and pulls genuinely new/upcoming titles from TMDB, and can likewise be narrowed to a streaming service. A search box on the home screen drops results straight into the queue — type a title, actor, or vibe and it plays. Try TMDB first (exact/new titles), fall back to the LLM (moods, genres). Trailers autoplay; an Auto toggle + Fullscreen lets me watch hands-free, one trailer flowing into the next. Below the player, a graph (Constellations) maps connections between the current title and its cast, director, and related films. Nav includes a dedicated Watchlist page (global list) plus Channels, Settings, Help. Ratings page: Seen (signed delta vs AI), Watchlist, Not interested. Keep all data in localStorage — no accounts, no server database, no ads.
Detailed spec for rebuilding the app from scratch.
# Trailer Vision — full spec Build a Next.js 16 (App Router) web app called Trailer Vision. Use Tailwind CSS v4 for styling. All persistence in localStorage. No database. ## Core concept TikTok-style taste calibration for movies and TV. The real goal is to surface films the user has NOT seen but will love. Rating seen films is the training signal; the watchlist of unseen-but-wanted titles is the actual product. Each round: 1. The next card is served instantly from a prefetch queue (see below). 2. The user rates with half-star stars: red = seen (one tap); if unseen, "Not yet" then blue stars. 3. **Next** skips without saving. After a rating, the next title loads automatically. 4. The last result (accuracy vs prediction) is shown inline in the chart panel. ## LLM API route POST /api/next-movie Request body: - sessionId / historySync / history / baseLength / historyAppend — server-side session cache (historySync: "full" | "delta" | "reuse"; avoids resending full history every request) - skipped: string[] — all titles the user has decided on (rated + watchlist + not-interested) - watchlistTitles: { title, rtScore }[] — want-to-watch entries with RT score - notInterestedItems: { title, rtScore }[] — not-interested entries with RT score - tasteSummary?: string — the running taste profile (used as primary signal context) - userRequest?: string — free-text user request appended to system prompt as a hard steer - mediaType: "movie" | "tv" | "both" - llm: "deepseek" | "claude" | "gpt-4o" | "custom:<base64 config>" (bring your own, OpenAI-compatible) - count?: number (default 5, max 8) RatingEntry stores { title, type, userRating, predictedRating, error, rtScore? }. Token-efficient user message: rated history is truncated to the top ~32 entries by |user−RT| divergence (fallback |user−AI| if RT missing), blended with the most recent entries for freshness. Want-to-watch lists only low-RT saves (<60%). Not-interested lists only high-RT dismissals (≥70%). Full exclusion title lists are NOT sent — counts only. The client dedupes returned titles against its own excluded set. Diversity: the LLM is instructed to spread each batch across disparate areas of cinema (regions, eras, genres, languages, traditions) based on rating history. The app does not prescribe which categories to explore — only the user request field and channel constraints steer picks when set. User request: if userRequest is non-empty, it is appended as a hard constraint in the system prompt. When userRequest changes on the client, flush the prefetch queue (debounced 600ms). The model returns ONLY valid JSON: { "items": [ { title, type, year, director, predicted_rating, actors[], plot, rt_score }, ... ] } type must be "movie" or "tv". rt_score is the Tomatometer % or null. All string values must be on one line (no newlines inside JSON strings). Parse JSON with fallbacks (top-level array, legacy single-object, brace-depth walker). Response body: { movies: CurrentMovie[] } — one entry per accepted item (posters attached). Fetch posters AND trailer keys via TMDB (TMDB_API_KEY): - Search for the title to get its TMDB id and poster_path. - Call /movie/{id}/videos (or /tv/{id}/videos) and pick the first YouTube result with type "Trailer" or "Teaser". Return its key as trailerKey. - Fall back to Serper Images API (SERPER_API_KEY) for poster only when TMDB absent. Upgrade http:// poster URLs to https:// before returning. CurrentMovie interface includes trailerKey: string | null. ## Taste summary POST /api/taste-summary Separate lightweight endpoint. Request: { history, watchlistSignals, notInterestedSignals, existingSummary, llm }. Returns { tasteSummary: string | null }. Generates a 2–4 sentence profile of the user's taste written in second person ("You tend to prefer…"). max_tokens: 256. Called by the client in the background after the 1st rating and every 5 ratings thereafter (1, 5, 10, 15 …). Stored in localStorage under movie-recs-taste-summary. Displayed as a card with a purple left border below the accuracy chart. The existing summary is sent back as context each call so it refines incrementally rather than starting from scratch. ## Prefetch queue with daisy-chain replenishment Maintain a client-side prefetch queue (ref, not state) of pre-fetched CurrentMovie objects. LLM_BATCH_SIZE = 5. MAX_REPLENISH_IN_FLIGHT = 2. HIGH_WATER_MARK = 8 (caps the upcoming queue at roughly 5–10 titles so new ratings affect picks sooner). On card pop: show the card instantly; if replenishInFlight < MAX_REPLENISH_IN_FLIGHT, start a background replenish immediately (don't wait for the queue to run low). Daisy-chain: when any replenish completes, if queue < HIGH_WATER_MARK and a slot is free, immediately start another. This keeps up to MAX_REPLENISH_IN_FLIGHT fetches running so the queue refills. Stop the chain if zeroYieldStreak >= 3 (3 consecutive batches with 0 fresh items — LLM is stuck). Reset the streak on any user action. Pre-display check: before showing a card popped from the queue, verify the title is not already in the excluded set (race condition: user could rate/skip a title while it was queued). Silently discard stale entries; drain the queue until a fresh title is found. Empty-queue fallback: reset zeroYieldStreak, kick off a replenish if nothing is in-flight, then poll every 200ms until a card arrives or 90s elapse. Show error pill if nothing found. On failure, show a friendly error pill with a Retry button. ## Star rating system Half-star precision on 1–5. Trailer and poster layouts use the same interaction model. Initial state (seen path default): - One horizontal row: compact StarRow red ("Seen it") + button "Not yet" (sets unseen flow) + **Next** (skip). - Clicking a red star calls submitRating(n, "seen") immediately — one tap for seen titles. Unseen flow (after "Not yet"): - Compact StarRow blue ("Interest") + "I have seen it" (back to red row) + **Next**. - submitRating(_, "unseen") calls recordNotSeen(kind, interestStars): kind = want if stars≥4 else skip. - 4–5 stars: add to global watchlist, accuracy chart diamond at 85. - 1–3 stars: not-interested signal, chart diamond at 20. - Both add to skipped; every blue submit appends movie-recs-unseen-interest-log for /channels. StarRow supports optional compact mode (tighter label + smaller stars) for the one-line bar. Props: filled, color ("red"|"blue"), label, onRate(n), compact?: boolean. Hover preview, key={title} remount, touchAction: "manipulation" on star buttons. ## Accuracy chart Hand-rolled SVG, no library. Shows accuracy (100 - error) so up is always good. - Blue vertical bars for rated titles - Green diamonds at y=85 for "want to watch" events - Red diamonds at y=20 for "not interested" events - Indigo line = rolling average of last 5 decisions (NOT cumulative) - Dashed reference lines at y=85 and y=20 - Label: "How well the AI knows your taste" ## Trailer card (when trailerKey is present) Use the YouTube IFrame API to embed and auto-play the trailer. TrailerPlayer component: - Global Window.YT shim; load iframe_api script once (singleton). - useEffect: create inner mountEl, append to wrapperRef; new YT.Player(mountEl, { videoId, playerVars }). Never pass React's wrapper directly — YT replaces the node. - playerVars: autoplay, mute, controls, rel, modestbranding, playsinline, enablejsapi; include origin: window.location.origin (http and https) for postMessage with the JS API. - onReady: unmute; optional loadVideoById when videoId changes; destroy on cleanup. - Return wrapper div aspect-video; volume can persist session-wide via module var. Trailer layout: TrailerPlayer on top; metadata; same single-line rating strip as poster (Seen it / Not yet / Next, then Interest / I have seen it / Next) — no watch-% auto stars in the current implementation. ## Main card UI (poster layout, when trailerKey is null or displayMode = "posters") On mobile: small portrait thumbnail (w-28) on the LEFT, metadata on the RIGHT; plot line-clamped. On sm+: thumbnail w-48. Poster opens lightbox. Metadata: type/year, RT badge, title, director, cast, plot. Without trailerKey, title links to YouTube search for a trailer. Below: one rounded box with the compact one-line rating UI (see Star rating system). While the LLM is fetching: dim the card to 45% opacity. Show a fixed pill at bottom-center of the viewport: "LLM is thinking..." with bouncing dots. On response: fade card to 0, swap content, fade back to 1. Page max-width: max-w-3xl. ## Navigation Shared sticky nav bar at the top of every page (via layout.tsx): App | Watchlist | Channels | Settings | Help /watchlist is the global watchlist (same data as Ratings → Watchlist tab). Help explains end-user usage and links to Dev Journal (/journal) and Prompt History (/prompt). Ratings (/ratings) is not in the bar. Active page is highlighted. ## Channels (/channels) and per-channel prefetch - Channel model: id, name, mediums[], genres[], timePeriods[], streaming[], language, artists, freeText, popularity. streaming[] holds service names (Netflix, Amazon Prime, Apple TV+, Disney+, HBO Max, Hulu, Paramount+, Peacock); normalizeChannel backfills it to [] for legacy channels. - Immutable first channel id "all" named "All". CRUD for other channels; export/import includes movie-recs-channels and movie-recs-active-channel. - **Recommendation islands:** Each channel is an independent recommendation context: its own prefetch queue, its own activeChannel object sent to POST /api/next-movie, and its own slice of seen ratings in history. RatingEntry includes optional channelId (set from the active channel when the user rates). The same human-readable title may appear as **multiple** history rows with different channelId values and different userRating / predictedRating — e.g. 4★ vs the AI in "Korean Horror" and 2★ in "All" — because the user is judging the fit under different taste lenses. Per-channel ratings UIs filter by channelId; "All" includes rows with channelId "all" or missing channelId (legacy). - Each channel has its own prefetch queue in localStorage: movie-recs-prefetch-queue:{channelId} (legacy key movie-recs-prefetch-queue may be migrated on read). Replenish and card pop use the active channel's queue; switching channel persists the previous queue and loads the new one. - Channel editor (ChannelEditorForm + channelEditorConfig): typing a description into the freeText box auto-fills an empty name with its first ~4 words. A "Streaming" chip row (showStreaming + streamingOptions, STREAMING_OPTIONS) lets the user require a title be on one of the named services. Saving an edited channel clears that channel's queue + stored current card (clearChannelPersistedData) so the next titles reflect the new definition; returning home triggers a fresh replenish. For LLM channels, the selected services become a hard constraint in buildChannelConstraint ("Only recommend titles currently available to stream on X or Y in the US"); for Coming Soon they map to TMDB watch-provider IDs (see /api/upcoming). - **Coming Soon** is a special bundled channel (matched by name "Coming Soon"). It does NOT call the LLM — fetchMovieBatch routes it to POST /api/upcoming (TMDB). Cards require a trailerKey. Its editor uses COMING_SOON_CHANNEL_EDITOR_CONFIG which hides Time periods and Artists (new titles have no meaningful era and often-unknown casts) and it shows no star ratings. Its history is excluded from the LLM context of other channels (we don't rate these). Filterable by genre, language, format (movies / TV), and streaming service (its editor shows the Streaming chips but still hides Time periods + Artists). - Client dedupe for the *next card* still merges canonical title keys from the **full** history plus skipped/watchlist/passed — so in normal browsing a title already decided in one channel is not offered again as a fresh pick until that history row is removed (e.g. reconsider flow). Islands affect **stored ratings, queues, and LLM channel context**, not a second concurrent offer of the same title across channels without clearing history. ## Factory starter channels (factory-channels.json) Bundled JSON in the same shape as a v1 export (data object with channel list, active channel, prefetch keys, etc.). The starter set includes a per-service channel for each major streamer (Netflix, Amazon Prime, Apple TV+ → "Apple TV", Disney+, HBO Max → "HBO") whose streaming[] is set to that one service, alongside taste channels (Film Noir, Spaghetti Westerns, Korean Horror, Nouvelle Vague, Kaiju/Tokusatsu, Euphoria) and the special Coming Soon channel. - First visit: if movie-recs-channels has never been written (getItem === null), copy every key from data into localStorage once (applyFactoryBootstrap) on home and channels hydrate. - Settings: "Merge starter channels" appends any bundled non-All channels whose ids are not already present; fills prefetch keys only when local key is missing (mergeFactoryChannelsAndQueues). - Home channel row: if the user has no custom channels (only "All" or empty during hydrate), show a "Load starter channels" pill next to the + link that runs the same merge and refreshes channels state + prefetch refs + fetchNext. ## Home channel row UI - flex flex-wrap gap-2: channel pills wrap instead of single-line horizontal scroll. - Pills: text-sm font-semibold; inactive text-zinc-800; selected bg-zinc-900 text-white. - Deletable channels: × control on hover (sm+); ConfirmDialog before delete; if active channel deleted, fall back to first remaining channel and fix prefetch/active key. - Above the channel pills sits a **Search** box (searchAndQueue). Typing text + Enter / the Search button drops results straight into the current channel's queue: the first result becomes the current card immediately (bypassing history exclusion), the rest are unshifted to the front of the prefetch queue (visible + clickable), ahead of the channel's auto-replenished picks. ## Home search box POST /api/search TMDB-first, LLM-fallback search feeding the player queue. - Client searchAndQueue(text): POST /api/search { query } first; if it returns no usable result (title with trailer or poster), fall back to POST /api/next-movie { userRequest: text, llm, mediaType, count, activeChannel } so moods / genres / "like X" still resolve. - /api/search: TMDB /search/multi, keep movie + TV hits (drop people), sort by popularity, top 8; fetch trailers (/videos) + credits in parallel; return { movies: CurrentMovie[] } for hits with a poster or trailer. Nails exact and brand-new titles the LLM may not know. ## Ratings (/ratings) and channel history (/channels) Seen tab on /ratings and the **Seen** block inside /channels **Channel history**: - Do NOT show RT / Tomatometer badge on these rows (watchlist and not-interested tabs may still show RT). - Show StaticStars for userRating (red) plus a signed half-star delta = userRating − predictedRating, formatted like +1.5 or -2 (tabular-nums; emerald if delta>0, rose if delta<0, zinc if 0). - Sort bar: "Your stars" (sort descending by user rating) | "vs predicted" (sort descending by delta). - Helpers in app/lib/ratingDelta.ts: starDelta(), formatStarDelta(). - /channels "All" channel: include history where !channelId OR channelId === "all". Other channels: filter channelId === selected.id. Channel history also lists **Unseen** rows from movie-recs-unseen-interest-log (append on every blue-star submit in recordNotSeen: title, metadata snapshot, interestStars, kind want|skip, channelId, at). Each row shows blue StaticStars and pills (Added / Not on list for saves vs Not interested for passes). **Add to watchlist**: minimum interest (2.5–4.5★), adds titles **not** already on the global watchlist with interest ≥ threshold — **skip** rows and **want** rows you removed from the watchlist — then removes promoted skips from skipped + not-interested and POST /api/streaming per new entry. ## Controls Content format (movie / TV / both) is NOT a global control — it is set per channel via the channel's mediums[]. The prefetch request for a channel honors its mediums. (A legacy global mediaType state remains in code, defaults to "both", and has no UI.) Display mode segmented control Trailers | Posters lives in the player's media-actions toolbar (displayMode state, default "trailers", persisted in settings). When "posters" is selected, always use the poster layout even if trailerKey is available. Auto-advance: in trailer mode the media-actions toolbar shows an "Auto" checkbox (autoAdvance state, persisted). When checked, the trailer's onEnded handler passes to the next card so the user can watch hands-free; pairs well with Fullscreen. LLM choice and a Global request (free-text steer applied to every recommendation) live on the Settings page. The LLM list is populated from GET /api/config which checks which of DEEPSEEK_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY exist in env and returns { llms: [{ id, label }] }. When the global request value changes, flush the prefetch queue (debounced 600ms) so upcoming cards come from a batch that knew about it. **Bring your own model:** Settings also offers a "Custom" option — base URL, model name, and an optional API key for any OpenAI-compatible /chat/completions endpoint (OpenRouter, Together, Groq, a local Ollama / LM Studio server…). The config is encoded into the "llm" value itself ("custom:" + base64 JSON via app/lib/customModel.ts), so it flows through every existing API route that already forwards "llm" to callLLM — no route needs a new parameter. The key lives only in the browser's settings and is forwarded solely to the user's endpoint. callLLM detects the prefix, decodes, and POSTs an OpenAI-style chat completion. The Global request value is read from a ref (userRequestRef) at fetch time so background replenish calls always use the latest text, and is appended to the system prompt as a hard steer. ## Re-rate / reconsider The "All ratings" list and the "Not interested" list below the card are fully clickable rows (cursor-pointer, hover highlight). Clicking a row loads that title as the current card: - Rated title: remove the entry from history, reconstruct a CurrentMovie from { title, type, predictedRating, rtScore } (year/director/actors/plot/posterUrl all null/empty), call setCurrent(movie) and scroll to top. - Not-interested title: remove from the skipped list and notInterested list in localStorage and state, then reconstruct and setCurrent similarly (type defaults to "movie", predictedRating defaults to 50). In both cases the user rates or categorises it using the normal card UI; handleRate() adds a fresh history entry as usual. ## Ratings page /ratings Single page with tabs (Seen | Watchlist | Not interested) when the user has any data in those lists. - Seen: rated history — signed half-star delta (user − predicted), sort by your stars vs delta, no RT badge on rows; click a row to remove from history and load that title on / for re-rate. - Watchlist: read-only list of movie-recs-watchlist (posters, metadata, RT, streaming pills). - Not interested: derived skipped titles (same logic as legacy lists below the home card). ## Watchlist page /watchlist Linked from the main nav. Same global movie-recs-watchlist as Ratings → Watchlist: poster (w-24), type+year, RT badge, title, director, cast, plot, streaming pills (blue). × removes from watchlist AND moves to not-interested (writes movie-recs-not-interested + skipped). ## Streaming lookup POST /api/streaming Request: { title, year, llm } Prompt: "What streaming services currently have {title} ({year}) in the US? Return ONLY a JSON array: ["Netflix", "Max", ...]. Return [] if unsure." Called when a title is saved to the watchlist (blue 4–5★ or bulk add from Channels); result stored on the entry. ## Shared LLM caller app/api/next-movie/llm.ts export async function callLLM(llm, systemPrompt, userMessage): Promise<string> Handles: deepseek (deepseek-v4-flash), claude (claude-opus-4-6, anthropic-version header), gpt-4o (openai), and custom:* (bring your own — decodes the config and POSTs to the user's OpenAI-compatible {baseUrl}/chat/completions with an optional Bearer key). The custom branch is checked first via isCustomLlm(); unknown ids throw. Split the prompt into a stable systemPrompt (instructions, format rules, media constraint) and a per-request userMessage (rating history + excluded titles list). This enables Anthropic prompt caching: add header "anthropic-beta: prompt-caching-2024-07-31" and wrap the system prompt as { type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }. OpenAI automatically caches prompt prefixes ≥1024 tokens. DeepSeek and custom OpenAI-compatible endpoints use the standard system/user message array. ## Coming Soon channel POST /api/upcoming TMDB-backed feed for genuinely new/upcoming titles (no LLM). Request body: { page, genres[], language (CSV), mediums[] ("movie"|"tv"), streaming[], skipped[] }. - Movies: /movie/upcoming, filtered to release_date >= ~2 weeks ago. - TV: /discover/tv sorted by first_air_date desc within a recent window (first_air_date.gte = 6 months ago, .lte = ~4 weeks out) — NOT /tv/on_the_air, which surfaces decades-old shows that merely have an episode airing this week. - Streaming filter: streaming[] names map to TMDB watch-provider IDs (STREAMING_PROVIDER_IDS, US region) joined with "|" (= available on any). When set, both movies AND TV use /discover with with_watch_providers + watch_region=US over the recent-release window (movies switch off /movie/upcoming, since genuinely-upcoming theatrical films aren't on a service yet; the 2-week floor is skipped because the discover window already bounds dates). - Genre filter uses separate movie vs TV genre-id maps (same name → different id). - Language filter splits the CSV and matches original_language (ISO 639-1). - Credits: director (movie) or created_by (TV) + top cast; trailer key via /videos. Returns { movies: CurrentMovie[], totalPages, page }; the client pages through results and requires a trailerKey to show a card. A sample of the raw TMDB payloads is documented at /tmdb-sample (linked from Help) to guide future filters (e.g. region/country). ## Constellations graph (embedded) TrailerVisionConstellationsEmbed renders the @johndimm/constellations host below the player, seeded with the now-playing title; it maps cast / director / related films and expands on interaction. "Open full screen" hands off via sessionStorage to /constellations. The package proxies its AI + cache calls to same-origin routes (NEXT_PUBLIC_VITE_CACHE_URL is set to the app's own origin so calls stay same-origin and CORS-safe): - POST /api/ai/connections — intercepted: resolves the node to a TMDB title and returns real credits (director + cast + writers) instead of the external LLM, which hallucinates people for newer films not in Wikipedia. Falls back to the external server when TMDB finds nothing. - /api/ai/[...path] — catch-all forwarder for classify / classify-start / works, etc. - /node and /expansion — forwarders for the package's cache service. All proxy routes set permissive CORS headers, handle OPTIONS, and use maxDuration 60 to absorb cold starts of the external Constellations backend (CONSTELLATIONS_EXTERNAL_URL). ## History as playlist On /history and /channel-history, selecting rows and pressing Play writes the chosen titles to localStorage key movie-recs-playlist and navigates home. The player drains a playlistRef first (bypassing the normal history-exclusion in fetchNext, since the user picked these explicitly), playing them in order before resuming normal recommendations. ## localStorage keys movie-recs-history — RatingEntry[] (includes rtScore per entry) movie-recs-playlist — CurrentMovie[] (history titles queued to play back-to-back) movie-recs-skipped — string[] (all excluded titles) movie-recs-watchlist — WatchlistEntry[] movie-recs-notseen — NotSeenEvent[] (for chart plotting) movie-recs-unseen-interest-log — UnseenInterestEntry[] (unseen blue-star events with channelId) movie-recs-not-interested — { title, rtScore }[] (for high-RT taste signal) movie-recs-taste-summary — string (LLM-generated taste profile, second person) movie-recs-llm-session-id — UUID for server-side history session movie-recs-llm-history-synced — number of ratings confirmed synced to server movie-recs-settings — { llm, displayMode, autoAdvance, userRequest, mediaType(legacy) } (llm may be a "custom:<base64>" bring-your-own-model config) movie-recs-channels — MovieChannel[]; movie-recs-active-channel — active channel id ## Required env vars DEEPSEEK_API_KEY — DeepSeek (default LLM) ANTHROPIC_API_KEY — Claude (optional) OPENAI_API_KEY — GPT-4o (optional) (Bring-your-own-model needs no env var — the user supplies base URL + model + key in Settings.) TMDB_API_KEY — TMDB posters, trailers, Coming Soon feed, Constellations credits (recommended) SERPER_API_KEY — Serper Images fallback for posters (optional) NEXT_MOVIE_LOG_LLM_PROMPTS — set to "1" to log full prompts to server console (debug only) NEXT_PUBLIC_APP_URL — app's own production origin; used as the Constellations proxy base so AI/cache calls stay same-origin (avoids CORS). Falls back to VERCEL_PROJECT_PRODUCTION_URL, then VERCEL_URL, then localhost. CONSTELLATIONS_EXTERNAL_URL — upstream Constellations backend the proxy routes forward to.