This is the first ability in this course that isn’t primarily about restraining a
destructive action, it’s about generating outbound customer communication responsibly.
That still deserves care: an AI-written message sent to a real customer under your
store’s name is a reputational and legal surface, not just a text-generation exercise.
This lesson builds a cart-recovery ability grounded in WooCommerce’s real persistent
cart data, phrases a recovery message with wp_ai_client_prompt() constrained to the
actual items in the cart, and stops short of sending anything until a person reviews and
approves it.
Comfortable with wp_ai_client_prompt() from the PHP AI Client SDK course, and with the
draft-only publishing pattern from
Course 3.
This lesson applies that same draft-only posture to outbound email rather than content.
Step 1: What WooCommerce’s persistent cart actually gives you
WooCommerce stores a logged-in customer’s cart in user meta, under the key
_woocommerce_persistent_cart_{blog_id}, specifically so a returning, authenticated
customer sees their cart again even after their session expires. That’s a real,
built-in mechanism, and it’s the only cart-persistence WooCommerce ships without a
dedicated cart-abandonment plugin. Be honest about what it doesn’t cover: guest
checkouts have no equivalent built-in persistence, WooCommerce’s cart session for a
guest lives only in a transient tied to that browser session, typically expiring within
48 hours. If your store relies heavily on guest checkout, this ability only reaches
part of your actual abandoned-cart volume without an additional, custom
session-tracking layer, which is genuinely more work than this lesson covers.
Step 2: Tracking when a cart was actually last touched
The persistent cart meta alone has no timestamp, so track one yourself on the hooks that fire when a logged-in customer’s cart changes:
// File: my-plugin.php
add_action( 'woocommerce_add_to_cart', 'my_plugin_track_cart_activity' );
add_action( 'woocommerce_cart_item_removed', 'my_plugin_track_cart_activity' );
add_action( 'woocommerce_after_cart_item_quantity_update', 'my_plugin_track_cart_activity' );
function my_plugin_track_cart_activity() {
if ( is_user_logged_in() ) {
update_user_meta( get_current_user_id(), '_agent_cart_last_updated', time() );
}
}
Step 3: The scan ability, read-only, Tier 1 in this course’s risk model
// File: my-plugin.php
wp_register_ability( 'my-plugin/scan-abandoned-carts', array(
'label' => __( 'Scan for abandoned logged-in customer carts', 'my-plugin' ),
'description' => __( 'Finds logged-in customers with a non-empty persistent cart untouched for at least the given number of hours. Read-only, does not contact anyone.', 'my-plugin' ),
'input_schema' => array(
'type' => 'object',
'properties' => array(
'min_hours_idle' => array( 'type' => 'integer', 'default' => 24 ),
),
),
'permission_callback' => function () {
return current_user_can( 'manage_woocommerce' );
},
'execute_callback' => function ( $input ) {
$min_idle_seconds = ( $input['min_hours_idle'] ?? 24 ) * HOUR_IN_SECONDS;
$blog_id = get_current_blog_id();
$users = get_users( array(
'meta_key' => '_woocommerce_persistent_cart_' . $blog_id,
'meta_compare' => 'EXISTS',
'number' => 100,
) );
$candidates = array();
foreach ( $users as $user ) {
$last_updated = (int) get_user_meta( $user->ID, '_agent_cart_last_updated', true );
if ( ! $last_updated || ( time() - $last_updated ) < $min_idle_seconds ) {
continue;
}
$cart_data = get_user_meta( $user->ID, '_woocommerce_persistent_cart_' . $blog_id, true );
$items = $cart_data['cart'] ?? array();
if ( empty( $items ) ) {
continue;
}
$product_names = array();
foreach ( $items as $item ) {
$product = wc_get_product( $item['product_id'] ?? 0 );
if ( $product ) {
$product_names[] = $product->get_name();
}
}
if ( ! empty( $product_names ) ) {
$candidates[] = array(
'user_id' => $user->ID,
'email' => $user->user_email,
'products' => $product_names,
'idle_hours' => floor( ( time() - $last_updated ) / HOUR_IN_SECONDS ),
);
}
}
return array( 'candidates' => $candidates );
},
'meta' => array(
'annotations' => array( 'readonly' => true ),
'mcp' => array( 'public' => false ),
),
) );
Step 4: Drafting a message constrained to the real cart contents
// File: my-plugin.php
function my_plugin_draft_recovery_message( $candidate ) {
$prompt = sprintf(
"Write a short, friendly cart-recovery email for a customer who left these exact " .
"products in their cart: %s. Do not mention any product not in this list, do not " .
"invent a discount or promise a specific price change. Sign off as the store team.",
wp_json_encode( $candidate['products'] )
);
$draft = wp_ai_client_prompt( $prompt )->generate_text();
$draft_id = wp_insert_post( array(
'post_type' => 'agent_email_draft',
'post_status' => 'draft',
'post_title' => 'Cart recovery draft for ' . $candidate['email'],
'post_content' => $draft,
'meta_input' => array(
'_recipient_user_id' => $candidate['user_id'],
'_recipient_email' => $candidate['email'],
'_cart_products' => $candidate['products'],
),
) );
return $draft_id;
}
Storing the draft as a real post, in a custom agent_email_draft post type, gives staff
a familiar review surface (they can open it in wp-admin like any other draft) and gives
you a durable, auditable record of exactly what was generated before anyone approves it.
wp_insert_post() with post_status => 'draft' creates a reviewable record, it does
not call wp_mail(). Sending should be a deliberate, separate action a staff member
takes after reading the draft, ideally through your normal wp-admin publishing flow
rather than a one-click “approve and send” button that reintroduces the same
no-human-actually-read-it risk this pattern exists to avoid.
Test it
wp-env run cli wp eval '
$scan = wp_get_ability( "my-plugin/scan-abandoned-carts" )->execute( array( "min_hours_idle" => 12 ) );
print_r( $scan["candidates"] );
'
For each candidate, confirm every product named in the generated draft also appears in
that candidate’s real products list, exactly like the anti-hallucination check from
Course 7’s recommendation lesson.
If a meaningful share of your customers check out as guests, this ability structurally can’t see their abandoned carts, since WooCommerce doesn’t persist guest cart data beyond a short session. Don’t report recovery-candidate counts from this ability as “total abandoned carts” to stakeholders, it’s specifically logged-in abandoned carts, and conflating the two will misrepresent your actual cart-abandonment rate.
Recap
Cart recovery here is built on WooCommerce’s real persistent-cart user meta, narrowed to
carts genuinely idle past a threshold you track yourself, since the meta alone carries
no timestamp. The recovery message is generated with wp_ai_client_prompt() under a hard
constraint against inventing products or prices, and it’s saved as a draft post for a
human to read and send, never sent automatically. The honest limitation, guest carts
aren’t covered, is worth stating to anyone using this ability’s output, not glossed over.
Resources & further reading
- WooCommerce Cart Sessions and Persistent Cart Explained, businessbloomer.com
- Letting Agents Create and Publish Content Safely, this site
- PHP AI Client SDK repository, GitHub