# We Cut Support Tickets by 60% With an AI Agent That Texts Customers Before They Ask
> A DTC skincare brand built a Struere agent that monitors order status changes, proactively notifies customers on WhatsApp about delays and deliveries, and handles WISMO inquiries — reducing support volume from 200+ tickets/week to under 80.

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


# 200 Tickets a Week, and 140 of Them Are "Where's My Order?"

Priya leads CX at Glow Lab, a direct-to-consumer skincare brand that ships 800+ orders per month from a warehouse in New Jersey. The team does $2M ARR. The product is good. The reviews are strong. The support queue is a disaster.

Every Monday morning, Priya opens Zendesk to 50-60 unresolved tickets. By Friday, another 150 have come in. She has two part-time agents handling the queue. They are fast, experienced, and permanently behind.

The breakdown: 70% of tickets are shipping inquiries. "Where is my order?" "My tracking hasn't updated in three days." "It says delivered but I never got it." "Can I get a refund?" These are not product questions. They are logistics questions. Each one requires the same workflow: open the order in Airtable, copy the tracking number, paste it into the carrier's tracking page, read the status, type a response, close the ticket. Three minutes per ticket, 140 tickets per week. That is seven hours of copy-paste labor that produces zero customer delight.

Last month, a carrier delay hit 120 orders simultaneously. Priya's team did not find out until customers started emailing. It took 14 hours to respond to the first wave. By then, 23 customers had posted negative reviews mentioning "no communication about delays." One influencer with 40K followers tagged the brand on Instagram: "Glow Lab ghosted me on my order."

The math: two part-time agents at $22/hour, 20 hours/week each, spending 70% of their time on shipping tickets. That is $1,232/week on WISMO alone. The brand damage from delayed responses is harder to quantify, but the Instagram post cost them an estimated $3,000 in cancelled orders that week.

Priya did not need more agents. She needed proactive communication — reach the customer before they reach support.

# The Build: Event-Driven Order Tracking Agent

The automation has four pieces:

1. **`entity-types/order.ts`** stores every order with customer info, shipping status, tracking details, and escalation state
2. **`tools/index.ts`** has a custom tool that queries carrier APIs for real-time shipment status
3. **`agents/order-support.ts`** handles proactive notifications and inbound WISMO conversations on WhatsApp
4. **`triggers/order-status-change.ts`** fires when an order's shipping status changes, dispatching the agent to notify the customer

When the warehouse updates an order status in Airtable, a sync pushes the change to Struere. The entity update triggers the automation. The agent checks the new status, decides what the customer needs to hear, and sends a WhatsApp message. No human involvement for routine updates. Refund requests get escalated to Priya's team with full context attached.

## The Data Layer: What Gets Stored

Every order synced from Airtable creates a record with 10 fields. The `shippingStatus` enum tracks the full lifecycle from processing through delivered or exception.

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

