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.
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.
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
- MCP & Abilities API in WooCommerce 10.9, developer.woocommerce.com
- PHP AI Client SDK repository, GitHub
- Abilities API reference, developer.wordpress.org