Project Write-Up · Agentic Commerce & 3D World-Building

Pola — A Virtual Market of AI Shopkeepers

Seven AI shopkeepers, one live commerce MCP, and the bet that a market beats a chat widget. Originally built for the Kapruka Agent Challenge 2026, now sitting in my portfolio.

When I was a kid I used to imagine tiny shopkeepers living inside ecommerce websites. A little person behind every category who actually knew what they were selling. That image never really left me. And that image was my inspiration.

The Brief

Don't Build a Chat Widget, Build a Place

The Kapruka Agent Challenge asked for "a polished, immersive conversation as the main surface — not a tiny widget in a corner." I thought most entries would read that as an instruction to build a bigger chat box. I decided to read it literally: the chat is the destination, but you reach it by walking into a small, warm, hand-built 3D village first.

Pola is the Sinhala word for an open-air marketplace. It's the kind of place where people browse, linger, and talk before they buy. That was my base: a market square with seven lit-up stalls, each with its own shopkeeper, rather than a flat list of product categories. It also gave every idea I wanted to explore a place to live. I believe personality needs a character. Kapruka's enormous, genuinely undifferentiated catalog (groceries next to electronics next to baby trolleys) becomes something a customer can understand spatially in two seconds — gifts are over there, tech is over there — instead of something they have to read through a menu to find.

The obvious risk is that a 3D scene reads as a tech demo, performs badly, or gets in the way of actually shopping. Every decision below exists to make sure the 3D layer earns its place without ever blocking the real loop: discovery → cart → checkout.

0 Shopkeeper pavilions
0 Model endpoints, 3 providers
0 Hosting cost


World-Building

The World Itself

The whole frontend is a single index.htmlThree.js r128 loaded straight from a CDN, vanilla JavaScript, zero bundler, zero build step. Inside it is a deliberate Ghibli-leaning, toon-shaded, low-poly look, chosen instead of anything trying to be photorealistic.

MeshToonMaterial with a hand-built four-step gradient texture sits on almost every mesh, and a slightly oversized, backface-culled duplicate of each shape gives the dark ink-outline look of hand-drawn animation. It's a style choice: outlines and flat shading read as intentional in a screenshot in a way default-lit PBR never does, and they happen to render for free on a laptop with no GPU guarantee. A gradient sky dome, a firefly particle system, and a procedural cherry-blossom petal fall round out the atmosphere. All of these were built from primitive geometry, which means nothing imported, nothing to license, and nothing to fail to load.

Seven pavilions sit on a lit courtyard, each a cylinder-and-cone hut coloured to its trade and decorated with a small clue to what it sells: an apple for the grocer, a diamond for fashion, a gift box for gifting, a flatscreen for tech, etc. Small details, but I included them because I wanted a difference between "seven identical boxes with labels" and "seven stalls that look like what they sell." A guide character, Sudu, anchors the courtyard's centre — more on her below.

Camera as Motion

Walking up to a stall doesn't teleport the camera. A render-loop lerp eases the camera position and look-target in over several frames while a darkening overlay fades up and a ← Back to Courtyard button appears. I included this small bit of motion to try and turn "this is a webpage with a 3D background" into "I am walking up to a stall" — cheap to implement, disproportionately effective.

Failing gracefully Below 760px, a full-screen card explains plainly that this is a 3D market town happier on a bigger screen, with a "Continue anyway" override. Ambient market sound is opt-in and only starts on the first real click, respecting autoplay policy, with the mute preference remembered across visits. No one gets an unexpected audio blast on load.


Character Design

Seven Pavilions, Turning a Catalog Into Characters

The hardest part of Kapruka's MCP for any single agent is that the catalog is enormous and the categories cut across each other in non-obvious ways — liquor and non-alcoholic wine both file under "Grocery," adult products and intimate pharmacy both file under "Pharmacy." A single generalist bot would need a huge, brittle system prompt trying to hold the whole taxonomy in its head, and would constantly answer from the wrong part of the catalog.

My answer was to split the catalog into seven pavilions that mirror how a Sri Lankan market is actually organised, and give each one its own dedicated shopkeeper: a name, an age, a backstory, a speaking style, and a narrow slice of the taxonomy. Walk through the courtyard below and tap a stall to meet whoever's behind the counter.

🐈
🍎
🎁
👶
👗
📱
📚
🌙

↓ tap a stall, or the cat in the middle

Tap any pavilion above to meet its shopkeeper.

