AWS Builder Center
A date instead of a fire: promising Punjab's farmers a machine, on AWS

A date instead of a fire: promising Punjab's farmers a machine, on AWS

Punjab farmers burn paddy stubble partly because nobody can tell them when a Super Seeder will come. Vaari gives each farmer a promised machine date, planned by OR-Tools CP-SAT on EC2, with Bedrock only allowed to phrase the WhatsApp reply. Here is the stack, simulated proof over 50 seasons, and every AWS error that fought back.

Four days, 91 commits, one product rule: never break a promise silently. The bugs that hurt us most were the ones that broke silently.
It is the last day of October in Sangrur, Punjab. Jaswinder Singh has 6 acres of paddy stubble in Balian and a short window to get wheat into the ground. The local Custom Hiring Centre (CHC) has a Super Seeder, a machine that sows wheat straight through the stubble so nothing needs burning. Jaswinder has called the centre about it. The answer is the one every farmer in the cluster gets: soon.
"Soon" is the problem. CEEW, who interviewed Punjab farmers about why they burn, found that uncertainty over when a machine will arrive is one of the main reasons. A matchbox costs nothing and works today. A machine that might come on Thursday, or might not, loses to it.
So we did not build another way to book a machine; Punjab already has the Unnat Kisan app for that. We built what should come after the booking and nobody provides: a date. A firm one if it is close, a window that narrows to a firm date if it is further out, and a one-sentence reason if it ever moves. We called it Vaari (ਵਾਰੀ, Punjabi for "turn").
The short version. An OR-Tools CP-SAT model plans every machine's next 10 days and treats every promise it has made as a hard constraint. A language model may phrase replies on WhatsApp, but code rejects any reply containing a number or date the solver did not produce. Cedar decides who sees what. It runs on EC2 with Docker Compose behind CloudFront, the web app on Amplify, with DynamoDB, SQS, Bedrock, Transcribe, Polly and Cognito. In 50 simulated seasons, a median of 68 farmers a season ran out of time under Vaari, against 332 under a phone queue, and Vaari did better in all 50.
Honesty first. Nobody can recruit real farmers in four days. Every farmer, field, machine and breakdown here is simulated, and the names are invented. The village names and positions (OpenStreetMap), the harvest timing (NASA FIRMS fire detections) and every cited figure are real. Assumptions are labelled as assumptions.



Part 1: The problem is not machines. It is a date.

PAU's Package of Practices puts the wheat optimum in the first fortnight of November and estimates that each week of sowing delay past it costs about 150 kg of wheat per acre. A typical CHC has 3 to 5 machines for a cluster of villages. Bookings come by phone and go out first come, first served. When a machine breaks, everyone behind it slips, and nobody is told. Each farmer is left counting down their own patience.
We designed for one person and one daily decision: the CHC operator, deciding what each machine does tomorrow. Vaari's answer is one line, and the operator approves it. Nothing a farmer sees changes until they do.

The operator's Today page: tomorrow's approved plan, 34 of 34 promises on track, each machine's stops as a lane, and the fields closest to their last waiting day on the right. "Simulated season" sits in the top bar on every page.
The farmer gets a numbered token, like the one you hold at a bank or a mandi, with a date on it. They need no account and no app: a farmer is their phone number. A village helper books for them with their consent, or the farmer registers with a one-time code. The date arrives on a signed link and on WhatsApp, in Punjabi, Hindi or English.

Left: the slot link a farmer opens without an account. Right: the farmer's own page, reached from a one-time WhatsApp login link. Same token, same date, same reason.



Part 2: Code decides. The model explains.

No date, number or assignment in Vaari comes from a language model. The engine is Python 3.12 with OR-Tools and no AWS imports, so it runs and is tested on any laptop.
  • Assignment (CP-SAT): each field goes to a (machine, day). Never before it is ready, never on a machine's down day, never on a rain day, and sowing plus travel must fit in the machine's day. Every promise already made is a hard constraint.
  • Two passes: first minimise fields sown after their burn deadline (ready day plus how long that farmer says they will wait), then minimise yield loss, travel and fairness slack.
  • Promises use a pessimistic speed. Sources disagree on Super Seeder speed: CEEW's interviews suggest about 5.3 acres in an 8-hour day, and an agriculture department camp reported 10 to 12. Promises use the 20th percentile of each machine's learned speed, so they hold on slow days. Routes use the median.
  • Breaking a promise is a mode, not an accident. After a breakdown, the re-plan keeps every promise hard. Only if that is impossible does it break the fewest, starting with the farmers who can wait longest, and tell them at once.

