Introduction: Search Bars Are Becoming Conversations
Think about the last time you bought something slightly complicated online. Maybe a trail-running shoe for wide feet that also works on wet rock. You probably typed three or four searches, opened a dozen tabs, read reviews, compared shipping times, and abandoned at least one cart. The store's search box didn't understand you. It matched keywords.
That gap is exactly what agentic commerce closes. Instead of a search box, the shopper talks to an agent that understands intent, remembers preferences, compares vendors, applies offers, and gets everything ready for checkout. The shopper just approves.
This isn't a fringe idea anymore. McKinsey estimates that by 2030 the US B2C retail market alone could see up to $1 trillion in revenue orchestrated by agentic commerce, with global projections reaching $3 trillion to $5 trillion. A joint ICSC and McKinsey report published in spring 2026 also found that 68% of surveyed US consumers had used at least one AI tool in the previous 90 days.
In this guide, I'll walk you through a production-minded architecture that I've refined while building agent-driven storefronts. We'll use:
- Next.js + TypeScript + Shadcn UI for a fast, streaming storefront
- LangGraph (Python) + Ollama for the agent brain, running local LLMs
- Node.js + PostgreSQL (with pgvector) as the transactional source of truth
- Stripe Connect for multi-vendor checkout
- Docker + AWS for deployment
By the end, you'll have a clear picture of how the pieces fit, real code you can adapt, and a list of the mistakes I'd rather you didn't repeat.

