Docs
Geospatial air-traffic chat agent: you ask in natural language and the answer is an interactive visual — not a wall of text. Built for the ClickHouse × Trigger.dev hackathon 2026.
01Overview
The OpenSky API is a snapshot with no memory. Contrail is the queryable memory: a ClickHouse-backed record of what flew, where, and when — including the most dramatic 30 months in aviation history, the COVID-19 collapse. A Trigger.dev chat agent turns questions into interactive components: a live radar, an animated route network, a replay of a real day.
Parameters, not datasets.
The rule that governs everything: the model emits the parameters of a query — a bounding box, a date, a limit — never rows. Components fetch their own rows from the read API. A 60,000-flight replay costs the model about thirty tokens, and it can never hallucinate a coordinate because data never travels through it.
02Architecture
you ──chat──▶ apps/web (Next.js 16)
│ Trigger.dev React transport
▼
contrail-agent (chat.agent, packages/jobs)
│ system prompt + component spec packages/agent
│ tools → @repo/db → ClickHouse (aggregates only)
│ lifecycle hooks → @repo/chat-store → Postgres (transcripts)
▼
answer streams as prose + OpenUI Lang
▼
apps/web renders Components (@repo/ui)
│ each Component fetches its own rows
▼
apps/web/app/api/* → @repo/db → ClickHouseThe agent runs as a durable Trigger.dev chat.agent task; the browser talks to it through the Trigger React transport with per-chat access tokens minted by a server action. Answers stream as ordinary prose plus OpenUI Lang — a line-oriented DSL riding the text stream inside a fenced block (root = Livemap("Traffic over Florida", [24, 31, -88, -80])), rendered client-side against a closed component library.
03Data & ingestion
liveA Trigger.dev task polls the OpenSky REST API every 60 seconds and ingests every aircraft aloft over the continental US into ClickHouse: states (append-only history, ordered for trajectory reads), states_latest (materialized latest position per aircraft) and traffic_density (materialized geohash-5 × minute counts). A watchdog task monitors the poll, and credential rotation across accounts rides out the OpenSky daily quota.
historicalThe deep record is the OpenSky flightlist: 66,010,819 flights worldwide, 2019-01 through 2021-06 — a window that brackets COVID-19. Seeded 100% server-side: ClickHouse Cloud downloads 30 monthly CSVs straight from Zenodo with the url() table function into a staging table, verifies the expected row count, and publishes atomically with EXCHANGE TABLES — readers never see a half-loaded dataset.
flights is 66M rows deep and static after the seed, so no request-time query ever aggregates it. Every read path goes through a derived table, rebuilt with the same staging + exchange publish:
| Derived table | Rows | Feeds |
|---|---|---|
| route_stats | ~1.6M | All-time Routemap, top-routes |
| route_stats_monthly | ~5.5M | Routemap timeline, dead routes, COVID tools |
| flight_counts_daily | ~900 | TrendChart, summarizeCovidImpact |
| airline_stats_monthly | ~80K | AirlineTrends, summarizeAirlines |
| flight_tracks | ~64M | FlightSwarm day-ordered replay reads |
04The agent
Every number the model states comes from a summary tool — summarizeAirspace, summarizeRoutes, summarizeCovidImpact, summarizeAirlines— small aggregates, never rows. The stock OpenUI prompt rule telling models to "generate realistic/plausible data" is stripped at build time, and the build fails if that stripping ever stops matching.
The component library is closed: the nine entries below are the complete set of things the agent can draw. Each is a name, a Zod prop schema and a React implementation declared together.
Live radar: current State Vectors over CONUS; clicking a plane becomes a chat turn.
The historical route network as volume-weighted arcs; month snapshots one month, timeline animates all 30.
Flight volume over time — the COVID collapse curve, with dated event markers.
Monthly volume per airline; indexed mode normalises each to its 2019 level.
One or two real days replayed as moving dots on a shared clock.
One aircraft's flown path with its altitude profile and detected events.
Rows from a named source: top-routes, dead-routes, flights-timeline…
Pre-formatted headline KPIs.
Layout and in-layout prose.
05Conversations
Every chat is a Conversation: it has a URL (/chat/[id]), lives in the sidebar, and survives a refresh even mid-answer. Chat state is the OLTP side of the project — transcripts, titles and per-chat session state in Postgres(ClickHouse Cloud managed, behind Drizzle), written from the agent's lifecycle hooks: the user message is durable beforethe first chunk streams, and the assistant reply lands with the stream's resume cursor in one transaction. Identity is a single anonymous httpOnly cookie — no accounts.
Reload while the agent is answering and the transport reconnects to the durable Session's output stream, skipping chunks the browser already applied — the answer keeps streaming where it was. Come back a day later and a continuation run rebuilds the model's context from Trigger.dev's internal snapshot; the database only feeds the UI, never the model. Open the same chat in two tabs and the second goes read-only with live updates. Deleting a conversation removes its rows and closes the Session, stopping any in-flight generation first.
06The opening
The landing is a software-rendered ASCII scene — no WebGL, no model files. A hand-rolled rasteriser projects a ~700-triangle procedural widebody onto a character grid with a per-cell z-buffer, maps luminance to a glyph ramp, and draws it as text on a 2D canvas at 60 fps. The aircraft is draggable with inertia; scroll drives a one-way launch sequence that hands your first message straight to the agent. The title is set in Geist Pixel to match.
07Built on the new shadcn/ui set
The interface leans on the components shadcn/ui shipped recently — not as a checklist, but where each one is the right tool:
The chat composer — in the landing's final keyframe and in the chat itself.
The “Checking the sky…” pending state while the agent's first token lands.
The component vocabulary and this very list — every entry on this page.
The navigation cluster at the top of this page.
The composer's keyboard contract, documented below.
Shipped in @repo/ui, standing by for the next surfaces.
The composer's keyboard contract:EntersendsShift+Enterbreaks the line
08Honesty notes
- FlightSwarm dots are interpolated origin→destination on the real flight's schedule — labelled as such, never presented as radar tracks.
- The flightlist's
dayis the arrival date, so a day's replay includes red-eyes already airborne at 00:00 — as in reality. - 2019-09-22 is an upstream ingestion gap (3,299 flights), so "quietest day" tools are scoped to 2020 and can never quote it.
- Airline means the three-letter ICAO callsign prefix — 73% of flights; general aviation is excluded from airline comparisons.
- Live coverage is CONUS only; the historical record is worldwide. No commercial data (schedules, delays, passengers) exists in either — and the agent says so rather than guess.