Build a Shopping Assistant

In this tutorial you will build an AI-powered shopping assistant where users describe what they need and the AI guides them through browsing, selecting variants, and checking out — all through interactive UI, not a wall of text.

Ecommerce is inherently visual. Users browse product grids, pick sizes from dropdowns, and review their cart. A traditional chatbot forces all of this into plain text — “We have 3 options: 1) Nike $129, 2) Adidas $189, 3) New Balance $134. Type a number.” The display stack lets the AI show real product cards, real selectors, and real checkout forms.

Prerequisites: You should have completed Getting Started and read The Display Stack.

What you will build

A shopping assistant where a user can say “I need running shoes” and the app will:

  1. Fetch products from a server API and show them as clickable product cards (pushAndWait)
  2. Let the user pick size, color, and quantity through an interactive selector (pushAndWait)
  3. Show the running cart as a persistent card that updates as items are added (pushAndForget)
  4. Collect shipping information through a dynamic form (pushAndWait)
  5. Show a full order review and wait for confirmation before placing the order (pushAndWait + server call)

Five tools, two server routes. The AI figures out the shopping flow at runtime — if a user says “I need size 10 Nike running shoes,” the AI can skip the browse step and go straight to the variant picker with the right product pre-selected.

Understanding the architecture

This app sits between the travel planner (all client-side) and the coding agent (heavy server use). The shopping assistant needs a server for two things: product data and order processing. Everything else — variant selection, cart display, checkout forms — runs in the browser.

The do function bridges both worlds. It runs in the browser, so it can call display.pushAndWait() for UI and fetch() for server data in the same function.

1. Project setup

Start from a Next.js project with Glove installed:

terminalbash
pnpm add glove-core glove-react glove-next zod
app/api/chat/route.tstypescript
import { createChatHandler } from "glove-next";

export const POST = createChatHandler({
  provider: "openai",
  model: "gpt-4o-mini",
});

2. Server API routes

Two routes: one for product data, one for order processing.

app/api/products/route.tstypescript
import { NextResponse } from "next/server";

interface Product {
  id: string;
  name: string;
  price: number;
  description: string;
  category: string;
  sizes: string[];
  colors: string[];
}

// In a real app this would be a database query
const PRODUCTS: Product[] = [
  {
    id: "nike-pegasus",
    name: "Nike Air Zoom Pegasus",
    price: 129,
    description: "Responsive cushioning for everyday runs",
    category: "running-shoes",
    sizes: ["8", "9", "10", "11", "12"],
    colors: ["Black", "White", "Blue"],
  },
  {
    id: "adidas-ultra",
    name: "Adidas Ultraboost",
    price: 189,
    description: "Energy-returning boost midsole",
    category: "running-shoes",
    sizes: ["8", "9", "10", "11"],
    colors: ["Black", "White", "Grey"],
  },
  {
    id: "nb-foam",
    name: "New Balance Fresh Foam X",
    price: 134,
    description: "Plush cushioning for long distances",
    category: "running-shoes",
    sizes: ["8", "9", "10", "11", "12", "13"],
    colors: ["Black", "Red", "Navy"],
  },
  {
    id: "asics-nimbus",
    name: "Asics Gel-Nimbus 26",
    price: 159,
    description: "Maximum cushion for neutral runners",
    category: "running-shoes",
    sizes: ["8", "9", "10", "11", "12"],
    colors: ["Black", "White", "Lime"],
  },
];

export async function POST(req: Request) {
  const { category } = await req.json();
  const results = PRODUCTS.filter((p) =>
    p.category.includes(category.toLowerCase().replace(/\s+/g, "-")),
  );
  return NextResponse.json({ products: results });
}
app/api/orders/route.tstypescript
import { NextResponse } from "next/server";

export async function POST(req: Request) {
  const { items, shipping } = await req.json();

  // In a real app: validate, charge payment, create order
  const orderId = `ORD-${Date.now().toString(36).toUpperCase()}`;
  const total = items.reduce(
    (sum: number, item: any) => sum + item.price * item.quantity,
    0,
  );

  return NextResponse.json({
    orderId,
    total,
    estimatedDelivery: "3-5 business days",
  });
}

