Every ability so far in this course has returned inside one request/response cycle, fast enough that an MCP client just waits for the answer. Some fulfillment work isn’t like that. Actually reshipping an order might mean calling a shipping carrier’s API, waiting on a response, updating inventory, and sending a customer notification, real work that can take seconds to minutes, not milliseconds. Blocking an agent’s tool call for that long risks a client-side timeout, and it means one slow job holds up whatever else that agent might do next. This lesson covers the actual fix: Action Scheduler, a real, widely used background job library, not raw WP-Cron.
The order-ops/reship-order ability and the handoff pattern from Lesson 3. This lesson
assumes Action Scheduler is available on your site, it ships bundled with WooCommerce and
several other popular plugins, or can be required directly as a Composer dependency
(woocommerce/action-scheduler).
Step 1: Why not just run it inline, or just use wp_schedule_event()
Running slow work inline inside execute_callback means the MCP request stays open the
whole time, at the mercy of whatever timeout the client, your web server, or a reverse proxy
in between enforces. WP-Cron’s wp_schedule_event() avoids blocking the request but brings
its own problems for this use case: it only fires on incoming site traffic, has no built-in
retry if a job fails partway, and gives you no persisted record of what ran, when, or with
what result. Action Scheduler solves exactly this gap: it persists every scheduled action as
a row in the database, retries failed jobs, logs outcomes, and lets you control concurrency,
which is why WooCommerce and a large share of the WordPress plugin ecosystem already depend
on it for background processing.
Step 2: Enqueuing the job from an ability
The ability itself stays fast. It validates the request, hands the actual work off to Action Scheduler, and returns immediately with something the agent can relay to whoever is waiting.
// File: order-ops/class-reship-order.php
wp_register_ability( 'order-ops/reship-order', array(
'label' => 'Reship an order',
'description' => 'Queues a reshipment for a confirmed lost-or-damaged order. This runs '
. 'in the background, since it involves a carrier API call; it does not complete '
. 'before this call returns. Use order-ops/get-reship-status to check on progress.',
'input_schema' => array(
'type' => 'object',
'properties' => array( 'order_id' => array( 'type' => 'integer' ) ),
'required' => array( 'order_id' ),
),
'execute_callback' => 'order_ops_reship_order',
'permission_callback' => 'order_ops_can_resolve',
'meta' => array(
'mcp' => array( 'public' => true ),
'annotations' => array( 'destructive' => true, 'idempotent' => false ),
),
) );
function order_ops_reship_order( $input ) {
$order = get_post( $input['order_id'] );
if ( ! $order ) {
return new WP_Error( 'not_found', 'No order with that ID exists.' );
}
update_post_meta( $input['order_id'], '_order_ops_reship_status', 'queued' );
$action_id = as_enqueue_async_action(
'order_ops_process_reshipment', // hook
array( 'order_id' => $order->ID ), // args
'order-ops-fulfillment', // group
true // unique: don't double-queue the same order
);
return array(
'order_id' => $order->ID,
'status' => 'queued',
'action_id' => $action_id,
);
}
add_action( 'order_ops_process_reshipment', 'order_ops_process_reshipment', 10, 1 );
function order_ops_process_reshipment( $order_id ) {
update_post_meta( $order_id, '_order_ops_reship_status', 'processing' );
$carrier_response = order_ops_call_carrier_api( $order_id ); // the genuinely slow part
if ( is_wp_error( $carrier_response ) ) {
update_post_meta( $order_id, '_order_ops_reship_status', 'failed' );
throw new Exception( $carrier_response->get_error_message() ); // lets Action Scheduler log and retry
}
update_post_meta( $order_id, '_order_ops_reship_status', 'completed' );
update_post_meta( $order_id, '_order_ops_tracking_number', $carrier_response['tracking_number'] );
}
as_enqueue_async_action() schedules the hook to run as soon as possible, in the background,
outside the current request. Passing true for unique stops the same order from being
queued twice if the agent, or a person, calls reship-order again before the first job has
finished. Throwing an exception from the hook callback on failure is deliberate, Action
Scheduler catches it, marks the action failed, and logs it, giving you the retry and
audit trail WP-Cron never provided.
Step 3: Letting the agent (and a human) check on progress
Since the ability itself returns before the job finishes, register a second, read-only ability the agent can call to check status, using the post meta the background job writes as it progresses:
// File: order-ops/class-reship-status.php
wp_register_ability( 'order-ops/get-reship-status', array(
'label' => 'Check the status of a queued reshipment',
'description' => 'Returns the current status (queued, processing, completed, failed) of '
. 'a reshipment started by order-ops/reship-order, and the tracking number once complete.',
'input_schema' => array(
'type' => 'object',
'properties' => array( 'order_id' => array( 'type' => 'integer' ) ),
'required' => array( 'order_id' ),
),
'execute_callback' => 'order_ops_get_reship_status',
'permission_callback' => 'order_ops_can_resolve',
'meta' => array( 'mcp' => array( 'public' => true ), 'annotations' => array( 'readonly' => true ) ),
) );
function order_ops_get_reship_status( $input ) {
return array(
'order_id' => $input['order_id'],
'status' => get_post_meta( $input['order_id'], '_order_ops_reship_status', true ) ?: 'unknown',
'tracking_number' => get_post_meta( $input['order_id'], '_order_ops_tracking_number', true ) ?: null,
);
}
A client’s model, told in reship-order’s own description that the job runs in the
background, will typically call get-reship-status a moment later, or tell the person it’s
talking to that the reshipment is queued and to check back, exactly the behavior you want
instead of the model waiting on a call that was never going to finish inline.
Test it
# File: terminal
wp-env run cli wp eval '
$result = wp_get_ability( "order-ops/reship-order" )->execute( array( "order_id" => 4821 ) );
var_dump( $result ); // status should be "queued", with an action_id
'
# Run the Action Scheduler queue (normally handled by its own async runner or WP-Cron)
wp-env run cli wp action-scheduler run
wp-env run cli wp eval '
var_dump( wp_get_ability( "order-ops/get-reship-status" )->execute( array( "order_id" => 4821 ) ) );
'
The second var_dump() should show completed, with a tracking number, or failed if you
deliberately break order_ops_call_carrier_api() to test the failure path, confirming
Action Scheduler actually logged the failure rather than losing it silently.
It’s tempting to reach for wp_schedule_single_event() here since it’s built into core with
no extra dependency. It runs, at best, once, whenever the next page load happens to trigger
WP-Cron, with no persisted log of success or failure and no retry if the callback throws.
Action Scheduler exists specifically because that’s not good enough for background jobs
real plugins depend on, and it’s already a dependency of WooCommerce and much of the plugin
ecosystem, reach for it directly rather than rebuilding a weaker version of it yourself.
Recap
Any ability whose real work takes long enough to risk a timeout, a carrier API call, a bulk
operation, anything with an external round trip, should enqueue that work with
as_enqueue_async_action() and return immediately with a status the agent can relay. The
background hook callback does the actual slow work and updates a status field a second,
read-only ability can report on. Action Scheduler’s database-backed persistence, retry, and
logging are exactly the guarantees raw WP-Cron doesn’t provide, which is why it’s the right
tool here rather than an invented queueing mechanism.
Resources & further reading
- Action Scheduler API documentation, GitHub
- Action Scheduler repository, GitHub
- Abilities API reference, developer.wordpress.org