DEV Community

Cover image for Giving a WordPress Support Bot Your Real Product Data (Specs, Stock, Shipping)
AumCreate
AumCreate

Posted on

Giving a WordPress Support Bot Your Real Product Data (Specs, Stock, Shipping)

Most "AI support" setups fail in the same three places: they invent a spec, they claim something is in stock when it isn't, and they promise a delivery window nobody can honour.

The cause isn't the model. It's that the answers live in WooCommerce — in product attributes, stock tables, shipping classes and post meta — and the bot was never given a way to read them at question time.

This tutorial builds the missing piece: a small, read-only context endpoint per product, a rules layer that answers stock and shipping deterministically, and a model that only gets to paraphrase what the endpoint returned. Everything below is plain WordPress + WooCommerce code you can drop into an mu-plugins file.

The design rule: ground, don't fine-tune

Specs change when someone edits a product. Stock changes when someone buys. Shipping classes change maybe twice a year.

A fine-tune bakes all three into weights and goes stale silently. A retrieval call per product is cheaper, debuggable, and you can curl it to see exactly what the bot saw.

Step 1 — Decide what the bot may answer

Write this down before writing code, because it defines the payload:

Question type Source of truth Answered by
Specs (material, dimensions, compatibility) product attributes model, quoting the payload
Stock _stock_status, _stock, backorder setting rules
Restock date a custom meta field you maintain rules
Delivery window shipping class + your own ETA map rules
Price, discounts, contracts not in this payload hand off to a human

Two of those rows say rules, not model. That's deliberate: a language model reformatting "in_stock" into a sentence is safe. A language model deciding whether something is in stock is not.

Step 2 — A read-only context endpoint

<?php
/**
 * Plugin Name: Catalog Context
 * Description: Read-only product context for a support assistant.
 */

add_action('rest_api_init', function () {
    register_rest_route('catalog-ctx/v1', '/product/(?P<id>\d+)', array(
        'methods'             => 'GET',
        'permission_callback' => '__return_true',
        'callback'            => 'acx_product_context',
    ));
});

function acx_product_context(WP_REST_Request $request) {
    $ctx = acx_build_context((int) $request['id']);
    if (is_wp_error($ctx)) {
        return $ctx;
    }
    return rest_ensure_response($ctx);
}

function acx_build_context($id) {
    $id     = (int) $id;
    $cached = get_transient('acx_ctx_' . $id);
    if (is_array($cached)) {
        return $cached;
    }

    $product = wc_get_product($id);
    if (!$product || 'publish' !== $product->get_status()) {
        return new WP_Error('acx_not_found', 'Unknown product', array('status' => 404));
    }

    $payload = array(
        'id'       => $id,
        'name'     => $product->get_name(),
        'url'      => get_permalink($id),
        'specs'    => acx_specs($product),
        'stock'    => acx_stock($product),
        'shipping' => acx_shipping($product),
        'updated'  => gmdate('c'),
    );

    set_transient('acx_ctx_' . $id, $payload, 10 * MINUTE_IN_SECONDS);

    return $payload;
}
Enter fullscreen mode Exit fullscreen mode

Now the three builders. Specs come straight from visible attributes, so an editor changing a product updates the bot with no code change:

function acx_specs(WC_Product $product) {
    $out = array();

    foreach ($product->get_attributes() as $attribute) {
        if (!$attribute->get_visible()) {
            continue;
        }
        $label = wc_attribute_label($attribute->get_name());

        if ($attribute->is_taxonomy()) {
            $values = wc_get_product_terms($product->get_id(), $attribute->get_name(), array('fields' => 'names'));
        } else {
            $values = $attribute->get_options();
        }

        if (!empty($values)) {
            $out[$label] = implode(', ', array_map('wp_strip_all_tags', $values));
        }
    }

    return $out;
}
Enter fullscreen mode Exit fullscreen mode

Stock is returned as a band, not a number. Publishing exact quantities invites people to do arithmetic on your inventory, and the bot never needs the digit — it needs to know whether to say "yes", "only a few left", or "no":

function acx_stock(WC_Product $product) {
    $qty = null;
    if ($product->get_manage_stock() && null !== $product->get_stock_quantity()) {
        $qty = (int) $product->get_stock_quantity();
    }

    if (!$product->is_in_stock()) {
        $band = 'out_of_stock';
    } elseif (null === $qty) {
        $band = 'in_stock';
    } elseif ($qty > 20) {
        $band = 'in_stock';
    } else {
        $band = 'low_stock';
    }

    return array(
        'band'        => $band,
        'backorders'  => $product->backorders_allowed() ? 'allowed' : 'not_allowed',
        'restock_eta' => get_post_meta($product->get_id(), '_acx_restock_eta', true) ?: null,
    );
}
Enter fullscreen mode Exit fullscreen mode

Shipping: map the product's shipping class to a window you maintain yourself, and keep the map filterable so you can change it without touching the file:

function acx_shipping(WC_Product $product) {
    $class_id  = $product->get_shipping_class_id();
    $class     = $class_id ? get_term($class_id, 'product_shipping_class') : null;
    $class_key = ($class && !is_wp_error($class)) ? $class->slug : 'standard';

    $eta_map = apply_filters('acx_shipping_eta', array(
        'standard' => '3-5 business days after dispatch',
        'bulky'    => '7-12 business days after dispatch',
    ), $product);

    return array(
        'class'      => $class_key,
        'eta'        => isset($eta_map[$class_key]) ? $eta_map[$class_key] : null,
        'dispatches' => get_post_meta($product->get_id(), '_acx_dispatch_days', true) ?: null,
    );
}
Enter fullscreen mode Exit fullscreen mode