What Exactly Is an Autonomous Shopping Assistant?
A traditional eCommerce flow is user-driven: search, filter, click, add, pay. An autonomous shopping assistant flips that. The user states a goal, and the agent drives the workflow.
A capable shopping agent does five things:
- Understands intent – turns "something warm for Iceland in March, under $250, no wool" into structured constraints.
- Discovers products – runs hybrid semantic and filter search across one or many vendor catalogs.
- Reasons and compares – ranks results against the user's priorities, not the store's merchandising rules.
- Negotiates within limits – applies coupons, bundles, or vendor offers, but never exceeds the user's mandate.
- Executes checkout – builds a cart, splits it across vendors, and charges the user only after approval.
The Three Interaction Models
McKinsey describes agentic commerce as taking shape across three models, and it helps to know which one you're building:
- Agent to site – your agent shops your own catalog (what we build here).
- Agent to agent – the user's personal agent talks to your merchant agent via APIs.
- Brokered agent to site – a third-party platform agent shops across many merchants.
The architecture in this post is designed for the first model but keeps clean API boundaries so you can expose the same tools to external agents later.
The Reference Architecture
Here's the high-level system. Notice that the LLM never talks directly to the database or Stripe. Everything goes through narrow, validated tools.
flowchart LR
U[Shopper] -->|chat + UI| FE[Next.js Storefront<br/>Shadcn UI]
FE -->|SSE stream| AG[Agent Service<br/>LangGraph + FastAPI]
AG -->|inference| OL[Ollama<br/>Local LLMs]
AG -->|tool calls| API[Commerce API<br/>Node.js REST]
API --> PG[(PostgreSQL<br/>+ pgvector)]
AG -->|checkpoints| PG
API -->|PaymentIntents<br/>Transfers| ST[Stripe Connect]
ST -->|webhooks| API
API --> V1[Vendor A]
API --> V2[Vendor B]
Why This Split Works
| Layer | Responsibility | Why it's separate |
|---|---|---|
| Next.js storefront | Rendering, streaming chat, product cards | UX can evolve without touching agent logic |
| LangGraph agent | Reasoning, planning, tool orchestration | Python has the strongest agent ecosystem |
| Ollama | Local inference | Data stays in your VPC, predictable cost |
| Node.js API | Carts, inventory, pricing, orders | Deterministic, testable, auditable |
| PostgreSQL | Source of truth + vectors + agent checkpoints | One database to secure, back up, and query |
| Stripe Connect | Payments and vendor payouts | Compliance and split payments handled for you |
The golden rule: the agent proposes, the API disposes. Prices, stock levels, and totals are always recalculated server-side. The agent's output is treated as a suggestion, never as truth.
Step 1: Modeling the Data in PostgreSQL
Start with the schema because everything depends on it. We'll use pgvector for semantic search and keep carts strongly consistent.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE vendors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
stripe_account_id TEXT NOT NULL UNIQUE,
max_discount_pct NUMERIC(5,2) NOT NULL DEFAULT 0
);
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vendor_id UUID NOT NULL REFERENCES vendors(id),
title TEXT NOT NULL,
description TEXT NOT NULL,
attributes JSONB NOT NULL DEFAULT '{}',
price_cents INTEGER NOT NULL CHECK (price_cents > 0),
stock INTEGER NOT NULL CHECK (stock >= 0),
embedding VECTOR(768)
);
CREATE INDEX products_embedding_idx
ON products USING hnsw (embedding vector_cosine_ops);
CREATE INDEX products_attributes_idx
ON products USING gin (attributes);
CREATE TABLE carts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'pending_approval', 'paid', 'abandoned')),
version INTEGER NOT NULL DEFAULT 1,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE cart_items (
cart_id UUID REFERENCES carts(id) ON DELETE CASCADE,
product_id UUID REFERENCES products(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price_cents INTEGER NOT NULL,
PRIMARY KEY (cart_id, product_id)
);
CREATE TABLE spending_mandates (
user_id UUID PRIMARY KEY,
max_order_cents INTEGER NOT NULL,
allowed_categories TEXT[] NOT NULL DEFAULT '{}',
require_approval BOOLEAN NOT NULL DEFAULT true
);Two design decisions here matter a lot:
versionon carts gives us optimistic locking. The agent and the human UI can both modify a cart, and we never want one to silently overwrite the other.spending_mandatesstores the user's rules for the agent. This is what makes "autonomous" safe. The agent literally cannot exceed what's in this table, because the API enforces it.
Step 2: The Commerce API with Node.js and PostgreSQL
The REST API is deliberately boring. Boring is good when money is involved. I'm using Fastify for its schema validation and speed, plus pg for direct SQL.
// api/src/routes/cart.ts
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { pool } from "../db";
const AddItemSchema = z.object({
productId: z.string().uuid(),
quantity: z.number().int().min(1).max(20),
expectedVersion: z.number().int(),
});
export async function cartRoutes(app: FastifyInstance) {
app.post("/carts/:cartId/items", async (req, reply) => {
const { cartId } = req.params as { cartId: string };
const body = AddItemSchema.parse(req.body);
const userId = req.user.id; // set by auth middleware
const client = await pool.connect();
try {
await client.query("BEGIN");
// Lock the cart row and verify ownership + version
const cart = await client.query(
`SELECT id, version, status FROM carts
WHERE id = $1 AND user_id = $2 FOR UPDATE`,
[cartId, userId]
);
if (cart.rowCount === 0) return reply.code(404).send({ error: "cart_not_found" });
if (cart.rows[0].status !== "open") return reply.code(409).send({ error: "cart_locked" });
if (cart.rows[0].version !== body.expectedVersion) {
return reply.code(409).send({ error: "version_conflict", current: cart.rows[0].version });
}
// Price always comes from the database, never from the agent
const product = await client.query(
`SELECT price_cents, stock FROM products WHERE id = $1`,
[body.productId]
);
if (product.rowCount === 0) return reply.code(404).send({ error: "product_not_found" });
if (product.rows[0].stock < body.quantity) {
return reply.code(409).send({ error: "insufficient_stock" });
}
await client.query(
`INSERT INTO cart_items (cart_id, product_id, quantity, unit_price_cents)
VALUES ($1, $2, $3, $4)
ON CONFLICT (cart_id, product_id)
DO UPDATE SET quantity = cart_items.quantity + EXCLUDED.quantity`,
[cartId, body.productId, body.quantity, product.rows[0].price_cents]
);
const updated = await client.query(
`UPDATE carts SET version = version + 1, updated_at = now()
WHERE id = $1 RETURNING version`,
[cartId]
);
await client.query("COMMIT");
return { cartId, version: updated.rows[0].version };
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
});
}A few things worth highlighting:
SELECT ... FOR UPDATEprevents two concurrent writes from racing.- The price is read from
products, so even if the LLM hallucinates a price of $1, it doesn't matter. - Zod validation caps quantity at 20. Agents occasionally misread "2" as "20" or worse. Hard limits catch that.
Semantic Search Endpoint
The agent's discovery tool calls a hybrid search endpoint. It combines vector similarity with strict filters:
// api/src/routes/search.ts
app.post("/search", async (req) => {
const { embedding, maxPriceCents, excludeTerms = [], limit = 12 } = req.body as {
embedding: number[];
maxPriceCents?: number;
excludeTerms?: string[];
limit?: number;
};
const { rows } = await pool.query(
`SELECT p.id, p.title, p.price_cents, p.attributes, v.name AS vendor,
1 - (p.embedding <=> $1::vector) AS score
FROM products p
JOIN vendors v ON v.id = p.vendor_id
WHERE p.stock > 0
AND ($2::int IS NULL OR p.price_cents <= $2)
AND NOT (p.description ILIKE ANY ($3::text[]))
ORDER BY p.embedding <=> $1::vector
LIMIT $4`,
[JSON.stringify(embedding), maxPriceCents ?? null,
excludeTerms.map((t) => `%${t}%`), Math.min(limit, 50)]
);
return { results: rows };
});This is where hybrid search shines. The vector handles fuzzy meaning ("warm for Iceland"), while the SQL filters handle hard constraints ("under $250", "no wool") that vectors are notoriously bad at.
Step 3: Building the Agent Brain with LangGraph and Ollama
Now for the interesting part. LangGraph lets us describe the shopping workflow as a graph of nodes with shared state. Each node does one job. Edges decide what happens next.
stateDiagram-v2
[*] --> ParseIntent
ParseIntent --> Clarify: missing constraints
Clarify --> ParseIntent: user replies
ParseIntent --> Discover: constraints complete
Discover --> Rank
Rank --> Negotiate
Negotiate --> BuildCart
BuildCart --> CheckMandate
CheckMandate --> HumanApproval: within mandate
CheckMandate --> Rank: over budget
HumanApproval --> Checkout: approved
HumanApproval --> Rank: rejected / edit
Checkout --> [*]
Defining State
# agent/state.py
from typing import Annotated, Literal, Optional
from pydantic import BaseModel, Field
from langgraph.graph.message import add_messages
from typing_extensions import TypedDict
class ShoppingConstraints(BaseModel):
query: str = Field(description="Natural language description of the product")
max_price_cents: Optional[int] = None
exclude_terms: list[str] = Field(default_factory=list)
must_have: list[str] = Field(default_factory=list)
quantity: int = 1
is_complete: bool = Field(description="True if enough info to search")
clarifying_question: Optional[str] = None
class AgentState(TypedDict):
messages: Annotated[list, add_messages]
user_id: str
cart_id: str
cart_version: int
constraints: Optional[ShoppingConstraints]
candidates: list[dict]
selected: list[dict]
approval: Optional[Literal["approved", "rejected"]]Intent Parsing with a Local Model
Ollama makes local inference easy, and ChatOllama supports structured output. In my experience, a well-quantized 14B to 32B instruction model is the sweet spot for intent extraction and tool calling.
# agent/nodes.py
import httpx
from langchain_ollama import ChatOllama, OllamaEmbeddings
from langgraph.types import interrupt
from .state import AgentState, ShoppingConstraints
llm = ChatOllama(model="qwen3:14b", temperature=0, base_url="http://ollama:11434")
embedder = OllamaEmbeddings(model="nomic-embed-text", base_url="http://ollama:11434")
API = "http://commerce-api:4000"
SYSTEM = """You are a careful shopping assistant. Extract shopping constraints.
Convert dollar amounts to cents. If budget or product type is unclear,
set is_complete=false and ask ONE short clarifying question."""
def parse_intent(state: AgentState) -> dict:
extractor = llm.with_structured_output(ShoppingConstraints)
constraints = extractor.invoke([("system", SYSTEM), *state["messages"]])
return {"constraints": constraints}
def route_after_intent(state: AgentState) -> str:
return "discover" if state["constraints"].is_complete else "clarify"
def clarify(state: AgentState) -> dict:
# Pause the graph and wait for the shopper's answer
answer = interrupt({"type": "question",
"text": state["constraints"].clarifying_question})
return {"messages": [("user", answer)]}Discovery, Ranking, and Negotiation
async def discover(state: AgentState) -> dict:
c = state["constraints"]
vector = await embedder.aembed_query(c.query)
async with httpx.AsyncClient(timeout=10) as client:
res = await client.post(f"{API}/search", json={
"embedding": vector,
"maxPriceCents": c.max_price_cents,
"excludeTerms": c.exclude_terms,
"limit": 15,
})
res.raise_for_status()
return {"candidates": res.json()["results"]}
def rank(state: AgentState) -> dict:
c = state["constraints"]
prompt = f"""Pick the best 3 products for these needs: {c.model_dump_json()}.
Return only product IDs from this list, best first, with a one-line reason each.
Products: {state['candidates']}"""
ranked = llm.with_structured_output(RankedPicks).invoke(prompt)
by_id = {p["id"]: p for p in state["candidates"]}
# Never trust IDs the model invents
selected = [by_id[pid] | {"reason": r} for pid, r in ranked.picks if pid in by_id]
return {"selected": selected[:3]}
async def negotiate(state: AgentState) -> dict:
# Ask the API for vendor offers; the API enforces each vendor's max discount
async with httpx.AsyncClient() as client:
res = await client.post(f"{API}/offers/quote", json={
"productIds": [p["id"] for p in state["selected"]],
"quantity": state["constraints"].quantity,
})
return {"selected": res.json()["quotes"]}That "negotiation" deserves a quick note. In 2026, real negotiation usually means the agent requesting quotes, bundle discounts, or loyalty pricing from a vendor endpoint that has its own rules. The agent can ask. The vendor's policy, enforced in code, decides. Letting an LLM freely invent discounts is a fast way to lose money.
Human Approval and Wiring the Graph
def human_approval(state: AgentState) -> dict:
decision = interrupt({
"type": "approval",
"items": state["selected"],
"total_cents": sum(q["final_cents"] for q in state["selected"]),
})
return {"approval": decision}# agent/graph.py
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from .nodes import *
def build_graph(checkpointer):
g = StateGraph(AgentState)
g.add_node("parse_intent", parse_intent)
g.add_node("clarify", clarify)
g.add_node("discover", discover)
g.add_node("rank", rank)
g.add_node("negotiate", negotiate)
g.add_node("build_cart", build_cart)
g.add_node("check_mandate", check_mandate)
g.add_node("human_approval", human_approval)
g.add_node("checkout", checkout)
g.add_edge(START, "parse_intent")
g.add_conditional_edges("parse_intent", route_after_intent,
{"discover": "discover", "clarify": "clarify"})
g.add_edge("clarify", "parse_intent")
g.add_edge("discover", "rank")
g.add_edge("rank", "negotiate")
g.add_edge("negotiate", "build_cart")
g.add_edge("build_cart", "check_mandate")
g.add_conditional_edges("check_mandate",
lambda s: "ok" if s["within_mandate"] else "over",
{"ok": "human_approval", "over": "rank"})
g.add_conditional_edges("human_approval",
lambda s: s["approval"],
{"approved": "checkout", "rejected": "rank"})
g.add_edge("checkout", END)
return g.compile(checkpointer=checkpointer)Using AsyncPostgresSaver means every step is persisted in the same PostgreSQL cluster. A shopper can close the tab mid-conversation, come back two days later on their phone, and the agent resumes exactly where it paused, because the thread_id maps to a checkpoint.
When the user answers an interrupt, you resume with a Command:
from langgraph.types import Command
await graph.ainvoke(Command(resume="approved"),
config={"configurable": {"thread_id": session_id}})Step 4: The Next.js Storefront with Shadcn UI
The storefront has two jobs: render a beautiful catalog for humans who still like browsing, and host a streaming chat that feels instant. I use the Next.js App Router with a Route Handler that proxies the agent's Server-Sent Events stream, so the agent service never gets exposed to the public internet.
// app/api/agent/route.ts
import { NextRequest } from "next/server";
import { auth } from "@/lib/auth";
export async function POST(req: NextRequest) {
const session = await auth();
if (!session) return new Response("Unauthorized", { status: 401 });
const { message, threadId, resume } = await req.json();
const upstream = await fetch(`${process.env.AGENT_URL}/stream`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Internal-Token": process.env.AGENT_INTERNAL_TOKEN!,
},
body: JSON.stringify({ message, threadId, resume, userId: session.user.id }),
});
return new Response(upstream.body, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}On the client, the approval interrupt renders as a proper Shadcn card instead of a wall of text. This matters more than people think. Shoppers trust buttons with clear totals far more than they trust a chat bubble saying "Shall I buy it?"
// components/approval-card.tsx
"use client";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { formatCents } from "@/lib/money";
type Quote = { id: string; title: string; vendor: string; final_cents: number; reason: string };
export function ApprovalCard({
items, totalCents, onDecision,
}: {
items: Quote[];
totalCents: number;
onDecision: (d: "approved" | "rejected") => void;
}) {
return (
<Card className="w-full max-w-lg">
<CardHeader>
<CardTitle>Ready to check out</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{items.map((item) => (
<div key={item.id} className="flex items-start justify-between gap-4">
<div>
<p className="font-medium">{item.title}</p>
<p className="text-sm text-muted-foreground">{item.reason}</p>
<Badge variant="secondary" className="mt-1">{item.vendor}</Badge>
</div>
<span className="font-semibold tabular-nums">{formatCents(item.final_cents)}</span>
</div>
))}
<div className="flex justify-between border-t pt-3 text-lg font-bold">
<span>Total</span>
<span className="tabular-nums">{formatCents(totalCents)}</span>
</div>
</CardContent>
<CardFooter className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={() => onDecision("rejected")}>
Change something
</Button>
<Button className="flex-1" onClick={() => onDecision("approved")}>
Approve and pay
</Button>
</CardFooter>
</Card>
);
}
Performance Notes for the Storefront
- Render product listing pages as static or cached Server Components, and keep only the chat panel as a Client Component.
- Stream tokens as they arrive. A first token under 500 ms makes even a slow local model feel responsive.
- Show intermediate agent steps ("Searching 3 vendors…", "Checking stock…"). Transparency reduces drop-off.
Step 5: Multi-Vendor Checkout with Stripe Connect
Multi-vendor checkout is where most agentic commerce demos quietly stop. In real life, one cart might contain a jacket from Vendor A and boots from Vendor B. The customer should see one charge. Each vendor should get paid their share.
Stripe Connect's separate charges and transfers pattern fits this perfectly.
sequenceDiagram
participant A as Agent
participant API as Commerce API
participant S as Stripe
participant W as Webhook Handler
A->>API: POST /checkout (cartId, approvalToken)
API->>API: Recalculate totals, verify mandate
API->>S: Create PaymentIntent (transfer_group = order_id)
S-->>API: client_secret
API-->>A: client_secret for confirmation
S->>W: payment_intent.succeeded
W->>S: Transfer to Vendor A
W->>S: Transfer to Vendor B
W->>API: Mark order paid, decrement stock
// api/src/routes/checkout.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
app.post("/checkout", async (req, reply) => {
const { cartId, approvalToken } = req.body as { cartId: string; approvalToken: string };
const userId = req.user.id;
await verifyApprovalToken(approvalToken, { userId, cartId }); // signed, short-lived
const order = await createOrderFromCart(cartId, userId); // recalculates every price
const mandate = await getMandate(userId);
if (order.totalCents > mandate.maxOrderCents) {
return reply.code(403).send({ error: "exceeds_mandate" });
}
const intent = await stripe.paymentIntents.create(
{
amount: order.totalCents,
currency: "usd",
customer: order.stripeCustomerId,
transfer_group: order.id,
automatic_payment_methods: { enabled: true },
metadata: { orderId: order.id, initiatedBy: "agent" },
},
{ idempotencyKey: `order-${order.id}-pi` }
);
return { clientSecret: intent.client_secret, orderId: order.id };
});And the webhook that pays each vendor:
// api/src/routes/webhooks.ts
app.post("/webhooks/stripe", { config: { rawBody: true } }, async (req, reply) => {
const event = stripe.webhooks.constructEvent(
req.rawBody!, req.headers["stripe-signature"] as string,
process.env.STRIPE_WEBHOOK_SECRET!
);
if (event.type === "payment_intent.succeeded") {
const pi = event.data.object as Stripe.PaymentIntent;
const orderId = pi.metadata.orderId;
const splits = await getVendorSplits(orderId); // [{ stripeAccountId, amountCents }]
for (const split of splits) {
await stripe.transfers.create(
{
amount: split.amountCents,
currency: "usd",
destination: split.stripeAccountId,
transfer_group: orderId,
source_transaction: pi.latest_charge as string,
},
{ idempotencyKey: `order-${orderId}-transfer-${split.stripeAccountId}` }
);
}
await markOrderPaid(orderId);
}
return reply.send({ received: true });
});Why source_transaction matters: it ties each transfer to the original charge, so transfers don't fail if your platform balance is temporarily low, and funds flow only once the charge's funds are available.
Why idempotency keys matter even more: agents retry. Networks retry. Webhooks get delivered twice. Without idempotency keys, a single hiccup could pay a vendor twice or charge a customer twice.
Step 6: Containerizing with Docker
Each service gets its own image. Here's a compact docker-compose.yml for local development that mirrors production:
services:
postgres:
image: pgvector/pgvector:pg17
environment:
POSTGRES_PASSWORD: ${PG_PASSWORD}
POSTGRES_DB: commerce
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
ollama:
image: ollama/ollama:latest
volumes: [ollama:/root/.ollama]
deploy:
resources:
reservations:
devices: [{ driver: nvidia, count: 1, capabilities: [gpu] }]
commerce-api:
build: ./api
environment:
DATABASE_URL: postgres://postgres:${PG_PASSWORD}@postgres:5432/commerce
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
depends_on:
postgres: { condition: service_healthy }
agent:
build: ./agent
environment:
DATABASE_URL: postgresql://postgres:${PG_PASSWORD}@postgres:5432/commerce
OLLAMA_URL: http://ollama:11434
depends_on: [ollama, commerce-api]
web:
build: ./web
ports: ["3000:3000"]
environment:
AGENT_URL: http://agent:8000
AGENT_INTERNAL_TOKEN: ${AGENT_INTERNAL_TOKEN}
depends_on: [agent]
volumes:
pgdata:
ollama:For the Next.js image, use output: "standalone" in next.config.ts and a multi-stage build. Your final image will be a fraction of the size of a naive build.
Step 7: Deploying on AWS
Here's the production topology I recommend for most teams:
- Amazon ECS on Fargate for
web,commerce-api, andagent(stateless, autoscaled). - ECS on EC2 GPU instances (G5 or G6 families) for Ollama, in a private subnet.
- Amazon RDS for PostgreSQL with pgvector enabled, Multi-AZ, and automated backups.
- Application Load Balancer in front of
webonly. Everything else stays private. - AWS Secrets Manager for Stripe keys and database credentials.
- CloudWatch + OpenTelemetry for traces across agent steps and API calls.

Autoscale the agent service on concurrent sessions, not CPU. Agent workloads spend most of their time waiting on inference, so CPU metrics will lie to you.
Real-World Example: "Gear Up for Iceland"
Let's trace a real conversation through the system.
Shopper: "I'm going hiking in Iceland in March. Need a waterproof jacket and boots, total under $450. I'm allergic to wool."
- ParseIntent extracts: two products,
max_price_cents: 45000,exclude_terms: ["wool", "merino"],must_have: ["waterproof"]. Complete, so no clarification. - Discover runs two hybrid searches and returns 15 candidates from three vendors.
- Rank picks a Gore-Tex style shell from Vendor A and insulated boots from Vendor B, with short reasons.
- Negotiate requests quotes. Vendor B offers 8% off for a bundle with gaiters. The agent includes this option because the total stays under budget.
- CheckMandate confirms $412 is under both the user's $450 request and their saved $600 mandate.
- HumanApproval renders the Shadcn card. The shopper taps "Approve and pay."
- Checkout creates one PaymentIntent. The webhook transfers each vendor its share.
Total shopper effort: one message and one tap. That's the experience that converts.
Best Practices
- Treat the LLM as an untrusted client. Validate every tool argument with Zod or Pydantic. Reject IDs the model invents.
- Recalculate everything server-side. Prices, discounts, taxes, and totals come from the database, always.
- Use spending mandates. Budget caps, category allowlists, and vendor allowlists turn "autonomous" into "safely autonomous."
- Persist agent state in PostgreSQL. One durable store for checkpoints and commerce data simplifies backups, compliance, and debugging.
- Make tools small and specific.
add_to_cart(product_id, qty)beats a genericcall_api(url, body)every single time. - Log every decision. Store the agent's reasoning snippet, inputs, and outputs per node. When a customer disputes a purchase, you'll need it.
- Evaluate continuously. Maintain a test set of 200+ realistic shopping prompts and run it on every prompt or model change.
Common Mistakes to Avoid
- Letting the agent set prices. I've seen demos where the model "negotiated" a 70% discount because the user asked nicely. Discounts must come from vendor policy code.
- Skipping idempotency. Duplicate charges destroy trust faster than any bug.
- One giant prompt instead of a graph. A 3,000-token system prompt that tries to do everything is impossible to test. Split it into nodes.
- Pure vector search. Embeddings can't reliably enforce "under $250" or "no wool." Use hybrid search.
- Exposing the agent service publicly. Proxy through Next.js and authenticate every request.
- Ignoring cart concurrency. The human UI and the agent will edit the same cart. Optimistic locking prevents ghost items and lost updates.
- Auto-buying without approval. Even with a mandate, default to explicit approval until you have strong data showing users want otherwise.
🚀 Pro Tips
- Route by difficulty. Use a small local model for intent parsing and a larger model only for ranking tricky trade-offs. You'll cut latency and GPU cost significantly.
- Pre-warm Ollama models on container start with a dummy request, and set
OLLAMA_KEEP_ALIVEso models stay loaded in GPU memory. - Cache embeddings for popular queries. "Black running shoes" gets asked a thousand times a day. Don't re-embed it a thousand times.
- Expose your tools as an agent-facing API. The same
search,quote, andcheckoutendpoints can serve third-party shopping agents, which positions you for the agent-to-agent model. - Store product facts as structured attributes. Agents rank far better on
{"waterproof_rating_mm": 20000}than on marketing copy. - Use LangGraph's time travel for debugging. Replay a failed session from any checkpoint to see exactly where the reasoning went wrong.
- Sign approval tokens. Issue a short-lived, signed token when the user taps approve, and require it at
/checkout. That way, a prompt-injected agent can't skip the approval step.
📌 Key Takeaways
- Agentic commerce is moving from experiment to revenue channel, and the stores ready for agents will win the next wave of discovery.
- LangGraph's explicit graph, interrupts, and Postgres checkpoints give you control, durability, and auditability that simple chains can't.
- Keep a hard boundary: the agent reasons and proposes, while the Node.js + PostgreSQL API owns prices, carts, and mandates.
- Stripe Connect with separate charges and transfers, plus idempotency keys, makes multi-vendor checkout reliable.
- Local LLMs via Ollama keep data private and costs predictable, especially when you route tasks by difficulty.
- Docker and AWS ECS give you a clean path from laptop to production without re-architecting.
Conclusion
The shift from search boxes to shopping agents feels a lot like the shift from desktop to mobile a decade and a half ago. At first, it looked like a nice extra. Then it became the default, and the stores that waited spent years catching up.
The good news is that the building blocks are mature. LangGraph gives you a reliable way to orchestrate multi-step reasoning. Ollama makes local inference practical. PostgreSQL with pgvector handles both your transactional data and your semantic search. Next.js and Shadcn UI let you ship a polished experience quickly. And Stripe Connect takes care of the hardest part of multi-vendor payments.
What separates a demo from a real product isn't the model. It's the boring, careful engineering around it: validation, idempotency, mandates, approvals, and observability. Get those right, and you'll have a shopping assistant your customers actually trust with their money.
Start small. Pick one product category, build the graph, wire up the approval card, and measure conversion against your existing search. The numbers will tell you where to go next.
References
- McKinsey: The agentic commerce opportunity – How AI agents are ushering in a new era for consumers and merchants
- ICSC and McKinsey report coverage: Retailers must rethink stores as agentic AI reshapes shopping
- LangGraph Documentation
- LangGraph Human-in-the-Loop and Interrupts
- Ollama Documentation
- pgvector: Open-source vector similarity search for PostgreSQL
- Stripe Connect: Separate charges and transfers
- Stripe API: Idempotent requests
- Next.js App Router Documentation
- shadcn/ui Documentation
- Amazon ECS Developer Guide
- Amazon RDS for PostgreSQL with pgvector