ChanlChanl
Tools & MCP

Why AI Shopping Still Feels Like a Search Bar

Most AI shopping assistants return walls of text. Learn how ChatKit widgets and Vercel AI SDK structured output turn AI recommendations into interactive product cards with images, prices, and add-to-cart buttons.

DGDean GroverCo-founderFollow
March 21, 2026
13 min read
Warm watercolor illustration of a workshop bench assembling colorful product cards

You ask an AI shopping assistant for hiking boots under $100. It understands perfectly. It knows your size, your preference for waterproof materials, and that you hiked Patagonia last year. Then it returns three paragraphs of text describing boots you can't see, with prices buried in sentences, and no way to buy any of them without copy-pasting a product name into a search bar.

The AI did its job. The interface didn't.

TailorTalk's 2026 ecommerce research found that shoppers engaging with AI chatbots convert at 12.3% compared to 3.1% without. But only 22% of consumers have ever purchased directly inside an AI tool. The demand is there. The rendering layer isn't. Let's build one.

We're going to take a shopping assistant that returns text and make it return interactive product cards with images, prices, and buy buttons. Three steps: define the shape of a product card, make the AI fill that shape, then render it. By the end you'll have a working prototype. Let's go.

Part 1 of 3: rendering layer. Part 2 covers semantic search, memory, and MCP. Part 3 covers testing and production readiness.

Step 1: Define What a Product Card Looks Like

The core fix is simple: stop letting the AI decide how to format product data. Give it a schema. If every product card must have a title (string), a price (number), and a stock status (boolean), the AI physically cannot return a paragraph instead. Your frontend gets predictable JSON every time. No regex to parse prices. No guessing whether the rating says "4.5 out of 5" or "4.5 stars" this time.

Here's the contract between your AI and your UI:

json
{
  "title": "Trailblazer Pro",
  "price": 89,               // Always a number, never "$89" — frontend formats it
  "rating": 4.5,             // 0-5 scale, drives star rendering
  "imageUrl": "https://store.example.com/images/trailblazer-pro.jpg",
  "productUrl": "https://store.example.com/products/trailblazer-pro",
  "badges": ["Waterproof", "Best Seller"],  // UI renders these as pills
  "inStock": true             // Controls Add to Cart vs Out of Stock button
}

Every field has a type. Every type has a constraint. Your product card component can trust this shape completely, because the AI never gets a chance to improvise. Here's how that data flows from question to card:

Show me hiking boots under $100 searchProducts({ category: "hiking-boots", maxPrice: 100 }) Raw product data (DB rows) Structured JSON (typed product cards) Interactive product cards (images, prices, buttons) Customer LLM + Tool Call Product API Widget Renderer
From user question to rendered product card: each step transforms data into a richer format

The critical handoff is between the LLM and the renderer. Text gives the renderer nothing to work with. Validated JSON lets it build product cards, comparison tables, carousel galleries. Two approaches exist: constrain the model's output format directly (structured generation), or have tools return structured data that the model passes through. Both start from the same place: an explicit schema.

Now, how do you actually enforce that schema? You need something that turns it into a runtime constraint the model can't violate.

Step 2: Enforce the Schema and Call the AI

Vercel AI SDK 6 unified tool calling and structured output into a single function: generateText. The old generateObject() is deprecated. Now you pass an output option with a Zod schema, and the SDK guarantees your response matches that shape. Combined with a tool that searches your product catalog, one function call handles everything: calling the tool, getting the data, and validating the output.

First, the Zod schema. This is the TypeScript version of that JSON contract we just defined, with validation baked in:

typescript
import { z } from 'zod';
import { Output } from 'ai';
 
const ProductCardSchema = z.object({
  title: z.string().describe('Product name as displayed in the store'),
  price: z.number().describe('Current price in USD'),     // Number, not string — prevents "$89" vs "89" chaos
  originalPrice: z.number().nullable().describe('Original price before discount, or null'),  // nullable > optional for LLMs
  rating: z.number().min(0).max(5).describe('Average customer rating'),
  reviewCount: z.number().describe('Total number of reviews'),
  imageUrl: z.string().url().describe('Primary product image URL'),   // .url() rejects garbage strings
  productUrl: z.string().url().describe('Link to product detail page'),
  badges: z.array(z.string()).describe('Tags like Waterproof, Best Seller, On Sale'),
  inStock: z.boolean().describe('Whether the product is currently available'),
});
 
const ProductResultsSchema = z.object({
  query: z.string().describe('The interpreted search query'),  // Echo back so UI can show "Results for: ..."
  products: z.array(ProductCardSchema).describe('Matching products, max 6'),
  summary: z.string().describe('One sentence explaining why these were selected'),
});

Every .describe() call is a hint to the model. When the LLM sees 'Primary product image URL', it knows to pull the image field from the tool result rather than inventing one. These descriptions are part of your prompt engineering surface area. Treat them like documentation the model reads at inference time.

