The river was normal. My flood alert called it an emergency.
How separating judgment, translation, escalation, and self-monitoring into five flows made a flood warning system trustworthy

On the morning of 10 June 2026, the Global Flood Awareness System forecast the Brahmaputra river at Dhubri, Assam to peak at 27,924 cubic metres per second over the next three days. For that river, in that month, this is an unremarkable reading. The river’s own 42-year history says nothing interesting begins until the flow passes 52,000. The first version of my flood alert bot classified 27,924 as an emergency, and its schedule would have repeated that emergency to my family’s Telegram group every thirty minutes, forty-eight times a day.
The bot was not broken. Every execution was green. It did exactly what I asked, which is the most dangerous thing a pipeline can do when you have asked for the wrong thing.
Let me back up to why I was building a flood bot at all.
Last year I visited Assam. What stayed with me was not the scenery. It was learning how routine the disaster is: people there lose their homes to the rivers every single year, rebuild, and wait for the next monsoon to do it again. Flooding is not an event in Assam. It is a season.
Back home, I kept reading about it, and one fact refused to leave me alone. These floods are not surprises. India’s Central Water Commission runs flood forecasting at 350 stations and publishes warnings with up to 24 hours of lead time on a public website. The forecast usually exists before the water arrives. It sits on a government dashboard, in English, measured in cubic metres per second. Meanwhile the 2025 flood wave affected over 2.4 million people across 30 districts, and the IDMC counted 2.5 million flood displacements in Assam in its 2025 global report. In Dhubri district alone, 775,721 people were affected. The people in the water’s path are on Telegram and WhatsApp, mostly in Assamese, and none of them are refreshing a hydrology dashboard at 2am.
Disaster researchers call this the last mile problem, and they have been writing about it for years. This June, with the 2026 monsoon arriving, I tried to build that last mile for exactly one point on one river. Not a global aggregation platform, not another dashboard. One river, one set of coordinates, one family group chat.
Here is what the project taught me, up front:
- A threshold you borrow is a guess wearing a number’s clothing. A threshold you calibrate from the river’s own history costs about 30 lines of YAML and 15 of Python, and it reversed the bot’s verdict on day one.
- An alert without memory is a metronome. One key in a state store turns “48 warnings a day” into “a message when something changes.”
- An LLM in a warning pipeline should translate, never judge. The judging belongs in YAML you can read aloud.
- Every failure path in a safety system needs a decided direction, open or closed. I had to pick one for the AI, one for the human reviewer, and one for the pipeline itself.
Version 1: fetch, compare, regret
The data source is the Open-Meteo Flood API, which serves river discharge data from GloFAS, the EU’s Global Flood Awareness System, on a roughly 5 km grid. Free, no API key. Discharge is the volume of water passing a point each second; think of it as the river’s pulse. One GET request returns a forecast for any coordinates on a major river:
https://flood-api.open-meteo.com/v1/flood?latitude=26.02&longitude=89.98&daily=river_discharge&forecast_days=3
My first flow was the napkin sketch, almost line for line:
id: river_alert_naive
namespace: assam.floodwatch.v1
variables:
family_chat: "-1001234567890"
tasks:
- id: fetch_forecast
type: io.kestra.plugin.core.http.Request
uri: "https://flood-api.open-meteo.com/v1/flood?latitude=26.02&longitude=89.98&daily=river_discharge&forecast_days=3"
- id: check_threshold
type: io.kestra.plugin.core.flow.If
condition: "{{ (outputs.fetch_forecast.body | jq('.daily.river_discharge[0]') | first) > 20000 }}"
then:
- id: send_alert
type: io.kestra.plugin.notifications.telegram.TelegramSend
token: "{{ secret('TELEGRAM_BOT_TOKEN') }}"
channel: "{{ vars.family_chat }}"
payload: "⚠️ Brahmaputra discharge above 20,000 m³/s"
triggers:
- id: every_30_min
type: io.kestra.plugin.core.trigger.Schedule
cron: "*/30 * * * *"
Ten minutes to write. It ran perfectly, which was the problem.
I caught it before my family did, and not by being smart. While testing, I compared the live forecast against my threshold by hand. The river stood at 27,924 m³/s, an ordinary monsoon reading, roughly half of what this river considers dangerous. My bot saw a flood. It would have seen one on most days of any normal monsoon, two to five waves of which hit Assam every year. I had built a machine for crying wolf.

