AI chat that sells and books on any website, without trusting the browser.
- Problem
- Businesses wanted an AI assistant on their own website that knows their products and can act: add to cart, check out, book a time. The widget runs on sites I don't control, so I treat every request from it as untrusted.
- Role
- I built and ran all seven services alone: the widget, the edge Worker, the backend, the card library, the nudge framework, the tool service and the D1 mirror.
- Stack
- Svelte
- TypeScript
- Node.js
- Express
- PostgreSQL
- pgvector
- Vercel AI SDK
- Cloudflare Workers
- D1
- Result
- Two paying clients and six live demos. A new client goes live in about a week.
Context
SmartCog installs with one script tag. The widget lives in a Shadow DOM, so the host site's styles can't break it. It works in 13 languages, including Hebrew and Arabic.
The answers aren't just text. The model calls tools, and the results show up as cards in the chat. Each client has its own set of cards, loaded from a CDN. A band's assistant plays songs and sells merch. A shop's assistant shows products, a cart and a checkout.
That puts the browser inside the conversation. Every click on a card has to reach the model somehow. And the browser is the one place anyone can tamper with.
Each business uses its own AI key, with any of eight providers. Tools, knowledge, cards and actions are all rows in the database. Adding a client is setup work, not a code change.
Architecture
- Widgetone script tag → Edge Worker
- Edge Workersigns every request → Backend
- Backendaction registry → LLM provider→ PostgreSQL
- LLM providertenant's own key
- PostgreSQLpgvector
Three decisions
- Choice
- the server writes every prompt a card can trigger.
- Why
- a card sends an action name and a few allowed fields. The wording lives in a server template the browser never sees. Nobody can steer the model from DevTools.
- Cost
- every new button needs a registry entry, listing its allowed fields and which cards may send it.
- Choice
- cards handle the transaction and tell the assistant how it went.
- Why
- a booking card submits the form itself, with signed headers. Then it reports 'confirmed' or 'slot taken' as a registered action. The model never sees the form data.
- Cost
- each card manages its own states: form, sending, confirmed, failed. Every outcome needs its own action.
- Choice
- keep setup in the database, not in code.
- Why
- I write a tool's input schema as JSON in the dashboard. The backend turns it into a Zod schema when a request comes in. New tools and clients ship without a deploy.
- Cost
- a broken schema fails when a request runs, not when I build. The dashboard's schema test catches it first.
What broke
Prompts from the browser. My first cards sent prompt text, base64-encoded, through a browser event. Then I realised anyone with DevTools could fire that event with their own text. I replaced the whole channel. Cards now send actions with a fixed list of fields. The server checks which card sent each one. Then it fills in its own template and cleans the values.
Text and cards out of order. One answer can include speech, several cards and a call to action. Shown as they arrived, the typing, fade-ins and scrolling fought each other. I wrote an orchestrator that plays one answer at a time. Text finishes typing first, and each card waits until its images load.
Missing fonts. Browsers don't load fonts declared inside a Shadow DOM, so the widget's fonts never showed on client sites. The loader now adds the font rules to the host page and keeps everything else isolated.
// Components send an action type and a payload. The server owns the words. (trimmed)
const actionRecord = await getActionRegistryByType(actionType);
if (!actionRecord) return res.status(404).end();
if (actionRecord.requiresAuth) {
const isAllowed = actionRecord.allowedComponents.includes(allowedUrl)
|| actionRecord.allowedNudges.includes(allowedUrl);
if (!isAllowed) return res.status(403).end();
}
const invalidKeys = Object.keys(payload)
.filter((k) => !actionRecord.allowedPayloadKeys.includes(k));
if (invalidKeys.length > 0) return res.status(403).end();
const finalMessage = interpolateAndSanitize(actionRecord.messageTemplate, payload);Results
Two paying clients, and six live demos across music, retail, health products, hospitality and tax.
A new client goes live in about a week. I follow a seven-phase playbook I wrote after the MyndReader install.
Eight AI providers sit behind one function. Each business pays for its own usage with its own key.
Every widget request passes three layers of checks before it reaches the model: in the browser, at a Cloudflare Worker and on the backend. Codes work once, and tokens expire after a minute.
The same system runs a band's merch store and a B2B lead funnel. Only the data and the copy change.
Next time
Start with actions, not prompts. The action registry should have been the first design, not the fix.
Build the nudge framework sooner. I wrote the first site's script by hand and only turned it into a framework after the third.
Move the core to Cloudflare: a Durable Object for each conversation, Vectorize for search and D1 as the main database. The edge layer is already there, so I'd estimate about a week of porting.