Two things worth noting: z.number().nullable() is more reliable than .optional() with LLMs. Models are better at generating null than omitting fields entirely. And z.string().url() catches garbage strings before they hit your <img> tag.

Now wire the schema into generateText alongside a search tool:

typescript
import { generateText, Output, tool } from 'ai';
import { openai } from '@ai-sdk/openai';
 
const { output } = await generateText({
  model: openai('gpt-4o'),
  prompt: userMessage,
  output: Output.object({ schema: ProductResultsSchema }),  // Forces validated JSON matching our Zod schema
  tools: {
    searchProducts: tool({
      description: 'Search the product catalog by query, category, and price range',
      parameters: z.object({
        query: z.string().describe('Natural language search query'),
        category: z.string().optional().describe('Product category filter'),
        maxPrice: z.number().optional().describe('Maximum price in USD'),
        minRating: z.number().optional().describe('Minimum star rating'),
      }),
      execute: async ({ query, category, maxPrice, minRating }) => {
        const results = await searchProductCatalog({ query, category, maxPrice, minRating });
        return results;  // Raw data — the Output.object constraint shapes the final response
      },
    }),
  },
  maxSteps: 3,  // Tool call → observe result → format output (3 turns)
  system: `You are a shopping assistant. When users ask about products,
    use the searchProducts tool to find relevant items. Return structured
    product cards with accurate prices, ratings, and images from the tool results.
    Never invent product data.`,
});

maxSteps: 3 lets the model call the tool, observe the results, and format them into our schema across multiple turns. If you've worked with streaming AI responses, you'll recognize the pattern: the SDK handles protocol complexity so your code stays focused on business logic.

Run this and output is a fully validated ProductResults object. Not "probably valid." Validated. The Zod schema guarantees every product has a numeric price, a valid image URL, and a boolean stock status. Now let's render it.

Step 3: Render the Cards in React

Your React component doesn't do any parsing, extraction, or guessing. It receives the exact shape the Zod schema guarantees and renders it. That's the whole point of doing the schema work upstream: the component is trivially simple because the hard work already happened.

tsx
interface ProductCard {
  title: string;
  price: number;
  originalPrice: number | null;
  rating: number;
  reviewCount: number;
  imageUrl: string;
  productUrl: string;
  badges: string[];
  inStock: boolean;
}
 
function ProductCard({ product }: { product: ProductCard }) {
  return (
    // Entire card is an <a> — clicking anywhere navigates to PDP
    <a href={product.productUrl} className="group block rounded-lg border p-3
      hover:shadow-md transition-shadow">
      <div className="aspect-square overflow-hidden rounded-md mb-3">
        <img
          src={product.imageUrl}
          alt={product.title}
          className="h-full w-full object-cover group-hover:scale-105 transition-transform"
        />
      </div>
      <h3 className="font-medium text-sm truncate">{product.title}</h3>
      <div className="flex items-center gap-2 mt-1">
        <span className="font-semibold">${product.price}</span>
        {product.originalPrice && (  // Only renders strikethrough when a discount exists
          <span className="text-sm line-through text-gray-400">
            ${product.originalPrice}
          </span>
        )}
      </div>
      <div className="flex items-center gap-1 mt-1 text-sm">
        <span className="text-yellow-500">{''.repeat(Math.round(product.rating))}</span>
        <span className="text-gray-500">({product.reviewCount})</span>
      </div>
      <div className="flex flex-wrap gap-1 mt-2">
        {product.badges.map((badge) => (
          <span key={badge} className="text-xs px-2 py-0.5 rounded-full bg-gray-100">
            {badge}
          </span>
        ))}
      </div>
      <button
        className="w-full mt-3 py-2 rounded-md bg-blue-600 text-white text-sm
          font-medium hover:bg-blue-700 disabled:opacity-50"
        disabled={!product.inStock}  // Greys out when inventory is zero
      >
        {product.inStock ? 'Add to Cart' : 'Out of Stock'}
      </button>
    </a>
  );
}

Remember those hiking boots from the opening? When searchProducts returns them as structured JSON, this component doesn't need to parse "The Trailblazer Pro costs $89" out of a paragraph. It gets { title: "Trailblazer Pro", price: 89 } and renders a card with an image, a price tag, and a buy button.

The Next.js API route ties the backend to the frontend:

typescript
import { z } from 'zod';
import { generateText, Output, tool } from 'ai';
import { openai } from '@ai-sdk/openai';
import { ProductResultsSchema } from '@/lib/schemas';
import { searchProductCatalog } from '@/lib/products';
 
export async function POST(req: Request) {
  const { message } = await req.json();
 
  const { output } = await generateText({
    model: openai('gpt-4o'),
    prompt: message,
    output: Output.object({ schema: ProductResultsSchema }),  // Schema = contract with frontend
    tools: {
      searchProducts: tool({
        description: 'Search products by query, category, and price range',
        parameters: z.object({
          query: z.string(),
          category: z.string().optional(),
          maxPrice: z.number().optional(),
        }),
        execute: async (params) => searchProductCatalog(params),  // Swap this for real catalog later
      }),
    },
    maxSteps: 3,
    system: 'You are a shopping assistant. Use searchProducts to find items. Never invent data.',
  });
 
  return Response.json(output);  // Already validated — safe to send to client
}

