AWS Builder Center

The Complaints Nobody Connected

In Indore, people complained about foul water for about two weeks before anyone got sick. We built a WhatsApp bot that adds those complaints up. It took four days, one SAM template, and a Bedrock account we wrongly thought we weren't allowed to use.

The Complaints Nobody Connected

In Indore, people complained about foul water for about two weeks before anyone fell ill. Nobody added the complaints up. We built PaaniAlert to do exactly that, in four days on AWS. Along the way our AI scored 42% until we stopped letting it decide anything, and we spent two days convinced we were blocked from Bedrock when we were knocking on the wrong door.


In mid-December 2025, families in Bhagirathpura, Indore, noticed their tap water had turned foul and discoloured. By Christmas, many households could taste the bitterness and smell it clearly, and kept drinking it anyway, because there was nothing else to drink. On 27 December people started vomiting. Two days later the mayor confirmed the first deaths.
Residents had told officials, more than once. But each complaint, on its own, looked like one household with bad water, which happens every week in every Indian city. Nobody added them up.
Around 1,400 people fell ill. By February the health department had been told of 35 deaths in the area, and considered 22 of them possibly linked to the water. On 1 October 2026, a one-member judicial commission reported that the contamination "could have been prevented": work to replace the damaged pipelines had been held up by tender delays of up to 859 days.
PaaniAlert is our attempt at the part nobody did. It doesn't test water or fix pipes. It counts complaints, and it makes sure the right people hear about them.
The short version: residents report bad water on WhatsApp or from a web page, in Hindi, English or Hinglish, by tapping, typing, sending a photo or speaking. When enough different households in the same few streets report it within 48 hours, PaaniAlert tells everyone nearby to boil their water and emails the ward engineer. If nobody acts within 24 hours, it emails the district health officer. Officials can also issue a warning themselves, see exactly how many people it will reach, and send an all-clear when it's fixed.
[LINKS: Demo video · Try it (/report) · Officials' dashboard · GitHub]



Part one: the warning already exists

India's disease surveillance works from health facilities. It sees an outbreak once patients turn up at a clinic and get recorded. By then the water has been bad for days.
The complaints come first. In Bhagirathpura, the smell and colour came about two weeks before the first patient. That gap is the whole product. Nobody needs a sensor in every pipe. The signal is already being sent, in WhatsApp groups and at councillors' offices, by the people drinking the water. It just never reaches anyone as a pattern.
So we gave ourselves one rule for the weekend: don't ask anyone to do anything new. Residents already complain on WhatsApp. Officials already read email. PaaniAlert sits in between and does the arithmetic.



Part two: two front doors, and neither needs an account

This is what a real complaint looks like:
Plain text
Nal ka paani peela aa raha hai, badboo hai, 2 din se

"The tap water is coming out yellow, it stinks, for 2 days." Roman-script Hindi, no punctuation, a number buried in the middle. Others come in Devanagari, in English, in Hinglish with creative spelling ( bdboo ,  gnda ), or as a voice note recorded over a running tap.
So PaaniAlert takes reports two ways.
WhatsApp, because that's where people already complain. They send text, a photo, a location pin or a voice note, and get a reply in their language.
A web page,  /report , because our WhatsApp number is a Twilio sandbox, and every user has to send a join code first. That's fine for a demo and terrible for a crisis. The web page needs nothing: open a link, report, done. It installs to the home screen like an app, and we wrapped it as an Android app too.
On the web page, people don't have to type at all. They tap what's wrong: a smell tile, real colour swatches for yellow, brown, black and cloudy, a taste tile, and "someone is sick" with a counter. Then they set a location and press send. Typing and recording still work for anyone who prefers them.

We designed the resident pages for where they'd actually be used: outdoors, on a cheap Android phone, in bright sun. That means a light theme, big text, colour only where it carries meaning, and Hindi treated as a first-class language rather than a translation pasted on top.
After sending, people see what we understood, what to do right now (boil water, ORS, call 108 for blood in stool or dehydration, with a button that dials it), and an offer to be warned about their area.




Part three: Twilio gives you fifteen seconds

Our first worry was boring and ended up shaping the backend. Twilio calls your webhook for every WhatsApp message and gives up after about fifteen seconds. A model call, let alone transcribing a voice note, can take longer than that.
So the webhook does almost nothing. It checks Twilio's signature, puts the message on an SQS queue, and replies at once:
Python
ACK = "Got it, checking… / Mil gaya, dekh rahe hain…"

A worker Lambda takes the message off the queue and does the slow part. If the worker fails, the message waits in the queue and is retried, so it never disappears. Each Twilio message ID is recorded too, so a retry is never saved twice.
The same lesson came back with voice. Amazon Transcribe handles Hindi and Indian English well, but a batch job takes 30 to 45 seconds even for a five-second clip, mostly start-up time. API Gateway gives a web request 30 seconds. So voice reports from the web page are asynchronous: the page uploads the recording, gets a receipt straight away, and shows the steps ticking off ("Uploaded", "Listening…", "Reading your report") while it waits, along with a line saying the report is already safe and they can close the page. Streaming transcription would be near-instant, and it's next on our list.



Part four: the model reads, the code decides

This is the decision we're happiest with. The language model never decides whether to raise an alert. It only reads messages. Telling a few thousand people their water may be unsafe is too serious to leave to a prompt.
The cluster rule is a plain Python function with no AWS calls in it, so the tests, the scheduled job and the Indore replay all run exactly the same code:
Python
WINDOW_HOURS = 48
WATCH_PHONES, WATCH_SEVERITY = 3, 10
ALERT_PHONES, ALERT_SICK_HOUSEHOLDS = 5, 2

def severity(r: Report) -> float:
score = 0.0
if r.smell == "sewage":
score += 3
if r.colour not in ("clear", "unknown"):
score += 2
if r.taste in ("bad", "salty"):
score += 1
if (r.sick_count or 0) > 0:
score += 4
if r.photo_key:
score += 1
if r.clears_quickly:
score /= 2 # dirty only for the first minutes of supply is common and less worrying
return score

Every 15 minutes, Amazon EventBridge runs it over the last 48 hours of reports. Each choice in it came from asking what could go wrong:
Worry
What the rule does
One angry neighbour sends ten messages
It counts distinct phones, not messages
Water is murky for a minute after supply resumes, which is normal with intermittent supply
A report of water that clears quickly counts half
The city has announced pipeline work in the area
A maintenance notice caps the area at Watch, never Alert
An outbreak sits on the edge of a map grid cell
Each cell is checked together with its 8 neighbours (a few hundred metres)
An official checks a report and finds it's false
Reports marked false are left out of the rule
Illness is the strongest signal
2 households reporting someone sick is an Alert straight away
Three different phones make a Watch: the area turns amber on the officials' map. Five phones, or two sick households, make an Alert: subscribers nearby are told to boil their water, Amazon SNS emails the ward engineer, and a 24-hour escalation clock starts.
The thresholds are guesses, and we say so. They're constants at the top of one file, with tests around them, so a city that knows better can change them in one line.
One bug taught us about identity. A cluster was named after its strongest grid cell, so when an outbreak grew into the next cell, the system decided it was a new outbreak and warned the same street twice. Now every new result continues whichever open cluster it overlaps, keeping its name, its status and its alert history.



Part five: closing the loop with officials

A warning system that only shouts is half a system. The officials' dashboard is where the loop closes.

Officials see every report: what the person said (or the transcript of what they said), the photo, the place, which method read it, and whether it came from WhatsApp or the web. They can mark a report Reviewing, Resolved or False. They can acknowledge an incident, mark it fixed, or call it a false alarm.
They can also issue a warning themselves, before the automatic rule fires. Pick a report or incident, choose "boil water" or "do not use", and choose a radius of 500 m, 1 km or 2 km. The circle is drawn on the map, and before anything is sent the dialog says plainly: "This will warn N people." Then a second confirmation. Warnings go out on WhatsApp and as push notifications to phones that turned on "Warn me about my area".

[SCREENSHOT: dashboard-warning-1440.png, the Issue Warning dialog with the radius and recipient count]
When the pipe is fixed, the official lifts the warning and everyone who was warned gets an all-clear. We added that after realising a warning that never ends just teaches people to ignore warnings. And if complaints keep arriving after something was marked fixed, the incident reopens and alerts again.
Every action lands in an Activity log: each advisory, each email, each escalation, each "fixed", and who did it. When something goes wrong, you can see exactly who was told what, and when.



Part six: the day Bedrock said no (it hadn't)

On Thursday, the first day, every Bedrock call from our code came back with "Operation not allowed". Our account had an overdue invoice, so we assumed that was the reason.
We did two things. First, we made the pipeline work without a model.  fallback.py  is about a hundred lines of regular expressions for Hindi, Devanagari, English and Hinglish: badboo, peela, dast, ulti, number words like teen and paanch, and negations like badboo nahi. It's weaker than a model, but reports, clusters and alerts keep working when the model is down. A water-warning system that stops warning because of a billing problem isn't one.
Second, we planned to borrow a friend's Bedrock through an IAM role that would trust only our worker. We wrote the whole setup, and never needed it.
On Saturday we noticed something odd. Bedrock's chat playground worked fine in our console; only our code was refused. It turns out Bedrock has two inference endpoints.  bedrock-runtime , which our code called, said no.  bedrock-mantle , the newer OpenAI-compatible one the console uses, said yes. Two days of believing we were blocked came down to calling the wrong door.
Moving to Mantle in Mumbai ( ap-south-1 ) turned out better than our original plan anyway: message text now never leaves India. We also dropped the stored API key; the worker gets short-lived tokens from its own IAM role.
Our original model wasn't offered there, so we tested the candidates on unseen Hinglish and Devanagari messages:
Model
Result
Decision
Qwen3 235B
Read every message correctly
Primary
Qwen3 VL 235B
Read every message correctly, also reads images
Backup
DeepSeek V3.1
Fine on 2 test messages, saved only 2 of 50 later
Dropped
DeepSeek V3.2
Replied "saved" without saving anything
Rejected
Gemma 3 27B
Never called the tool
Rejected
DeepSeek V3.2's failure is the frightening kind. A resident told "your report is saved" when nothing was stored is worse off than one who got an error. So the code now checks: if a message is a complaint and no report exists afterwards, that model has failed and the next one gets a turn. If every model fails, the keyword rules save it. A complaint is never lost, and every report records which method read it.



Part seven: 42% until we took the decision away

We wrote 50 labelled test messages: 15 in Roman-script Hindi, 10 in Devanagari, 10 in English, 10 in Hinglish with typos, and 5 negations ("no smell, just a bit cloudy"). A message only counts as correct if all five fields match: smell, colour, taste, how many days, and how many people are sick.
The agent's first score was worse than our regular expressions: 21 of 50, at 3 seconds a message. The values it extracted were usually right. The problem was that it often didn't save at all. Asked to hold a conversation and decide when to call  save_report , the models kept asking for a location pin instead of saving the complaint.
So we took that decision away from the model. Now every message is read with forced extraction: the model has to fill a schema whose fields are the exact allowed values. Code decides whether it's a complaint, saves it, and writes the reply from fixed templates, including a one-line "here's what I understood" so the resident can correct us.
How the bot reads a message
All five fields right
Median time
Keyword rules (tuned on this same set)
50 / 50
instant
Agent decides when to save
21 / 50 (42%)
3.0 s
Forced extraction, code saves
45–46 / 50 (90–92%)
1.2 s
The remaining misses are honest Hindi problems: khara (salty) read as a general bad taste, a misspelt pila (yellow) missed, keede (worms) read as cloudy water. And a caveat we keep repeating: the keyword rules were tuned on these 50 messages, and the schema's hints use the same everyday words, so both numbers are upper bounds. A fresh test set written by someone who hasn't seen the rules is the number to trust.
[TODO: if you collect real messages from testers, add their score here.]



Part eight: would it have caught Indore?

We rebuilt the Bhagirathpura timeline from news reports and played it, hour by hour, through the same rule that runs in production. Everything here is a SIMULATION, and two of its inputs are our guesses: that about 1,000 households were on the contaminated supply, and how their complaints were spread across December. Nobody published either number. We also don't know how many residents would have used PaaniAlert, so we ran three levels:
If this share of households reported
First Watch
First Alert
Alert before first illness (27 Dec)
Alert before deaths confirmed (29 Dec)
2% (20 households)
25 Dec, 17:00
26 Dec, 15:00
0.4 days
about 3 days
5% (50 households)
25 Dec, 12:00
26 Dec, 08:00
0.7 days
about 3 days
10% (100 households)
16 Dec, 11:00
25 Dec, 12:00
1.5 days
about 4 days
Across 100 random runs for each level, the Alert came before the first illness every time at 5% and 10%, and in 90 runs out of 100 at 2%.
The honest reading is less dramatic than we hoped. The Alert doesn't come weeks early. It comes a day or so before the first person falls ill, because it fires when five different homes complain about taste and smell within 48 hours, and in Bhagirathpura that happened around Christmas. But that's still the point: the alarm is triggered by complaints, not patients. In the real outbreak, the official response came after people had died. In the simulation, the ward engineer is emailed, and every subscriber is told to boil their water, days before that.
We also played three scenarios that must not raise an alarm: one person sending ten complaints, murky water that clears after a minute in four homes, and six homes complaining inside an area with a published maintenance notice. All three stopped at Watch. A separate test shows the third one would have been an Alert without the notice, so that rule is doing real work.
This doesn't prove PaaniAlert would have saved anyone. That depends on residents using it and on an official acting on an email. What it shows is that the warning was in the complaints, and that a rule simple enough to read in one sitting would have caught it.



The part that keeps people safe from us

A map of who complained about their water, with phone numbers attached, is a sensitive thing to hold. An official sometimes needs to call a resident back, though, so we split the two.
Reports and clusters only ever see a keyed hash of a phone number:
Python
def phone_hash(raw: str, key: str | None = None) -> str:
key = secret("hmac_secret") if key is None else key
return hmac.new(key.encode(), normalise_number(raw).encode(), hashlib.sha256).hexdigest()

An Indian mobile number has only about ten billion possibilities, so a plain SHA-256 hash can be reversed by trying them all. A keyed HMAC, with the key in SSM Parameter Store, can't be reversed without the key.
The full number lives in one place, a separate Contacts table. On the dashboard a report shows the person's WhatsApp name and a masked number (+91 98•••••210). Seeing the full number takes a click on "Show number", and that click writes an audit row first. Web reports come with no name or number at all, just a random ID on the phone. The public map shows only clusters, rounded to about 100 metres, never individual reports. And warnings to residents always need two clicks and a recipient count first.



What we cut

Some of our best decisions were things we didn't build.
We had planned a TDS meter check: photograph your water purifier's meter and learn whether you need an RO system. We'd designed the whole thing, with a vision model reading the display, a confirmation step and the BIS limits. Then we asked the obvious question: how many households in Bhagirathpura own a TDS meter? Very few. A feature for people who already have the equipment would have looked good in the demo and helped almost no one. Worse, a TDS meter can't detect sewage or germs, the very things that made people sick, so a "good" reading could give false comfort. We cut it.
We also never built logins for residents, a chatbot that "answers questions about water", or any hardware. Each would have added a step between a worried person and a report.



What it runs on

The backend is one AWS SAM template in Mumbai ( ap-south-1 ):
  • API Gateway + Lambda: the Twilio webhook, the WhatsApp worker, the web report API, the cluster check and the dashboard API
  • SQS between the webhook and the worker, so nothing is lost when the model is slow
  • Amazon Bedrock through the  bedrock-mantle  endpoint (Qwen3 235B), called with the Strands Agents SDK, with short-lived IAM tokens
  • Amazon Transcribe for voice notes and web recordings, in Hindi and Indian English
  • Amazon Location Service to turn pins into place names
  • DynamoDB for reports, clusters, subscribers, contacts, warnings, the audit log and the activity log; S3 for photos and recordings
  • EventBridge to run the cluster check every 15 minutes
  • SNS to email ward engineers and health officers
  • Amplify Hosting for the React + MapLibre dashboard and the report page, with web push via a service worker
  • CloudWatch for logs, and SSM Parameter Store for every secret
Cost so far: [TODO: check Billing → Bills and add the real number].



Three things I'd tell myself on day one

When something fails, find out exactly why before building around it. We spent two days believing our account was blocked, when we were calling an endpoint it couldn't use. The keyword fallback we built in the meantime started as a workaround and ended up as a feature, but we got lucky.
Let the model read, and let code decide. The model understands "nal se kala paani aa raha hai" far better than our regular expressions do. But when we let it also decide whether to save, accuracy was 42%. When code decided, it was 90%. And whether a neighbourhood is told to boil its water is decided by about forty lines of Python you can read, test and argue with.
Ask who will actually use it before you build it. The TDS check was clever and nearly finished. The tap tiles were simple, and they're what a tired person on a cheap phone will actually use. The best feature is the one that removes a step, not the one that adds a capability.


PaaniAlert was built in four days by Team Crystal Red (J Lokesh Rao, Jaya Ruthvik Parasu and Shrikar Tadkasat) for  Environmental Hacks  by WeMakeDevs and AWS, in the Heat and Water track.
Try it: [/report link] · WhatsApp: message +1 (415) 523-8886 with  join either-tried , then report in Hindi, English or Hinglish · [GitHub link] · [Demo video]
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article