Each system prompt bakes in a full character sheet, not just an instruction to "be friendly" — a name, an age, a speaking style, and behaviour rules tuned to what could actually go wrong in that specific aisle, tap around above to see a few.

No shopkeeper is allowed to pretend they can sell everything, either. Every prompt carries the full market directory and a standing rule to refer a customer elsewhere by name when their need fits another pavilion better — Sampath Aiya genuinely wouldn't know which perfume to recommend, and having him admit that and point you to Safi is more trustworthy than having him bluff it. And every reply is written as a voice note, not a bullet list: markdown renders as literal asterisks in the chat bubble, so a server-side strip_markdown pass backstops the rare turn where a model ignores that instruction anyway.



Navigation

Sudu — The Guide Who Doesn't Sell Anything

Most agent demos put one bot in charge of everything. I deliberately split "figuring out where to go" from "actually buying something," and gave the first job to a separate, much simpler character: Sudu, a small white cat who knows every corner of the market but explicitly sells nothing herself.

Sudu is intentionally tool-free, i.e. no MCP round-trips, so replies are snappy and the architecture stays cleanly split between navigation and commerce. Rather than forcing her into structured output, which would fight against her loose, cat-like voice, a lightweight keyword scan turns any place-name signal in her free-text reply into a tappable chip:

Sudu says, "That's Tharushi, at Gifts & Celebrations — go on, she'll sort you out." Sudu, when asked where to find a birthday gift

Sudu offers four preset chips on first open so a visitor can get moving with one tap instead of staring at a blank input. Every reply after that regenerates chips dynamically from what she just said. It's the Creativity answer, too: instead of a settings menu or a category sidebar, "I don't know where to go" is handled by a character in the world, in plain English.



Architecture

System Architecture

The frontend never talks to Gemini, Cerebras, Groq, or the Kapruka MCP directly. It only ever calls its own backend's four endpoints — /api/chat, /api/delivery-cities, /api/check-delivery, /api/cart-checkout — which is what lets one backend run the same MCP session lifecycle, the same product-card curation, and the same anti-hallucination guardrails no matter which model provider actually answered a given turn.

Request Flow
index.html (static) ── POST /api/chat ──▶ FastAPI backend
FastAPI backend ── [fallback: GeminiCerebrasGroq] ──▶ chosen model
FastAPI backend ── [tools/call] ──▶ mcp.kapruka.com/mcp
4 client-facing endpoints · 1 health check · the frontend never sees which model or MCP call answered

A fifth route, GET /, is a lightweight health check. This reports which model API keys are loaded without triggering any model or MCP call, so the backend's state is readable in one request, no model call required.



Engineering

The Backend, In Depth

The 3D scene gets the attention, but most of the actual engineering risk lived in three backend decisions. Click any card to read the reasoning.

↓ Click to expand

Reliability

A Model Fallback Chain — Because Free Tiers Rate-Limit

GeminiCerebrasGroq. Three providers, six model endpoints, tried in order within a single request.

/api/chat tries Gemini first, cheapest-first (gemini-2.5-flash-litegemini-2.5-flashgemini-2.0-flash), then falls back to Cerebras (gpt-oss-120b, zai-glm-4.7), then Groq (llama-3.3-70b-versatile, llama-4-scout).

Every provider here runs on a free tier, and free tiers rate-limit. A single-provider build is one 429 away from failing live at exactly the wrong moment — mid-conversation, in front of whoever's trying it. The fallback chain means a transient outage degrades response quality slightly, with a different model finishing, the same agentic loop, rather than breaking the demo outright. An honest _fallback field exists for my own debugging, but it's never shown to customers, because a shopkeeper apologising for "switching AI providers" would break character for no benefit.

Infrastructure

A Hand-Rolled MCP Client

Streamable-HTTP transport implemented directly, instead of an off-the-shelf MCP library.

initialize → capture Mcp-Session-Idnotifications/initialized → repeatable tools/call, with both plain-JSON and Server-Sent-Events response bodies handled by hand.

Session correctness under concurrency. A single chat turn can trigger several parallel tool calls, so the lazy session-init path is guarded with an asyncio.Lock and a double-check, so two coroutines racing to initialise at the same moment can't each spin up their own session and silently clobber each other.

429 resilience. The free MCP tier caps at 60 requests/minute per IP, and a busy chat turn can burst past that — one short, capped retry absorbs a momentary spike without making every dropped request fatal.