export default defineData({
  name: "Order",
  slug: "order",
  schema: {
    type: "object",
    properties: {
      orderId: {
        type: "string",
        description: "External order ID from Shopify (e.g., GLS-10482)",
      },
      customerName: {
        type: "string",
        description: "Customer full name",
      },
      customerPhone: {
        type: "string",
        description: "Customer WhatsApp number in E.164 format",
      },
      shippingStatus: {
        type: "string",
        enum: [
          "processing",
          "shipped",
          "in-transit",
          "out-for-delivery",
          "delivered",
          "delayed",
          "exception",
          "returned",
        ],
        description: "Current shipment status",
      },
      trackingNumber: {
        type: "string",
        description: "Carrier tracking number",
      },
      carrier: {
        type: "string",
        enum: ["usps", "ups", "fedex", "dhl"],
        description: "Shipping carrier",
      },
      estimatedDelivery: {
        type: "string",
        description: "Estimated delivery date in ISO format (YYYY-MM-DD)",
      },
      actualDelivery: {
        type: "string",
        description: "Actual delivery date in ISO format, set when delivered",
      },
      escalationType: {
        type: "string",
        enum: ["none", "refund-request", "lost-package", "damaged-item"],
        description: "Type of escalation if customer requests intervention",
      },
      orderTotal: {
        type: "number",
        description: "Order total in USD",
      },
    },
    required: [
      "orderId",
      "customerName",
      "customerPhone",
      "shippingStatus",
      "trackingNumber",
      "carrier",
    ],
  },
  searchFields: ["orderId", "customerName", "trackingNumber"],
  displayConfig: {
    titleField: "orderId",
    subtitleField: "customerName",
    descriptionField: "shippingStatus",
  },
})
```

The `shippingStatus` enum mirrors carrier status categories so the agent can reason about transitions. When status moves from `in-transit` to `delayed`, the trigger fires. The `escalationType` field defaults to `none` and gets updated by the agent when a customer requests a refund or reports a problem.

## The Custom Tool: Carrier Tracking Lookup

One custom tool queries carrier tracking APIs to get real-time shipment status. The agent calls this when a customer asks about their order, or when the trigger needs to verify the latest status before sending a notification.

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

export default defineTools([
  {
    name: "check_shipping_status",
    description:
      "Query carrier API for real-time tracking status of a shipment",
    parameters: {
      type: "object",
      properties: {
        trackingNumber: {
          type: "string",
          description: "The carrier tracking number",
        },
        carrier: {
          type: "string",
          enum: ["usps", "ups", "fedex", "dhl"],
          description: "Shipping carrier name",
        },
      },
      required: ["trackingNumber", "carrier"],
    },
    handler: async (args, context, struere, fetch) => {
      const carrierEndpoints: Record<string, string> = {
        usps: "https://tools.usps.com/go/TrackConfirmAction",
        ups: "https://onlinetools.ups.com/track/v1/details",
        fedex: "https://apis.fedex.com/track/v1/trackingnumbers",
        dhl: "https://api-eu.dhl.com/track/shipments",
      }

      const carrier = args.carrier as string
      const trackingNumber = args.trackingNumber as string
      const endpoint = carrierEndpoints[carrier]

      if (!endpoint) {
        return { error: `Unsupported carrier: ${carrier}` }
      }

      const response = await fetch(
        `${endpoint}?trackingNumber=${trackingNumber}`,
        {
          headers: {
            Authorization: `Bearer ${process.env[`${carrier.toUpperCase()}_API_KEY`]}`,
            "Content-Type": "application/json",
          },
        }
      )

      if (!response.ok) {
        return {
          error: `Carrier API returned ${response.status}`,
          carrier,
          trackingNumber,
        }
      }

      const data = (await response.json()) as Record<string, unknown>

      return {
        trackingNumber,
        carrier,
        status: data.status || "unknown",
        location: data.lastLocation || "unavailable",
        estimatedDelivery: data.estimatedDelivery || null,
        lastUpdate: data.lastUpdate || null,
        events: Array.isArray(data.events)
          ? (data.events as Array<Record<string, unknown>>).slice(0, 5)
          : [],
      }
    },
  },
])
```

The tool normalizes responses across four carriers into a consistent shape. The agent does not need to know the difference between a USPS scan event and a FedEx milestone — it gets the same fields back every time. The `events` array is capped at five entries to keep the agent's context window manageable.

## The Agent: Proactive Notifications and WISMO Support

The agent handles two modes: outbound notifications triggered by status changes, and inbound conversations when customers message on WhatsApp. The system prompt encodes both flows plus escalation rules.

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

