> ## Documentation Index
> Fetch the complete documentation index at: https://docs.runflow.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Abandoned Cart Recovery

> A WhatsApp commerce agent that keeps cart state in the KV Store and automatically wins back customers who didn't check out

A sales agent that builds shopping carts during WhatsApp conversations, persists them in the **KV Store** (so they survive sessions, restarts and days of silence), and runs an hourly recovery job that finds abandoned carts and follows up — resuming the conversation with full **Memory** context.

This is the canonical KV + Memory split: the **cart is business state** (KV), the **conversation is context** (Memory). Neither could do the other's job.

## Project Structure

```
cart-recovery-agent/
├── main.ts            # Webhook entry point (customer messages)
├── recovery.ts        # CRON entry point (abandoned cart sweep)
├── agent.ts
├── tools/
│   └── cart-tools.ts
├── .runflow/
│   └── rf.json
├── package.json
└── tsconfig.json
```

## How It Works

```
SCENARIO 1: Customer shops via WhatsApp
────────────────────────────────────────
Customer: "quero 2 camisetas pretas M"
  → Agent calls add_to_cart → KV set carts/cart:{phone} (ttl: 24h)
  → Every cart change refreshes the 24h TTL
Customer: "fecha o pedido"
  → Agent calls checkout → payment link → KV delete cart:{phone}

SCENARIO 2: Customer goes silent with items in the cart
────────────────────────────────────────
CRON trigger fires every hour
  → recovery.ts lists carts idle for 2+ hours (KV updatedAt)
  → Skips carts already reminded (KV namespace "cart-reminders")
  → For each cart, calls agent.process() with entityType/entityValue
  → Memory loads → agent sends a natural, personalized nudge
  → Marks the reminder in KV so the customer is never nudged twice
  → Carts untouched for 24h simply expire — no cleanup job needed
```

## Step 1: Cart Tools Backed by the KV Store

The cart lives in the `carts` namespace, keyed by the customer's phone. Tools are built with a factory so the key never depends on the LLM getting a phone number right:

```typescript tools/cart-tools.ts theme={null}
import { createTool, KV } from '@runflow-ai/sdk';
import { z } from 'zod';

const CART_TTL = 24 * 3600; // cart expires after 24h of inactivity

export function createCartTools(phone: string) {
  const carts = KV.namespace('carts');
  const cartKey = `cart:${phone}`;

  const addToCart = createTool({
    id: 'add-to-cart',
    description: 'Add an item to the customer cart. Use whenever the customer decides on a product.',
    inputSchema: z.object({
      sku: z.string(),
      name: z.string(),
      quantity: z.number().min(1),
      unitPrice: z.number(),
    }),
    execute: async (item) => {
      const cart = (await carts.get<{ items: any[] }>(cartKey)) ?? { items: [] };
      cart.items.push(item);

      // Every write refreshes the TTL — activity keeps the cart alive
      await carts.set(cartKey, cart, { ttl: CART_TTL });
      return { itemCount: cart.items.length };
    },
  });

  const viewCart = createTool({
    id: 'view-cart',
    description: 'Show the current cart contents and total.',
    inputSchema: z.object({}),
    execute: async () => {
      const cart = await carts.get<{ items: any[] }>(cartKey);
      if (!cart) return { empty: true };
      const total = cart.items.reduce((s, i) => s + i.quantity * i.unitPrice, 0);
      return { items: cart.items, total };
    },
  });

  const checkout = createTool({
    id: 'checkout',
    description: 'Close the order and generate a payment link. Use when the customer confirms the purchase.',
    inputSchema: z.object({}),
    execute: async () => {
      const cart = await carts.get<{ items: any[] }>(cartKey);
      if (!cart) return { error: 'Cart is empty' };

      const order = await createOrder(phone, cart.items); // your order system
      await carts.delete(cartKey); // cart fulfilled — remove it

      return { paymentLink: order.paymentLink, orderId: order.id };
    },
  });

  return { addToCart, viewCart, checkout };
}
```

## Step 2: The Agent

```typescript agent.ts theme={null}
import { Agent, openai } from '@runflow-ai/sdk';
import { createCartTools } from './tools/cart-tools';

export function createSalesAgent(phone: string) {
  const { addToCart, viewCart, checkout } = createCartTools(phone);

  return new Agent({
    name: 'Sales Agent',
    instructions: `You sell products for ACME Store via WhatsApp.