Self-healing sessions. If a tool call fails mid-conversation because a session quietly expired, the backend re-initialises once and retries automatically instead of surfacing a raw error and derailing the conversation.

Concurrency

Two Agentic Loops, One Dialect Problem

Gemini and the OpenAI-compatible providers don't speak the same tool-calling format.

gemini_loop() uses Gemini's native function_declarations / functionCall format; openai_loop(), shared by Cerebras and Groq, uses the OpenAI-style tools[] / tool_calls[] format. Both run up to MAX_LOOPS = 8 rounds of "model decides to call a tool → tool runs → result goes back → model decides again," so a fallback model gets exactly the same live-catalog grounding the primary model does.

Within a round, tool calls run concurrently via asyncio.gather — if a model wants to search two things and check delivery in the same turn, those happen in parallel. asyncio.gather preserves input order in its output regardless of which call finishes first, which matters for the OpenAI-style loop because tool_call_ids have to pair back to the right result.

Both loops write into one shared tool_history list, so every downstream step — product extraction, order-summary construction, card curation — works identically no matter which of the six models answered.

Guardrails, Because Models Lie Confidently

A raw LLM-plus-tools loop against a real commerce API will, left alone, occasionally hallucinate a category slug, invent a product ID, or surface an adult-section product to a general audience. Every row below exists because I watched the failure happen during testing, and a fix in the system prompt alone didn't reliably hold.

Guardrail What breaks without it
Category slugs only forwarded once confirmed Kapruka's category filter is exact-match; a guessed slug returns zero results, and the model confidently tells the customer the product doesn't exist.
SHOW_CARDS allowlist Search spans the whole catalog, not one pavilion — a "rice" query legitimately returns rice cookers (Kavindu's) alongside rice meals (Sampath's). The model, not the raw API response, decides which IDs belong on screen for the shopkeeper you're actually talking to.
Lounge filter, both directions A keyword filter drops adult/liquor/tobacco items outside The Lounge even if a persona's card line includes one, and the inverse filter keeps a rose bouquet out of the 21+ section.
IDF re-ranking on 20 fetched results Kapruka's default ordering doesn't always surface the most relevant result first; a self-calibrating term-weight pass re-sorts before the model ever sees the list.
_query_matched: false flag Without it, a "fish tank" search returning cleaning supplies looks plausible enough that the model recommends it anyway instead of admitting the gap.
Hard cap of 8 cards, enforced twice The prompt asks for 6–8; the backend caps it again regardless, so a model that ignores curation can't dump twenty cards on one message.
Today's date injected into every prompt Left to infer "today" from training data, the three providers disagreed with each other and with the calendar — which rejected valid near-future delivery dates as "in the past."


Commerce

Closing the Loop, Twice

Getting all the way to a placed order mattered most here. I built two distinct, complete checkout paths, because they serve two genuinely different shopping behaviours.

Conversational
Cart-Drawer

Inside the chat itself. The shopkeeper collects recipient name, phone, delivery address, city, date, and — for Tharushi especially — an optional gift message, entirely through dialogue, then calls kapruka_create_order directly. This path is scoped to each shopkeeper's own pavilion, mirroring a real market stall: Tharushi handles gifting orders, Kavindu handles electronics, and so on. Cross-pavilion carts are what the other tab is for.

  • The response is parsed as structured JSON so the price breakdown comes back as real numbers, not something scraped out of prose.
  • If checkout is called on a product ID that was never actually looked up this conversation, the backend detects the gap and backfills a fresh product lookup before the summary renders.
  • Dev, at The Lounge, is the only shopkeeper who also offers an anonymous sender option — the one context in the market where someone might actually want it.

For the browse-then-decide shopper. Add to cart from any product card or the lightbox; the cart drawer has its own quantity controls and running total, and "Checkout at the Till" opens a delivery-details form. These three endpoints are deliberately thin, stateless, and model-free — there's no conversation to extract structure from, so there's no reason to pay for an LLM round-trip just to fill in a form.

  • Live city autocomplete and a live delivery-fee/date preview update the moment both city and date are chosen, before the order is even placed.
  • Delivery details are remembered in localStorage, updated only after a successful order, with an explicit "Clear saved details" escape hatch.
  • Submitting builds the same order-summary shape the chat flow does, and hands off to the same Order Summary modal — one UI, two ways to reach it.