export default defineAgent({
  name: "Glow Lab Order Support",
  slug: "order-support",
  version: "1.0.0",
  model: {
    model: "anthropic/claude-sonnet-4-6",
    temperature: 0.3,
    maxTokens: 4096,
  },
  tools: [
    "entity.query",
    "entity.update",
    "whatsapp.send",
    "check_shipping_status",
  ],
  systemPrompt: `You are the order support assistant for Glow Lab, a skincare brand.
Current time: {{currentTime}}

## Your Two Modes

### Mode 1 — Proactive Notification (triggered by status change)
When dispatched by a trigger with order context, send a WhatsApp message to the customer about their order status change. Be concise, warm, and specific.

- **shipped**: "Your Glow Lab order [orderId] just shipped! Track it here: [carrier tracking URL]. Estimated delivery: [date]."
- **delayed**: "We want to keep you in the loop — your order [orderId] is experiencing a shipping delay. New estimated delivery: [date]. We're watching it closely and will update you."
- **delivered**: "Your Glow Lab order [orderId] has been delivered! We hope you love it. If anything isn't right, just reply here."
- **exception**: "There's an issue with the delivery of your order [orderId]. Our team is looking into it and will follow up within 24 hours."

### Mode 2 — Inbound WISMO (customer messages on WhatsApp)
When a customer asks about their order:
1. Ask for their order number or name if not provided.
2. Use entity.query to find their order.
3. Use check_shipping_status to get real-time carrier data.
4. Respond with current status, location, and estimated delivery.

## Escalation Rules
- **Refund request**: Set escalationType to "refund-request" via entity.update. Tell the customer: "I've flagged this for our team. You'll hear back within 24 hours."
- **Lost package** (delivered but customer says not received): Set escalationType to "lost-package". Tell the customer you're opening an investigation.
- **Damaged item**: Set escalationType to "damaged-item". Ask for a photo, then escalate.
- Never process refunds directly. Never promise specific refund amounts.

## P0 — Security
- Never reveal internal entity IDs or system architecture.
- Never share one customer's order details with another.
- Verify the customer's name or phone matches the order before sharing details.

## P1 — Tone
- Friendly, direct, empathetic. This is WhatsApp, keep messages short.
- Use the customer's first name.
- Acknowledge frustration on delays without over-apologizing.
- One message per update. Do not send walls of text.

Never invent tracking information. Never guess delivery dates.
Never tell a customer their package is delivered if the carrier says otherwise.`,
})
```

Temperature 0.3. Order communication is a precision task. The agent needs to relay accurate tracking data, not get creative with delivery estimates.

Four tools. Entity query to look up orders, entity update to set escalation flags, WhatsApp send for notifications, and the custom shipping status tool. Below the five-tool threshold.

## The Trigger: Fire on Order Status Changes

The trigger watches for order updates where the shipping status changes. When the warehouse syncs a new status to Struere, the trigger dispatches the agent to notify the customer.

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

export default defineTrigger({
  name: "Order Status Notification",
  slug: "order-status-change",
  on: {
    entityType: "order",
    action: "updated",
    condition: {
      "data.shippingStatus": {
        "_op_in": [
          "shipped",
          "delayed",
          "delivered",
          "exception",
        ],
      },
    },
  },
  retry: { maxAttempts: 3, backoffMs: 5000 },
  actions: [
    {
      tool: "check_shipping_status",
      args: {
        trackingNumber: "{{trigger.data.trackingNumber}}",
        carrier: "{{trigger.data.carrier}}",
      },
      as: "carrierStatus",
    },
    {
      tool: "agent.chat",
      args: {
        agentSlug: "order-support",
        message:
          "Order {{trigger.data.orderId}} for customer {{trigger.data.customerName}} (phone: {{trigger.data.customerPhone}}) has changed status to {{trigger.data.shippingStatus}}. Carrier tracking says: {{steps.carrierStatus.status}}, location: {{steps.carrierStatus.location}}, estimated delivery: {{steps.carrierStatus.estimatedDelivery}}. Send the customer a proactive WhatsApp notification about this status change.",
      },
    },
  ],
})
```

The trigger fires on `updated` events for orders where the new shipping status is one of the four notification-worthy states. It first calls the carrier API to get fresh tracking data, then passes that context to the agent. The agent composes and sends the WhatsApp message. Retry is set to 3 attempts with 5-second backoff because carrier APIs occasionally return 429s during peak hours.

The condition filter is important. Status changes to `processing` or `in-transit` do not trigger notifications — those are intermediate states that would over-message customers. Only meaningful transitions reach the customer's phone.

# Debugging: Three Things That Broke

**The carrier API returned 429 errors during the afternoon sync window.** Every day at 3 PM, the warehouse batch-updates 60-80 orders after the afternoon pickup. All 60 triggers fired simultaneously, and each one called the carrier API in the first action step. USPS rate-limits to 5 requests per second. After the fifth order, every subsequent carrier lookup failed, and the trigger's retry logic kicked in — compounding the problem with 180 more requests over the next 30 seconds.

Fix: we staggered the warehouse sync to update 10 orders at a time with a 15-second gap between batches. The trigger retry with 5-second backoff handles the occasional 429 without cascading.

```bash
struere triggers logs order-status-change --failed
```

The output showed 47 failed runs in a 2-minute window, all with `error: Carrier API returned 429`. The timestamps were clustered within the same second.

**Status updates arrived out of order and the agent sent contradictory messages.** A FedEx shipment went from `in-transit` to `delivered` to `out-for-delivery` in the carrier's system — the "delivered" scan was a hub mis-scan that got corrected 20 minutes later. The trigger fired on `delivered`, the agent sent "Your order has been delivered!", and then the status reverted. The customer replied "No it hasn't" and the agent had no context about the reversal.