If a product has no ETA entry, eta is null — and null is a signal to the bot to say "let me confirm that", not to guess. More on that below.

Step 3 — Invalidate the cache on the events that matter

A ten-minute cache is fine until someone drops the stock to zero. Hook the three writes that change the answer:

add_action('woocommerce_update_product', 'acx_flush_context');
add_action('woocommerce_product_set_stock', 'acx_flush_context');
add_action('save_post_product', 'acx_flush_context');

function acx_flush_context($subject) {
    $id = $subject instanceof WC_Product ? $subject->get_id() : (int) $subject;
    if ($id > 0) {
        delete_transient('acx_ctx_' . $id);
    }
}
Enter fullscreen mode Exit fullscreen mode

Variations need the same treatment — hook woocommerce_variation_set_stock and flush the parent id too, or a variable product will keep serving the pre-purchase stock band for ten minutes.

Step 4 — Answer stock and shipping with rules, not with the model

Intent routing, before anything is sent to a language model:

function acx_route_message($message, $product_id) {
    $text = strtolower($message);
    $ctx  = acx_build_context($product_id);

    if (is_wp_error($ctx)) {
        return array('mode' => 'handoff', 'answer' => null, 'ctx' => null);
    }

    if (preg_match('/\b(in stock|available|availability|how many left)\b/', $text)) {
        return array('mode' => 'rule', 'answer' => acx_stock_sentence($ctx), 'ctx' => $ctx);
    }

    if (preg_match('/\b(ship|shipping|delivery|dispatch|arrive|lead time)\b/', $text)) {
        return array('mode' => 'rule', 'answer' => acx_shipping_sentence($ctx), 'ctx' => $ctx);
    }

    return array('mode' => 'model', 'answer' => null, 'ctx' => $ctx);
}

function acx_stock_sentence(array $ctx) {
    $stock = $ctx['stock'];

    if ('out_of_stock' === $stock['band']) {
        $msg = sprintf('%s is out of stock right now.', $ctx['name']);
        if ('allowed' === $stock['backorders']) {
            $msg .= ' Backorders are open, so you can still place an order.';
        }
        if (!empty($stock['restock_eta'])) {
            $msg .= ' Next restock: ' . $stock['restock_eta'] . '.';
        }
        return $msg;
    }

    if ('low_stock' === $stock['band']) {
        return sprintf('%s is in stock, but only a few units are left.', $ctx['name']);
    }

    return sprintf('%s is in stock.', $ctx['name']);
}

function acx_shipping_sentence(array $ctx) {
    $ship = $ctx['shipping'];

    if (empty($ship['eta'])) {
        return sprintf('I need to confirm the delivery window for %s — handing this to the team.', $ctx['name']);
    }

    return sprintf('%s ships on our %s service: %s.', $ctx['name'], $ship['class'], $ship['eta']);
}
Enter fullscreen mode Exit fullscreen mode

Two of the three intents never touch the model at all, which means zero hallucination surface and near-zero latency.

Step 5 — A grounding contract for the remaining questions

For everything else (specs, compatibility, "will this fit"), send the model a tight system prompt and the JSON:

You answer questions about exactly one product.
Use only the JSON in <product>. Do not use outside knowledge.

Rules:
1. If a field is null or missing, say you will confirm it with the team. Never estimate.
2. Never state a delivery date that is not in shipping.eta.
3. Quote specs verbatim; do not round or convert units.
4. Maximum 3 sentences. End with the product URL when the answer is about a spec.
5. If the user asks about price exceptions, bulk contracts, or refunds, reply HANDOFF.

If you cannot answer from <product>, reply with exactly: HANDOFF
Enter fullscreen mode Exit fullscreen mode

Then treat the literal string HANDOFF as a routing signal, not as text to show the user.

Step 6 — Test it with curl before you wire the chat layer

curl -s "https://example.com/wp-json/catalog-ctx/v1/product/123" \
  | python3 -m json.tool
Enter fullscreen mode Exit fullscreen mode

Check four cases:

  1. A product with no shipping class → shipping.class is standard, eta is set.
  2. A product whose class has no ETA entry → eta is null, and the rule sentence says it needs confirmation.
  3. A product set to backorder → stock.band is out_of_stock, backorders is allowed.
  4. A variable product → each variation id returns its own context; flush on variation stock change.

If any of those four are wrong, the bot will be wrong in production, and no prompt tuning will fix it.

Step 7 — Make the handoff a first-class path

Set the escalation trigger in the routing layer, not in the prompt: refunds, bulk or contract pricing, complaints, and anything the model tagged HANDOFF.

This is the part most builds get wrong — they escalate by opening a ticket and telling the user to email in. The conversation should continue with a person in the same thread, with the product context already attached so the customer doesn't repeat themselves.

That relay — rules first, then the model, then a human taking over inside the same conversation — is how AumChat is built; it runs as a standalone service, with an installer plugin that puts it on a WordPress site. There's a public demo where you can see the shop-owner side without registering: chat.aumcreate.com/demo. If you want the setup described above running against your own catalog, start here: aumcreate.com/aumchat.

The checklist

  • [ ] Specs read from visible attributes, never hard-coded in a prompt
  • [ ] Stock returned as a band, with backorder state
  • [ ] Shipping ETA from your own filterable map, null when unknown
  • [ ] Cache invalidated on product save and stock change
  • [ ] Stock and shipping answered by rules, not by the model
  • [ ] null fields produce "let me confirm", never an estimate
  • [ ] Handoff continues in the same conversation

Get those seven right and the bot stops being a liability on the three questions customers ask most.

Top comments (0)