Foundation

Building a Custom WooCommerce Inventory Ability

⏱ 16 min

Lesson 1 covered the seven official WooCommerce abilities, and none of them reach down to the variation level or handle bulk stock scanning. woocommerce/product-update can change a simple product’s stock, but a store selling shirts in five sizes and four colors needs something that reasons about individual variations, and a low-stock sweep across the whole catalog. That’s a real gap, and it’s exactly the kind of thing this course’s custom abilities are for.

What you'll learn in this lesson
A read-only "find low-stock variations" ability
Scans variable products and their variations for stock below a threshold.
A write "adjust variant stock" ability
Uses wc_get_product(), set_stock_quantity(), set_stock_status(), and save().
Registering a dedicated inventory category
Keeping custom abilities discoverable alongside WooCommerce's own.
Correct permission scoping for inventory writes
Why manage_woocommerce, not edit_posts, is the right capability check here.
Prerequisites

A WooCommerce install with at least one variable product (varying by size, color, or similar) and its variations carrying individual stock quantities. WordPress 6.9+ for the Abilities API. WooCommerce version doesn’t need to be 10.9+ for this lesson, the functions used here (wc_get_product(), set_stock_quantity()) have been stable in WooCommerce for years.

Step 1: Registering a category for your custom abilities

Keep custom abilities visually and organizationally distinct from WooCommerce’s own, so anyone (or any agent) browsing the list can tell them apart:

// File: my-plugin.php
add_action( 'wp_abilities_api_categories_init', function () {
	wp_register_ability_category(
		'store-inventory',
		array( 'label' => __( 'Store inventory (custom)', 'my-plugin' ) )
	);
} );

Step 2: A read-only ability, finding low-stock variations

This ability loops every variable product, checks each variation’s stock quantity against a threshold, and returns a flat list, exactly the kind of catalog-wide sweep woocommerce/products-query isn’t built for.

// File: my-plugin.php
add_action( 'wp_abilities_api_init', function () {
	wp_register_ability(
		'my-plugin/find-low-stock-variations',
		array(
			'label'         => __( 'Find low-stock product variations', 'my-plugin' ),
			'description'   => __( 'Scans all variable products and returns variations at or below a given stock threshold.', 'my-plugin' ),
			'category'      => 'store-inventory',
			'input_schema'  => array(
				'type'       => 'object',
				'properties' => array(
					'threshold' => array( 'type' => 'integer', 'default' => 5 ),
				),
			),
			'output_schema' => array(
				'type'       => 'object',
				'properties' => array(
					'variations' => array( 'type' => 'array' ),
				),
			),
			'permission_callback' => function () {
				return current_user_can( 'manage_woocommerce' );
			},
			'execute_callback' => function ( $input ) {
				$threshold = $input['threshold'] ?? 5;
				$low_stock = array();

				$variable_products = wc_get_products( array(
					'type'   => 'variable',
					'limit'  => -1,
					'status' => 'publish',
				) );

				foreach ( $variable_products as $parent ) {
					foreach ( $parent->get_children() as $variation_id ) {
						$variation = wc_get_product( $variation_id );
						if ( ! $variation || ! $variation->managing_stock() ) {
							continue;
						}
						$qty = $variation->get_stock_quantity();
						if ( null !== $qty && $qty <= $threshold ) {
							$low_stock[] = array(
								'variation_id' => $variation_id,
								'parent_name'  => $parent->get_name(),
								'attributes'   => $variation->get_attributes(),
								'stock'        => $qty,
							);
						}
					}
				}

				return array( 'variations' => $low_stock );
			},
			'meta' => array(
				'annotations' => array( 'readonly' => true ),
				'mcp'         => array( 'public' => true ),
			),
		)
	);
} );

Step 3: A write ability, adjusting one variation’s stock

This is the write half: given a variation ID and a new quantity, it updates stock and keeps stock status consistent.

// File: my-plugin.php
add_action( 'wp_abilities_api_init', function () {
	wp_register_ability(
		'my-plugin/adjust-variant-stock',
		array(
			'label'         => __( 'Adjust product variation stock', 'my-plugin' ),
			'description'   => __( 'Sets the stock quantity for a specific product variation and updates its stock status accordingly.', 'my-plugin' ),
			'category'      => 'store-inventory',
			'input_schema'  => array(
				'type'       => 'object',
				'properties' => array(
					'variation_id' => array( 'type' => 'integer' ),
					'quantity'     => array( 'type' => 'integer', 'minimum' => 0 ),
				),
				'required'   => array( 'variation_id', 'quantity' ),
			),
			'output_schema' => array(
				'type'       => 'object',
				'properties' => array(
					'success'      => array( 'type' => 'boolean' ),
					'new_status'   => array( 'type' => 'string' ),
				),
			),
			'permission_callback' => function () {
				return current_user_can( 'manage_woocommerce' );
			},
			'execute_callback' => function ( $input ) {
				$variation = wc_get_product( $input['variation_id'] );
				if ( ! $variation ) {
					return array( 'success' => false, 'new_status' => '' );
				}

				$variation->set_stock_quantity( $input['quantity'] );
				$variation->set_stock_status( $input['quantity'] > 0 ? 'instock' : 'outofstock' );
				$variation->save();

				return array(
					'success'    => true,
					'new_status' => $variation->get_stock_status(),
				);
			},
			'meta' => array(
				'annotations' => array( 'readonly' => false, 'destructive' => false, 'idempotent' => true ),
				'mcp'         => array( 'public' => false ),
			),
		)
	);
} );

Notice the write ability keeps meta.mcp.public false, unlike the read-only sweep. An agent finding low-stock variations is safe to expose broadly, an agent adjusting real stock numbers is not, until you’ve decided who’s allowed to trigger it and reviewed that decision.

Step 4: Why manage_woocommerce, not edit_posts

Choosing the right capability check
1
edit_posts is too broad and too narrow at once
It would let any contributor-level content editor adjust stock, and it says nothing about WooCommerce-specific permissions.
2
manage_woocommerce is the capability WooCommerce itself uses
It's the same check WooCommerce's own admin screens use for store management, so your ability's access model matches the rest of the store.
3
Match your permission_callback to the blast radius
The read-only sweep is low-risk enough to allow more broadly, the write ability touches real inventory and deserves the stricter check.

Test it

wp-env run cli wp eval '
$ability = wp_get_ability( "my-plugin/find-low-stock-variations" );
var_dump( $ability->execute( array( "threshold" => 10 ) ) );
'

Then test the write path against a known variation ID from that output:

wp-env run cli wp eval '
$ability = wp_get_ability( "my-plugin/adjust-variant-stock" );
var_dump( $ability->execute( array( "variation_id" => 123, "quantity" => 20 ) ) );
'
Forgetting save(), or checking managing_stock() out of order

WC_Product objects (variations included) are change-tracked, calling set_stock_quantity() alone does nothing to the database until save() runs. Just as common: checking get_stock_quantity() before confirming managing_stock() is even true for that variation, a variation not managing its own stock returns null for quantity, which your low-stock comparison needs to explicitly skip, not silently treat as zero.

Recap

The official abilities cover simple-product stock changes but not variation-level sweeps or targeted adjustments, so a custom ability pair fills that gap: a read-only scan built on wc_get_products() and get_stock_quantity(), and a write ability built on set_stock_quantity(), set_stock_status(), and save(). Scoping the write ability’s permission check to manage_woocommerce and keeping it off meta.mcp.public until reviewed follows the same default-safe pattern from Course 1.

Resources & further reading

← WooCommerce's Official MCP Abilities: What's Already There Project: Product Description Generator →