Store Projects

Project: Natural-Language Store Manager

⏱ 18 min

Every project so far has been one ability, or a small, focused pair. This one composes several of them, inventory checks and adjustments from Lesson 2, order flagging from Lesson 5, sales summaries from Lesson 6, into a single conversational admin assistant. Combining that much capability into one surface raises the blast radius of a mistake, so this lesson leans hard on the confirmation-gating pattern from Course 3: any write action requires an explicit second step, not a single model decision.

What you'll learn in this lesson
Composing multiple abilities behind one conversational entry point
Without collapsing them into one mega-ability that loses individual permission checks.
The two-call confirmation pattern
A "propose" call that previews a change, and a separate "confirm" call that actually executes it.
Marking abilities destructive so clients can warn appropriately
meta.annotations.destructive, and why you can't rely on the model volunteering a warning itself.
One consistent, admin-only permission boundary
Applying manage_woocommerce uniformly across the whole composed set.
Prerequisites

WooCommerce 10.9+, and Lessons 2, 5, and 6 of this course completed, this lesson builds directly on their abilities. Course 3’s confirmation-gating pattern for destructive actions is assumed background, this lesson applies the same idea to WooCommerce specifically.

Step 1: Why composition, not one giant ability

Keeping abilities composed, not collapsed
1
Each ability keeps its own permission_callback
A single mega-ability would need one permission check covering everything it can do, which tends toward being either too permissive or too restrictive for at least one of its actions.
2
Each ability keeps its own annotations
A stock check and a stock write have different readonly/destructive annotations, collapsing them loses that signal for MCP clients.
3
The conversational layer is orchestration, not a new ability
It decides which existing ability to call based on the request, it doesn't reimplement their logic.
// File: my-plugin.php
function my_plugin_store_manager_available_abilities() {
	return array(
		'my-plugin/find-low-stock-variations',
		'my-plugin/adjust-variant-stock',
		'my-plugin/scan-fraud-risk-orders',
		'my-plugin/summarize-sales-trends',
	);
}

Step 2: The confirmation-gating pattern for writes

Any ability that writes (here, adjust-variant-stock) gets called through a propose/confirm pair rather than directly from a single model decision:

// File: my-plugin.php
add_action( 'wp_abilities_api_init', function () {
	wp_register_ability(
		'my-plugin/propose-stock-adjustment',
		array(
			'label'         => __( 'Propose a stock adjustment', 'my-plugin' ),
			'description'   => __( 'Previews a stock change without applying it. Returns a confirmation token required by apply-stock-adjustment.', 'my-plugin' ),
			'category'      => 'store-inventory',
			'input_schema'  => array(
				'type'       => 'object',
				'properties' => array(
					'variation_id' => array( 'type' => 'integer' ),
					'quantity'     => array( 'type' => 'integer' ),
				),
				'required'   => array( 'variation_id', 'quantity' ),
			),
			'permission_callback' => function () {
				return current_user_can( 'manage_woocommerce' );
			},
			'execute_callback' => function ( $input ) {
				$variation = wc_get_product( $input['variation_id'] );
				$token     = wp_generate_password( 12, false );
				set_transient( 'my_plugin_confirm_' . $token, $input, 5 * MINUTE_IN_SECONDS );

				return array(
					'current_quantity' => $variation ? $variation->get_stock_quantity() : null,
					'proposed_quantity' => $input['quantity'],
					'confirmation_token' => $token,
				);
			},
			'meta' => array(
				'annotations' => array( 'readonly' => true ),
				'mcp'         => array( 'public' => false ),
			),
		)
	);

	wp_register_ability(
		'my-plugin/apply-stock-adjustment',
		array(
			'label'         => __( 'Apply a confirmed stock adjustment', 'my-plugin' ),
			'description'   => __( 'Applies a stock change previously previewed by propose-stock-adjustment. Requires a valid confirmation token.', 'my-plugin' ),
			'category'      => 'store-inventory',
			'input_schema'  => array(
				'type'       => 'object',
				'properties' => array(
					'confirmation_token' => array( 'type' => 'string' ),
				),
				'required'   => array( 'confirmation_token' ),
			),
			'permission_callback' => function () {
				return current_user_can( 'manage_woocommerce' );
			},
			'execute_callback' => function ( $input ) {
				$key    = 'my_plugin_confirm_' . $input['confirmation_token'];
				$pending = get_transient( $key );
				if ( ! $pending ) {
					return array( 'success' => false, 'error' => 'Token expired or invalid, re-propose the change.' );
				}
				delete_transient( $key );

				$adjust_ability = wp_get_ability( 'my-plugin/adjust-variant-stock' );
				return $adjust_ability->execute( $pending );
			},
			'meta' => array(
				'annotations' => array( 'readonly' => false, 'destructive' => true, 'idempotent' => false ),
				'mcp'         => array( 'public' => false ),
			),
		)
	);
} );

The confirmation token expires in five minutes and is single-use (deleted on apply), so a stale or replayed confirmation can’t silently apply an old, no-longer-accurate change.

Step 3: Marking destructive actions, and not trusting the model alone

meta.annotations.destructive => true on apply-stock-adjustment gives any MCP client the information it needs to surface its own warning to a human before calling it. But that’s a hint to the client, not an enforcement mechanism your PHP can rely on. The actual enforcement is the token requirement in Step 2, an agent (or a careless client) cannot skip straight to applying a change without first calling the read-only propose step and obtaining a token tied to that specific proposed change.

Don't rely on the model volunteering a confirmation step

An AI model asked to “restock item X to 50 units” may go straight for whatever ability looks like it does that, it has no inherent notion of “ask first.” The two-ability, token-gated design in Step 2 is what actually prevents a direct write, not a hope that the model will pause and ask the user to confirm. Apply this pattern to every destructive action this composed assistant can reach, not just the one shown here.

Step 4: Uniform permission scoping across the whole set

Every ability in my_plugin_store_manager_available_abilities() should share the same floor: manage_woocommerce, checked in each one’s own permission_callback independently. Resist the temptation to check permission once at the conversational layer and skip it in the abilities themselves, abilities can still be called directly (by a script, by another plugin, by a future integration), and each needs to defend itself regardless of what called it.

Test it

wp-env run cli wp eval '
$proposal = wp_get_ability( "my-plugin/propose-stock-adjustment" )->execute( array( "variation_id" => 123, "quantity" => 40 ) );
print_r( $proposal );
$result = wp_get_ability( "my-plugin/apply-stock-adjustment" )->execute( array( "confirmation_token" => $proposal["confirmation_token"] ) );
print_r( $result );
'

Then confirm a stale token is rejected by re-running only the second call after waiting past the five-minute expiry.

Forgetting to delete the transient on use

If the confirmation token isn’t deleted the moment it’s applied, the same proposed change could be replayed multiple times within its expiry window, applying a stock adjustment repeatedly instead of once. Always treat a confirmation token as single-use, delete it as the very first thing inside the confirm ability’s execute_callback.

Recap

A natural-language store manager composes existing abilities behind one conversational entry point without collapsing their individual permission checks or annotations. Anything destructive, adjusting stock in this lesson’s example, goes through a propose/confirm pair with a short-lived, single-use token, giving you real enforcement rather than relying on the model to ask before acting.

Resources & further reading

← Project: Sales-Reporting Assistant Project: AI Product Recommendations →