cd /news/ai-agents/clean-air-window-the-cleanest-hour-t… · home › topics › ai-agents › article
[ARTICLE · art-145636] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=↑ positive

Clean Air Window: the cleanest hour to go outside in smog season

A developer built Clean Air Window, a Streamlit app that uses Google's open-weight Gemma 4 31B model through OpenRouter to pick the cleanest hour for outdoor activity in smog-prone cities. The tool-calling agent queries Open-Meteo hourly AQI forecasts, retrieves passages from five EPA and AirNow documents via Qwen3-Embedding-8B RAG, and links local air-quality news headlines, rating each candidate time window by its dirtiest hour rather than the average.

by read9 min views1 publishedOct 5, 2026

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass

▶ Try it live: clean-air-window.onrender.com (five plans per visit)

⌨ Code: github.com/talhaanwarch/clean-air-window

Clean Air Window picks the cleanest time to go outside today. You give it your city, your activity and the hours you are free. You can also say whether you are in an EPA sensitive group, such as people with asthma. The app answers with a time card, an hourly air quality chart, and advice from the U.S. EPA's health guidance. Some weeks a local news site reports a smog alert or a school closure. In those weeks, the answer names the headline and links to the article.

The EPA splits the air quality index (AQI) into six bands: Good (0–50), Moderate (51–100), Unhealthy for Sensitive Groups (101–150), Unhealthy (151–200), Very Unhealthy (201–300), and Hazardous (above 300). On 5 October, the forecast for Lahore was AQI 137 at 15:00 and AQI 166 at 18:00. A person with asthma wanted a 45-minute walk between 15:00 and 21:00. The app picked 15:00 and suggested a shorter, slower walk. The same answer linked a headline from that week in Dawn, a Pakistani newspaper. The headline said that Lahore again ranked as the most polluted city in the world.

The app is for anyone who walks, runs, cycles or plays outside in a city with seasonal smog. The app's instructions to the AI model say never to tell people to stay inside. When even the best hour is unhealthy, the model suggests a shorter and easier version of the activity.

The AQI values are Open-Meteo's hourly forecast, not measurements.

The demo plans a walk and clicks "I went outside" to mark that the person went. Then the demo opens the record of that plan in AcruxCore, the platform that stores the app's prompt, tools and run history. The demo ends on two AcruxCore screens that the post explains below. The full-quality MP4 is in the repo.

Clean Air Window tells you the cleanest time to go outside today. You give it your city, your activity, the hours you are free, and whether you are in a sensitive group such as people with asthma. It answers with one time window, an hourly air-quality chart, advice taken from the U.S. EPA's health guidance, and any local air-quality news from this week that changes the plan.

Live app: clean-air-window.onrender.com, on Render.

The card at the top and the chart come straight from the forecast data. The model writes the lines below the chart. The health advice links to the EPA document it came from, and the news line links to the headline it came from.

Gemma 4 31B, an open-weight model from Google, runs a tool-calling loop with three tools:

Tool What it does

The app is a Streamlit page on top of a tool-calling agent. The model is Gemma 4 31B (google/gemma-4-31b-it), an open-weight model from Google under Apache 2.0, served through OpenRouter. The model can call three tools:

get_air_and_weather downloads today's hourly AQI, temperature and rain chance from Open-Meteo. The tool tries every time window of the activity's length inside your free hours. The tool rates each window by the dirtiest hour it covers, not by the average hour, and returns the best and the worst window. The dirtiest hour decides, because a 45-minute walk that passes through one bad hour still spends part of the walk in that bad air.search_health_guidance does the retrieval for RAG (retrieval-augmented generation). This tool searches five EPA and AirNow documents and returns the four passages closest to the model's search query. Qwen3-Embedding-8B makes the embeddings. The documents split into only 83 chunks. A set that small needs no vector database: a NumPy matrix and a dot product do the search. The 83 vectors ship inside the Docker image as a 1.3 MB file.get_local_air_news searches Google News through Python code, not the model, draws the time card and the chart from the tool's best window. A model that copies numbers by hand can copy them wrong. The model writes only the explanation, the health advice, a tip and the news line. The model ends with a JSON answer that has six fixed fields:

class PlanAnswer(BaseModel):
    why: str           # best window vs worst window, with their numbers
    for_you: str       # what the EPA guidance says for this person
    source_title: str  # the guidance document the advice came from
    tip: str           # one practical tip for the activity
    local_news: str    # one sentence about a headline, or empty
    news_title: str    # the exact title of that headline, or empty

The app links source_title to the real EPA document. The app links news_title only when the title matches a headline that get_local_air_news returned in the same run. So the page never shows a news link that the model made up.

AcruxCore is an open-source LLM-ops platform that I build. The app uses the hosted version at acruxcore.com. AcruxCore keeps four things for the app: the prompt, the three tools, the list of tools that the model may call with that prompt, and a trace of every plan.

