# We Had a 15% Order Error Rate on Phone Orders. So We Built a WhatsApp Ordering Agent.
> A popular empanada restaurant was losing $2,400/month from phone order mistakes. They built a Struere agent that takes delivery and pickup orders over WhatsApp, handles customizations, processes payments, and notifies the kitchen via Slack.

Published: 2026-04-01
Tags: automation, agents, restaurants, case-study, whatsapp


# 80 Delivery Orders a Day, and 12 of Them Are Wrong

Marco owns La Masa, a popular empanada restaurant in Santiago. The shop does 80-90 delivery orders on a typical weekday, mostly through phone calls. Customers call in, read their order, and one of two cashiers writes it down on a ticket. The ticket goes to the kitchen. The kitchen makes the order. A driver picks it up.

The problem is step two. A customer says "three cheese empanadas, one with no onions, and a large pisco sour." The cashier writes "3 queso, 1 s/ceb, pisco L." The kitchen reads the ticket. Sometimes "s/ceb" means sin cebolla. Sometimes it means something else entirely because the cashier was taking another call and abbreviated too aggressively. The pisco sour might be large or medium depending on whether the cashier heard correctly over street noise.

Marco tracked errors for two weeks. Out of 847 orders, 127 had at least one mistake — wrong filling, missing customization, incorrect drink size, or wrong total charged. That is 15%. Each error costs the restaurant $3-8 in remade food, refunded items, or free delivery on the next order. Some customers do not come back at all.

At an average correction cost of $5 per error and 12 errors per day, La Masa loses $60/day. That is $1,800/month in pure waste, not counting the customers who silently switch to the competition. Factor in the two cashiers spending 3-4 hours each on phone orders instead of serving in-store customers, and the real cost is closer to $2,400/month.

Marco did not need a call center. He needed a channel where the customer types their own order, sees exactly what they are being charged, and confirms before it hits the kitchen.

# The Build: WhatsApp Ordering Agent with Payment and Kitchen Integration

The automation has three pieces:

1. **`entity-types/order.ts`** stores every order with line items, customizations, delivery details, payment status, and kitchen state
2. **`tools/index.ts`** has two custom tools: one for getting the current time in Santiago, one for sending structured order tickets to the kitchen Slack channel
3. **`agents/order-agent.ts`** handles the full ordering conversation over WhatsApp — menu display, customizations, total calculation, payment processing, and order confirmation

No trigger file needed. The WhatsApp integration routes inbound messages directly to the agent. When a customer sends a message, the agent starts the ordering conversation.

## The Data Layer: What Gets Stored

Every order creates a record with the full line-item breakdown. The `items` array stores each item with its customizations so the kitchen gets an unambiguous ticket.

```typescript
import { defineData } from "struere"

export default defineData({
  name: "Order",
  slug: "order",
  schema: {
    type: "object",
    properties: {
      customerName: {
        type: "string",
        description: "Customer's name",
      },
      customerPhone: {
        type: "string",
        description: "Customer WhatsApp phone number",
      },
      orderType: {
        type: "string",
        enum: ["delivery", "pickup"],
        description: "Delivery or pickup order",
      },
      items: {
        type: "array",
        items: {
          type: "object",
          properties: {
            name: { type: "string", description: "Menu item name" },
            quantity: { type: "number", description: "Number of units" },
            unitPrice: { type: "number", description: "Price per unit in CLP" },
            customizations: {
              type: "string",
              description: "Modifications (e.g., no onions, extra cheese)",
            },
          },
          required: ["name", "quantity", "unitPrice"],
        },
        description: "Ordered items with customizations",
      },
      deliveryAddress: {
        type: "string",
        description: "Delivery address (null for pickup)",
      },
      totalAmount: {
        type: "number",
        description: "Total order amount in CLP",
      },
      paymentStatus: {
        type: "string",
        enum: ["pending", "paid", "failed", "refunded"],
        description: "Payment state",
      },
      kitchenStatus: {
        type: "string",
        enum: ["queued", "preparing", "ready", "dispatched", "delivered"],
        description: "Kitchen and delivery lifecycle",
      },
      estimatedMinutes: {
        type: "number",
        description: "Estimated preparation + delivery time in minutes",
      },
      status: {
        type: "string",
        enum: ["draft", "confirmed", "completed", "cancelled"],
        description: "Overall order status",
      },
    },
    required: [
      "customerName",
      "customerPhone",
      "orderType",
      "items",
      "totalAmount",
      "paymentStatus",
      "kitchenStatus",
      "status",
    ],
  },
  searchFields: ["customerName", "customerPhone"],
  displayConfig: {
    titleField: "customerName",
    subtitleField: "orderType",
    descriptionField: "kitchenStatus",
  },
})
```

