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.
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.
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:
// 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.
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.
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
- Building Permission-Gated Tools, this site
- Designing Confirmation-Gated Agent Actions, this site
- WC_Order class reference, woocommerce.github.io
- WooCommerce stock functions reference, woocommerce.github.io