Simple routes with mock data. In production you would connect these to a database and payment processor.

3. The product browser tool

This is the storefront. The AI calls it with a category, the tool fetches products from the server, and shows them as a grid of clickable cards. The user clicks one — the tool returns the full product data so the AI can move to variant selection.

Using defineTool, the display props and resolve value are fully typed through Zod schemas. The displayStrategy is set to "hide-on-complete" so the product grid disappears after the user makes a selection. A renderResult callback shows a compact summary of the selected product in its place.

lib/tools/browse-products.tsxtsx
import { z } from "zod";
import { defineTool } from "glove-react";

const productSchema = z.object({
  id: z.string(),
  name: z.string(),
  price: z.number(),
  description: z.string(),
  sizes: z.array(z.string()),
  colors: z.array(z.string()),
});

export const browseProducts = defineTool({
  name: "browse_products",
  description:
    "Search the product catalog by category and show results as " +
    "clickable cards. Blocks until the user selects a product. " +
    "Returns the selected product's full details (id, name, price, " +
    "sizes, colors).",
  inputSchema: z.object({
    category: z
      .string()
      .describe("Product category, e.g. 'running shoes', 'sneakers'"),
  }),
  displayPropsSchema: z.object({
    products: z.array(productSchema),
  }),
  resolveSchema: productSchema,
  displayStrategy: "hide-on-complete",

  async do(input, display) {
    // Fetch from server — product data lives server-side
    const res = await fetch("/api/products", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ category: input.category }),
    });
    const { products } = await res.json();

    if (!products.length) {
      return { status: "success" as const, data: "No products found in that category." };
    }

    // Show product grid and wait for selection
    const selected = await display.pushAndWait({ products });

    return {
      status: "success" as const,
      data: JSON.stringify(selected),
      renderData: { productName: selected.name },
    };
  },

  render({ props, resolve }) {
    const { products } = props;
    return (
      <div style={{ padding: 16, border: "1px solid #262626", borderRadius: 12 }}>
        <p style={{ fontSize: 12, color: "#888", marginBottom: 12 }}>
          {products.length} product{products.length !== 1 ? "s" : ""} found
        </p>
        <div
          style={{
            display: "grid",
            gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
            gap: 10,
          }}
        >
          {products.map((product) => (
            <button
              key={product.id}
              onClick={() => resolve(product)}
              style={{
                display: "flex",
                flexDirection: "column",
                gap: 6,
                padding: 14,
                border: "1px solid #333",
                borderRadius: 10,
                background: "#0a0a0a",
                color: "#ededed",
                cursor: "pointer",
                textAlign: "left",
              }}
            >
              <div
                style={{
                  width: "100%",
                  aspectRatio: "1",
                  background: "#1a1a1a",
                  borderRadius: 6,
                  display: "flex",
                  alignItems: "center",
                  justifyContent: "center",
                  fontSize: 24,
                  marginBottom: 4,
                }}
              >
                👟
              </div>
              <strong style={{ fontSize: 13 }}>{product.name}</strong>
              <span style={{ fontSize: 12, color: "#888" }}>
                {product.description}
              </span>
              <span style={{ fontSize: 15, fontWeight: 600, color: "#9ED4B8" }}>
                ${product.price}
              </span>
            </button>
          ))}
        </div>
      </div>
    );
  },

  renderResult({ data }) {
    const { productName } = data as { productName: string };
    return (
      <div style={{ fontSize: 13, color: "#888", padding: "4px 0" }}>
        Selected: {productName}
      </div>
    );
  },
});

The AI provides the category. The tool fetches products from the server, then shows a grid where each card is a button. When the user clicks a card, resolve(product) sends the full product object back to the do function, which returns it to the AI as JSON. The AI now knows the product name, price, available sizes, and colors — it has everything it needs to call pick_variant next.

After selection, the "hide-on-complete" strategy removes the product grid, and renderResult shows a compact “Selected: Nike Air Zoom Pegasus” label in its place.

4. The variant picker tool