The prompt is a versioned template. An alias is a name, such as production or staging, that points at one version. The template has an if block that adds a line only for people in a sensitive group.

The three tools are in the AcruxCore tool catalog. The @acrux.tool decorator turns each Python function's signature and docstring into a tool definition. A setup script in the repo, scripts/setup_acruxcore.py, sends those definitions to the catalog, and the catalog keeps a version of each tool. The code below is the whole definition of the news tool:

@acrux.tool
async def get_local_air_news(city: str) -> dict:
    """Get the last seven days of news headlines about air quality and smog in a city,
    newest first, such as official smog alerts or school closures. Use them only if a
    headline changes what the person should do today; many cities have none."""

The tools are connected to the prompt in the dashboard. The prompt's Tools tab is a table with one row per tool. Each alias can have its own column, but only when that alias needs different tools. An alias without its own column uses the first column, named default. The app calls run_prompt_with_tools, which reads that list of connected tools from the prompt. The app code never names the tools it offers the model. The code only maps each tool name to the Python function that runs it.

rendered = await hub.prompts.render("clean-air-window", PROMPT_ALIAS, variables)
stream = await hub.gateway.run_prompt_with_tools(
    rendered, model="google/gemma-4-31b-it:nitro", client_tools=CLIENT_TOOLS,
    response_format=acrux.pydantic_response_format(PlanAnswer, name="plan_answer"),
    stream=True,
)

The first version of the app had no news tool. Version 1 of the prompt asked for two tools, get_air_and_weather and search_health_guidance, and the production alias pointed at version 1. I added the news tool in four steps:

local_news and news_title. I pointed staging at version 2 and left production on version 1.PROMPT_ALIAS=staging and checked that the news line appeared in the answer. The production copy kept serving version 1 with two tools. The screenshot below shows the Tools tab during step 3. staging has its own column and serves version 2 with three tools. production still follows the default column and serves version 1 with two tools:

One code change came before these four steps: I shipped the app with the news tool's Python function. AcruxCore stores only the tool's definition, and the app runs the function itself. The function did nothing until step 2 connected the tool to an alias. Steps 1 to 4 changed only data in AcruxCore. The running production app picked up step 4 on its next plan, with no deploy.

A trace is the stored record of one run: every model call and every tool call, in order, with their inputs and outputs. The trace below is the Lahore plan from the screenshot at the top. In its first model call, Gemma asked for the weather tool and the news tool together, and the app ran both tools at the same time. The plan made four model calls in total, and the last call wrote the JSON answer. The "I went outside" button saves its feedback on the same trace.

The model calls go straight to OpenRouter with my own OpenRouter key, and they do not pass through AcruxCore. For each plan, the app first reads the prompt from AcruxCore. When the plan is done, the app sends the trace to AcruxCore. On version 2, plans took between 12 and 22 seconds in my runs, including one on the live app. The time card and the chart appear as soon as the weather tool returns, after about 5 seconds.

Best Use of Gemma. Gemma 4 chooses which tools to call, calls two of them in parallel, and writes the typed JSON answer in every plan.

Best Use of SerpApi. get_local_air_news uses SerpApi's Google News engine to find this week's smog alerts and school closures. The app shows a news link only when the title matches a headline that SerpApi returned.

Best Use of Render. The live app runs on Render as a Docker web service, built from the repo's Dockerfile. The 1.3 MB guidance index ships inside the image, so the service needs no disk and no database, and a restart loads the index in under a second.

Open weights mean no single company serves the model. Gemma 4's weights are public, so on 6 October 13 providers served Gemma 4 31B through OpenRouter. The :nitro suffix asks OpenRouter for the fastest of those providers on each request. One environment variable chooses the model. Before Gemma, the app ran on Qwen3-Next-80B, another open-weight model, and the switch was this one line:

CHAT_MODEL=google/gemma-4-31b-it:nitro

The repo includes the health guidance. Work by the U.S. federal government, such as EPA and AirNow publications, is not protected by copyright. So the extracted text sits in data/guidance/, and every file names its source URL.

The app does not depend on one company's server. AcruxCore is under Apache 2.0. The setup script creates the tools, the prompt and the tool connections from the repo's code. So the same app can run against a self-hosted copy of AcruxCore by changing ACRUXCORE_BASE_URL. A trace stores a person's city, their free hours, and whether they have asthma, and a self-hosted copy keeps that data in your own Postgres database.

This project was built with help from AI. I used Claude Code to write most of the code and the first draft of this post, and I reviewed and tested both.

── more in #ai-agents 4 stories · sorted by recency
── more on @clean air window 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/clean-air-window-the…] indexed:0 read:9min 2026-10-05 · —