Inspiration

Every morning I check the same thing: what is the air like today, is it safe, and can I play tennis with my son this evening?

I check because my son was admitted to the hospital for it: a severe asthma attack, his airways closing, after breathing PM2.5 over a stretch of days when readings in our area were many times the level the World Health Organization calls safe. That is why "is the air safe today?" is not a curiosity in my house. It is a decision, every morning, before anything else gets decided.

So I started building LUMLON: a Thai app that reads air, weather, and hazard conditions in real time. It exists, and you can open it — https://lumlon-web-dev.vercel.app is our development build, mid-redesign and not launched yet. I would rather you looked than took my word for it. What it is for is simple — to tell a person, day by day, at a time they choose or the moment they ask, whether it is safe to be outside. Not so they can study a chart, but so a father and son can plan an ordinary evening without gambling on it. Air you can plan around is also cheaper than a hospital admission, and in my family, that is not a figure of speech.

The data already exists. Checking it does not scale. Today that means jumping between several apps, government agencies, and websites — each with its own shape, its own gaps, and its own idea of what "no data" means. It takes time, and on a morning you are in a hurry, you skip it. Would it not be better to simply ask — or to be told at the moments that matter — without opening anything at all?

That is the kind of problem WebMCP is actually for. The person asking does not want a dashboard; they want an answer, in their own words, about their own province, right now.

And people have already moved. They reach for an AI tool first now, which makes an agent a practical way to get this kind of information to someone — not a novelty. LUMLON was always meant to reach people through whatever channel they already use, not only inside one app. What WebMCP adds is that the answer can come from us: specific readings, each carrying its source and the time it was taken, instead of whatever an agent can assemble on its own.

An agent also does something a website cannot do for everyone at once — it turns a reading into an answer in the language and the words of whoever is asking. That is why one of these six tools does not return a number at all. It returns a judgment about a specific activity, together with the readings it used and the reason it reached that answer. A number tells you what the air is like. A person still has to decide whether to go outside.

So we did not build a chatbot on top of a website. We made the website itself something an agent can read, and kept the human looking at the same screen the whole time.

What it does

LUMLON exposes six tools to any WebMCP-capable agent, for all 77 provinces of Thailand.

Tool What it does Kind
search_locations Turns a place name into a province code. Thai or English, loose spelling. read
get_environment_snapshot Air quality, short forecast, and official hazard alerts — each alert carries its own expiry, because they come from a dated snapshot. read
get_safety_briefing Decides whether an activity is advisable and explains why, quoting the readings. read
create_watch Saves a rule that warns when a reading crosses a threshold. write
list_watches Lists the rules saved in this browser. read
delete_watch Removes one rule. destructive

It is a working page before an agent arrives. Open it with no agent at all and it reads three provinces on load — live PM2.5 and AQI, plus the official hazard alerts from a dated snapshot, which every card says on its face — and plots them on a map of all 77 provinces drawn from our own registry. You can search a province in Thai or English and read it yourself, with one click, through the same data path a tool call takes. This matters for a reason beyond looks: an agent-ready site can only be judged on what the agent adds, and that needs a "before" to compare against. A blank page waiting for a tool call is not a product an agent improves — it is a log viewer.

What people and agents do together, concretely. You ask "is the air safe for a morning run in Chiang Mai tomorrow?" The agent calls two tools and answers in a sentence. At the same moment, the page in front of you updates — the same reading, the same timestamp, the same source. The map moves to Chiang Mai on its own; the card that appears is labelled read by the agent, next to cards labelled read by this page, so you can always tell which of the two of you did what. You did not lose the screen by talking to an agent; you gained a second way to drive it. Then you say "watch this and warn me if it goes above 75," and a rule appears as a card you can see — and that you remove the same way you made it, by asking. The panel lists your watches; it does not hide them, and it does not let anything but a tool call change them.

The map moves without a seventh tool. We deliberately did not add a set_map_view tool to let the agent pan the screen. The view follows the tools it already has: search a place, the camera frames it; read a place, the pin lights up. Tool descriptions are charged against the agent's context on every single turn, so a tool is a cost, not an achievement — and this one would have bought nothing that the existing events did not already carry.

Three decisions that make this usable rather than merely present:

  • Every block of data carries when it was observed and where it came from. A number without a timestamp is a number nobody can check — and the person hearing an agent read it aloud cannot see the screen to judge for themselves.
  • Missing data is reported, never hidden. If a source cannot be read, the answer says so, in a gaps field the agent reads out. "Could not read" and "there are no alerts" are different facts, and collapsing them is the single most dangerous thing a safety tool can do. For the same reason, a briefing never says good while something it needed was unavailable: missing data can lower our confidence, but it never lowers a warning.
  • Read tools and write tools fail in opposite directions, on purpose. A read tool with one number missing still answers the question that was actually asked, and names what it could not read. A write tool with anything unvalidated refuses the whole command. Both write tools are annotated as writes, and delete_watch is additionally marked destructive. In the session we recorded — with approvals set to allow everything — the agent still stopped and asked before it deleted a rule.

How we built it

The tools do not know where their data comes from. They only ever talk to a provider interface, and that interface is in this repository: src/webmcp/providers/types.ts. The implementation behind it here is the bundled one, which calls only public sources.

