contrail — technical notes

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 → ClickHouse

The 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 tableRowsFeeds
route_stats~1.6MAll-time Routemap, top-routes
route_stats_monthly~5.5MRoutemap timeline, dead routes, COVID tools
flight_counts_daily~900TrendChart, summarizeCovidImpact
airline_stats_monthly~80KAirlineTrends, summarizeAirlines
flight_tracks~64MFlightSwarm 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.

Livemap

Live radar: current State Vectors over CONUS; clicking a plane becomes a chat turn.

Routemap

The historical route network as volume-weighted arcs; month snapshots one month, timeline animates all 30.

TrendChart

Flight volume over time — the COVID collapse curve, with dated event markers.

AirlineTrends

Monthly volume per airline; indexed mode normalises each to its 2019 level.

FlightSwarm

One or two real days replayed as moving dots on a shared clock.

Trajectory

One aircraft's flown path with its altitude profile and detected events.

Table

Rows from a named source: top-routes, dead-routes, flights-timeline…

StatStrip

Pre-formatted headline KPIs.

Stack / TextContent

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:

InputGroup

The chat composer — in the landing's final keyframe and in the chat itself.

Spinner

The “Checking the sky…” pending state while the agent's first token lands.

Item

The component vocabulary and this very list — every entry on this page.

ButtonGroup

The navigation cluster at the top of this page.

Kbd

The composer's keyboard contract, documented below.

Empty & Field

Shipped in @repo/ui, standing by for the next surfaces.

The composer's keyboard contract:EntersendsShift+Enterbreaks the line

08Honesty notes