Sitting down with the v1 YAML, I found three mistakes stacked on top of each other, and not one of them would have appeared in a code review as a bug.
I was polling a daily product every 30 minutes. GloFAS updates its forecast once per day. Of my 48 daily polls, 47 could only re-read yesterday’s answer. I had confused checking often with knowing more.
My threshold was a unit error wearing a trench coat. The 20,000 came from a quick search, and it is roughly the Brahmaputra’s annual average discharge. A Himalayan river in monsoon runs above its annual average most days. That is what a monsoon is. The number I actually wanted, the official danger mark for Dhubri, turned out to be unusable for a sneakier reason: CWC publishes danger levels as river stage, the water’s height in metres (28.62 m at Dhubri). GloFAS gives discharge in m³/s. A height and a flow rate are different physical quantities. I had been comparing a level to a speed and feeling productive about it.
The flow had no memory. It alerted whenever the river was high, not when something changed. The river does not stop being high between polls, so every poll during a high spell was a fresh warning about old news.
Version 2: five flows, five ways to fail
The rebuild is five flows. Before you raise an eyebrow at the count: each one exists because it fails differently from the others. Calibration goes stale. Monitoring misjudges. Translation hallucinates. Escalation waits on a human who might be asleep. And the whole pipeline can die silently. Five failure modes, five YAML files, each readable in about a minute. The full project is in the repo linked at the end.

Calibrate: ask the river what abnormal means
GloFAS reanalysis data goes back to 1984, served by the same free endpoint. So a weekly flow downloads the full history for my exact coordinates (42 years of daily values arrive in 2.3 seconds, which still amuses me) and computes percentiles of the monsoon-season distribution:
- id: compute_thresholds
type: io.kestra.plugin.scripts.python.Script
containerImage: ghcr.io/kestra-io/pydata:latest
inputFiles:
history.json: "{{ outputs.fetch_history.body }}"
script: |
import json
from kestra import Kestradata = json.load(open("history.json"))
monsoon = sorted(
v for d, v in zip(data["daily"]["time"], data["daily"]["river_discharge"])
if v is not None and int(d[5:7]) in (6, 7, 8, 9) # June to September
)
pct = lambda p: monsoon[round(p / 100 * (len(monsoon) - 1))]
Kestra.outputs({"watch_level": pct(90), "danger_level": pct(98)})
Note the filter. The percentiles come from monsoon-season days only, because the distribution you alert against has to be the distribution you alert during. Include February in the baseline and every June looks like an emergency.
Two values land in Kestra’s KV store, a small key-value state store that lives outside any single execution. For Dhubri, 3,548 monsoon-season days of history produced a WATCH level of 52,480 m³/s (the 90th percentile) and a DANGER level of 64,564 m³/s (the 98th).

Put those numbers next to v1 for a second. My borrowed threshold was 20,000. The river’s own data says even mildly interesting starts at 52,000. I did not need a better alert. I needed a better definition of abnormal, and the river had been holding it for 42 years.
One honest footnote: the first calibration run took 3 minutes 22 seconds, of which 3 minutes 18 were Kestra pulling the Python container image. The arithmetic itself is sub-second. First runs lie about steady state.

Monitor: transitions, not levels
The monitor runs every six hours, which is honest about how often the upstream data actually changes. It reads the next three days of forecast and takes the worst case across GloFAS’s ensemble members, river_discharge_max rather than the median. For a dashboard you want the median. For a warning you want the pessimist, because a false positive costs an apology and a false negative costs more than that.
Then it classifies the peak against the calibrated thresholds, and here is the entire fix for the wolf crying, one remembered value:
- id: last_state
type: io.kestra.plugin.core.kv.Get
key: LAST_SEVERITY
errorOnMissing: false
- id: on_transition
type: io.kestra.plugin.core.flow.If
condition: "{{ (outputs.last_state.value ?? 'NORMAL') != outputs.severity.value }}"
Severity changed: say something. Severity held: log and exit. A de-escalation counts as a transition too, because “water expected to recede” is a message people genuinely want. The whole decision, fetch included, executes in 2.6 seconds, four times a day.


Translate: the AI never decides
Only now does an LLM enter, and it enters in handcuffs. The alert subflow hands Google’s Gemini a decided severity and the numbers behind it through Kestra’s AIAgent task. The first rule of the system prompt is the design philosophy of the whole project:
- id: ai_compose
type: io.kestra.plugin.ai.agent.AIAgent
provider:
type: io.kestra.plugin.ai.provider.GoogleGemini
modelName: gemini-2.5-flash
apiKey: "{{ secret('GEMINI_API_KEY') }}"
systemMessage: |
You translate river flood bulletins for families in Assam, India.
Output: 2 or 3 short sentences in Assamese, the same in simple
English, then exactly two concrete actions. Maximum 700 characters.
The severity level given to you is FINAL. Never upgrade, downgrade,
soften, or second-guess it. You translate; the orchestrator judges.
The severity decision itself lives upstream, a ternary over two KV reads. Boring on purpose. Anything that decides whether a family gets warned should be code you can verify by reading it.
Even that boring line cost me a failed execution, and the failure is worth sharing. My first version piped the KV reads through Kestra’s number filter, out of reflex, because everything coming out of a template feels like a string. But the KV store is typed. I had stored the thresholds as NUMBER, so kv('DANGER_LEVEL') returned an actual Double and the filter refused it: 'number' filter can only be applied to String. Actual type was: java.lang.Double. The fix was deleting code, not adding it. I have written enough stringly-typed pipeline glue that a state store which preserves types broke my intuition. It is a better intuition now.