Super Seeder 2 goes to the workshop for two days. Before the operator approves anything, the draft says exactly what happens: 2 promises break (named, with their new window), 10 fields move inside their promise, and "No plan could break fewer promises (the planner proved it)."
"Why Thursday?" comes from the solver, not a model. We ask CP-SAT whether the field could go earlier, with every constraint switched on by an assumption literal. If the answer is no, CP-SAT returns the assumptions that make it impossible. We shrink that set, and it is the list of reasons ( explain.py ):
Python
core_indices = list(solver.sufficient_assumptions_for_infeasibility())
core = [literal for literal in literals if literal.index in core_indices]
# Deletion filter: drop each assumption that is not needed for infeasibility.
position = 0
while position < len(core):
trial = core[:position] + core[position + 1 :]
trial_status, _ = _solve_with(model, trial, seconds=SHRINK_STEP_SECONDS)
if trial_status == cp_model.INFEASIBLE:
core = trial
else:
position += 1
The output is codes and numbers ( RAIN ,  MACHINE_DOWN ,  DAYS_FULL ,  EARLIER_DEADLINES_AHEAD ), which templates turn into sentences in three languages.



Part 3: A WhatsApp agent that is not allowed to invent a date

Farmers live on WhatsApp. Meta's webhook hits the API, which checks Meta's signature, drops the message on Amazon SQS and returns 200. A worker container polls the queue. Menu taps run tools with no model involved. Free text goes to an agent built with the Strands Agents SDK on Amazon Bedrock (Nova 2 Lite), under three rules:
  1. Tools are bound to the phone. No tool takes a phone number or field ID; a field is "your 2nd field", resolved on the server and checked by Cedar.
  1. Changes need a tap. "I cannot wait longer" only drafts a change until the farmer taps Confirm.
  1. Every number is checked. Every number in a reply, and every month or weekday name in English, Punjabi or Hindi, must appear in this turn's tool results ( output-check.ts ). Otherwise the farmer gets a template built from the reasons list.
ts
for (const match of text.matchAll(/\d+(?:[.,]\d+)*/g)) {
if (!allowedNumbers.has(canonicalNumber(match[0]))) unsupported.push(match[0]);
}
return { ok: unsupported.length === 0, unsupported };
Voice notes work too: Amazon Transcribe hears the note and its language, the same agent turn answers, and Amazon Polly reads the answer back as a voice note.

A farmer books in Hindi on WhatsApp. The bot asks for the missing details, then replies with the token and the date. Every number in its reply came from a tool result.



Part 4: Who sees what, decided by Cedar

Authorization is Cedar, evaluated in-process, deny by default ( vaari.cedar ). The policies read like the product rules:
cedar
// A farmer's phone sees the fields booked with that phone, nothing else.
@id("farmer-phone-own-fields")
permit (principal is FarmerPhone, action in [Action::"ViewField", Action::"AskWhy", Action::"RequestSooner", Action::"ChangeBooking"], resource is Field)
when { resource.farmerPhone != "" && resource.farmerPhone == principal.phone };

// An officer never sees a farmer's contact details.
@id("officer-no-contact")
forbid (principal is User, action == Action::"ViewFarmerContact", resource)
when { principal.role == "officer" };
Operators see only their own centre. Helpers see only the fields they booked. A test asserts that a farmer's phone number never appears in a slot, officer or public response.

The district officer sees aggregates only, for their own district: 20 simulated centres, villages with fields at burn risk, and two suggested machine loans that would get every centre's fields done in time. No farmer names or phone numbers.



Part 5: What it runs on


