Skip to main content
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:
tools/cart-tools.ts
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

agent.ts
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

main.ts
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:
recovery.ts
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 });
  }
}
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 example.

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

KV Store

TTL, namespaces and pattern search reference

Memory

How memory and identify() work

Schedule

CRON triggers and scheduled callbacks

SDR Follow-up

The same pattern applied to lead qualification