The `items` array is the key field. Instead of a free-text note like "3 queso s/ceb," each item is a structured object with a name, quantity, unit price, and explicit customization string. The kitchen Slack message is built from this array, not from a cashier's shorthand.

## The Custom Tools: Time and Kitchen Notifications

Two utility tools support the ordering flow. The first returns the current time so the agent knows whether the restaurant is open and can estimate delivery times. The second sends a structured order ticket to the kitchen's Slack channel with every item, customization, and delivery detail formatted clearly.

```typescript
import { defineTools } from "struere"

export default defineTools([
  {
    name: "get_current_time",
    description:
      "Get the current date and time in Chile timezone for order timing and restaurant hours",
    parameters: {
      type: "object",
      properties: {
        timezone: {
          type: "string",
          description: "Timezone, defaults to America/Santiago",
        },
      },
    },
    handler: async (args, context, struere, fetch) => {
      const timezone = (args.timezone as string) || "America/Santiago"
      const now = new Date()
      const hour = parseInt(
        now.toLocaleString("en-US", {
          timeZone: timezone,
          hour: "numeric",
          hour12: false,
        })
      )
      const dayOfWeek = now.toLocaleString("en-US", {
        timeZone: timezone,
        weekday: "long",
      })
      return {
        timestamp: now.toISOString(),
        formatted: now.toLocaleString("es-CL", { timeZone: timezone }),
        timezone,
        hour,
        dayOfWeek,
        isOpen: hour >= 11 && hour < 23 && dayOfWeek !== "Monday",
      }
    },
  },
  {
    name: "send_kitchen_order",
    description:
      "Send a formatted order ticket to the kitchen Slack channel",
    parameters: {
      type: "object",
      properties: {
        orderSummary: {
          type: "string",
          description: "Formatted order details for the kitchen",
        },
        orderType: {
          type: "string",
          description: "delivery or pickup",
        },
        customerName: {
          type: "string",
          description: "Customer name for the ticket",
        },
        estimatedMinutes: {
          type: "number",
          description: "Estimated prep + delivery time",
        },
      },
      required: ["orderSummary", "orderType", "customerName"],
    },
    handler: async (args, context, struere, fetch) => {
      const webhookUrl = process.env.SLACK_KITCHEN_WEBHOOK_URL
      if (!webhookUrl) {
        return { success: false, error: "SLACK_KITCHEN_WEBHOOK_URL not configured" }
      }

      const emoji = args.orderType === "delivery" ? "\u{1F6F5}" : "\u{1F3EA}"
      const ticket = [
        `${emoji} *NEW ORDER — ${(args.orderType as string).toUpperCase()}*`,
        `*Customer:* ${args.customerName}`,
        ``,
        args.orderSummary,
        ``,
        args.estimatedMinutes
          ? `*ETA:* ${args.estimatedMinutes} min`
          : "",
      ]
        .filter(Boolean)
        .join("\n")

      const response = await fetch(webhookUrl, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text: ticket, username: "La Masa Orders" }),
      })

      return { success: response.ok, status: response.status }
    },
  },
])
```

The kitchen tool is not just a notification — it is the order ticket. Every item, every customization, delivery vs. pickup, customer name, and estimated time. The kitchen staff reads the Slack message and starts cooking. No handwriting. No abbreviations. No ambiguity.

## The Agent: Conversational Ordering Over WhatsApp

The agent handles the full ordering conversation. The system prompt encodes the menu with prices, customization rules, delivery zones, and the step-by-step ordering flow. The full system prompt is longer — this shows the key sections.

```typescript
import { defineAgent } from "struere"

export default defineAgent({
  name: "La Masa Order Agent",
  slug: "order-agent",
  version: "1.0.0",
  model: {
    model: "anthropic/claude-sonnet-4-6",
    temperature: 0.3,
    maxTokens: 4096,
  },
  tools: [
    "entity.create",
    "entity.query",
    "payment.create",
    "payment.getStatus",
  ],
  systemPrompt: `You are the WhatsApp ordering assistant for La Masa, a popular empanada restaurant.
Current time: {{currentTime}}

