
My AI agent kept inventing pollution numbers, so I made it prove every one (Strands Agents + Amazon Bedrock)
AirGuard plans Delhi commutes around PM2.5, 200 metres at a time. Its assistant runs on the Strands Agents SDK and Amazon Bedrock, and it isn't allowed to say a number no tool returned. Here's the trace-and-guard pattern behind it, with code you can reuse.
TL;DR
- AirGuard tells people in Delhi which road to take and when to leave so they breathe less PM2.5. It forecasts every 200 metres of a route, for every departure in the next six hours.
- Its assistant is a Strands agent. In production it runs on Amazon Nova 2 Lite through Amazon Bedrock. In development it ran on a 1.7B model on my laptop.
- The model never does the maths. Every tool call is recorded, and every number in an answer is checked against what the tools returned. If one doesn't match, the answer is replaced with a verified one.
- There's a 20-line version of the pattern near the end that you can drop into any Strands agent.
Every October, Delhi's air turns and every AQI app says the same thing: the air is bad today. Fine. But if you cycle to college or ride a scooter to work, one number for a city of twenty million people doesn't tell you what to do.
PM2.5 moves hour by hour and from one part of the city to another, and a lot of that movement can be forecast. So for the WeMakeDevs Environmental Hacks, my teammate Harshit and I built AirGuard. You give it a trip, and it tells you which road to take and when to leave so you breathe less smoke. Then you can ask it why.
That last part nearly broke me. The first version of the assistant was happy to tell a sensitive cyclist that leaving later meant "around 35% less exposure" when the analysis said something else entirely. In most apps that's a funny bug. In an app that tells people what to breathe, it's the whole ballgame. This post is how I fixed it with the Strands Agents SDK, and what fought back along the way.
What AirGuard does
You set up a profile once: how you travel, how sensitive you are to bad air, and how much extra time you'd accept. Then you plan a trip, say Hauz Khas to Delhi University's North Campus by bicycle. AirGuard pulls up to three real routes, cuts each into 200-metre pieces, and gives every piece the PM2.5 forecast for the minute you'd ride through it. It repeats that for every departure, every 15 minutes, up to six hours ahead. Then it comes back with one plain answer, a forecast range and a confidence score. Sometimes the answer is "leave now", because the air only gets worse from here, and it says so instead of inventing a saving.
Rule one: the model never does the maths
Everything that produces a number is ordinary code, and it runs before the assistant is involved at all:
- Forecast: XGBoost models trained on a year of OpenAQ data from 76 stations (about 525,000 hourly readings), plus Open-Meteo weather, the CAMS global forecast and NASA FIRMS fire detections. Six horizons, from now to 24 hours ahead.
- Exposure engine: routes from OSRM on OpenStreetMap, 200-metre segments, and a forecast for the exact minute you reach each one, weighted by how hard you breathe in that mode.
- Decision rule: every 10% cut in exposure is worth 4, 7 or 10 minutes depending on how sensitive you are. Waiting counts half, because you can wait indoors.
The result is saved as a trip analysis. The assistant's only job is to read that analysis and explain it in plain words. Everything below follows from that split.

