Growth Actions

Cart Recovery and Upsell Flows

⏱ 15 min

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.

What you'll learn in this lesson
WooCommerce's real persistent cart mechanism, and its real limits
The _woocommerce_persistent_cart_{blog_id} user meta key, logged-in customers only, no built-in guest tracking.
Detecting a genuinely abandoned cart, not just an old one
Combining persistent cart data with a last-activity timestamp you track yourself.
Generating a message constrained to real cart contents
The same anti-hallucination prompt pattern used for recommendations in Course 7.
Draft-only by default
A staff member approves and sends, the ability never emails a customer directly.
Prerequisites

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.

Nothing in this lesson sends an email

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.

Assuming this covers your whole abandoned-cart volume

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

← Processing Payments Through an Agent: Where to Draw the Line Conversational Product Search →