Be precise about what you can and cannot check in this export. The second implementation — the one that reads the backend of the product we are building — is not in this repository. The export is generated by following what the shipped entry actually imports, and since this entry runs on the bundled provider, the other implementation is never reached and never copied. So what you can verify here is the seam, not both sides of it: one interface, one implementation, and six tool files that name neither. That seam is the reason this entry can be frozen for judging while the product keeps moving, and it is also the reason we are telling you the second half is missing instead of letting the sentence imply it is here.

To be exact about what that does and does not mean: this WebMCP layer was built for this challenge, and the product ships with it switched off. The shared interface is what makes it portable — not a claim that agents are driving our production app today.

The module is invisible when it is off. WebMCP registration lives behind a build flag and a dynamic import. With the flag off, a scan of our own build output finds no trace of the module — 2,838 files swept, zero hits, on the tree we build from. We check this rather than asserting it, because we found the opposite of what we assumed the first time we measured: an unset environment variable is not a constant, so the bundler could not prove the branch was dead, and it emitted the chunk anyway.

The upstream sources are switchable without a code change. Two environment variables choose where air quality and forecast come from. A source that is registered but not yet connected returns no numbers at all plus a gap saying exactly why — it does not quietly fall back to cached data, because that would let an operator believe a source is working when it is not. An unrecognised value is reported as a misconfiguration in the response, never quietly mapped to a source that works: somebody who typos a source name is trying to switch something off.

Watches run in the browser, for real. Rules are stored in localStorage, evaluated against live data while the page is open, and raise a real banner. The wording says so plainly: in this browser only — nothing is stored on a server, and nothing is pushed anywhere else. We would rather show a smaller true thing than a larger implied one.

Stack: Next.js (App Router) · TypeScript · Tailwind. No dependency was added for WebMCP — the tool schemas are hand-written JSON Schema, and every tool description has a length budget that is enforced where the tool is constructed — go over it and the tool refuses to build — because tool descriptions enter the agent's context on every single turn.

What we check before we claim anything. In the repository this export is cut from, the module has 191 automated tests, and a release gate that generates this public repository, runs npm install && npm run build on that fresh copy, boots it, calls the API, and drills the failure paths — upstream down, source switched off, rate limit exceeded. The gate reports what it could not check as a separate state; it never counts "not tested" as "passed."

Neither the tests nor the gate are in this export, and you should know that before you go looking for them. The file list here is derived from what the six tools import, and a test file is imported by nothing. Publishing the gate script would be worse than useless: it carries the patterns it scans for. So this repository is the thing that was checked, not the checking — the length budget on tool descriptions is the one part of it you can see from here, because it is enforced where the tools are built rather than in a test.

Challenges we ran into — and what we learned

The gate that was green for the wrong reason. Six checks passed on every run: file list complete, no forbidden files, no secrets, module absent when the flag is off. Then we ran npm install && npm run build on a fresh clone of what we were about to publish — and it did not build. Six imports could not resolve, because the files that get relocated during export still used relative paths. Every check we had was answering "is the file list complete?" Nobody had asked "does the thing we are shipping run?" — which is the only question a judge will ask. The rule we took from it: when you claim a check proves something, ask what that check would say if the thing it checks were missing entirely.

Counting things that had already expired — and then doing it again. An early version of the hazard layer reported how many official alerts were in the feed. When we measured against the live feed, four of five had already expired — the oldest by two days. An agent reading that number aloud is not informing someone; it is frightening them about something that ended. We fixed it by having the source hand us only the alerts that were in effect.

That fix was true at the moment it ran, and it quietly stopped being true. This entry ships a dated snapshot of the alert feed so that judging does not depend on our servers staying up. The snapshot was correctly filtered when it was captured — and nothing re-checked it afterwards. So the briefing tool went on saying "official alert in effect" about a warning that had expired hours earlier, and would have kept saying it for the whole judging window. The comment at the top of the file said the problem was already solved, which is exactly why nobody looked again.

The tool now compares each expiry against the time the question is asked, at the point where the decision is made rather than the point where the data is fetched. An expired alert no longer raises the advice level — and it is still reported, as expired, because expired and there were never any are different facts. If an expiry cannot be parsed at all, the alert counts as live: missing information is never allowed to lower a warning. Each alert still reaches the agent with its own expiry attached, so the agent can reach its own conclusion instead of taking ours. The payload does cap how many alerts it carries and trims long headline text, because tool output competes for the agent's context — but no expiry is ever stripped from an alert that is sent.

The code keeps disabled, unavailable, none and now expired as four separate states that are never collapsed into each other.

Timestamps that are wrong are worse than timestamps that are missing. The forecast upstream returns local wall-clock time with no zone attached, so new Date() silently interprets it in the timezone of whatever machine happens to run the code — hours off, with no error anywhere. Serverless deployments make that a live risk rather than a theoretical one.

What's next for LUMLON

The development build linked above is where this goes next. The interface is being redesigned now, and we are working towards a public launch in Thailand in Q4 this year. These six tools are the part we intend to carry back into it.

The demo you are looking at is a subset export of a product we are building for launch in Thailand. The next steps are the ones the tools were designed for from the start: watches delivered to a person instead of only to an open browser tab, and the flood and earthquake layers switched on once we hold written permission to redistribute those feeds publicly. Those layers already exist on the product side; they are simply not wired to these six tools yet.

We are deliberately not claiming those today. Everything described above either runs on the live URL or sits in this repository — and where something lives only in the repository this export was cut from, we have said so in the sentence that mentions it, rather than letting you find out by looking.

Built With

Share this project:

Updates

Submission history