That's it. User asks for hiking boots, the API calls the AI, the AI calls the tool, the tool returns data, the schema validates it, and the frontend renders cards. No text walls. No copy-pasting product names.

A Shortcut: ChatKit Widgets

If you're building on OpenAI, there's an even faster path. OpenAI's ChatKit lets the model return a JSON tree of widget primitives (Card, Image, Badge, Button, ListView) and ChatKit handles all the rendering. You don't write React components at all. The model assembles building blocks, and ChatKit turns them into styled, accessible UI.

Here's what a single product card looks like as ChatKit JSON:

json
{
  "type": "card",
  "padding": "md",
  "children": [
    {
      "type": "image",
      "url": "https://store.example.com/images/trailblazer-pro.jpg",
      "alt": "Trailblazer Pro Hiking Boot",
      "aspectRatio": "16:9"
    },
    {
      "type": "text",
      "value": "Trailblazer Pro",
      "format": "heading3"
    },
    {
      "type": "text",
      "value": "Waterproof hiking boot with Vibram outsole and Gore-Tex lining.",
      "format": "body"
    },
    {
      "type": "flex",
      "direction": "row",
      "gap": "sm",
      "children": [
        { "type": "badge", "value": "$89", "variant": "primary" },
        { "type": "badge", "value": "★ 4.5", "variant": "secondary" },
        { "type": "badge", "value": "In Stock", "variant": "success" }
      ]
    },
    {
      "type": "button",
      "label": "Add to Cart",
      "style": "primary",
      "action": { "type": "url", "url": "https://store.example.com/cart/add/trailblazer-pro" }
    }
  ]
}

For multiple products, ListView creates a scrollable grid:

json
{
  "type": "list_view",
  "padding": "md",
  "children": [
    {
      "type": "list_view_item",
      "title": "Trailblazer Pro",
      "subtitle": "$89 · ★ 4.5 · Waterproof",
      "image": { "url": "https://store.example.com/images/trailblazer-pro.jpg" },
      "action": { "type": "url", "url": "https://store.example.com/products/trailblazer-pro" }
    },
    {
      "type": "list_view_item",
      "title": "Summit Ridge GTX",
      "subtitle": "$95 · ★ 4.7 · Lightweight",
      "image": { "url": "https://store.example.com/images/summit-ridge.jpg" },
      "action": { "type": "url", "url": "https://store.example.com/products/summit-ridge" }
    },
    {
      "type": "list_view_item",
      "title": "Canyon Walker",
      "subtitle": "$72 · ★ 4.2 · Budget Pick",
      "image": { "url": "https://store.example.com/images/canyon-walker.jpg" },
      "action": { "type": "url", "url": "https://store.example.com/products/canyon-walker" }
    }
  ]
}

The key insight: MCP tools can return this widget JSON directly. An MCP server wrapping your product catalog becomes a widget factory. The tool takes a search query, hits your database, and returns ChatKit-compatible JSON. The model passes it through. The renderer displays it. The model never needs to know what a product card looks like.

ChatKit is OpenAI-specific, though. The Zod + React approach from Steps 1-3 works with any model and any frontend framework. The underlying pattern is the same either way: structured JSON in, interactive UI out.

What's Still Missing

Ask for those hiking boots now and you'll see the Trailblazer Pro as a tappable card with its image, $89 price, 4.5-star rating, and an "Add to Cart" button. No text walls. No copy-pasting product names. The rendering problem is solved.

But look behind the curtain. That searchProductCatalog function? Right now it's a hardcoded array or a simple keyword match. When a customer asks for "a warm jacket for hiking in November," they don't mean WHERE name LIKE '%warm jacket%'. They mean semantic understanding of warmth ratings, seasonal appropriateness, and activity-specific features. That requires vector search and embeddings.

And search is only half the story. A repeat customer shouldn't have to re-state their preferences every visit. "You bought a size 10 last time" isn't a feature request. It's table stakes. That requires persistent memory that spans sessions.

The rendering layer doesn't care where the data comes from. Swap the hardcoded array for a vector database, add memory-powered personalization, wire up MCP for catalog access. The product cards render the same way. The schema contract holds.

Part 2 covers semantic search with Commerce MCP, knowledge bases, and persistent customer memory. Part 3 shows you how to prove the whole system works before real customers see it.

DG

Co-founder

Building the platform for AI agents at Chanl — tools, testing, and observability for customer experience.

Aprende IA Agéntica

Una lección por semana: técnicas prácticas para construir, probar y lanzar agentes IA. Desde ingeniería de prompts hasta monitoreo en producción. Aprende haciendo.

500+ ingenieros suscritos

Frequently Asked Questions