Tools are just Python functions
In Strands, a tool is a function with a docstring and type hints:
Python
@tool@traceddef get_trip_result(trip_id: str) -> dict:"""The saved analysis of the trip the user is looking at: recommended route and departure time,the fastest option leaving now, every route's exposure, confidence, and the decision rule used."""t = trips.get(trip_id)if t is None:raise ValueError("trip not found; ask the user to plan the trip first")return trip_summary(t)
The docstring is the description the model reads, and the type hints become the input schema. No hand-written JSON schema, no registry. The other tools follow the same pattern: a forecast at a point, route scoring, a departure-time search, geocoding and the current time.
Recording every call
The second decorator,
@traced , is mine, and most of the design leans on it. It wraps each tool and records what went in, what came out and whether it failed, into a per-request trace held in a ContextVar . Here it is, lightly trimmed:Python
def traced(fn):@functools.wraps(fn)def wrapper(*args, **kwargs):tr = current.get()inputs = {**{f"arg{i}": a for i, a in enumerate(args)}, **kwargs}# Small models sometimes repeat an identical call; hand back the earlier result and a nudge.prev = next((c for c in tr.calls if c["name"] == fn.__name__ and c["input"] == inputs and c["ok"]), None)if prev is not None:return json.dumps({"note": "You already have this result. Answer the user now.","result": prev["output"]})tr.emit({"type": "tool_start", "name": fn.__name__, "input": inputs})try:out, ok = fn(*args, **kwargs), Trueexcept Exception as e: # the model sees the error text and can say so plainlyout, ok = {"error": str(e)}, Falsecall = {"name": fn.__name__, "input": inputs, "output": out, "ok": ok}tr.calls.append(call)tr.emit({"type": "tool_end", **call})return json.dumps(out)return wrapper
That trace does three jobs. It streams each tool call to the browser as it starts and finishes, so the user watches "Read your trip analysis" appear in the chat. It gives the number guard a complete record of everything the tools returned. And it catches the same call being made twice, a habit of small models, and hands back the earlier result with a nudge to get on with the answer.
Fewer tools, fewer wrong turns
Every request gets a fresh agent with only the tools that make sense for it:
Python
TRIP_TOOLS = [get_trip_result, now_ist]PLANNING_TOOLS = [geocode, best_departure_time, score_routes, get_forecast, now_ist]def tools_for(trip_id: str | None) -> list:return TRIP_TOOLS if trip_id else PLANNING_TOOLSagent = Agent(model=model, tools=tools_for(trip_id), system_prompt=system_prompt(profile, trip_id),messages=_history_messages(history), callback_handler=None)
With a trip on screen, the assistant has two tools, and the only useful one is reading that trip. Without one, it gets five tools for planning from scratch. Strands makes this cheap, because an agent is just a model, a list of tools and a system prompt.
Streaming, minus the thinking out loud
stream_async yields events. Text chunks arrive in data , and finished messages arrive in message . I forward the text to the browser over server-sent events so the answer types itself out. The catch: models often write something like "Let me check your trip" before calling a tool. When a finished assistant message contains a tool call, I throw away the text so far and tell the browser to reset, so the user only sees the real answer.Python
async for ev in agent.stream_async(prompt):if "data" in ev and isinstance(ev["data"], str):text_parts.append(ev["data"])queue.put_nowait({"type": "token", "text": ev["data"]})if "message" in ev and ev["message"].get("role") == "assistant":# A new assistant turn after tool use: drop text produced before the tool call.if any("toolUse" in c for c in ev["message"].get("content", [])):text_parts.clear()queue.put_nowait({"type": "reset"})
The number guard, in action
When the stream ends, every number in the answer is checked against the trace. A number passes if it matches something a tool returned after rounding, as an absolute value ("28% less" for -28.1), as a fraction written as a percentage (confidence 0.67 becoming "67%"), or within 3%. Clock times are checked against times in the tool outputs. The user's own message and profile count as sources too, and a short list of harmless numbers (0 to 10, 24, 100, and the 2.5 in PM2.5) always passes.
Here's the real guard from the repo, run on two drafts of the same answer. The tool said the cleaner option was 23.4% less exposure, 20 minutes later, at 67% confidence:
Python
>>> tool = [{"recommended": {"depart_at": "09:45", "label": "via Ring Road",... "exposure_change_pct": -23.4, "arrive_later_min": 20}, "confidence": 0.67}]>>> guard.check("Leave at 09:45 via Ring Road: about 23% less exposure, "... "arriving 20 minutes later. Confidence is 67%.", tool)(True, [])>>> guard.check("Leave at 09:45 via Ring Road for roughly 35% less smoke, "... "arriving 20 minutes later.", tool)(False, ['35'])
The first draft rounds, drops a minus sign and turns 0.67 into a percentage, and passes. The second invents a 35, and it's caught. When that happens, the model's answer is thrown away and replaced with an explanation written by code, straight from the saved analysis. The same fallback kicks in if the model errors or a tool fails. The chat always says which happened, so the user always gets a correct answer. Sometimes it's just less chatty.
Developing on a 1.7B model was the best decision I made
I built all of this against qwen3:1.7b running through Ollama on a laptop with a 4 GB GPU. Small models fail the same ways big ones do, just often enough that you can't look away. Mine answered from memory without calling a tool, called the same tool over and over, and invented numbers. Every guard rail above exists because the small model found the hole first.
One more rail: if a trip is on screen and the model answers without calling any tool, it gets exactly one retry, with an explicit instruction to read the trip first.
Swapping in Amazon Bedrock
The agent never knows which model it's talking to:
Python
def _model():cfg = settings()if cfg.llm_backend == "ollama":from strands.models.ollama import OllamaModelreturn OllamaModel(host=cfg.ollama_host, model_id=cfg.ollama_model, temperature=0.2,max_tokens=700, keep_alive="30m", additional_args={"think": False})if cfg.llm_backend == "bedrock":from strands.models import BedrockModelreturn BedrockModel(model_id=cfg.bedrock_model_id, region_name=cfg.aws_region,temperature=0.2, max_tokens=700)return None
In production we set
LLM_BACKEND=bedrock , and the assistant runs on Amazon Nova 2 Lite. Same tools, same trace, same guard. If no model is configured at all, the API still answers from the saved analysis.The guard stays on in production. A bigger model makes fewer mistakes, but "fewer" isn't a promise you can make to someone deciding what to breathe.
AWS in this project, end to end:
- Strands Agents SDK (AWS open source): the assistant, its tools and streaming.
- Amazon Bedrock with Amazon Nova 2 Lite: the model in production.
- AWS Lambda (container image in Amazon ECR, run through the AWS Lambda Web Adapter), Amazon API Gateway and AWS Amplify: hosting. Harshit wrote that part up in detail: [link]
Steal this pattern
Here's a stripped-down version you can drop into any Strands agent. Record tool outputs with a decorator, then check the answer's numbers against them:
Python
import contextvars, functools, json, refrom strands import Agent, toolcalls = contextvars.ContextVar("calls")NUM = re.compile(r"-?\d+(?:\.\d+)?")def traced(fn):@functools.wraps(fn)def wrapper(*args, **kwargs):out = fn(*args, **kwargs)calls.get().append(out)return json.dumps(out)return wrapperdef numbers(x):text = x if isinstance(x, str) else json.dumps(x)return {abs(float(n)) for n in NUM.findall(text)}def grounded(answer, outputs, tol=0.03):sources = numbers(outputs)sources |= {round(v * 100, 1) for v in sources if 0 < v <= 1} # 0.67 -> 67%bad = [n for n in numbers(answer)if n > 10 and not any(abs(n - s) <= max(0.5, tol * s) for s in sources)]return not bad, baddef ask(agent: Agent, question: str, fallback):calls.set([])answer = str(agent(question))ok, bad = grounded(answer, calls.get())return answer if ok else fallback(calls.get(), bad)
Put
@tool on top of @traced for each tool, and write a fallback that builds an answer from the tool outputs without the model. The version in AirGuard also handles clock times, user-supplied numbers and streaming, but this is the core idea.What fought back in Strands
Some notes for anyone using Strands with local models, and for the Strands team:
- With the Ollama provider,
tool_choiceis accepted but ignored, so there's no way to force a small local model to call a tool first. That's why the retry exists.
- The events from
stream_async(text chunks, tool use, finished messages) weren't documented clearly enough for me to separate text written before a tool call from the final answer. I read the source.
- Turning off Qwen3's thinking mode needed
additional_args={"think": False}, which I also found in the code.
None of these were blockers. They're the kind of thing a short "Strands with small local models" page would save people a few hours on.
Does the forecast hold up?
An honest assistant is pointless if the numbers it explains are bad. I tested the forecast on the two most recent weeks, which it never saw in training, against the last reading, the usual level for that hour, and CAMS.