One detail ties both paths together: conversation history is trimmed to the last 10 turns per request, which would normally make checkout forget a product ID mentioned several turns earlier. The frontend sends the current cart on every request, and the backend injects it as a confirmed-cart block in the system prompt — the one piece of state that has to survive the trim.



Priorities

Where the Effort Went

Six things mattered most while I was building this, roughly in the order I paid attention to them. Click any card for the reasoning behind it.

↓ Click to expand

Polish

One Visual Language, Held Together End to End

Toon-shaded 3D scene, camera-lerp navigation, ambient sound, mobile gate — nothing that breaks the illusion.

Every asset — the toon shading, the ink outlines, the gradient sky, the market sounds — pulls from the same reference mood board. The camera-lerp and the mobile gate exist for the same reason: the illusion has to hold whether someone's on a gaming laptop or a mid-range one with three other tabs open.

Richness

Making a Catalog Feel Tactile

Product card grids, an image lightbox, an Order Summary modal with real photos, hand-built stall decorations per pavilion.

A product card without a real photo reads as a placeholder, so the card, the lightbox, and the final order summary all pull the same image. Each pavilion also got one small hand-modelled detail tied to what it sells, instead of seven identical huts with different labels on the door.

Character

Seven Shopkeepers, Not One Bot in Different Hats

Names, ages, backstories, speech patterns, and worked example lines baked into every system prompt.

Each prompt reads more like a character brief than an instruction sheet — how they'd actually talk, what they'd notice, what they'd never say. The difference shows up fast: ask Sampath Aiya about skincare and he tells you flatly that's not his aisle, instead of bluffing an answer.

Utility

Behaviour Rules Tuned to Where Each Aisle Actually Breaks

Sudu as a routing layer, IDF re-ranking, domain-specific rules per shopkeeper, two complete checkout paths.

Most of this came from watching the agent fail during testing, not from planning it up front — a category slug that silently returns nothing, a search that surfaces the wrong half of the catalog, a customer with no idea which of seven stalls to even start at. Each fix patches a specific failure rather than a vague "be more careful" instruction.

Completeness

Both Paths Actually Place an Order

Conversational and cart-drawer checkout both flow into the same Order Summary modal with working pay links.

The easy version of this stops at "the shopkeeper can describe how checkout would work". Plenty of agent demos do exactly that. Both paths here call the same order-creation endpoint and land on the same confirmation screen, ending in an order that's actually been placed.

Concept

A Place You Walk Into, Not a Widget You Type Into

The market-as-place metaphor, and Sudu as a cat you ask in plain English instead of a menu.

The easiest version of this project is one chatbot with a category dropdown. Building an actual place — a courtyard, seven stalls, a guide who points you around instead of a settings screen — was the slower, riskier option, and the one I actually wanted to see exist.

A few other things just needed to work under the hood: a cart drawer with quantity controls and a running total, a live delivery-date preview before an order is placed, and gift messaging from Tharushi that flows straight through to the final order summary.



Retrospective

What's Next

Sinhala and Tanglish support. Every persona is currently English-only. The underlying models are multilingual and would likely handle either reasonably, but that's untested, not a claimed feature — and for a market meant to feel local, it's probably the one addition that would matter most.

Structured cross-stall referrals. Shopkeepers already refer customers to each other by name in conversation, and Sudu's chips work today. The one-tap cross-pavilion banner UI is built but not yet wired to a backend signal — a classifier step after the agentic loop, not a frontend rebuild.

Proactive delivery-date warnings. The delivery-check tool and cart preview both work correctly; no persona yet volunteers a tight-window warning unless asked. This would just be a prompt addition.



The Stack

Everything Used

3D scene Three.js r128 (CDN, no bundler) Free
Frontend Vanilla JS & hand-written CSS, single index.html Free
Backend FastAPI + httpx (async), on Render Free tier
Primary model Gemini (flash-lite → flash → 2.0-flash) Free tier
Fallback chain CerebrasGroq Free tier
Commerce Kapruka MCP — search, delivery, checkout, orders Provided
Frontend hosting GitHub Pages Free


Try It

Pola is live. Walk into the courtyard, find a pavilion, and talk to whoever's behind the counter.

If you're thinking about agentic commerce or multi-persona system design, or you have questions about any of the decisions above, I'd genuinely like to hear from you.




About

Built by Mohamed Amir Sohil Bishrul Hafi.
3D: Three.js  ·  Backend: FastAPI  ·  Commerce: Kapruka MCP