Green: a farmer's WhatsApp message. Black: the web app. The engine has no published port; only the API and the worker can reach it, with a shared secret.
Layer
AWS service
Why
Web app
Amplify Hosting
Next.js with server rendering; tokens stay in httpOnly cookies, never in the browser
API edge
CloudFront
HTTPS for the EC2 API without a custom domain
API, engine, worker
EC2 + Docker Compose
The OR-Tools engine costs 0.4 s just to import and runs on every plan change, so we keep it warm, private and next to the API
Data
DynamoDB
One table per entity; plan approval is a conditional write, so approving twice returns 409
WhatsApp queue
SQS
Meta wants a fast 200; voice notes take far longer
Language
Bedrock (via Strands), Transcribe, Polly
Phrasing and voice only, never decisions
Staff sign-in
Cognito
Google federation; operator access only from a Cognito group
Deploy
ECR, SSM, CloudWatch
GitHub OIDC (no keys) → ECR → SSM Run Command (no SSH) → health checks, rollback on failure
Cost: $1.62 per month



Part 6: Proof, not a claim

The repo ships a season simulator, and the  /proof  page shows only numbers read from its output. It plays 50 simulated seasons over the Sangrur area: 409 real villages, 25 machine centres, 100 Super Seeders, about 2,400 farmers a season. Harvest days come from real NASA FIRMS detections (2024 and 2025), shifted 3 days earlier because fire follows harvest (assumed). Three policies play identical seeded seasons: a phone queue, nearest village next, and Vaari. A farmer not reached by their last waiting day burns.

Medians over 50 simulated seasons, with the 10th to 90th percentile under each value. Farmers not reached in time: 332 (phone queue), 331 (nearest village), 68 (Vaari). Income per machine rises from ₹2,47,950 to ₹2,70,370.
Over all 50 seasons, 78% fewer farmers ran out of time under Vaari, and it did better in every single season. Burned acres become PM2.5 using sourced factors: 2.63 t of straw per acre (Punjab Pollution Control Board) and 12.95 g of PM2.5 per kg burned (Gadde et al., 2009).
What Vaari does not win, also on the page:
  • Wheat lost is about the same. Vaari reaches farmers who would have burned, and some are sown after the optimum.
  • Its worst-off farmer does worse. Vaari moves patient farmers back to reach urgent ones, so in the median season its worst-off farmer is sown 8 days later than the phone queue would have managed.
  • The headline leans on one assumption: that a farmer with no date gives up 2 days sooner than one holding a promise. Nobody has measured that, so we sweep it. With it set to zero, the hotspot centre still sees 8 farmers burn under the phone queue against 4 under Vaari, from planning alone.



Part 7: What fought back

