Worth being precise about what WooCommerce already does here, because it’s easy to assume more than it actually offers. WooCommerce’s built-in “related products” feature is similarity-based, not behavior-based: two products are considered related if they share a category or tag, full stop. There’s no native, built-in “customers who bought X also bought Y” feature computed from actual purchase behavior. Third-party services (Nosto, Barilliance, LimeSpot, and similar) fill that gap commercially, usually by sending customer data to their own servers. This lesson builds a much simpler, self-hosted version of the same idea, grounded in your own order data.
WooCommerce with meaningful order history involving multiple products per order, a
handful of single-item test orders won’t produce useful co-purchase signal. No
specific WooCommerce version requirement, this lesson uses wc_get_orders()
directly rather than the 10.9 abilities.
Step 1: Computing co-purchase counts
// File: my-plugin.php
function my_plugin_compute_co_purchases( $product_id, $limit = 200 ) {
$cache_key = 'my_plugin_co_purchase_' . $product_id;
$cached = get_transient( $cache_key );
if ( false !== $cached ) {
return $cached;
}
$orders = wc_get_orders( array( 'status' => array( 'completed', 'processing' ), 'limit' => $limit ) );
$co_counts = array();
foreach ( $orders as $order ) {
$product_ids_in_order = array();
foreach ( $order->get_items() as $item ) {
$product_ids_in_order[] = $item->get_product_id();
}
if ( ! in_array( $product_id, $product_ids_in_order, true ) ) {
continue;
}
foreach ( $product_ids_in_order as $other_id ) {
if ( $other_id === $product_id ) {
continue;
}
$co_counts[ $other_id ] = ( $co_counts[ $other_id ] ?? 0 ) + 1;
}
}
arsort( $co_counts );
$top = array_slice( $co_counts, 0, 5, true );
set_transient( $cache_key, $top, 6 * HOUR_IN_SECONDS );
return $top;
}
This is a deliberately simple approach, order-level co-occurrence counts, not a weighted or normalized collaborative-filtering model. It’s honest about that simplicity rather than dressing it up as something more sophisticated than it is.
Step 2: Registering the ability
// File: my-plugin.php
add_action( 'wp_abilities_api_init', function () {
wp_register_ability(
'my-plugin/recommend-related-products',
array(
'label' => __( 'Recommend related products', 'my-plugin' ),
'description' => __( 'Suggests products frequently purchased alongside a given product, based on real order history, then phrases the suggestion in plain language.', 'my-plugin' ),
'category' => 'store-inventory',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'product_id' => array( 'type' => 'integer' ),
),
'required' => array( 'product_id' ),
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'candidates' => array( 'type' => 'array' ),
'blurb' => array( 'type' => 'string' ),
),
),
'permission_callback' => '__return_true',
'execute_callback' => function ( $input ) {
$co_purchases = my_plugin_compute_co_purchases( $input['product_id'] );
if ( empty( $co_purchases ) ) {
return array( 'candidates' => array(), 'blurb' => '' );
}
$candidates = array();
foreach ( $co_purchases as $id => $count ) {
$p = wc_get_product( $id );
if ( $p ) {
$candidates[] = array( 'id' => $id, 'name' => $p->get_name(), 'co_purchase_count' => $count );
}
}
$prompt = sprintf(
"Write a one-sentence 'customers also bought' suggestion using ONLY these products, " .
"do not add any product not listed: %s",
wp_json_encode( $candidates )
);
$blurb = wp_ai_client_prompt( $prompt )->generate_text();
return array( 'candidates' => $candidates, 'blurb' => trim( $blurb ) );
},
'meta' => array(
'annotations' => array( 'readonly' => true ),
'mcp' => array( 'public' => true ),
),
)
);
} );
Note the order of operations: $candidates is fully computed by
my_plugin_compute_co_purchases(), real PHP, real order data, before the model ever
sees a single product name. The AI Client SDK call in this ability only phrases a
sentence, it has no ability to introduce a product that wasn’t in the candidate list.
Step 3: Why caching matters here specifically
wc_get_orders() over hundreds of orders on every single product-page view would be
expensive to run live, so the six-hour transient cache in Step 1 is not optional
polish, it’s the difference between this being viable on a real store versus a
noticeable slowdown on every page load. Widen or shorten the cache window based on how
often your order volume meaningfully shifts the top candidates.
Test it
wp-env run cli wp eval '
$ability = wp_get_ability( "my-plugin/recommend-related-products" );
$result = $ability->execute( array( "product_id" => 42 ) );
print_r( $result["candidates"] );
echo $result["blurb"] . "\n";
'
Confirm every product named in the blurb also appears in candidates, if it doesn’t,
the prompt constraint needs tightening.
If your prompt is too loosely worded (“suggest some related products” instead of “use
ONLY these products”), a capable model may recall product names from earlier in the
same conversation or session and slip one into the blurb that was never in your
computed candidate list. Always phrase the instruction as a hard constraint, and treat
any output that names something outside candidates as a prompt bug to fix, not
something to work around downstream.
Recap
This recommendation ability computes real co-purchase counts from order line items,
caches the expensive part, and uses wp_ai_client_prompt() purely to phrase a sentence
around candidates your own PHP already selected. It’s a meaningfully simpler approach
than WooCommerce’s native category/tag-based related products (which isn’t
behavior-based at all) and a much lighter-weight one than dedicated commercial
recommendation services, appropriate for stores that want something self-hosted and
data-grounded rather than a full recommendation platform.
Resources & further reading
- WC_Order_Item::get_product_id() reference, woocommerce.github.io
- Related Products in WooCommerce, woocommerce.com
- PHP AI Client SDK repository, GitHub