And because a warning system that goes quiet when an AI provider is down would be the deadliest bug in the project, the fallback is structural rather than defensive. A flow-level errors branch sends the raw numbers through the same Telegram task:
errors:
- id: send_plain_fallback
type: io.kestra.plugin.notifications.telegram.TelegramSend
token: "{{ secret('TELEGRAM_BOT_TOKEN') }}"
channel: "{{ vars.family_chat }}"
payload: |
🔴 RIVER ALERT, {{ inputs.station }}
Level: {{ inputs.severity }} | Forecast peak: {{ inputs.peak_m3s }} m³/s
(Automatic message, translation service unavailable.)
I tested this by feeding the flow a deliberately broken Gemini key. The AI task failed, the errors branch fired, and the plain message went out anyway. Less friendly, still alive.
Escalate: an approval gate that fails open
DANGER severity brings in a second audience, a wider community group where one false alarm burns trust you do not get back. I wanted a human glance before anything reaches it. But a flood warning cannot block forever on a human who might be asleep. Kestra’s Pause task gave me both halves in two lines:
- id: approval_window
type: io.kestra.plugin.core.flow.Pause
timeout: PT15M
A reviewer gets 15 minutes to check the numbers and resume the execution. If nobody does, the Pause times out and fails, which routes into the errors branch, which sends the escalation anyway, labelled as unreviewed. Most approval gates fail closed: no human, no action. A flood warning has to fail open: no human, send it, and say that no human checked it. I wrote no timer logic at all. The failure routing is the feature.
Watch: who alerts the alerter
The scariest failure mode in this whole system is silence. If the monitor stops running, no alert fires and nothing looks wrong until the water arrives. So the fifth flow listens for any FAILED execution in the floodwatch namespace and pings me on Telegram. One placement decision worth stealing: it lives in a different namespace, assam.ops, outside the one it watches. If it listened to its own namespace, a failure inside the watchdog would trigger the watchdog. Recursion is a bad look for a safety system.
It earned its keep on day one, in a way I did not plan. When the Double-type error killed a monitor run, the watchdog fired in the same second, entirely on its own. Then its alert to me bounced, because I had not yet configured the real bot token it needed. The watcher worked; its voice did not. Which is its own lesson: a watchdog is only as alive as its delivery channel, so the first thing to verify after deploying one is the watchdog’s own outbox.




What this is not
GloFAS discharge on a 5 km grid is not a gauge reading, and discharge is not inundation: how much water passes a point does not tell you whose floor it crosses. This system watches one point, for one community, as a supplement. If you live in a flood-prone area, official ASDMA and district administration channels come first. And my Assamese output still needs a native speaker’s verdict, which I am actively seeking. If the translation turns out to be mediocre, that will be a finding, not an embarrassment.
What I would tell you to steal
Not the flood bot. The boundaries.
Calibration, judgment, translation, escalation and self-monitoring are five jobs with five different failure modes, and the only reason a side project could afford to separate them is that each one cost a YAML file instead of a microservice. The orchestrator holds the state, the schedules, the retries and the failure routing. Python does arithmetic. The LLM does language. Humans do review. Each part is allowed to fail without the system going silent, and I can point at the exact lines where each failure lands.
If your alerting system, whether it watches a river or a Kafka topic, cannot answer “what happens when this part fails” by pointing at code, it has the v1 disease. Mine did. The fix was never smarter code. It was admitting that an alert is a state machine and giving the state somewhere to live.
The forecast for Dhubri existed before I wrote a line of YAML. It exists tonight, on a government dashboard, in units most of the people downstream will never read. The last mile turned out to be the cheap part. That is the uncomfortable lesson, and the actionable one.
Rajdeep Singh is a developer from India. The project is open source: https://github.com/RajdeepKushwaha5/brahmaputra-watch .
This is an independent side project, not affiliated with ASDMA or CWC.
Thanks to Kestra @WeMakeDevs
The river was normal. My flood alert called it an emergency. was originally published in Kestra Engineering on Medium, where people are continuing the conversation by highlighting and responding to this story.