Designing Deterministic Outputs From Unstructured Messages
Your users send messy, unstructured text. Your backend expects clean, validated JSON. Bridging that gap is the hard part — and most teams get it wrong.
This article walks through how to turn a raw WhatsApp message into a structured payload your application can safely act on.
The Problem
A real message from a hotel guest:
hey can u move bk-4521 to fri plzz?? also ned more towls in rm 312 thx
Typos. Abbreviations. Two requests in one message. No punctuation.
Your PMS expects:
{
"booking_ref": "BK-4521",
"new_date": "2026-03-14",
"room": "312",
"items": ["extra towels"]
}
How do you get from A to B — reliably?
Step 1: Define Your Schema
Before touching any LLM, define what your backend expects. This is your contract.
{
"intent": "string (enum)",
"actions": [
{
"type": "string (action identifier)",
"payload": {
"field_1": "string | number | null",
"field_2": "string | number | null"
}
}
],
"confidence": "number (0-1)",
"suggested_reply": "string | null"
}
Key principles:
- Finite set of intents — your agent only handles what you define
- Typed fields — each field has an expected type
-
Nullable — missing data is
null, not hallucinated - Confidence score — so your backend can decide when to auto-act vs. ask for confirmation
Step 2: Extract, Don't Converse
The classic mistake is building a multi-turn flow to gather missing fields. Instead, extract everything from the first message in a single LLM call.
The messy message:
hey can u move bk-4521 to fri plzz?? also ned more towls in rm 312 thx
Gets processed into:
{
"intent": "modify_booking",
"confidence": 0.93,
"actions": [
{
"type": "reservation.booking.reschedule",
"payload": {
"booking_ref": "BK-4521",
"new_date": "2026-03-14"
}
},
{
"type": "housekeeping.task.create",
"payload": {
"room": "312",
"items": "extra towels"
}
}
],
"suggested_reply": "Done! Booking BK-4521 moved to Friday. Extra towels are on the way to room 312."
}
One message → two actions. No follow-up questions needed.
Step 3: Validate Before Acting
Never trust raw LLM output blindly. Validate the payload before executing:
# Your webhook handler
def handle_agent_webhook(payload)
output = payload["data"]["output"]
# Check confidence threshold
return if output["confidence"] < 0.8
output["actions"].each do |action|
case action["type"]
when "booking.reschedule"
# Validate booking exists
booking = Booking.find_by(ref: action.dig("payload", "booking_ref"))
next unless booking
booking.reschedule!(new_date: action.dig("payload", "new_date"))
when "housekeeping.task.create"
HousekeepingTask.create!(
room: action.dig("payload", "room"),
items: action.dig("payload", "items")
)
end
end
# Send the suggested reply back via WhatsApp
if output["suggested_reply"]
send_whatsapp_message(
session_id: payload["data"]["session_id"],
text: output["suggested_reply"]
)
end
end
Your backend stays in control. The LLM proposes, your code decides.
Step 4: Handle the Edges
What about messages that don't match any intent?
"What's the weather like in Paris tomorrow?"
Output:
{
"intent": "system.noop.skip",
"confidence": 0.9,
"actions": [],
"suggested_reply": null,
"analysis_summary": "The message is unrelated to hotel services."
}
No action taken. No hallucinated response. No made-up answer about Paris weather. The system knows what it doesn't handle and stays silent.
This is the difference between a deterministic system and a chatbot that tries to answer everything. A chatbot would hallucinate a weather forecast. A message-driven system returns noop and moves on.
The Full Pipeline
Raw message
↓
Pre-filter (spam, noise → free, no LLM)
↓
Router (classify → which agent handles this?)
↓
Agent (LLM → structured payload with schema)
↓
Webhook (HMAC-signed → your backend)
↓
Validate (confidence check, field validation)
↓
Execute (create task, update booking, send reply)
Explore the full pipeline in detail →
Each layer adds reliability:
- Pre-filter removes noise before LLM cost
- Router ensures the right agent handles each message type
- Schema constrains LLM output to valid shapes
- Confidence lets you set auto-action thresholds
- Webhooks decouple processing from action
What You Get
| Metric | Typical chatbot | Message-driven |
|---|---|---|
| Round-trips per request | 3-5 | 1 |
| Latency | 5-15s (multi-turn) | 1-2s |
| State management | Complex | None |
| Accuracy on intent | ~70% | 95%+ (on our hotel dataset) |
| Handles typos/slang | Poorly | Well |
| Backend integration | Custom per-flow | Standard webhook |
This pipeline works because it treats messages as data extraction problems, not conversations. Define the shape, let the LLM fill it, validate before acting. If you want to skip building it yourself:
Try It Yourself
WhatsRB Cloud handles this entire pipeline — from raw WhatsApp message to structured webhook. Define your intents, set your schema, and let the platform do the extraction.
Beta opens March 31, 2026.
Join the waitlist → | Read the API docs →
No chatbot framework. No dialog trees. Just clean data from messy messages.
Built with Ruby, Rails, and the belief that most messages don't need a conversation — they need an action.


Top comments (0)