Skip to content

Repository files navigation

O2

Know when the air gets better.

o2.agent9.dev — live, free, no login.

Live Built with Codex Cloudflare Workers Tests No LLM in the answer

Modeled wildfire smoke moving across 48 hours of forecast time on O2

Real capture from the live site: modeled smoke advancing through forecast time. Recorded during the July 2026 Canadian wildfire event.


The problem

Every air-quality tool shows you a number and a colored map. During a wildfire smoke event, that is not the question people actually have.

The questions are:

What people ask What most tools show What O2 answers
When does it get better? today's AQI "Improvement expected around Tue, Jul 21, 10 PM."
When does it get worse? a colored map the deterioration hour, before it arrives
Is it safe to go out — for me? one generic sentence separate guidance for everyone and for sensitive groups
Is there an alert near me? a national alert list NWS alerts filtered to your coordinates
Can I watch the smoke move? a static overlay a 48-hour scrubber over modeled smoke
Can it tell me when it changes? refresh the page an email when your air crosses a threshold

Smoke is a timing problem. If you know the air clears at 10 PM, you move the run, close the windows now, and open them later. O2 is built to answer the timing question first, and everything else second.


What it does

The O2 dashboard: the air window answer, the live smoke map, current pollutants, and the spoken briefing

Open it and the answer is already there. No login, no account, no setup. It loads with Washington, D.C. so there is never an empty screen, and one tap switches to your location.

  • The O2 Air Window — a plain-language headline: when the air improves, the cleanest window in the loaded forecast, and how confident the model is.
  • Live smoke map — modeled surface smoke over a 48-hour scrubber, with active fire perimeters and incidents. Play it and watch the plume move across forecast time.
  • Two smoke models, separable — the US NWS NDGD forecast (amber, on by default) and the Canadian BlueSky/FireSmoke model (cyan, opt-in). Show either alone, or both, and see where two independent models agree.
  • Spoken briefing — a generated broadcast read aloud by Deepgram Aura on Workers AI, with the full transcript always available.
  • Email alerts — double opt-in, per-subscriber AQI threshold, alerts when the air turns unhealthy and when it is safe again, plus optional morning and evening briefings. One-tap unsubscribe in every message.
  • How to stay safe — all six AQI categories with the guidance O2 applies to each, your current band marked, plus practical steps during a smoke event.
  • Answers to the confusing part — an FAQ that explains why the map can show smoke overhead while your AQI reads Moderate. (Smoke aloft has not mixed to the surface yet. It often does later in the day.)
How to stay safe: all six AQI categories with the guidance O2 applies to each, current band marked O2 on mobile: the answer collapses to a single line so the smoke map stays visible
How to stay safe — every category rendered from the same rules that classify your reading, so the table cannot drift from the number on screen. Mobile — the answer collapses to one line by default, keeping the map visible. Tap to expand.

What makes it different

The answer is computed, not generated. No language model writes any of the words that matter. AQI categories, the improvement time, the safest window, and every piece of health guidance come from fixed rules in the code. The same reading always produces the same advice.

That is a deliberate constraint. Air quality guidance is safety information, and a model that occasionally invents a reassuring sentence is worse than no tool at all. The one place AI runs at request time is text-to-speech — it reads a script the deterministic layer already wrote. It does not choose what the script says.

It says when it does not know. Confidence drops and O2 says why, rather than stating a precise hour it cannot support.

It only claims smoke it can see. The spoken briefing mentions wildfire smoke only when a modeled plume actually covers your coordinates, or sits within ~150 km — in which case it names the distance and direction. Ask it about a location with no plume nearby and it says nothing about smoke at all.


How O2 was built: Codex + GPT-5.6

O2 was designed with GPT-5.6 and implemented with Codex.

GPT-5.6 (design). The product architecture was worked out in a long GPT-5.6 session before any code was written: positioning, feature scope, three Mermaid architecture diagrams, the alerts and TTS plans, deterministic safety boundaries, the implementation sequence, and the README structure. That session produced a handoff document that Codex then built from.

Codex (implementation). Codex wrote the codebase: the Cloudflare/TanStack foundation, the smoke-intelligence layer, the forecast engine (src/lib/forecast-intelligence.ts), the deterministic report and broadcast generator (src/lib/local-report.ts), the Workers AI Aura integration (src/server.ts), the AQI rules (src/lib/aq.ts), and the alerts pipeline.

