Order Actions

Permission-Gated Action Abilities for Orders

⏱ 16 min

woocommerce/order-update-status and woocommerce/order-add-note are real, official, and already permission-checked at the WordPress capability level. What they are not is scoped to what an AI agent specifically should be allowed to do. Handed the raw ability, a model can set any status WooCommerce recognizes, including ones with real financial and inventory consequences, the moment it decides that’s the right call. This lesson wraps both official abilities in a custom ability that whitelists exactly which status transitions an agent may request, refuses the ones that belong to a dedicated, more carefully gated flow, and requires confirmation for anything that isn’t purely informational.

What you'll learn in this lesson
Why the raw official ability is too permissive for agent use
It accepts any valid WooCommerce order status, not just the ones you've decided an agent should be able to request.
A status-transition whitelist enforced in permission_callback
Checking both the order's current status and the requested target, the object-state pattern from Course 4.
Confirmation-gating the transitions that actually move inventory
Using Course 3's propose-then-confirm pattern for anything beyond a note.
Why "refunded" is refused here entirely
Setting a status label is not the same as actually refunding money, and conflating them is a real, common bug.
Prerequisites

WooCommerce 10.9+ for woocommerce/order-update-status and woocommerce/order-add-note. Comfortable with wp_get_ability( ... )->execute() from Course 7, and with the propose-then-confirm token pattern from Course 3, which this lesson reuses rather than re-explains.

Step 1: Why the raw ability needs a wrapper, not just a role check

current_user_can( 'manage_woocommerce' ) tells you an account is generally allowed to manage store data. It says nothing about whether this specific status transition, on this specific order, right now, is one you want an agent making without a human’s explicit sign-off. WooCommerce’s own stock functions make the stakes concrete: wc_maybe_reduce_stock_levels() is hooked to woocommerce_order_status_completed, woocommerce_order_status_processing, woocommerce_order_status_on-hold, and woocommerce_payment_complete, meaning several ordinary-sounding status changes silently reduce real inventory the moment they’re set. A permission model built only on manage_woocommerce treats all of that as one undifferentiated capability. This wrapper doesn’t.

Step 2: A whitelist of transitions, checked against the order’s real state

Following Course 4’s object-and-state pattern directly, the propose ability re-fetches the order and checks both where it is and where it’s being asked to go, rather than trusting the input alone:

// File: my-plugin.php
function my_plugin_allowed_order_transitions() {
	return array(
		'pending'    => array( 'processing', 'on-hold', 'cancelled' ),
		'on-hold'    => array( 'processing', 'cancelled' ),
		'processing' => array( 'completed', 'on-hold' ),
	);
}

function my_plugin_can_request_order_transition( $input ) {
	$order = wc_get_order( $input['order_id'] ?? 0 );
	if ( ! $order ) {
		return new WP_Error( 'not_found', 'No order with that ID exists.' );
	}

	$target = $input['target_status'] ?? '';
	if ( 'refunded' === $target || 'failed' === $target ) {
		return new WP_Error(
			'use_dedicated_flow',
			'Refunds are handled by the dedicated, confirmation-gated refund ability, not this one.'
		);
	}

	$allowed = my_plugin_allowed_order_transitions();
	$current = $order->get_status();

	if ( empty( $allowed[ $current ] ) || ! in_array( $target, $allowed[ $current ], true ) ) {
		return new WP_Error(
			'transition_not_allowed',
			sprintf( 'Cannot move an order from "%s" to "%s" through this ability.', $current, $target )
		);
	}

	return current_user_can( 'manage_woocommerce' );
}

The whitelist is deliberately conservative. cancelled is reachable from pending and on-hold because cancelling an order that hasn’t shipped is genuinely reversible in practice, but completed is reachable only from processing, and refunded is refused outright, not because the underlying ability can’t set it, but because that status alone doesn’t move any money, it only changes a label. Actually refunding is Lesson 3’s job.

Setting status to 'refunded' does not refund anything

woocommerce/order-update-status will happily accept refunded as a target and change the order’s status label. It will not contact a payment gateway, calculate a refund amount, or move a single unit of currency. If an agent (or a person) sets that status directly, the order will display as refunded while the customer’s money sits untouched in your payment processor. This is a real, common mistake, and it’s exactly why this wrapper refuses that transition entirely and routes to Lesson 3’s dedicated ability instead.

Step 3: Confirmation-gating the transitions that move inventory

Following Course 3’s pattern, wrap the actual execution in a propose/confirm pair rather than executing directly once permission_callback passes:

