Inspiration

During a fire, an active-threat event or severe weather, a school district coordinates across buildings on frantic group chats, failing phone trees and paper rosters. The incident commander cannot answer the questions that matter: who is safe, who is unaccounted for, which route is blocked, who cannot use the stairs. Enterprise incident platforms solve this at $21+/user/month, which is not a number a public elementary school reaches for.

The specific thing that shaped the build: nobody opens a laptop during a lockdown. Whatever this became, the first message had to be able to arrive from a phone, in a corridor, from somebody who is frightened.

What it does

A human sends the message they would already have sent: a Slack command, a WhatsApp text. CrisisMesh does not detect anything and does not replace 911. It coordinates the organisational response after a person reports one, using the organisation's own building data: rooms, staff, routes, resources, assembly points, all loaded from CSVs the school controls.

The feature I would defend is the one nobody watches. A declared incident starts a scheduler. Every tick it looks at whoever has not checked in, pings them, re-pings them, and at a configured cap it stops pinging and hands that person to their floor warden by name, on the warden's channel rather than theirs. Once each. Then it goes quiet, because an escalated person is finished as far as the loop is concerned.

Around that:

  • Cross-channel sync. Declare on WhatsApp and the Slack room hears it. Declare in Slack and every reachable phone does.
  • Room-level check-in. room 104: 23 students are safe, 1 unaccounted. One message from a teacher under a desk accounts for 23 people.
  • A threat trail, not a point. Reported sightings accumulate (east wing -> gym), timestamped and attributed, every answer marked UNCONFIRMED.
  • An egress assessment that does the join. Thirteen routes and every reported position, cross-checked: which doors carry no reported sighting, which do, step-free options marked. Not a floor plan handed to a responder in a corridor.
  • A law-enforcement arrival brief with two separately labelled headcounts, silent rooms prioritised by threat-zone proximity, and no medical detail beyond a mobility flag.

Ten incident types are covered by playbooks: fire, active threat, severe weather, medical, flood, cyber ransomware, data breach, utility outage, hazmat and bomb threat.

How we built it

Seven agents on Google ADK, a coordinator delegating to intake, accountability, safety intel, SITREP, learning and compliance, running Gemini 3.5 Flash on Vertex AI. Gemini 2.5 Flash Lite handles follow-up questions through the Google GenAI SDK.

Five Google-managed services do real work, and /health names each one so a silent fallback cannot masquerade as success:

  • Cloud Run for the service, at --max-instances=4
  • Firestore for incident state, the reconciliation state machine (compare-and-set), the witness log, the room board and the check-in ledger
  • Pub/Sub for the event bus, 18 typed events
  • Model Armor for prompt-injection and jailbreak filtering
  • Vertex AI Agent Engine Memory Bank for cross-incident lessons, retrieved by semantic similarity. It ships empty and fills from resolved incidents, so a recalled lesson is one this deployment actually learned

Transports: Slack Events API with Block Kit, Twilio WhatsApp, and a web console.

The channel choice is the product decision. CrisisMesh puts the intelligence inside the channels a school already uses rather than asking the school to come to the intelligence, and that deletes the entire adoption cost in one move: nothing to install, no account to create, no training day, no drill to teach the tool itself, no per-seat licence, no procurement cycle. There is no new interface to remember, because there is no new interface. The teacher reporting from under a desk is typing in the app she texts her family with. The principal running the incident is in the room where the staff already talk.

That matters more in a crisis than anywhere else, because under stress people fall back on habit. A tool that has to be learned is a tool that fails at the exact moment it is needed, and a rollout that needs a training day is a rollout a school with no IT staff never finishes. The agents do the work that is new; the surface stays the one everybody already has muscle memory for.

Each channel then carries the half it is good at. Slack takes the coordination side: the adults are already in one place, and Block Kit renders a commander console, a check-in prompt and a reaction acknowledgement inside a room they have open anyway. WhatsApp takes the person side, because it reaches a phone that stays in a pocket through an evacuation in a way a desk phone and a wall PA do not, and because it rides wifi, which still works when a mass-casualty event congests the cell network.

The honest version of that story: the channel a US school would reach for first is SMS, and it is the one we cannot switch on. The transport is written and tested and the Twilio number's webhook points at it, but the A2P 10DLC campaign is unapproved, so US carriers will not carry the traffic and zero SMS have been sent or received. Shipping on the two channels that were already approved is a constraint we built inside, not a design win we are claiming.

