
Can students go outside today? Building a serverless air-quality decision tool for Indian schools on AWS
How Team Shadow Protocol turned India's AQI into a decision a principal can act on before morning assembly: a CPCB-method AQI engine in plain Python, AWS Lambda, DynamoDB, API Gateway, CloudFront and AWS SAM, with honest forecast labelling and no language model in the decision path.
7:45 a.m., somewhere in Delhi
Assembly starts in fifteen minutes. Four hundred children are about to line up on an open ground. The principal checks an air-quality app and sees a number: 145. Moderate.
Now what?
Is assembly fine? Should PE run as planned? What about recess? The number doesn't say. India's AQI categories describe health effects (CPCB's statement for "Moderate" even names children among those who may feel breathing discomfort), but no official source tells a school what to do about assembly, PE or recess. Every school translates the number on its own, usually in a hurry, often differently from the school down the road.
That gap is what we built SaansSafe to close. Saans (साँस) means "breath". We built it during Environmental Hacks, the WeMakeDevs × AWS hackathon on the Bharat Builds Tour. Our team, Shadow Protocol, had four days.
- Watch the 2:44 demo: https://www.youtube.com/watch?v=7fcaFbYrZwM
What SaansSafe does
Pick a school's location and SaansSafe answers in one line:
Reduce strenuous outdoor activity. Assembly: allowed. PE & sports: limited. Recess: allowed.

Then it goes further:
- Next 72 hours. The same policy runs hour by hour, so a principal can see the best school window (only Monday–Friday school hours count), the next change in advice ("4:30 p.m. today: move assembly, PE and sports indoors") and the worst expected hour.
- A note for parents. One tap turns today's decision into a notice in English or Hindi. It repeats the decision and never changes it. Nothing is sent until a teacher chooses to share it.
- Provenance on every screen. Data source, model grid cell, fetch time, cache status and policy version. If a parent asks "where did this come from?", the answer is right there.

The rule we set on day one: deterministic numbers, honest labels
Two decisions shaped everything else.
1. No language model in the decision path
It was tempting to "add AI". We chose not to, on purpose. A notice that goes to parents about their children's health must say exactly what the policy says, every time. So every number and every decision in SaansSafe is computed by tested, deterministic Python: same inputs, same output. No language model calculates the AQI, makes the decision or writes the notices.
We did build an optional notice writer with the Strands Agents SDK for Amazon Bedrock, with guardrails that check every number against the deterministic facts. It's in the repo and tested, but it's switched off in production (
NOTICE_AI_ENABLED=false ). Bedrock access wasn't authorized on our account, and honestly, a verified template is the safer thing to send to parents.2. Say exactly what the data are
Our forecasts come from the Copernicus Atmosphere Monitoring Service (CAMS) via Open-Meteo: model forecasts for a grid cell, not readings from a CPCB monitoring station. That's great for planning ahead, but it isn't the station near the school gate. So the app, the API and the video label the data "forecast/model" everywhere, and we keep two things strictly separate:
- the CPCB category and health statement (official), and
- the school action, which comes from SaansSafe's own published policy (
saanssafe-school-v1), not a CPCB rule.
CPCB category | SaansSafe action | Assembly | PE & sports | Recess |
|---|---|---|---|---|
Good | Outdoor activities as normal | Allowed | Allowed | Allowed |
Satisfactory | OK; take care of sensitive students | Allowed | Allowed | Allowed |
Moderate | Reduce strenuous outdoor activity | Allowed | Limited | Allowed |
Poor | Move assembly, PE and sports indoors | Indoors | Indoors | Limited |
Very Poor | Suspend outdoor activities | Indoors | Indoors | Indoors |
Severe | Suspend all outdoor activities | Indoors | Indoors | Indoors |
Inside the AQI engine
India's National AQI (CPCB report CUPS/82/2014-15) is more than "look up a number". Getting it right meant encoding several rules:
- Every pollutant needs at least 16 valid hours in the last 24, or it doesn't count. Missing hours are never filled in.
- PM2.5, PM10, NO2, SO2 and NH3 use 24-hour averages; O3 and CO use 8-hour averages.
- Each pollutant gets a sub-index by linear interpolation inside its breakpoint band.
- The AQI is the maximum sub-index, valid only with at least three pollutants, including PM2.5 or PM10.
- At or above the Severe threshold the scale is open-ended, so we show "401+" rather than inventing a number.
Here's the heart of it, the real sub-index function from
backend/src/saanssafe/aqi.py :Python
def sub_index(pollutant: Pollutant | str, concentration: float) -> float:pollutant = _to_pollutant(pollutant)c = _validate_concentration(concentration, pollutant.value)if c >= SEVERE_THRESHOLD[pollutant]:return float(SEVERE_SUB_INDEX)for band in BREAKPOINTS[pollutant]:if c <= band.c_hi:i_lo = band.interpolation_i_loif c < band.c_lo:return float(i_lo)return i_lo + (band.i_hi - i_lo) * (c - band.c_lo) / (band.c_hi - band.c_lo)
And the completeness rule that stops stale or patchy data from passing as current:
Python
def rolling_average(hourly, averaging_hours):recent = list(hourly)[-COMPLETENESS_WINDOW_HOURS:]checked = [None if v is None else _validate_concentration(v, f"hour {i}")for i, v in enumerate(recent)]if sum(v is not None for v in checked) < MIN_VALID_HOURS:return None # never a filled-in valuewindow = [v for v in checked[-averaging_hours:] if v is not None]return sum(window) / len(window) if window else None
A real example from our demo: on 11 October, Delhi's 24-hour PM10 average was 168.3 µg/m³. That sits in PM10's "Moderate" band and interpolates to a sub-index of 145.2. PM10 was the dominant pollutant, so the AQI shown is 145, Moderate, and the policy says reduce strenuous outdoor activity. Every one of those numbers is visible in the app's "Air now" card and in the raw API response.
The 72-hour outlook doesn't have a second AQI implementation. For each future hour it rebuilds the 24-hour window ending at that hour and runs the same engine and the same policy: one source of truth, hour by hour.
All of this is covered by 637 backend tests (CPCB boundaries, averaging rules, the policy, the outlook, caching, API errors, notice safety checks) and 83 frontend tests.
The architecture: serverless on AWS

