
From an AQI to decisions : Building VayuSaathi
How four students turned an air-quality number into a decision, a Hindi WhatsApp message and an SOS button, on AWS, in one hackathon week. With every wrong turn left in. Team Waterpark Enthusiasts: Chaitanya Saagar, Parul Garg, Nakshatra Kanchan, Divyansh Gupta Built for: WeMakeDevs × AWS Environmental Hacks, Air track Try it: https://breathesafe.waterpark-enthusiasts.workers.dev/ · https://github.com/Klaidoscope14/Amazon-Env-Challenge · 3-minute demo -> https://youtu.be/LT_jWSqg0Fc
1. A number is not a decision
It's an October evening in Delhi. Riya opens an air-quality app and it says AQI 268.
That number answers a question she didn't ask. Her real questions are:
- Can my son still play football at five?
- Is the evening walk with my father a bad idea? He has asthma.
- How do I tell him? He reads Hindi, and he will never install an air-quality app.
Every winter, millions of Indian families face this. The air turns Poor or worse, the apps show a number and a colour, and the actual decision is left to guesswork and forwarded screenshots.
So we set ourselves one rule for the hackathon: don't build another AQI display. Build the step after it.
The VayuSaathi dashboard: one card with the AQI, what to do, and a one-tap WhatsApp share
2. What VayuSaathi does
Vayu is air and saathi is a companion. The app does five things:
- Tells you what to do, not just how bad it is. Real-time air for any neighbourhood in India, on India's own CPCB scale, with one plain sentence of advice. Tap Explain for a longer explanation in English or Hindi.
- Checks your plans. Add "football, 90 minutes, kids are going" and get Go, Modify or Avoid, with a reason.
- Reaches the people who need it. One tap sends the advice to your family's WhatsApp, Hindi first. It links to a Notices page that can read the alert aloud. The person receiving it needs no app and no account.
- Watches the places you care about. Save Home, Work and "Papa's place", and see all three at a glance.
- Helps when it gets serious. An SOS button calls 112, lists the nearest hospitals with directions, and texts your emergency contacts your live location.
Plans getting Go, Modify and Avoid The Notices page in Hindi, with the listen button The SOS sheet: 112, nearest hospitals, emergency contacts
How it differs from existing AQI apps. Most are built to show a reading, and global apps often default to the US index rather than India's. VayuSaathi starts where they stop:
- it answers "can I do this?";
- it reaches relatives in Hindi;
- it connects bad air to help.
3. The architecture
Everything is serverless and defined in one AWS SAM template, with separate prod and dev stacks:
AWS service | What it does in VayuSaathi |
|---|---|
Amazon API Gateway | REST API with throttling; a Cognito authorizer guards every write |
AWS Lambda (Node.js 22, arm64) | 8 functions: air quality and places, advice and plans, trends, alerts, notices and audio, explanations, accounts, plus a Cognito trigger |
Amazon DynamoDB | Readings (auto-expiring), alert history, profiles and saved places, and the advice rules themselves |
Amazon EventBridge | Every reading, category change, alert and decision becomes an event, kept in a 30-day replayable archive |
Amazon Cognito | Sign-up, SRP sign-in, optional two-step verification, token revocation |
Amazon Polly | Reads alerts aloud in Hindi and English |
Amazon SNS | Alert and ops emails |
SSM Parameter Store | Encrypted API keys |
CloudWatch and X-Ray | Structured logs, business metrics, 7 alarms, tracing |
Live air data comes from the Google Air Quality API (India's National AQI, with hourly history), with IQAir as a backup. Search and hospitals come from Google Places.
4. What fought back
This is the part we'd most want to read in someone else's post.
4.1 Our data source died, and the replacements disagreed with each other
Our first source was CPCB's public feed. Midway through the week it started refusing connections from everywhere. We had built the data layer around one "get a reading for this area" step, so swapping the source didn't touch the rest of the app. Finding a source we could trust was much harder:
Source | What we found |
|---|---|
Google Air Quality | Reports India's AQI directly. Our first key's project didn't have the API enabled (403), so we spent days waiting on it. |
IQAir (free plan) | Only gives the US and China AQI, never India's. |
OpenWeather (a global model) | For Delhi it said PM2.5 ≈ 32 µg/m³ (AQI ~100, Satisfactory) while stations said ~69 µg/m³ (Moderate). For Mumbai it read more than double the stations. It was wrong in both directions. |
An open CPCB archive | Excellent history, but the newest CPCB data was a year old. |
Two lessons came out of this.
First, the wrong category is worse than no number. "Satisfactory, go out" on a Moderate day is a harmful bug. So the app never passes an old or modelled reading off as live. If no trustworthy reading exists, it says so.
Second, you can convert between AQI scales, but carefully. IQAir's US number describes only its main pollutant, so we work backwards to the concentration and then apply India's table. For Delhi, US 160 (driven by PM2.5) means about 69 µg/m³; China 144 (driven by PM10) means about 238 µg/m³. India's index takes the worse of the two: 192, Moderate.
Plain text
// Simplified from backend/shared/src/iqair.ts.// Each index -> its main pollutant's concentration -> India's sub-index; the highest wins.add(p.aqius, p.mainus, US); // US EPA scale (ozone on its 8-hour table)add(p.aqicn, p.maincn, CN); // China's scale (its ozone is 1-hour, so ozone comes from the US index only)for (const pollutant of ["pm25", "pm10", "o3"] as const) { const scale = IN[pollutant]; const aqi = Math.round(interpolate(concentration[pollutant], scale.conc, scale.index)); if (!best || aqi > best.aqi) best = { aqi, dominantPollutant: pollutant };}
The ozone detail matters. China's ozone index uses 1-hour averages, and putting those into India's 8-hour scale would turn Mumbai's ozone into "Very Poor". The final chain is Google, then IQAir (estimate), then a cached reading under 3 hours old, then "unavailable".
4.2 The advice lives in DynamoDB, not in code
Advice changes more often than code should be deployed. The six advice categories (Good to Severe, with their actions) live in a DynamoDB table, so we can reword the advice without redeploying.
That also means a typo could break the advice. So every load is checked with Zod: six categories, in order, no gaps. If the check fails, the built-in rules are used and the failure is logged.
4.3 A double tap must never send two alerts
On a phone with a weak network, people tap twice. Sending the same alert to a family twice wears out trust quickly. Each request carries an
Idempotency-Key , and DynamoDB enforces it with a conditional write:Plain text
ConditionExpression: "attribute_not_exists(PK)",ReturnValuesOnConditionCheckFailure: "ALL_OLD",
A retry of the same request gets the original alert back. Different content under the same key is rejected. A TTL cleans up the keys afterwards.
4.4 Infrastructure surprises
- CloudFront failed mid-deploy with
CREATE_FAILED, because our new account wasn't yet verified for it. Nothing had warned us. We made the website resources conditional in the SAM template (EnableWebsite, off by default) and moved the static site to another host, without touching the backend.
- SAM and esbuild in CI:
sam buildworked on our laptops and failed in GitHub Actions with "Cannot find esbuild". SAM bundles each function in a scratch folder, so esbuild has to be on the PATH. The fix was one global install in the CI job.
- CORS in three places: with REST APIs, CORS lives in the Lambda response, the method configuration and the gateway's error responses (for 401s from the authorizer). Adding
PUTfor profiles meant all three.
- A rotated key that wasn't picked up: Lambda keeps loaded values in memory between requests. When we switched to a new Google key in SSM, running functions kept using the old one, and the live site showed "unavailable". Now the key reloads every 15 minutes:
Plain text
if (apiKey !== undefined && Date.now() - apiKeyLoadedAt < KEY_RELOAD_MS) return apiKey;
4.5 Sign-in where the password never leaves the device
We use Cognito's SRP flow from the browser, through
amazon-cognito-identity-js . The browser proves it knows the password without sending it, and our API never sees it. Roles are added to the token by a Cognito pre-token-generation trigger, so permissions travel with the login.4.6 Designing for the person on the other end
Our most important user never opens VayuSaathi: the parent who gets a WhatsApp message.
- So messages lead with Hindi.
- The Notices page they land on is Hindi-first, with an Amazon Polly button that reads the alert aloud.
- Sharing to WhatsApp needs no account at all.
The Explain button can use AI to word things more naturally. Its output is shown only if guardrails confirm that every number comes from our data, the category is right, and there is no medical advice. Otherwise a reviewed explanation is shown.
4.7 Merging a teammate's redesign at the last minute
Our designer shipped a five-PR stacked redesign: a new theme, dashboard cards and a rotatable globe. It was built on code from before our SOS, profile and maps work. We merged the top of the stack once, kept his design and our features, and moved our changes into his rewritten components.
One decision mattered more than any merge conflict. His globe fetched model data straight from the browser, which would have contradicted the main card. We fed it from our own API instead, using the same reading for every city, with readings reused for up to 2 hours so the globe stays within Google's free allowance.
5. Keeping it free
We built on the AWS Free Tier and spent nothing:
- On-demand DynamoDB and arm64 Lambdas.
- Caching everywhere: 30 minutes per area, 24 hours for hospital lists, 7 days for place names.
- API Gateway throttling and quota caps on every paid third-party API.
- No paid SMS. SOS opens the phone's own SMS and WhatsApp with the message and live location ready to send.
6. What we'd tell ourselves on day one
- Find your user's real question first. Ours was never "what's the AQI?"
- Design for the receiver, not just the sender. Most of our impact happens on a phone that never installed the app.
- Never show a wrong number confidently. "Unavailable" is a valid answer.
- Put the things that change in data: advice in DynamoDB, keys in SSM, reference lists in an API.
- Let CI be the strict one. 197 unit tests, Playwright browser tests and template validation run on every push. CI caught a lint error and the missing esbuild before they reached a deploy.
7. What's next
- Forecast-aware plans: answer for 6 pm using the forecast for 6 pm.
- "Tell me when it gets bad": a new consumer of the
aqi.threshold.crossedevents already on our EventBridge bus.
- Air at schools: a Notices page per school, for parents.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article