We logged every surprise with the exact error, the cause and the fix. The pattern was clear: loud errors were cheap. The expensive ones said nothing at all.
1. Saying "thanks" broke the bot. A real Hindi message, "मुझे एक मशीन बुक करनी है" ("I want to book a machine"), got: "Sorry, I can only help with your machine dates." Bedrock answered fine and the logs were clean. Our worker replaced any reply that called no tool with that fixed text, so the model's correct follow-up question was thrown away. After we fixed that, "शुक्रिया" ("thanks") still failed the number check, because it contains "शुक्र", the short word for Friday. JavaScript's  \b  treats Indic vowel signs as word edges, so we match whole words with Unicode classes instead:
ts
new RegExp(`(?<![\\p{L}\\p{M}])${escaped}(?![\\p{L}\\p{M}])`, "gu")
The log now records why a model reply was refused, so the next silence explains itself.
2. Every request returned 200, and the page still said the planning service was down. After a  git pull  on EC2, the API kept serving a stale compiled  dist/ . The web app's Zod parse failed on the missing fields, and the generic error blamed the engine. We moved to CI-built containers and hit two more surprises. Compose rejected our  .env  with  line 32: unexpected character "#" in variable name "q# memory ..." , a stray  q  that Node had been silently ignoring for days. Then containers could not get credentials ( Could not load credentials from any providers ). IMDSv2 answers one network hop by default, and a container on a bridge network is two hops away. Setting the hop limit to 2 fixed it, and  deploy.sh  now tests DynamoDB from inside a container before switching traffic.
3. Bedrock: the model is not in Mumbai, and IAM wanted a Region-less ARN. Nova 2 Lite in  ap-south-1  is listed In-Region: No, Geo: No, Global: Yes, so the only way in is  global.amazon.nova-2-lite-v1:0 . Then:  not authorized to perform: bedrock:InvokeModel on resource: arn:aws:bedrock:ap-south-1:<account>:inference-profile/global.amazon.nova-2-lite-v1:0 . Global inference needs three statements: the profile, the model in your Region, and  arn:aws:bedrock:::foundation-model/amazon.nova-2-lite-v1:0  with  aws:RequestedRegion = unspecified . Until that was added, the bot ran on menus and templates, which is how we know Vaari works with the model off.
4. A booking sent no WhatsApp message, three ways at once. Notices went out only after approval, not on booking.  DEMO_FARMER_PHONES=*  (added so sign-in codes show on screen) quietly made every phone a demo phone, and demo phones are never messaged. And Meta's temporary token had expired:  190 Authentication Error ... Session has expired on Friday, 09-Oct-26 10:00:00 PDT . Every booking now logs its notice outcome ( sent ,  demo account ,  failed ). We also caught a race there: re-reading the field straight after writing it, when DynamoDB  GetItem  is eventually consistent by default.
5. Local search could not find the fewest broken promises. After a breakdown plus rain, CP-SAT proved one promise had to break, but our fast search planner broke two on 7 of 20 seeds. Smarter re-packing found the minimum in 0 of 300 tries: it is bin packing with time windows. Now, whenever the search would break a promise, it asks CP-SAT for the proven minimum and continues from there: 20 of 20 seeds, with  breaksProvenMinimal: true .
Quick hits:
  • DynamoDB rejects NULL on an index key ( ValidationException: Invalid attribute value type ), so we omit empty keys and keep the indexes sparse.
  • Sort keys compare as strings, so  #10  sorts before  #2 ; we pad versions to 6 digits.
  • Polly has no Punjabi voice, and Transcribe's streaming language ID takes only PCM and FLAC (WhatsApp sends OGG/Opus), so voice notes go through S3 as batch jobs.
  • Chrome printed Punjabi months as "M11" (reduced ICU data), causing a hydration error.
  • Next.js cached a prefetched  307 → /signin  and replayed it after sign-in.
  • Shortening the plan horizon from 10 to 7 days broke 3 promises that reached past the new end. The engine was right about the request it got.
What
Before
After
Search planner matches brute-force optimum (200 instances)
180
198
Seeds where a re-plan broke more promises than necessary (of 20)
7
0
Engine container image
914 MB
570 MB
Redirects on  /welcome  with a stale cookie
infinite
2



Part 8: Try it in 60 seconds

  1. Open  https://main.d2n4tg60h10ven.amplifyapp.com/  and click Try as operator.
  1. On Plan, click Mark in workshop on any Super Seeder, then Make a fresh plan.
  1. Read What approving does: which promises move, which break, and the proof that no plan could break fewer.
  1. Go back and click Try as farmer to see the same field the way Jaswinder sees it.



Three things we would tell ourselves on day one

  1. Make every silence log its reason. Almost every expensive bug above returned 200 or sent nothing.
  1. Put the rule in code, not in the prompt. "Never invent a date" in a prompt is a wish. A function that refuses unsupported numbers is a guarantee.
  1. Make the baseline honest before you celebrate. Our first baselines were better than any real centre, so we rebuilt them from CEEW's numbers before trusting a single result.
When your system has to break a promise, who does it tell, and how fast?


Built in four days (8–11 October 2026) for the WeMakeDevs x AWS Environmental Hacks, Air track.
Every farmer, field, machine and breakdown in Vaari's demo and simulator is simulated; the names are invented. Village positions are from OpenStreetMap; harvest timing is from NASA FIRMS (satellites see heat, not intent, and Vaari never accuses a farmer of burning). Sources: PAU Package of Practices (Rabi); CEEW on stubble burning and Custom Hiring Centres; Punjab Pollution Control Board report to the NGT (OA 632 of 2023); Gadde et al., Environmental Pollution 157 (2009); Shyamsundar et al., Science 365 (2019).
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