Codex on GPT-5.6 later did the local-smoke-evidence work (src/lib/local-smoke.ts) — point-in-polygon, haversine and bearing math that gates the spoken smoke line on plumes near the user's own coordinates — plus the collapsible answer panel and the first-run location hint.

A representative agentic loop: Codex read the plan, reconciled it against the actual repo state, corrected its own terminology when it drifted from the project's, installed Playwright, wrote the evidence-capture tooling, ran the test suite, and committed — then flagged what it could not verify itself.


Architecture

flowchart TB
    subgraph edge["Cloudflare Worker · edge"]
        SSR["TanStack Start SSR"]
        FN["Server functions<br/>air quality · fires · smoke · briefing"]
        TTS["/api/o2/broadcast-audio<br/>Workers AI · Deepgram Aura"]
        CRON["Cron · every 15 min<br/>alert engine"]
    end

    subgraph deterministic["Deterministic core · no LLM"]
        AQ["aq.ts<br/>AQI categories + guidance"]
        FI["forecast-intelligence.ts<br/>improvement · deterioration<br/>safest window · confidence"]
        LS["local-smoke.ts<br/>plume proximity gating"]
        LR["local-report.ts<br/>report + broadcast script"]
    end

    subgraph sources["External sources"]
        OM["Open-Meteo · CAMS"]
        NIFC["NIFC WFIGS"]
        NDGD["NWS NDGD smoke"]
        FS["BlueSky / FireSmoke"]
        NOAA["NOAA HMS + NWS alerts"]
    end

    D1[("D1 · o2-alerts<br/>subscriptions · deliveries")]
    MAIL["Resend<br/>alert@updates.agent9.dev"]

    sources --> FN --> deterministic --> SSR
    LR --> TTS
    CRON --> FI
    CRON --> D1
    CRON --> MAIL
    SSR --> UI["Browser · Mapbox GL"]
Loading

Stack: TanStack Start · React 19 · TypeScript · Tailwind v4 · Mapbox GL · Cloudflare Workers, D1, Workers AI · Resend · Turnstile.

Everything runs on Cloudflare's free tier. There is no origin server.


Data sources

Source Provides Class
Open-Meteo (CAMS) PM2.5, PM10, O₃, hourly + 48h forecast Modeled
NIFC WFIGS Active fire perimeters and incidents Measured
NWS NDGD Hourly North America surface-smoke forecast Modeled
BlueSky / FireSmoke (UBC) Canadian wildfire smoke model (opt-in overlay) Modeled
NOAA HMS Smoke narrative, North America overview Observed
NWS alerts Active alerts filtered to your coordinates Official
Mapbox / OpenStreetMap Basemap —

Every air-quality, fire, and smoke source is listed in-app under Data sources with a link and a measured/modeled label.


Forecast methodology

How the improvement time is calculated
  1. Build an hourly series from the loaded forecast for the selected location.
  2. Convert PM2.5 to US AQI using EPA breakpoints (src/lib/aq.ts).
  3. Walk forward to the first hour that crosses into a better AQI category and hold it — that is the improvement time.
  4. Independently find the lowest-AQI hour in the loaded window — the safest window, which is often later than the moment things start improving.
  5. Score confidence from how much usable forecast coverage exists and how clearly it supports a timing estimate. Thin or ambiguous coverage lowers confidence and O2 states the reason.

Forecasts are estimates and get less certain further out. Wind shifts and new fires move the timing. O2 presents it as planning guidance, never a guarantee.

How the smoke line is gated to your location

src/lib/local-smoke.ts takes the user's coordinates, the modeled smoke polygons, and current PM2.5, and returns one of three tiers:

  • over — a plume at or above the significance threshold contains the point (ray-casting point-in-polygon, with interior-ring/hole handling).
  • nearby — the nearest qualifying plume centroid is within ~150 km (haversine), reported with an 8-point compass bearing.
  • none — nothing qualifying nearby, and O2 says nothing about smoke.

This replaced an earlier approach that keyword-matched a continental narrative and consequently implied wildfire smoke everywhere on the continent.


Trust boundaries

