Don't cancel play, move it: an AI duty officer for school air built on AWS
Delhi's 8 AM air is about twice as toxic as 1 PM air, yet schools hold PE at 8. How I built CleanHour with Strands Agents, Cedar, Step Functions and Bedrock, and why I had to correct the forecast against government stations.
The 8 AM problem
Every winter, Delhi's schools face the same choice: let children run in toxic air, or cancel their exercise for weeks. In November 2025, outdoor school sports were suspended across the NCR.
I wanted to know whether "ban or don't ban" was even the right question. So I pulled 15-minute data from two CPCB monitoring stations (Rohini and R K Puram) for the last two smog seasons. At Rohini, between 15 October and 30 November 2025, PM2.5 averaged 315 µg/m³ at 8 AM and 167 at 1 PM.
On a smog morning, cold air near the ground traps pollution under an inversion layer. As the sun heats the city, that layer lifts and the smog dilutes. By early afternoon, the same school sits in very different air.
Schools hold assembly and PE at 7:45–9:30 AM, the worst hour of the day.
On the median smog-season day, moving an 8 AM outdoor slot to the cleanest hour of the school day cut PM2.5 by 57% at Rohini (46–57% across both stations and both seasons). Across winter 2024-25 at Rohini, 8 AM air was unsafe for PE on 97 of 121 school days. On 45 of those days a later slot was safe: moving PE would have rescued it. On the other 52, nothing was safe, and the right answer was to keep children indoors.
The lever isn't the day. It's the hour.
What CleanHour does
CleanHour is an air-safety duty officer for a school. At 5:30 AM it:
- Reads the hourly PM2.5 and feels-like temperature forecast for the school's coordinates.
- Moves each outdoor PE period to the cleanest allowed hour, or in summer the coolest one. It respects playground capacity and teacher clashes, and younger classes pick first.
- Checks every outdoor slot against rules: GRAP stages, India's air-quality bands, stricter limits for classes 1–5, asthma-watch children (opted in by parents) and heat thresholds.
- Emails the principal a three-line brief. One tap approves it.
- Gives parents a Hindi/English WhatsApp message, a Hindi voice note and a page showing their child's day.
At 9:30 and 11:30 it re-checks the rest of the day. If the air worsens or GRAP is raised at noon, it can move the afternoon indoors on its own. It never makes a day riskier without a new approval.
Schools join by photographing their timetable.
Architecture
Plain text
EventBridge Scheduler (05:30 IST) → Step Functions "DailyPlan"Plan (Lambda) → forecast → optimizer → Cedar → Strands agent on Bedrock → DynamoDB → SNS emailIsQuiet? ── yes → Publish└─ no → WaitForApproval (task token, 75 min) → Publish (Polly voice note → S3)└─ timeout → AutoPublish (GRAP-required changes only)Amplify Hosting → API Gateway (Cognito JWT) → Lambda → DynamoDB / Step Functions / BedrockEventBridge Scheduler (09:30, 11:30) → re-check: tighten only
Design rule: the model writes words, code decides actions
I didn't want a language model deciding whether eight-year-olds go outside. So the optimizer is plain Python, and every outdoor slot passes through Cedar policies. Here is one:
cedar
@id("very_poor_exertion")forbid(principal, action == Action::"ScheduleOutdoor", resource)when { context.pm25 > 120 && resource.exertion != "low" };@id("poor_asthma_watch")forbid(principal in Group::"asthma_watch", action == Action::"ScheduleOutdoor", resource)when { context.pm25 > 90 };
Cedar returns which policy denied a request. I show those
@id s as "why" chips next to every change. A principal sees "3A PE moved 8:05 → 1:05 PM, because Very Poor air means no outdoor exertion", not "the AI decided".A second Cedar policy set handles staff authority on top of Amazon Cognito:
- Principals approve their own school.
- Teachers can view and ask, but not approve.
- District admins add schools but never approve on a school's behalf.
The Strands Agents SDK on Amazon Bedrock does what language is good at:
- writing the principal brief and parent notices from the plan's facts, with Pydantic structured output;
- answering questions like "Kya 7A ka practice 12:30 baje ho sakta hai?" through read-only tools that run the same Cedar checks;
- reading a photographed timetable into periods, classes and teachers.
It has no tool that can change a timetable.
Human approval with a deadline, no polling
Step Functions made the approval step easy to get right:
YAML
WaitForApproval:Type: TaskResource: arn:aws:states:::lambda:invoke.waitForTaskTokenTimeoutSeconds: 4500 # 05:30 → 06:45Catch:- ErrorEquals: [States.Timeout]Next: AutoPublish # only changes GRAP requires
The task token is stored with the plan in DynamoDB. The principal's tap calls
SendTaskSuccess .Two things bit me:
- A race: the plan could be ready before its token was stored.
- Double taps: two approvals arriving at once.
A DynamoDB conditional write (
status IN (pending_approval) ) fixed both. Only one approval can ever win.What fought back: the forecast was 2–4× too low
The forecast comes from the CAMS model, free through Open-Meteo. When I checked it against the CPCB station at Rohini, hour by hour, it read 1.2–1.6× low in early October and 2.5–4× low from late October through November. It misses local inversions and stubble smoke.
Because CleanHour's thresholds are absolute (no PE above 120 µg/m³), an uncorrected forecast would under-protect children on exactly the worst days.
The fix is a simple bias correction: hour-of-day factors from the nearest station, fitted per part of the season. The daily shape the model gets right; the level the station corrects. A script in the repo reproduces every number in this post from the raw CPCB files.
It's the most important thing I learned this weekend: check your data source against ground truth before you build thresholds on it.
The same engine, opposite direction
Heat breaks the "afternoon is better" assumption. So the forecast also carries feels-like temperature, and Cedar has heat rules: 35 °C for classes 1–5, 37 °C for sports, 41 °C for everyone. The optimizer knows why a slot is bad.
- Smog: it looks for the cleanest allowed slot.
- Heat: it looks for the coolest allowed slot.
Replaying 8 April 2025, Delhi's first heatwave of the year, CleanHour pulls 8A's PE earlier, from 11:45 to 9:25 AM (feels-like 38 → 33 °C). Same engine, all year.
Making impact measurable
With Powertools for AWS Lambda, every morning run emits CloudWatch metrics:
- PE periods moved;
- asthma-watch children kept indoors;
- estimated PM2.5 dose cut.
A CloudWatch dashboard turns those into an impact view, and an alarm pages me if the 5:30 run ever fails.
On the 6 November 2025 replay (measured 486 µg/m³ at 8 AM, 85 by 1 PM, while GRAP Stage II still allowed outdoor PE), five PE periods move. Average PE air drops from about 368 to 88 µg/m³, and no PE period is cancelled.
AWS I used
- Open source: Strands Agents SDK, Cedar (via cedarpy), Powertools for AWS Lambda, AWS SAM.
- Services: Amazon Bedrock (Nova 2 Lite), AWS Step Functions, Amazon EventBridge Scheduler, AWS Lambda, Amazon API Gateway (HTTP API + JWT authorizer), Amazon Cognito, Amazon DynamoDB, Amazon Polly (neural Hindi voice "Kajal"), Amazon SNS, Amazon S3, Amazon CloudWatch, AWS Amplify Hosting.
Honest limitations and what's next
- The bias factors come from one station and one season. December–February are extrapolated.
- India's air-quality bands are defined on 24-hour averages; applying them hour by hour is a planning approximation.
- Dose estimates use assumed breathing rates.
Next:
- a live morning nowcast from the nearest station;
- more languages (the agent writes from structured facts, so Punjabi or Bengali is a prompt change);
- a pilot with a real school.
CleanHour was built during the Environmental Hacks weekend of the Bharat Builds Tour, with Claude Code as my coding assistant.
Not medical advice. Air data: CPCB monitoring stations (via OpenCity) and Open-Meteo (CAMS, CC BY 4.0).
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article