Fix: we added a `check_shipping_status` call as the first trigger action to verify the status with the carrier before the agent sends anything. If the carrier's current status does not match the trigger's status, the agent skips the notification. The carrier API is the source of truth, not the entity update.

```bash
struere triggers run <run-id> --verbose
```

The run log showed the trigger received `shippingStatus: "delivered"` but the carrier API returned `status: "out-for-delivery"`. After the fix, the agent sees the mismatch and stays silent.

**Duplicate WhatsApp messages when the same order was updated twice in rapid succession.** The warehouse system sometimes sends two update events for the same status change — one for the status field, one for the estimated delivery date. Both triggered notifications. The customer received two nearly identical "Your order has shipped" messages 4 seconds apart.

Fix: we added `entity.query` as a deduplication check in the agent's logic. Before sending a WhatsApp message, the agent checks if a notification for this order and status combination was sent in the last 10 minutes by querying recent thread history. If it finds a match, it skips the message.

```bash
struere logs list --last 20
```

The conversation list showed two threads for the same customer phone number, created 4 seconds apart, both containing identical "Your order has shipped" messages.

# 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-support
struere add trigger order-status-change
```

Edit `entity-types/order.ts` with the schema shown above. Customize the `carrier` enum if you use carriers not listed. The `shippingStatus` enum should match whatever statuses your warehouse system sends.

Edit `tools/index.ts` with the carrier tracking tool. Set your carrier API keys in the dashboard under Settings > Environment Variables: `USPS_API_KEY`, `UPS_API_KEY`, `FEDEX_API_KEY`, `DHL_API_KEY`. You only need keys for carriers you actually use.

Edit `agents/order-support.ts` with your brand name, notification templates, and escalation rules. Customize the tone section for your brand voice.

Edit `triggers/order-status-change.ts` with the condition filter matching your notification-worthy statuses.

Add your Airtable personal access token under Integrations > Airtable. This syncs order data from your Airtable order database into Struere entities.

Connect WhatsApp in the dashboard under Integrations > WhatsApp. This enables both outbound notifications and inbound customer messages.

Sync and start watching:

```bash
struere dev
```

Test the trigger by creating a test order and updating its status:

```bash
struere run-tool entity.create --args '{"type": "order", "data": {"orderId": "GLS-TEST-001", "customerName": "Test Customer", "customerPhone": "+15551234567", "shippingStatus": "processing", "trackingNumber": "9400111899223456789012", "carrier": "usps"}}'
```

Then update the status to fire the trigger:

```bash
struere run-tool entity.update --args '{"type": "order", "data": {"shippingStatus": "shipped", "estimatedDelivery": "2026-04-05"}}'
```

Watch the trigger fire and the WhatsApp message send:

```bash
struere triggers logs order-status-change
```

Verify the order was updated:

```bash
struere data list order
```

# What Changed

| Metric | Before | After |
|--------|--------|-------|
| Weekly support tickets | 200+ | ~80 |
| WISMO tickets specifically | 140/week | 15/week |
| First response time (shipping) | 4 hours avg | 12 seconds (automated) |
| Customer CSAT (shipping) | 3.1/5 | 4.6/5 |
| Hours spent on shipping tickets | 14 hrs/week | 2 hrs/week (escalations only) |
| Proactive delay notifications | 0 | 100% of delayed orders |
| Negative reviews mentioning "no communication" | 8/month | 0 |

The two part-time agents still handle the queue. But instead of spending 70% of their time on "where's my order," they spend it on product questions, returns, and the complex cases the agent escalates. Refund requests arrive in their queue with the full order context, carrier status, and conversation history already attached. They resolve them in half the time.

The carrier delay that hit 120 orders last month would play out differently now. Within seconds of the status changing to `delayed`, every affected customer would receive a WhatsApp message: "We want to keep you in the loop — your order is experiencing a shipping delay." No one would need to email. No one would need to wait 14 hours. No one would post about being ghosted.

Priya still reviews escalations every morning. But the daily ritual of opening Zendesk to a wall of "where's my order" tickets is over. The agent handles the question before the customer thinks to ask it. That is the difference between reactive support and proactive communication — and it is worth more than the 60% ticket reduction. It is the 23 negative reviews that never get written.
