Growth Actions

Conversational Product Search

⏱ 14 min

Not everything in this course is Tier 2 or Tier 3. This lesson is a genuinely low-risk, Tier 1 ability, it reads product data and phrases a response, nothing more, and it earns its place in this course as the payoff for building customer-facing agentic commerce responsibly: a real shopping-assistant search experience, built from a natural-language query, structured product filters, and an official WooCommerce ability, with no invented products in the response.

What you'll learn in this lesson
Splitting "understand the request" from "query the catalog"
wp_ai_client_prompt() turns free text into structured filters, woocommerce/products-query does the actual search.
Constraining the model's output to a fixed filter schema
So a vague query becomes valid, safe input to a real query, not free-form guesswork.
Phrasing an answer that can only reference real results
The same grounding discipline used in Course 7 and Lesson 5 of this course.
Why this ability can reasonably be public-facing
A read-only search doesn't carry the same risk as anything in Lessons 2 through 5.
Prerequisites

WooCommerce 10.9+ for woocommerce/products-query. Comfortable with wp_ai_client_prompt() and constrained/structured output from the PHP AI Client SDK course.

Step 1: Parsing the query into a fixed filter shape

Ask the model for exactly one thing: a structured object matching a fixed schema, never a product list of its own invention.

// File: my-plugin.php
function my_plugin_parse_shopping_query( $query_text ) {
	$prompt = sprintf(
		"Convert this shopper's request into a JSON object with exactly these keys: " .
		"search (string, keywords only), min_price (number or null), max_price (number " .
		"or null), category (string or null). Do not include any other keys. Do not " .
		"invent a category name, only extract one if the shopper clearly named one. " .
		"Request: %s",
		$query_text
	);

	$raw = wp_ai_client_prompt( $prompt )->generate_text();
	$parsed = json_decode( $raw, true );

	return is_array( $parsed ) ? $parsed : array( 'search' => $query_text );
}

Treat the model’s output as untrusted structured input, exactly like any other user input, validate types before it reaches a real query in the next step.

Step 2: Calling the official ability with validated filters

// File: my-plugin.php
wp_register_ability( 'my-plugin/conversational-product-search', array(
	'label'       => __( 'Search products from a natural-language request', 'my-plugin' ),
	'description' => __( 'Parses a shopper\'s free-text request into structured filters, then searches the real catalog. Only ever describes products actually returned by the search.', 'my-plugin' ),
	'input_schema' => array(
		'type'       => 'object',
		'properties' => array( 'query' => array( 'type' => 'string' ) ),
		'required'   => array( 'query' ),
	),
	'output_schema' => array(
		'type'       => 'object',
		'properties' => array(
			'products' => array( 'type' => 'array' ),
			'reply'    => array( 'type' => 'string' ),
		),
	),
	'permission_callback' => '__return_true',
	'execute_callback' => function ( $input ) {
		$filters = my_plugin_parse_shopping_query( $input['query'] );

		$query_args = array( 'status' => 'publish' );
		if ( ! empty( $filters['search'] ) && is_string( $filters['search'] ) ) {
			$query_args['search'] = sanitize_text_field( $filters['search'] );
		}
		if ( isset( $filters['min_price'] ) && is_numeric( $filters['min_price'] ) ) {
			$query_args['min_price'] = (float) $filters['min_price'];
		}
		if ( isset( $filters['max_price'] ) && is_numeric( $filters['max_price'] ) ) {
			$query_args['max_price'] = (float) $filters['max_price'];
		}
		if ( ! empty( $filters['category'] ) && is_string( $filters['category'] ) ) {
			$query_args['category'] = sanitize_text_field( $filters['category'] );
		}

		$results = wp_get_ability( 'woocommerce/products-query' )->execute( $query_args );
		$products = $results['products'] ?? array();

		if ( empty( $products ) ) {
			return array( 'products' => array(), 'reply' => "I couldn't find anything matching that, want to try a broader search?" );
		}

		$reply_prompt = sprintf(
			"Write one short, friendly sentence introducing these search results, using " .
			"ONLY the products listed, do not add any product not listed: %s",
			wp_json_encode( $products )
		);

		return array(
			'products' => $products,
			'reply'    => trim( wp_ai_client_prompt( $reply_prompt )->generate_text() ),
		);
	},
	'meta' => array(
		'annotations' => array( 'readonly' => true ),
		'mcp'         => array( 'public' => true ),
	),
) );

This ability calls the model twice, once to understand the request, once to phrase the result, and calls woocommerce/products-query exactly once, in between, with filters your own code validated. The model never sees an opportunity to name a product that didn’t come back from a real query.

Trusting the parsed category name without checking it exists

If a shopper’s request names a category your store doesn’t actually have, passing it straight through to woocommerce/products-query just returns zero results, which is fine, but it’s worth catching earlier if you want a more helpful reply than a bare empty list. Validate category against your real taxonomy terms if you want to tell a shopper “we don’t carry that category” instead of a generic no-results message.

Step 3: Why this one is reasonably public-facing

Compare this ability’s meta.mcp.public setting against every other ability in this course. Lessons 2 through 5 all set it false, because their actions carry real financial or operational consequences. This one is true, deliberately, because it’s Tier 1 by this course’s own model from Lesson 1: it reads published product data, already public on your storefront, and produces text describing only what it read. There is no confirmation step here because there is nothing here that needs confirming.

Test it

wp-env run cli wp eval '
$result = wp_get_ability( "my-plugin/conversational-product-search" )->execute( array(
	"query" => "something warm for under 50 dollars",
) );
print_r( $result["products"] );
echo $result["reply"] . "\n";
'

Confirm every product mentioned in reply also appears in products, and try a query naming a category or price range your catalog genuinely doesn’t have, to confirm the empty-result path reads as helpful rather than broken.

Recap

A conversational search experience splits cleanly into two model calls around one real catalog query: parse the free-text request into a fixed, validated filter shape, run that filter through the official woocommerce/products-query ability, then phrase a reply constrained to exactly the products that came back. Because the ability only reads already-public catalog data, it’s the one ability in this course reasonable to expose publicly without a confirmation gate.

Resources & further reading

← Cart Recovery and Upsell Flows A Hybrid Recommendation Engine →