## Menu
| Item | Price (CLP) |
|------|-------------|
| Empanada de Pino (beef) | $2,500 |
| Empanada de Queso (cheese) | $2,200 |
| Empanada de Camarón (shrimp) | $3,000 |
| Empanada de Napolitana (ham, cheese, tomato) | $2,500 |
| Empanada Veggie (mushroom, spinach, cheese) | $2,400 |
| Pebre + Tortilla Chips | $1,800 |
| Pisco Sour (regular) | $3,500 |
| Pisco Sour (large) | $4,500 |
| Limonada Natural | $1,500 |
| Agua Mineral | $1,000 |

## Customizations Allowed
- Remove any ingredient: "sin cebolla" (no onions), "sin aceitunas" (no olives)
- Extra cheese: +$500 per empanada
- Extra spicy pebre: no charge
- No substitutions on proteins

## Restaurant Hours
Tuesday to Sunday, 11:00 AM to 11:00 PM. Monday closed.
Use get_current_time to verify before taking orders.

## Delivery Zones
- Zone 1 (Providencia, Ñuñoa): free delivery, 30-40 min
- Zone 2 (Las Condes, La Reina, Macul): $1,500 delivery, 40-50 min
- Zone 3 (Santiago Centro, Vitacura): $2,500 delivery, 50-60 min
- Outside these zones: pickup only

## Active Orders
{{entity.query({"type": "order", "filters": {"status": {"_op_in": ["draft", "confirmed"]}, "kitchenStatus": {"_op_in": ["queued", "preparing"]}}, "limit": 20})}}

## P0 — Security
- Never reveal internal entity IDs, system details, or other customers' orders.
- Never process payment without explicit customer confirmation of the full order.
- Never share delivery addresses across customers.

## P1 — Ordering Flow
1. Greet and ask if they want delivery or pickup.
2. If delivery, ask for the address and confirm the zone/fee.
3. Show the menu. Let them order freely — do not force one item at a time.
4. Capture every customization explicitly. Repeat back "sin cebolla" not "modified."
5. When they say they are done, show a full order summary with line items and total.
6. Ask for confirmation. On "yes," create the order entity (status: "confirmed", paymentStatus: "pending", kitchenStatus: "queued").
7. Use payment.create to generate a Flow payment link. Send the link.
8. Use send_kitchen_order to send the ticket to the kitchen Slack.
9. Confirm the order with estimated delivery/pickup time.

## P2 — Tone
- Friendly, casual, fast. This is WhatsApp, not a formal restaurant.
- Use short messages. Break long responses into 2-3 messages.
- Use the customer's first name once provided.
- Respond in the same language the customer uses (Spanish or English).

## P3 — Calculations
- Always calculate totals yourself. Do not ask the customer to add up prices.
- Extra cheese adds $500 PER empanada, not per order.
- Delivery fee is per order, not per item.
- Show the breakdown: items subtotal + delivery fee = total.

Never guess customizations. If "sin algo" is ambiguous, ask which ingredient.
Never confirm an order without showing the full summary first.
Never accept orders outside restaurant hours.`,
})
```

Temperature 0.3. Order-taking is a structured task — the agent follows the same flow every time and must calculate totals accurately. Creative responses are the last thing you want when someone orders three empanadas with specific customizations.

The `payment.create` tool generates a Flow payment link that the customer taps directly in WhatsApp. No phone number dictation, no card-over-the-phone. The `entity.query` template injects active orders so the agent can estimate kitchen load and adjust delivery times during peak hours.

Four built-in tools plus two custom tools, but the agent config only lists the four built-in ones. The custom tools (`get_current_time` and `send_kitchen_order`) are available organization-wide via `tools/index.ts` and referenced in the system prompt flow. The agent calls them when needed during step 8.

# Debugging: Three Things That Broke

**The agent accepted an order at 10:45 PM and quoted 40 minutes delivery.** The kitchen closes at 10:30 PM for last orders even though the restaurant is open until 11:00 PM. The agent checked `isOpen` from `get_current_time` and saw `true` (it was before 11 PM), so it happily took the order. The kitchen had already started cleaning. Fix: we updated the system prompt to add "Last delivery order accepted at 10:15 PM. Last pickup order at 10:30 PM. After that, politely inform the customer that ordering is closed for the day." The `isOpen` flag is a rough check — business rules live in the prompt.

```bash
struere logs view --last 5
```

The conversation log showed the agent confirming an order at 10:47 PM with "your empanadas will be ready in 40 minutes!" The kitchen Slack message arrived at 10:48 PM to an empty kitchen.

