DEV Community

Cover image for Designing Deterministic Outputs From Unstructured Messages
Seryl Lns
Seryl Lns

Posted on • Originally published at whatsrb.com

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
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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."
}
Enter fullscreen mode Exit fullscreen mode

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"]
    whatsrb.sessions.send_message(
      session_id: payload["data"]["session_id"],
      text: output["suggested_reply"]
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

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?"
Enter fullscreen mode Exit fullscreen mode

Output:

{
  "intent": "system.noop.skip",
  "confidence": 0.9,
  "actions": [],
  "suggested_reply": null,
  "analysis_summary": "The message is unrelated to hotel services."
}
Enter fullscreen mode Exit fullscreen mode

No action taken. No hallucinated response. No made-up answer about Paris weather. The system knows what it doesn't handle and stays silent.

Agent noop

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)
Enter fullscreen mode Exit fullscreen mode

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%+
Handles typos/slang Poorly Well
Backend integration Custom per-flow Standard webhook

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)