The order-status confirm flow
1
Propose
Validates the transition against the whitelist, describes the real effect ("this will reduce stock for 3 line items"), issues a short-lived, user-bound token.
2
Human confirms in the conversation
An explicit turn, not implied by the original request.
3
Confirm
Re-validates the token and the order's current status (it may have changed since the proposal), then calls the official abilities.
// File: my-plugin.php
wp_register_ability( 'my-plugin/propose-order-status-change', array(
	'label'               => __( 'Propose an order status change', 'my-plugin' ),
	'description'         => __( 'Describes what changing an order to a target status would do, including inventory effects, and returns a confirmation token. Does not change anything.', 'my-plugin' ),
	'input_schema'        => array(
		'type'       => 'object',
		'properties' => array(
			'order_id'      => array( 'type' => 'integer' ),
			'target_status' => array( 'type' => 'string' ),
			'note'          => array( 'type' => 'string' ),
		),
		'required'   => array( 'order_id', 'target_status' ),
	),
	'permission_callback' => 'my_plugin_can_request_order_transition',
	'execute_callback'    => function ( $input ) {
		$order  = wc_get_order( $input['order_id'] );
		$token  = wp_generate_password( 24, false );
		$stock_note = in_array( $input['target_status'], array( 'processing', 'completed', 'on-hold' ), true )
			? ' This will trigger WooCommerce\'s stock reduction for this order\'s line items if not already reduced.'
			: '';

		set_transient(
			'agent_order_status_token_' . $token,
			array(
				'order_id' => $order->get_id(),
				'target'   => $input['target_status'],
				'note'     => $input['note'] ?? '',
				'user_id'  => get_current_user_id(),
			),
			5 * MINUTE_IN_SECONDS
		);

		return array(
			'order_id'            => $order->get_id(),
			'current_status'      => $order->get_status(),
			'target_status'       => $input['target_status'],
			'effect'              => 'Order status will change to "' . $input['target_status'] . '".' . $stock_note,
			'confirmation_token'  => $token,
		);
	},
	'meta' => array( 'mcp' => array( 'public' => false ) ),
) );

wp_register_ability( 'my-plugin/confirm-order-status-change', array(
	'label'               => __( 'Confirm and execute an order status change', 'my-plugin' ),
	'description'         => __( 'Executes a status change previously described by my-plugin/propose-order-status-change. Requires the confirmation_token from that call.', 'my-plugin' ),
	'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     = 'agent_order_status_token_' . $input['confirmation_token'];
		$pending = get_transient( $key );

		if ( ! $pending || $pending['user_id'] !== get_current_user_id() ) {
			return new WP_Error( 'invalid_or_expired_token', 'This confirmation has expired or was never issued.' );
		}
		delete_transient( $key );

		wp_get_ability( 'woocommerce/order-add-note' )->execute( array(
			'order_id' => $pending['order_id'],
			'note'     => 'Agent-requested status change, confirmed by user: ' . $pending['note'],
		) );

		$result = wp_get_ability( 'woocommerce/order-update-status' )->execute( array(
			'order_id' => $pending['order_id'],
			'status'   => $pending['target'],
		) );

		return array( 'updated' => true, 'order_id' => $pending['order_id'], 'new_status' => $pending['target'] );
	},
	'meta' => array( 'mcp' => array( 'public' => false ) ),
) );

Both the note and the status change go through the official abilities via wp_get_ability()->execute(), not WC_Order methods directly, keeping this in step with whatever validation WooCommerce’s own abilities run and inheriting any future fixes to them automatically.

Re-check the order's status inside confirm, not just the token

An order’s real-world status can change between propose and confirm, a staff member might process it manually in the meantime. This implementation trusts the token’s recorded target but you should add a fresh $order->get_status() check against the whitelist inside confirm too if your store sees meaningful concurrent access, so a stale proposal can’t apply a transition that no longer makes sense against the order’s current state.

Test it

wp-env run cli wp eval '
$propose = wp_get_ability( "my-plugin/propose-order-status-change" )->execute( array( "order_id" => 101, "target_status" => "processing", "note" => "Payment confirmed manually" ) );
print_r( $propose );
'

Then confirm using the returned token, and separately confirm that an invalid or reused token, and a disallowed transition like pending straight to completed, both fail with a clear error rather than silently succeeding.

Testing only the transitions you expect to work

Deliberately test a disallowed transition (pending to refunded, completed back to pending) and confirm the propose step rejects it before a token is ever issued. A whitelist that’s never been tested against its own edges is a whitelist you don’t actually know works.

Recap

The official woocommerce/order-update-status and woocommerce/order-add-note abilities are the right building blocks, but they’re not scoped for agent use on their own. This wrapper adds a status-transition whitelist checked against the order’s real current state, refuses refunded and failed entirely in favor of the dedicated flow in the next lesson, and requires a propose-then-confirm round trip, built directly on Course 3’s pattern, before anything actually changes.

Resources & further reading

← From Answering to Acting: What Agentic Commerce Means Building Return and Refund Abilities Safely →