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:

  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.