AWS Builder Center
Saans: smog plans for Delhi schools, built on Strands + AWS

Saans: smog plans for Delhi schools, built on Strands + AWS

A bot that tells a Delhi principal at 18:00 what to change before tomorrow's smog. Rules decide, a Strands agent writes one line, DynamoDB keeps state.

Student
On 11 November 2025, Delhi's air hit AQI 425. The order moving young classes to hybrid arrived after school had started. Principals had no plan the evening before, and no fast way to tell parents.
Saans (Hindi for "breath") fixes the timing. At 18:00 IST it forecasts tomorrow's school-hours PM2.5, applies fixed rules, and sends the principal a Telegram plan with one button: Approve & send. Parents then get a Hindi-first notice and staff get a line per class.
Replaying that evening with only data published by 16:00, Saans forecasts a Severe school day for 11 Nov. That is at least 15 hours before the official order.

How it works

  1. Data. Hourly PM2.5 from six CPCB stations, through the OpenAQ API. If the CPCB feed is more than 6 hours stale, three corrected AirGradient sensors take over, and the message says so.
  1. Forecast. Pure persistence, made as of 18:00 from data available at 16:00. I tested a trend-adjusted baseline too. Persistence won the backtest, so that's what runs.
  1. Rules. Six rules in a YAML file, each citing EPA, CPCB or CAQM sources. They decide everything: PE indoors, outdoor activity cancelled on Severe days, windows shut.
  1. Message. A bilingual plan for the principal, with previews of what parents and staff will see.
  1. One tap. Approve posts to both channels. Skip sends nothing. An unanswered plan expires at 07:00.

Where Strands Agents fits

The model never decides anything. I used the Strands Agents SDK for one narrow job: a one-sentence summary of a plan that is already decided.
A model can still invent a time, a class name or a reassuring "it should be fine". So the summary passes through a validator before anyone sees it. It is dropped if it:
  • contains any number, time or class that is not in the plan,
  • names an air band the plan doesn't contain,
  • mentions GRAP, "official", "safe", "fine" or "normal",
  • runs over two sentences or 280 characters.
If the model fails or is rejected, the plan goes out without the sentence. The header line already carries the day, band and time window, so nothing is lost.
The model is pluggable. Locally I run Strands with Gemini's free tier. On AWS the same code uses  BedrockModel . Switching is an environment variable, because Strands hides the provider behind the same  Agent  call.

Where AWS fits

The state machine lives in Amazon DynamoDB, in a single table. A plan moves SENT → APPROVED → POSTED, or SKIPPED, or EXPIRED. Every transition is a conditional update from the expected state. That one choice removes a class of bugs:
  • a double tap on Approve can only post once,
  • a late Approve after the 07:00 expiry is rejected,
  • a webhook retry can't resend a finished plan.
The same table holds the rate limits and feedback for the public  /try  demo.
The deployment template (AWS SAM) defines the rest of the shape:
  • two AWS Lambda functions: the Telegram webhook behind a Function URL, and the scheduled job,
  • Amazon EventBridge crons at 18:00, 06:30 and 07:00 IST,
  • AWS Systems Manager Parameter Store for the bot token and API keys,
  • Amazon Bedrock for the summary model,
  • a CloudWatch alarm that emails me through SNS if no nightly run completes in 26 hours.
I'll be straightforward about status: the demo runs locally on DynamoDB Local with Telegram long polling, so it needs no AWS account. The Lambda, EventBridge, Bedrock, SSM and CloudWatch pieces are defined in  template.yaml  and ready to deploy, but I haven't deployed them yet. Locally and on AWS, the same handler code runs, because the local runner pushes every update through the same  webhook.handle  function.

Being honest about accuracy

Over 44 school days (Oct-Dec 2025), the forecast caught 86% of Very Poor-or-worse days with 11% false alarms. Severe days are harder: right about 60% of the time, with around 50% false alarms. For that reason the "GRAP Stage III likely" line goes to the principal and staff only, never to parents. Parents also never see class names, because asthma counts stay private. I also tested a feature that moves PE to cleaner afternoon hours. It failed its own pre-set test, so I removed it.

What I learned

  • Put the model at the edge, not in the middle. Rules decide, the model words, and a validator is the contract between them.
  • Conditional writes beat locks. DynamoDB's  ConditionExpression  made the plan lifecycle safe without extra infrastructure.
  • Write the failure message first. "No plan tonight, check the official app" is a better product than a confident guess from stale data.
  • Pick your honest numbers before you see the results. It made the backtest worth trusting.

Try it

Send  /try  to @SaansSchoolBot on Telegram to see the real 10 Nov 2025 plan in your own chat. The bot only answers while my local runner is up.
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