After the user picks a product, the AI calls this tool to let them choose size, color, and quantity. The AI passes in the available options based on the product data it got from browse_products.

lib/tools/pick-variant.tsxtsx
import { z } from "zod";
import { useState, useCallback } from "react";
import { defineTool } from "glove-react";

const variantResult = z.object({
  productName: z.string(),
  price: z.number(),
  size: z.string(),
  color: z.string(),
  quantity: z.number(),
});

export const pickVariant = defineTool({
  name: "pick_variant",
  description:
    "Show a size, color, and quantity selector for a product. " +
    "Blocks until the user confirms. Returns the selected variant.",
  inputSchema: z.object({
    productName: z.string().describe("Product name to display"),
    price: z.number().describe("Product price"),
    sizes: z.array(z.string()).describe("Available sizes"),
    colors: z.array(z.string()).describe("Available colors"),
  }),
  displayPropsSchema: z.object({
    productName: z.string(),
    price: z.number(),
    sizes: z.array(z.string()),
    colors: z.array(z.string()),
  }),
  resolveSchema: variantResult,
  displayStrategy: "hide-on-complete",

  async do(input, display) {
    const variant = await display.pushAndWait({
      productName: input.productName,
      price: input.price,
      sizes: input.sizes,
      colors: input.colors,
    });
    return { status: "success" as const, data: JSON.stringify(variant) };
  },

  render({ props, resolve }) {
    const { productName, price, sizes, colors } = props;

    const [size, setSize] = useState("");
    const [color, setColor] = useState("");
    const [qty, setQty] = useState(1);

    const canSubmit = size !== "" && color !== "";
    const handleSubmit = useCallback(() => {
      if (canSubmit) {
        resolve({ productName, price, size, color, quantity: qty });
      }
    }, [canSubmit, productName, price, size, color, qty, resolve]);

    return (
      <div style={{ padding: 16, border: "1px dashed #9ED4B8", borderRadius: 12 }}>
        <p style={{ fontWeight: 600, marginBottom: 4 }}>{productName}</p>
        <p style={{ fontSize: 14, color: "#9ED4B8", marginBottom: 14 }}>
          ${price}
        </p>

        {/* Size */}
        <div style={{ marginBottom: 12 }}>
          <label style={{ display: "block", fontSize: 12, color: "#888", marginBottom: 6 }}>
            Size
          </label>
          <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
            {sizes.map((s) => (
              <button
                key={s}
                onClick={() => setSize(s)}
                style={{
                  padding: "6px 14px",
                  border: size === s ? "1px solid #9ED4B8" : "1px solid #333",
                  borderRadius: 6,
                  background: size === s ? "rgba(158,212,184,0.1)" : "#0a0a0a",
                  color: size === s ? "#9ED4B8" : "#ededed",
                  cursor: "pointer",
                  fontSize: 13,
                }}
              >
                {s}
              </button>
            ))}
          </div>
        </div>

        {/* Color */}
        <div style={{ marginBottom: 12 }}>
          <label style={{ display: "block", fontSize: 12, color: "#888", marginBottom: 6 }}>
            Color
          </label>
          <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
            {colors.map((c) => (
              <button
                key={c}
                onClick={() => setColor(c)}
                style={{
                  padding: "6px 14px",
                  border: color === c ? "1px solid #9ED4B8" : "1px solid #333",
                  borderRadius: 6,
                  background: color === c ? "rgba(158,212,184,0.1)" : "#0a0a0a",
                  color: color === c ? "#9ED4B8" : "#ededed",
                  cursor: "pointer",
                  fontSize: 13,
                }}
              >
                {c}
              </button>
            ))}
          </div>
        </div>

        {/* Quantity */}
        <div style={{ marginBottom: 14 }}>
          <label style={{ display: "block", fontSize: 12, color: "#888", marginBottom: 6 }}>
            Quantity
          </label>
          <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
            <button
              onClick={() => setQty((q) => Math.max(1, q - 1))}
              style={{
                width: 32, height: 32, border: "1px solid #333",
                borderRadius: 6, background: "#0a0a0a", color: "#ededed",
                cursor: "pointer", fontSize: 16,
              }}
            >

            </button>
            <span style={{ fontSize: 14, fontWeight: 600, minWidth: 24, textAlign: "center" }}>
              {qty}
            </span>
            <button
              onClick={() => setQty((q) => q + 1)}
              style={{
                width: 32, height: 32, border: "1px solid #333",
                borderRadius: 6, background: "#0a0a0a", color: "#ededed",
                cursor: "pointer", fontSize: 16,
              }}
            >
              +
            </button>
          </div>
        </div>

        <button
          onClick={handleSubmit}
          disabled={!canSubmit}
          style={{
            padding: "8px 20px",
            border: "none",
            borderRadius: 6,
            background: canSubmit ? "#9ED4B8" : "#333",
            color: "#0a0a0a",
            cursor: canSubmit ? "pointer" : "not-allowed",
            opacity: canSubmit ? 1 : 0.5,
            fontWeight: 600,
          }}
        >
          Add to Cart
        </button>
      </div>
    );
  },
});