## Tools — when to use each
- **add-to-cart**: whenever the customer decides on a product and quantity.
- **view-cart**: when the customer asks what's in the cart, or before checkout.
- **checkout**: when the customer confirms they want to buy.

## Cart recovery follow-ups
When asked to recover an abandoned cart, mention the items by name,
offer help finishing the order, and keep it short and friendly.
Never pressure. One message only.`,

    model: openai('gpt-4o'),
    tools: { addToCart, viewCart, checkout },

    memory: {
      maxTurns: 30,
      summarizeAfter: 20,
      summarizePrompt: 'Summarize: customer name, products discussed, sizes/preferences, objections.',
    },
  });
}
```

## Step 3: The Entry Point

```typescript main.ts theme={null}
import { identify } from '@runflow-ai/sdk/observability';
import { createSalesAgent } from './agent';

export default async function main(input: { message: string; phone: string }) {
  // Bind conversation memory to the customer
  identify(input.phone);

  const agent = createSalesAgent(input.phone);
  return agent.process({ message: input.message });
}
```

## Step 4: The Recovery Sweep

A CRON trigger (hourly) scans the `carts` namespace. `listEntries()` returns `updatedAt` for every entry, so finding idle carts requires no extra bookkeeping — the KV Store already tracks the last write:

```typescript recovery.ts theme={null}
import { KV } from '@runflow-ai/sdk';
import { createSalesAgent } from './agent';

const IDLE_MS = 2 * 60 * 60 * 1000; // nudge after 2h of silence

export default async function recovery() {
  const carts = KV.namespace('carts');
  const reminders = KV.namespace('cart-reminders');

  const { items } = await carts.listEntries({ pattern: 'cart:*', limit: 500 });
  const cutoff = Date.now() - IDLE_MS;

  for (const entry of items) {
    // Not idle long enough yet
    if (new Date(entry.updatedAt).getTime() > cutoff) continue;

    // Already reminded — never nudge twice (idempotency via KV)
    if (await reminders.has(entry.key)) continue;

    const phone = entry.key.replace('cart:', '');
    const itemNames = entry.value.items.map((i: any) => i.name).join(', ');

    const agent = createSalesAgent(phone);
    await agent.process({
      message: `The customer left these items in the cart 2+ hours ago: ${itemNames}. Send a friendly recovery message.`,
      entityType: 'phone',
      entityValue: phone,
      channel: 'whatsapp',
    });

    // Reminder marker expires with the same window as the cart
    await reminders.set(entry.key, { at: new Date().toISOString() }, { ttl: 24 * 3600 });
  }
}
```

<Note>
  Don't call `identify()` inside the loop — it sets a global singleton. Pass `entityType`/`entityValue` directly in `agent.process()`, exactly like the [SDR follow-up](/use-cases/sdr-follow-up) example.
</Note>

## Key Concepts

**Why the cart lives in KV, not Memory.** Memory is conversation context: it gets trimmed by `maxTurns` and compacted by summarization, and it's scoped to the dialogue. A cart parked inside message history could be summarized away, and reading it back would mean parsing prose. In the KV Store the cart is structured data with its own lifecycle — readable by the recovery job, the checkout tool, or a dashboard, independent of any conversation.

**Why the follow-up uses Memory.** The nudge is only natural because the agent resumes with full conversation history — the customer's name, the sizes they asked about, the objection they raised. State says *what* is in the cart; Memory says *how the conversation got there*.

**TTL as the cleanup policy.** Carts refresh their 24h TTL on every write and silently expire after a day of inactivity. The reminder markers expire on the same window. Nobody writes a cleanup job; expiration *is* the data model.

**Idempotent reminders.** The `cart-reminders` namespace is a dedup ledger: `has()` before sending, `set()` after. If the CRON fires twice or overlaps, customers still get at most one nudge.

## Next Steps

<CardGroup cols={2}>
  <Card title="KV Store" icon="database" href="/core-concepts/kv-store">
    TTL, namespaces and pattern search reference
  </Card>

  <Card title="Memory" icon="brain" href="/core-concepts/memory">
    How memory and identify() work
  </Card>

  <Card title="Schedule" icon="calendar" href="/core-concepts/schedule">
    CRON triggers and scheduled callbacks
  </Card>

  <Card title="SDR Follow-up" icon="phone" href="/use-cases/sdr-follow-up">
    The same pattern applied to lead qualification
  </Card>
</CardGroup>