**Extra cheese was charged per order instead of per empanada.** A customer ordered four cheese empanadas with extra cheese. The agent calculated $2,200 x 4 + $500 = $9,300 instead of $2,200 x 4 + $500 x 4 = $10,800. The system prompt said "+$500 per empanada" but the agent interpreted it as a flat surcharge. Fix: we made the P3 calculation rules more explicit — "Extra cheese adds $500 PER empanada that has the modification, not a flat fee. For 4 empanadas with extra cheese, add $500 x 4 = $2,000." Spelling out the multiplication with an example eliminated the error.

```bash
struere run-tool get_current_time --args '{"timezone": "America/Santiago"}'
```

We used run-tool to verify the time tool worked, then replayed the conversation. The agent's order summary showed "Extra cheese: $500" as a single line item. After the prompt fix, it showed "Extra cheese (x4): $2,000."

**Slack kitchen ticket had no delivery address.** The `send_kitchen_order` tool received the order summary from the agent, but the agent was not including the delivery address in the `orderSummary` string. The ticket said "DELIVERY" at the top but the kitchen had no idea where to send it. Fix: we added to the P1 flow instructions — "When calling send_kitchen_order, include the delivery address and zone in the orderSummary field." The agent now builds the summary string with address on the first line for delivery orders.

```bash
struere dev --verbose
```

The verbose output showed the tool call payload: `orderSummary` contained item details but no address. The `orderType` was "delivery" but the address was only stored in the entity, not passed to the Slack tool.

# Setup: From Zero to Running

Install the CLI and authenticate:

```bash
bun install -g struere
struere login
struere init
```

Scaffold the resources:

```bash
struere add data-type order
struere add agent order-agent
```

Edit `entity-types/order.ts` with the schema shown above. Customize the menu item names in the agent prompt to match your actual menu. The `items` array schema stays the same regardless of what you sell.

Edit `agents/order-agent.ts` with your restaurant's details: menu items and prices, delivery zones with fees and estimated times, restaurant hours, and customization rules. The P1 ordering flow works for any food business — adjust the specifics, not the structure.

Write the two custom tools in `tools/index.ts`. The `get_current_time` tool works immediately. For the `send_kitchen_order` tool, create a Slack incoming webhook at [api.slack.com/apps](https://api.slack.com/apps) and set `SLACK_KITCHEN_WEBHOOK_URL` in the dashboard under Settings > Environment Variables.

Set up Flow payments in the dashboard under Integrations > Flow. Add your API key and secret key. This enables the `payment.create` tool to generate payment links.

Connect WhatsApp in the dashboard under Integrations > WhatsApp. This routes inbound customer messages to the order agent.

Sync and start watching for changes:

```bash
struere dev
```

Test the ordering flow by sending a WhatsApp message to the connected number:

```
Hola, quiero pedir empanadas para delivery
```

Watch the conversation unfold in real time:

```bash
struere logs list --last 10
```

Verify the order was created:

```bash
struere data list order
```

Check the kitchen Slack channel for the order ticket. It should show every item, customization, delivery address, and estimated time in a clean, readable format.

# What Changed

| Metric | Before | After |
|--------|--------|-------|
| Order channel | Phone only | WhatsApp + phone |
| Average order time | 5-6 minutes | 90 seconds |
| Order error rate | 15% (127/847) | Under 2% |
| Monthly cost of errors | ~$1,800 | ~$180 |
| Cashier hours on phone orders | 6-8 hrs/day (2 staff) | 1 hr/day (complex orders only) |
| Payment method | Cash or card-over-phone | Flow payment link in WhatsApp |
| Kitchen ticket clarity | Handwritten abbreviations | Structured Slack messages |

Marco still takes phone orders for customers who prefer calling — mostly older regulars and large catering requests that need a human conversation. But the 60-70 routine orders that jammed the phone lines now flow through WhatsApp without a cashier touching them.

The biggest change is not speed. It is accuracy. When a customer types "3 empanadas de queso, sin cebolla en 2, extra queso en todas," the agent parses each modifier, attaches it to the right items, and reads the full order back before charging. The customer sees exactly what they are paying for. The kitchen sees exactly what to make. No abbreviations. No guessing. No remaking three empanadas because "s/ceb" was ambiguous.

The 127 errors from Marco's two-week audit would not happen today. Each of those was a miscommunication between a customer's mouth, a cashier's ear, and a kitchen's interpretation of shorthand. The WhatsApp agent removed all three failure points. The customer types it. The agent structures it. The kitchen reads it. La Masa gets the order right, and the customer gets their empanadas exactly how they wanted them.