Notice the React state — useState for size, color, and quantity. The render function is a full React component. When the user clicks “Add to Cart,” resolve() sends the complete variant back to the do function, which returns it to the AI as JSON. The resolveSchema ensures the resolved value is type-safe.

This is entirely client-side — no server call needed. The AI already has the available sizes and colors from the previous browse_products call, so it passes them as tool arguments.

5. The cart display tool

After adding an item, the AI shows the cart. This uses pushAndForget — the cart card appears and stays visible, but the tool does not wait. The AI can immediately ask “Would you like to add anything else?”

Since the cart is display-only, there is no resolveSchema. The displayStrategy is "hide-on-new" so that when the AI calls show_cart again with updated items, the previous cart card is automatically hidden.

lib/tools/show-cart.tsxtsx
import { z } from "zod";
import { defineTool } from "glove-react";

const cartItemSchema = z.object({
  name: z.string(),
  size: z.string(),
  color: z.string(),
  quantity: z.number(),
  price: z.number(),
});

export const showCart = defineTool({
  name: "show_cart",
  description:
    "Display the current shopping cart as a persistent card. " +
    "Shows items, quantities, prices, and total. " +
    "Does not block — the AI can continue talking.",
  inputSchema: z.object({
    items: z.array(cartItemSchema).describe("Cart items"),
  }),
  displayPropsSchema: z.object({
    items: z.array(cartItemSchema),
    subtotal: z.number(),
  }),
  displayStrategy: "hide-on-new",

  async do(input, display) {
    const subtotal = input.items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0,
    );
    await display.pushAndForget({ items: input.items, subtotal });
    return {
      status: "success" as const,
      data: `Cart displayed. ${input.items.length} item(s), subtotal $${subtotal}.`,
    };
  },

  render({ props }) {
    const { items, subtotal } = props;
    return (
      <div
        style={{
          padding: 16,
          borderRadius: 12,
          borderLeft: "3px solid #9ED4B8",
          background: "#141414",
        }}
      >
        <p style={{ fontWeight: 600, marginBottom: 10 }}>
          Cart ({items.length} item{items.length !== 1 ? "s" : ""})
        </p>
        <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
          {items.map((item, i) => (
            <div
              key={i}
              style={{
                display: "flex",
                justifyContent: "space-between",
                alignItems: "center",
                padding: "8px 10px",
                borderRadius: 6,
                background: "#0a0a0a",
              }}
            >
              <div>
                <span style={{ fontSize: 13, fontWeight: 500 }}>{item.name}</span>
                <span style={{ fontSize: 11, color: "#888", marginLeft: 8 }}>
                  {item.size} · {item.color} · Qty {item.quantity}
                </span>
              </div>
              <span style={{ fontSize: 13, fontWeight: 600, color: "#9ED4B8" }}>
                ${item.price * item.quantity}
              </span>
            </div>
          ))}
        </div>
        <div
          style={{
            display: "flex",
            justifyContent: "space-between",
            marginTop: 10,
            paddingTop: 10,
            borderTop: "1px solid #262626",
          }}
        >
          <span style={{ fontSize: 13, color: "#888" }}>Subtotal</span>
          <span style={{ fontSize: 14, fontWeight: 700 }}>${subtotal}</span>
        </div>
      </div>
    );
  },
});