Everything is serverless and pay-per-request: no EC2, no NAT gateways, nothing idling.
Amazon CloudFront + a private Amazon S3 bucket. The React app is a static build served over HTTPS by CloudFront. The bucket blocks all public access; only our distribution can read it, through Origin Access Control (the modern replacement for OAI):
YAML
OriginAccessControl:Type: AWS::CloudFront::OriginAccessControlProperties:OriginAccessControlConfig:Name: !Sub ${AWS::StackName}-siteOriginAccessControlOriginType: s3SigningBehavior: alwaysSigningProtocol: sigv4
Amazon API Gateway (HTTP API). Three routes,
/aqi , /forecast and POST /notice , with CORS limited to our site (and local development) and throttling at 10 requests per second with a burst of 20.AWS Lambda: two Python 3.13 functions on arm64 in ap-south-1 (Mumbai).
ApiFunction computes the AQI, decisions and the 72-hour outlook; NoticeFunction builds parent notices. The backend has no third-party runtime dependencies (just the Python standard library), which kept packaging trivial.Amazon DynamoDB as a forecast cache. This is where a small design decision paid off. Each model grid cell gets one item (
pk = open-meteo#<lat>#<lon> ), and the service is cache-first:- data younger than 60 minutes is served straight from DynamoDB, with no call to the provider;
- otherwise we refetch and write the new series back;
- if the provider is temporarily unavailable (timeouts, 429s, 5xx), we serve cached data up to 6 hours old, clearly marked "stale" with its age, but a malformed or rejected response never falls back silently;
- TTL cleans up old items automatically.
YAML
AqiCacheTable:Type: AWS::DynamoDB::TableProperties:BillingMode: PAY_PER_REQUESTKeySchema:- AttributeName: pkKeyType: HASHTimeToLiveSpecification:AttributeName: expires_atEnabled: true
Amazon CloudWatch Logs keeps 14 days of function logs.
AWS SAM (open source) for everything. Both stacks,
saanssafe-dev (API, Lambdas, DynamoDB, logs) and saanssafe-web (S3 + CloudFront), are SAM templates in the repo, validated with sam validate --lint and deployed with the SAM CLI. We deployed with --no-execute-changeset , read every change set, and only then executed it. That habit kept surprises out of production.
What fought back
The CPCB rules have sharp edges. Gaps between published bands, the 16-of-24-hours completeness rule, which window counts as "latest" for 8-hour pollutants, and the open-ended Severe band all needed explicit decisions. We wrote each one down, cited the report, and pinned it with tests before trusting any number on screen.
"Live" is a dangerous word. Our first instinct was to call the data "live AQI". It isn't: it's a model forecast for a grid cell. Relabelling everything as forecast/model, and putting school actions under our own named policy, made the product more honest and more useful. A forecast is exactly what you need to plan tomorrow's assembly.
AI we decided not to ship. Building the Strands + Bedrock notice writer taught us a lot about guardrails, and convinced us that for messages to parents, a verified template beats generated prose. Knowing when not to use a model is a feature.
No Docker on the dev machine. Keeping the backend dependency-free meant SAM could package
src/ directly, with no container build.Phones are the real target. A location picker that overflowed narrow screens and a clock that could mislead about freshness both got fixed; the layout is checked from 320 px up.
How we built it: Claude Code as a teammate
We built SaansSafe with Claude Code (Anthropic) as our AI coding assistant: planning, writing code and tests, reviewing, drafting deployment commands, browser QA, documentation and even the demo-video pipeline. The rule was simple: the AI proposes, we review and approve. Every AWS change set was read before execution, and every milestone was checked before it was committed. The running app uses no AI at all.
The demo video is reproducible code too: real Playwright recordings of the live site and genuine AWS Console screenshots with account details redacted, edited in Remotion (React), with an original score synthesised by a small script.
Try it, break it, tell us
- Live app: https://d316zs0sydbi1r.cloudfront.net
- Demo video (2:44): https://www.youtube.com/watch?v=7fcaFbYrZwM
- Code (MIT): https://github.com/bled1908/SaansSafe
What's next: real school calendars and holidays, an evening-before alert when tomorrow morning looks restricted, and, where available, CPCB station readings shown alongside the forecast, never instead of it.
Every school morning, someone has to decide. We think they deserve a clear answer, and to know exactly where it came from.
Team Shadow Protocol (Subhadeep Roy & Sneha Singha), Environmental Hacks 2026
SaansSafe is not medical advice. Schools should follow instructions from local and education authorities. Air-quality data: Open-Meteo, based on Copernicus CAMS forecasts (CC BY 4.0). AQI method: CPCB National AQI report CUPS/82/2014-15.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article