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. This is a submission for the Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass https://dev.to/challenges/hacktoberfest-week1-2026-10-05 ▶ Try it live: clean-air-window.onrender.com https://clean-air-window.onrender.com five plans per visit ⌨ Code: github.com/talhaanwarch/clean-air-window https://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 https://www.airnow.gov/aqi/aqi-basics/ : 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 https://open-meteo.com/ '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 https://github.com/talhaanwarch/clean-air-window/blob/main/docs/demo.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 https://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 https://github.com/AcruxCore/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: php @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 https://openrouter.ai/google/gemma-4-31b-it 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 https://www.usa.gov/government-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.