Six hours ahead, AirGuard's error is 14.5 µg/m³, against 21.0 for "nothing changes" and 29.7 for CAMS. That's about 30% better than the first and about half the error of the second. One hour ahead it barely wins, since air doesn't change much in an hour.
The dashed line is the outage story. Delhi's government monitoring feed (CPCB) went silent on October 7th, the day before the hackathon started, and stayed silent the whole time we built. So I trained for it: a quarter of the training rows pretend the station's own feed is late, and half of those pretend the whole network is. With every government station hidden, the error is 16.5 to 21, still well under CAMS at about 30. The map shows those stations as faded dots, labelled as estimates.
One thing I'd rather admit than hide: the 80% forecast ranges catch the real value 85% of the time an hour out, but only 66% of the time a day out. That's the next thing to fix.
What I'd keep
Keep the model away from the maths. Let code produce every number, and let the model explain them.
Record every tool call. A trace is your UI, your audit log and your fact-checker in one place.
Develop on the smallest model you can. It finds the holes a bigger one would only hit in production.
Report your baselines, including the ones you barely beat.
What's next
More cities, since the pipeline only needs OpenAQ stations and coordinates. A school-run mode for parents. A public API. And low-cost sensors to fill the gaps between monitors, because confidence is the weakest number on most routes.
Demo video: [link]
If you're building an agent that hands people numbers, I'd love to know how you keep it honest.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article