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.