The discipline that mattered: tests assert claims about the world rather than return values. The loop hands this person to somebody who is not them. No route through a reported sighting is offered. A check-in recorded on one instance is counted on another. There are 1,331 of them, none needing GCP credentials.

Challenges we ran into

Every real bug was in a seam. Not one was a bad function.

The escalation was sent to the person it could not find. "Mrs. Rodriguez has not answered, please locate her", delivered to Mrs. Rodriguez. Then it never stopped: an escalated person stayed actionable, so the same warden was paged about the same person every 25 seconds, forever. Fixed with a terminal state, and then the same bug came back, because the guard went into the function the tests called while the running loop called a different one. Green the whole time.

A teacher asked "what's the fastest route out of east wing" during an active shooter and got corridor directions. The movement policy that exists to prevent exactly that ran inside the fan-out; a query answer is a transport reply and never passed through it.

The system reported 34 of 34 reachable because any non-empty Slack id counted, including the roster's placeholders, so the loop chased thirty people down channels that addressed nobody.

And the one that only a live call could find: turning on Model Armor correctly broke the product. Its RAI dangerous classifier refused "Smoke near the science lab, floor 2". A system that receives reports of danger cannot treat danger as grounds for refusal.

Accomplishments that we're proud of

It says 4 of 34 reachable. Thirty roster entries have no verified channel, and Slack ids are checked against the workspace before they count. That number is worse than 34 and it is true. Unreachable is a fact a commander acts on, not a gap to paper over.

The refusals generally. It will not name a route clear when it could not read the sighting log; the brief says EGRESS ASSESSMENT WITHHELD. It will not report zero unaccounted because the ledger was unreadable: the roster is the denominator, so a lost record counts as missing. It will not publish an assembly point during a lockdown. It will not send a tactical brief over WhatsApp, because that document names where people with mobility limitations are.

And the numbers say which scale they are on. Managed semantic recall returns a vector distance, the local store returns Jaccard tag overlap, and a correct top hit reads 0.166 on one and 0.75 on the other, so every result carries the basis that produced it.

What we learned

Managed services break assumptions that mocks cannot. Vertex Memory Bank persists fact and scope and silently drops display_name and description, which is where the structured record was, so retrieval returned memories it could not read. Scope matching is exact on the whole map rather than a subset. Neither is in the docs; both took one live call. The test doubles were made lossy in the same way afterwards, so a double can no longer pass while the service fails.

A comment claiming a property is not the property. Both Model Armor error paths returned blocked: False under a comment saying they failed closed for ambiguous cases. And the block signal was read as "MATCH_FOUND" in str(state), where str() of that enum is "2", so it never matched and Model Armor had never blocked anything.

A fixture can hide an outage. The Memory Bank shipped with five drill lessons so the PRIOR LESSONS panel always had something in it. Those lessons also filled the local fallback, so when the managed store failed the fallback answered with five and the failure read as a healthy result. Deleting the fixtures made the panel honest and made the outage visible in the same move: empty now means either nothing has been learned yet or the store could not be reached, which are different claims, so every recall says which one it is.

Prose is not executable. The README claimed 495 tests when there were 1,215, described a "7-beat demo" above an eight-row table, and documented an authorisation gate as accepting anyone when it refuses everyone. The checkable claims are now pinned by a test that fails when a number drifts.

What's next for CrisisMesh

Migrate the last process-local state, Slack reaction check-ins and the observability trace store, so the audit bundle is instance-independent. Give /sms the acknowledge-first treatment WhatsApp already has, keeping the carrier-mandated STOP/HELP paths synchronous. Finish A2P 10DLC so the SMS transport, written and tested but carrying no traffic today, becomes a channel rather than code. And multi-facility: the registry and Memory Bank are already shared across sites, so the per-facility knowledge bases are the remaining work.

Built With

  • agent-engine
  • cloud-run
  • docker
  • firestore
  • gemini
  • google-adk
  • google-genai
  • model-armor
  • pub-sub
  • pytest
  • python
  • server-sent-events
  • slack-api
  • twilio
  • vertex
  • whatsapp
Share this project:

Updates

Submission history