The AI decides when to show the cart — typically after an item is added, but also whenever the user asks “what's in my cart?” Because the AI maintains the cart state in the conversation, it can rebuild the items array any time.

6. The shipping form tool

When the user is ready to check out, the AI collects shipping info through a form. This is the same pushAndWait pattern as the travel planner's collect_form, but with ecommerce-specific fields.

lib/tools/collect-shipping.tsxtsx
import { z } from "zod";
import { useState, useCallback } from "react";
import { defineTool } from "glove-react";

const shippingAddress = z.object({
  name: z.string(),
  email: z.string(),
  address: z.string(),
  city: z.string(),
  zip: z.string(),
});

export const collectShipping = defineTool({
  name: "collect_shipping",
  description:
    "Collect the user's shipping address. Blocks until they submit. " +
    "Returns the address data.",
  inputSchema: z.object({
    message: z
      .string()
      .optional()
      .describe("Optional message to display above the form"),
  }),
  displayPropsSchema: z.object({
    message: z.string().optional(),
  }),
  resolveSchema: shippingAddress,
  displayStrategy: "hide-on-complete",

  async do(input, display) {
    const address = await display.pushAndWait({
      message: input.message,
    });
    return { status: "success" as const, data: JSON.stringify(address) };
  },

  render({ props, resolve }) {
    const { message } = props;

    const [form, setForm] = useState({
      name: "",
      email: "",
      address: "",
      city: "",
      zip: "",
    });

    const update = useCallback(
      (field: string, value: string) =>
        setForm((prev) => ({ ...prev, [field]: value })),
      [],
    );

    const canSubmit =
      form.name.trim() !== "" &&
      form.email.trim() !== "" &&
      form.address.trim() !== "" &&
      form.city.trim() !== "" &&
      form.zip.trim() !== "";

    const fields = [
      { key: "name", label: "Full Name", type: "text" },
      { key: "email", label: "Email", type: "email" },
      { key: "address", label: "Street Address", type: "text" },
      { key: "city", label: "City", type: "text" },
      { key: "zip", label: "ZIP Code", type: "text" },
    ];

    return (
      <div style={{ padding: 16, border: "1px dashed #9ED4B8", borderRadius: 12 }}>
        <p style={{ fontWeight: 600, marginBottom: 4 }}>Shipping Address</p>
        {message && (
          <p style={{ fontSize: 12, color: "#888", marginBottom: 12 }}>{message}</p>
        )}
        <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
          {fields.map((f) => (
            <div key={f.key}>
              <label
                style={{ display: "block", fontSize: 12, color: "#888", marginBottom: 4 }}
              >
                {f.label} <span style={{ color: "#ef4444" }}>*</span>
              </label>
              <input
                type={f.type}
                value={form[f.key as keyof typeof form]}
                onChange={(e) => update(f.key, e.target.value)}
                style={{
                  width: "100%",
                  padding: "8px 12px",
                  border: "1px solid #333",
                  borderRadius: 6,
                  background: "#0a0a0a",
                  color: "#ededed",
                  fontSize: 13,
                }}
              />
            </div>
          ))}
        </div>
        <button
          onClick={() => canSubmit && resolve(form)}
          disabled={!canSubmit}
          style={{
            marginTop: 14,
            padding: "8px 20px",
            border: "none",
            borderRadius: 6,
            background: canSubmit ? "#9ED4B8" : "#333",
            color: "#0a0a0a",
            cursor: canSubmit ? "pointer" : "not-allowed",
            opacity: canSubmit ? 1 : 0.5,
            fontWeight: 600,
          }}
        >
          Continue to Review
        </button>
      </div>
    );
  },
});

The form is a regular React component with useState. All fields are required — the submit button stays disabled until everything is filled in. When the user submits, the full address object goes back to the AI. The resolveSchema ensures the address shape is validated at the type level.

7. The order confirmation tool

The final gate. The AI assembles the full order — items, shipping address, total — and shows it for review. If the user confirms, the tool calls the server to place the order. This is the gate-execute-display pattern: gate with pushAndWait, execute on the server, display the result with pushAndForget.

The render function handles two phases via a phase field in the displayPropsSchema. The order review (phase: "review") uses resolve for the confirm/cancel buttons. The confirmation card (phase: "confirmed") is pushed with pushAndForget and has no interactive elements.

lib/tools/confirm-order.tsxtsx
import { z } from "zod";
import { defineTool } from "glove-react";

const cartItemSchema = z.object({
  name: z.string(),
  size: z.string(),
  color: z.string(),
  quantity: z.number(),
  price: z.number(),
});

const shippingSchema = z.object({
  name: z.string(),
  email: z.string(),
  address: z.string(),
  city: z.string(),
  zip: z.string(),
});

export const confirmOrder = defineTool({
  name: "confirm_order",
  description:
    "Show the full order summary for review. Blocks until the user " +
    "confirms or cancels. If confirmed, places the order on the server.",
  inputSchema: z.object({
    items: z.array(cartItemSchema).describe("Cart items"),
    shipping: shippingSchema,
  }),
  displayPropsSchema: z.object({
    phase: z.enum(["review", "confirmed"]),
    items: z.array(cartItemSchema).optional(),
    shipping: shippingSchema.optional(),
    total: z.number().optional(),
    orderId: z.string().optional(),
    estimatedDelivery: z.string().optional(),
  }),
  resolveSchema: z.boolean(),
  displayStrategy: "hide-on-complete",

  async do(input, display) {
    const total = input.items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0,
    );

    // Gate: show review, wait for confirmation (browser)
    const confirmed = await display.pushAndWait({
      phase: "review" as const,
      items: input.items,
      shipping: input.shipping,
      total,
    });

    if (!confirmed) {
      return { status: "success" as const, data: "Order cancelled by user." };
    }

    // Execute: place order on server
    const res = await fetch("/api/orders", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        items: input.items,
        shipping: input.shipping,
      }),
    });
    const order = await res.json();

    // Display: show confirmation card (browser, fire-and-forget)
    await display.pushAndForget({
      phase: "confirmed" as const,
      orderId: order.orderId,
      total: order.total,
      estimatedDelivery: order.estimatedDelivery,
    });

    return {
      status: "success" as const,
      data: `Order placed! ID: ${order.orderId}, Total: $${order.total}, Delivery: ${order.estimatedDelivery}`,
      renderData: { orderId: order.orderId },
    };
  },

  render({ props, resolve }) {
    const { phase } = props;

    // Order confirmation card (pushAndForget — no resolve needed)
    if (phase === "confirmed") {
      const { orderId, total, estimatedDelivery } = props;
      return (
        <div
          style={{
            padding: 16,
            borderRadius: 12,
            borderLeft: "3px solid #22c55e",
            background: "#141414",
          }}
        >
          <p style={{ fontWeight: 600, color: "#22c55e", marginBottom: 8 }}>
            Order Confirmed
          </p>
          <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
            <span style={{ fontSize: 13, color: "#888" }}>
              Order ID: <span style={{ color: "#ededed" }}>{orderId}</span>
            </span>
            <span style={{ fontSize: 13, color: "#888" }}>
              Total: <span style={{ color: "#ededed" }}>${total}</span>
            </span>
            <span style={{ fontSize: 13, color: "#888" }}>
              Delivery: <span style={{ color: "#ededed" }}>{estimatedDelivery}</span>
            </span>
          </div>
        </div>
      );
    }

    // Order review (pushAndWait — resolve is available)
    const { items, shipping, total } = props;

    return (
      <div style={{ padding: 16, border: "1px dashed #f59e0b", borderRadius: 12 }}>
        <p style={{ fontWeight: 600, marginBottom: 12 }}>Review Your Order</p>

        {/* Items */}
        <div style={{ display: "flex", flexDirection: "column", gap: 6, marginBottom: 12 }}>
          {(items ?? []).map((item, i) => (
            <div
              key={i}
              style={{
                display: "flex",
                justifyContent: "space-between",
                padding: "6px 10px",
                borderRadius: 6,
                background: "#0a0a0a",
                fontSize: 13,
              }}
            >
              <span>
                {item.name}{" "}
                <span style={{ color: "#888" }}>
                  ({item.size}, {item.color}) x {item.quantity}
                </span>
              </span>
              <span style={{ fontWeight: 600 }}>
                ${item.price * item.quantity}
              </span>
            </div>
          ))}
        </div>

        {/* Shipping */}
        {shipping && (
          <div
            style={{
              padding: "8px 10px",
              borderRadius: 6,
              background: "#0a0a0a",
              marginBottom: 12,
              fontSize: 12,
              color: "#888",
            }}
          >
            <p style={{ fontWeight: 500, color: "#ededed", marginBottom: 4 }}>Ship to</p>
            <p>{shipping.name}</p>
            <p>{shipping.address}</p>
            <p>{shipping.city}, {shipping.zip}</p>
          </div>
        )}

        {/* Total */}
        <div
          style={{
            display: "flex",
            justifyContent: "space-between",
            padding: "8px 10px",
            borderTop: "1px solid #262626",
            marginBottom: 12,
          }}
        >
          <span style={{ fontWeight: 600 }}>Total</span>
          <span style={{ fontWeight: 700, fontSize: 16, color: "#9ED4B8" }}>
            ${total}
          </span>
        </div>

        <div style={{ display: "flex", gap: 8 }}>
          <button
            onClick={() => resolve(true)}
            style={{
              padding: "8px 16px",
              border: "none",
              borderRadius: 6,
              background: "#22c55e",
              color: "#fff",
              cursor: "pointer",
              fontWeight: 600,
            }}
          >
            Place Order
          </button>
          <button
            onClick={() => resolve(false)}
            style={{
              padding: "8px 16px",
              border: "none",
              borderRadius: 6,
              background: "#262626",
              color: "#888",
              cursor: "pointer",
            }}
          >
            Cancel
          </button>
        </div>
      </div>
    );
  },

  renderResult({ data }) {
    const { orderId } = data as { orderId: string };
    return (
      <div style={{ fontSize: 13, color: "#888", padding: "4px 0" }}>
        Order placed: {orderId}
      </div>
    );
  },
});

The render function checks the phase field to determine which card to show. The order review phase uses resolve(true) and resolve(false) for the confirm/cancel buttons. The confirmation card phase ignores resolve — it is a fire-and-forget display pushed after the server responds. The renderResult callback provides a compact order ID label for the timeline after the slot is dismissed.

8. Wire it together

lib/glove.tstypescript
import { GloveClient } from "glove-react";
import { browseProducts } from "./tools/browse-products";
import { pickVariant } from "./tools/pick-variant";
import { showCart } from "./tools/show-cart";
import { collectShipping } from "./tools/collect-shipping";
import { confirmOrder } from "./tools/confirm-order";

export const gloveClient = new GloveClient({
  endpoint: "/api/chat",

  systemPrompt: `You are a helpful shopping assistant. You help users find
and purchase products through an interactive shopping experience.

Your workflow:
1. When a user describes what they want, use browse_products to show
   matching products. Let them click to select.
2. After they select a product, use pick_variant so they can choose
   size, color, and quantity.
3. After adding to cart, use show_cart to display the current cart.
   Ask if they want to add more items.
4. When the user is ready to check out, use collect_shipping to
   gather their address.
5. Finally, use confirm_order to show the full order review.
   Only place the order if they confirm.

Rules:
- Always show products visually — never list them as text.
- Keep track of all items the user has added across the conversation.
- If the user wants to change something, walk them through it.
- Be conversational but concise — the UI does the heavy lifting.`,

  tools: [browseProducts, pickVariant, showCart, collectShipping, confirmOrder],
});

The system prompt describes the workflow as a natural shopping flow. The AI follows this, but it adapts — if a user says “Add size 10 black Nike Pegasus to my cart,” the AI can skip the browse step and go straight to confirming the variant.

9. Build the chat UI

The <Render> component handles the entire chat layout — messages, streaming text, tool display slots, and user input. It also manages display strategies automatically: slots with "hide-on-complete" vanish when resolved, and slots with "hide-on-new" are replaced when the same tool fires again. The strategy="interleaved" mode places each tool's display slot inline with the conversation, right after the AI calls the tool.

app/page.tsxtsx
"use client";

import { useGlove, Render } from "glove-react";

export default function ShoppingAssistant() {
  const glove = useGlove();

  return (
    <div style={{ maxWidth: 640, margin: "2rem auto" }}>
      <h1>Shopping Assistant</h1>
      <Render
        glove={glove}
        strategy="interleaved"
        renderMessage={({ entry }) => (
          <div style={{ margin: "1rem 0" }}>
            <strong>{entry.kind === "user" ? "You" : "Shop"}:</strong> {entry.text}
          </div>
        )}
        renderStreaming={({ text }) => (
          <div style={{ opacity: 0.7 }}><strong>Shop:</strong> {text}</div>
        )}
        renderInput={({ send, busy }) => (
          <form onSubmit={(e) => {
            e.preventDefault();
            const input = e.currentTarget.elements.namedItem("msg") as HTMLInputElement;
            if (!input.value.trim() || busy) return;
            send(input.value.trim());
            input.value = "";
          }} style={{ display: "flex", gap: "0.5rem" }}>
            <input name="msg" disabled={busy} placeholder="What are you looking for?" style={{ flex: 1, padding: "0.5rem" }} />
            <button type="submit" disabled={busy}>Send</button>
          </form>
        )}
      />
    </div>
  );
}

Compare this to the manual timeline.map() + slots.map(renderSlot) approach from earlier tutorials. <Render> handles slot visibility, interleaving order, streaming text placement, and display strategy enforcement — all in a single component. You provide three render callbacks for the parts you want to customize (messages, streaming indicator, input form) and the framework takes care of the rest.

10. Run it

terminalbash
pnpm dev

Try these conversations:

Chatbot vs. display stack

Compare the same shopping flow in a traditional chatbot:

  1. AI: “I found 4 running shoes: 1) Nike Pegasus $129, 2) Adidas Ultraboost $189, 3) New Balance Fresh Foam $134, 4) Asics Nimbus $159. Which one?”
  2. User types: “2”
  3. AI: “What size? Available: 8, 9, 10, 11”
  4. User types: “10”
  5. AI: “What color? Black, White, Grey”
  6. User types: “black”
  7. AI: “Added. Your cart: 1x Adidas Ultraboost ($189). Ready to check out?”
  8. User types: “yes”

With the display stack:

  1. AI calls browse_products — a product grid with images and prices. User clicks the Adidas card.
  2. AI calls pick_variant — size buttons, color buttons, quantity stepper. User picks 10/Black/1 and clicks Add to Cart.
  3. AI calls show_cart — a styled cart card with line items and a running total.
  4. AI calls collect_shipping — a shipping form. User fills in and submits.
  5. AI calls confirm_order — a full order review with items, address, and total. User clicks Place Order.

Same flow, but every step is a real UI component instead of text parsing. The user never types “2” or “10” or “yes.” They click, select, fill, and confirm.

Where each piece runs

PieceWhereWhy
createChatHandlerServerLLM proxy — sends tool schemas, streams responses
Tool do functionsBrowserCalled by useGlove when AI requests a tool
/api/productsServerProduct catalog (database query in production)
/api/ordersServerOrder processing (payment + fulfillment)
Display stackBrowserProduct grids, variant pickers, cart cards, forms

Display patterns used

ToolPatternStrategyWhy
browse_productspushAndWaithide-on-completeGrid disappears after user selects a product; renderResult shows selection
pick_variantpushAndWaithide-on-completePicker disappears after user adds to cart
show_cartpushAndForgethide-on-newOld cart card hidden when updated cart appears
collect_shippingpushAndWaithide-on-completeForm disappears after user submits address
confirm_orderBothhide-on-completeReview disappears after confirm; confirmation card uses pushAndForget with "stay" behavior

Next steps