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.
A shopping assistant where a user can say “I need running shoes” and the app will:
pushAndWait)pushAndWait)pushAndForget)pushAndWait)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.
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.
/api/products — returns product data by category. Called by the browse_products tool./api/orders — places an order. Called by the confirm_order tool after the user confirms.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.
Start from a Next.js project with Glove installed:
pnpm add glove-core glove-react glove-next zodimport { createChatHandler } from "glove-next";
export const POST = createChatHandler({
provider: "openai",
model: "gpt-4o-mini",
});Two routes: one for product data, one for order processing.
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 });
}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.
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.
This is pushAndWait — the tool pauses until the user picks a product.
import { z } from "zod";
import type { ToolConfig, SlotRenderProps } from "glove-react";
export const browseProducts: ToolConfig = {
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'"),
}),
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 "No products found in that category.";
}
// Show product grid and wait for selection
const selected = await display.pushAndWait({
input: { products },
});
return JSON.stringify(selected);
},
render({ data, resolve }: SlotRenderProps) {
const { products } = data as {
products: {
id: string;
name: string;
price: number;
description: string;
sizes: string[];
colors: string[];
}[];
};
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>
);
},
};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 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.
import { z } from "zod";
import { useState, useCallback } from "react";
import type { ToolConfig, SlotRenderProps } from "glove-react";
export const pickVariant: ToolConfig = {
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"),
}),
async do(input, display) {
const variant = await display.pushAndWait({ input });
return JSON.stringify(variant);
},
render({ data, resolve }: SlotRenderProps) {
const { productName, price, sizes, colors } = data as {
productName: string;
price: number;
sizes: string[];
colors: string[];
};
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.
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.
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?”
import { z } from "zod";
import type { ToolConfig, SlotRenderProps } from "glove-react";
export const showCart: ToolConfig = {
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(
z.object({
name: z.string(),
size: z.string(),
color: z.string(),
quantity: z.number(),
price: z.number(),
}),
)
.describe("Cart items"),
}),
async do(input, display) {
const subtotal = input.items.reduce(
(sum: number, item: any) => sum + item.price * item.quantity,
0,
);
await display.pushAndForget({
input: { items: input.items, subtotal },
});
return `Cart displayed. ${input.items.length} item(s), subtotal $${subtotal}.`;
},
render({ data }: SlotRenderProps) {
const { items, subtotal } = data as {
items: {
name: string;
size: string;
color: string;
quantity: number;
price: number;
}[];
subtotal: number;
};
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.
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.
import { z } from "zod";
import { useState, useCallback } from "react";
import type { ToolConfig, SlotRenderProps } from "glove-react";
export const collectShipping: ToolConfig = {
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"),
}),
async do(input, display) {
const address = await display.pushAndWait({ input });
return JSON.stringify(address);
},
render({ data, resolve }: SlotRenderProps) {
const { message } = (data ?? {}) as { message?: string };
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 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.
import { z } from "zod";
import type { ToolConfig, SlotRenderProps } from "glove-react";
export const confirmOrder: ToolConfig = {
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(
z.object({
name: z.string(),
size: z.string(),
color: z.string(),
quantity: z.number(),
price: z.number(),
}),
)
.describe("Cart items"),
shipping: z.object({
name: z.string(),
email: z.string(),
address: z.string(),
city: z.string(),
zip: z.string(),
}),
}),
async do(input, display) {
const total = input.items.reduce(
(sum: number, item: any) => sum + item.price * item.quantity,
0,
);
// Gate: show review, wait for confirmation (browser)
const confirmed = await display.pushAndWait({
input: { items: input.items, shipping: input.shipping, total },
});
if (!confirmed) return "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 (browser)
await display.pushAndForget({
input: { ...order, phase: "confirmed" },
});
return `Order placed! ID: ${order.orderId}, Total: $${order.total}, Delivery: ${order.estimatedDelivery}`;
},
render({ data, resolve }: SlotRenderProps) {
const { phase } = data as { phase?: string };
// Order confirmation card (pushAndForget — no resolve)
if (phase === "confirmed") {
const { orderId, total, estimatedDelivery } = data as {
orderId: string;
total: number;
estimatedDelivery: string;
phase: string;
};
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 } = data as {
items: { name: string; size: string; color: string; quantity: number; price: number }[];
shipping: { name: string; address: string; city: string; zip: string };
total: number;
};
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}) × {item.quantity}
</span>
</span>
<span style={{ fontWeight: 600 }}>
${item.price * item.quantity}
</span>
</div>
))}
</div>
{/* 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>
);
},
};The render function handles two phases by checking data.phase. The order review uses resolve (the user must confirm or cancel). The confirmation card has no resolve — it is fire-and-forget. This is the same pattern the coding agent uses for its command runner.
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.
"use client";
import { useState } from "react";
import { useGlove } from "glove-react";
export default function ShoppingAssistant() {
const {
timeline,
streamingText,
busy,
sendMessage,
slots,
renderSlot,
} = useGlove();
const [input, setInput] = useState("");
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!input.trim() || busy) return;
sendMessage(input.trim());
setInput("");
}
return (
<div style={{ maxWidth: 640, margin: "2rem auto" }}>
<h1>Shopping Assistant</h1>
<div>
{timeline.map((entry, i) => {
if (entry.kind === "user")
return <div key={i} style={{ margin: "1rem 0" }}><strong>You:</strong> {entry.text}</div>;
if (entry.kind === "agent_text")
return <div key={i} style={{ margin: "1rem 0" }}><strong>Shop:</strong> {entry.text}</div>;
if (entry.kind === "tool")
return (
<div key={i} style={{ margin: "0.5rem 0", fontSize: "0.85rem", color: "#888" }}>
{entry.name} — {entry.status}
</div>
);
return null;
})}
</div>
{streamingText && (
<div style={{ opacity: 0.7 }}><strong>Shop:</strong> {streamingText}</div>
)}
{/* Display stack — product grids, variant pickers, cart, forms, reviews */}
{slots.length > 0 && (
<div style={{ margin: "1rem 0", display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{slots.map(renderSlot)}
</div>
)}
<form onSubmit={handleSubmit} style={{ display: "flex", gap: "0.5rem" }}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="What are you looking for?"
disabled={busy}
style={{ flex: 1, padding: "0.5rem" }}
/>
<button type="submit" disabled={busy}>Send</button>
</form>
</div>
);
}pnpm devTry these conversations:
pick_variant again with the same product, then updates the cartCompare the same shopping flow in a traditional chatbot:
With the display stack:
browse_products — a product grid with images and prices. User clicks the Adidas card.pick_variant — size buttons, color buttons, quantity stepper. User picks 10/Black/1 and clicks Add to Cart.show_cart — a styled cart card with line items and a running total.collect_shipping — a shipping form. User fills in and submits.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.
| Piece | Where | Why |
|---|---|---|
createChatHandler | Server | LLM proxy — sends tool schemas, streams responses |
Tool do functions | Browser | Called by useGlove when AI requests a tool |
/api/products | Server | Product catalog (database query in production) |
/api/orders | Server | Order processing (payment + fulfillment) |
| Display stack | Browser | Product grids, variant pickers, cart cards, forms |
| Tool | Pattern | Why |
|---|---|---|
browse_products | pushAndWait | AI needs to know which product was selected |
pick_variant | pushAndWait | AI needs size, color, and quantity before adding to cart |
show_cart | pushAndForget | Cart is informational — AI can keep talking |
collect_shipping | pushAndWait | AI needs address data before placing order |
confirm_order | Both | pushAndWait for review, pushAndForget for confirmation card |
glove-core directly without React or Next.jspushAndWait and pushAndForgetuseGlove, ToolConfig, and SlotRenderProps