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;
}
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;
}
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,
);
}
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,
);
}
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);
}
}
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']);
}
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
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
Check four cases:
- A product with no shipping class →
shipping.classisstandard,etais set. - A product whose class has no ETA entry →
etaisnull, and the rule sentence says it needs confirmation. - A product set to backorder →
stock.bandisout_of_stock,backordersisallowed. - 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,
nullwhen unknown - [ ] Cache invalidated on product save and stock change
- [ ] Stock and shipping answered by rules, not by the model
- [ ]
nullfields 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)