These are enforced in code, not by convention:

  • No language model writes user-facing copy. Forecast wording, the broadcast script, and map answers are templates filled by the deterministic layer.
  • Health guidance has one source. Every category label, range, and piece of advice is read from src/lib/aq.ts. Tests assert the on-page safety table matches the classifier at a probe value inside every band, so the two cannot drift.
  • Observed and forecast are always distinguished in the timeline and copy.
  • Fallbacks are labeled, never hidden. If Aura is unavailable the transcript is shown; if a source is stale its timestamp says so.
  • No automatic social posting, and no medical advice. O2 states plainly that it provides general information only.

Alerts

Live, and verified end to end on production infrastructure.

subscribe → confirmation email → tap link → active in D1
                                              ↓
                        cron (*/15) evaluates each subscriber
                                              ↓
              category crossing? → dedupe + cooldown → Resend → inbox
  • Double opt-in, protected by Cloudflare Turnstile. Nothing is sent before you confirm.
  • Per-subscriber AQI threshold; separate toggles for turns unhealthy, safe again, and morning/evening briefings.
  • Boundary hysteresis and a cooldown prevent alert storms at a category edge.
  • Idempotent delivery logging in D1: every send is recorded sent, queued, or failed with a reason.
  • With no API key configured the adapter records the message as queued and contacts no provider — a real record, never a fake success.
  • One-tap unsubscribe in every message.

Stored per subscription: email, latitude, longitude, a label for the place, and its timezone. That is what makes a local alert possible. Nothing is sold.


Accessibility

Verified in a browser on 2026-07-20. These four checks are the full extent of what was tested — no aggregate conformance claim is made.

Check Result
Category conveyed as text beside color, everywhere AQI appears Pass
Current reading, category, and improvement time reachable as text Pass
Timeline, map questions, alert signup, and FAQ keyboard-operable with a visible focus ring Pass (one missing focus ring found and fixed)
prefers-reduced-motion honored Pass

Run it locally

npm install
npm run dev

The map needs a Mapbox token:

VITE_MAPBOX_PUBLIC_TOKEN=pk.your_token_here

Email alerts use a Resend key held as a Worker secret, never in the repo:

npx wrangler secret put RESEND_API_KEY --config wrangler.jsonc

With no key configured the pipeline records deliveries as queued and contacts no provider, so you can exercise the whole flow without sending mail.

Note on local dev: Workers AI is proxied differently under wrangler dev, so the Aura voice falls back to browser speech on localhost. On the deployed Worker the real voice is used. Alert emails also require the deployed secret.


Testing

npm run test:intelligence   # 60 tests
npm run build
npm run lint

The suite covers AQI boundary categories, forecast improvement/deterioration detection, safest-window selection, confidence scoring, report and broadcast generation, smoke proximity gating (over / nearby / none, plus malformed plume values), alert engine crossings and cooldowns, subscription lifecycle, email adapter behavior with and without a key, and content integrity — including tests that fail if the FAQ ever reintroduces a claim the product cannot support.


Deploy

npm run deploy

Cloudflare Worker aqi-agent9 on the custom domain o2.agent9.dev, with a D1 binding (o2-alerts), the Workers AI binding, static assets, and a 15-minute cron trigger.


Known limitations

  • Forecast accuracy degrades with distance in time; timing can move by hours.
  • Modeled data is not a substitute for a nearby regulatory monitor. For official readings see AirNow.gov.
  • Smoke proximity uses plume centroids for the "nearby" tier, so distance is approximate for very large or irregular plumes.
  • Source-disagreement detection (flagging when two models conflict) is not built yet; both models are shown so the difference is at least visible.
  • O2 is an independent tool, not affiliated with the EPA, NOAA, or any agency.

Roadmap

  • Text (SMS) alerts — the same "your air just crossed a line" notifications, delivered by text as well as email.
  • Global coverage — O2 already runs on the worldwide CAMS model, so forecasting air quality beyond North America is the natural next step.
  • Multi-location subscriptions — watch home, work, and a loved one's town from a single account.
  • Sharper confidence — per-source freshness timestamps and source-disagreement penalties, so you can see exactly how sure each forecast is.

License

Licensed under the Apache License 2.0. © 2026 Agent9.

o2.agent9.dev · Built by Agent9

About

O2 is a wildfire-smoke and air-quality companion that forecasts when your air improves, not just how bad it is now.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages