El is a voice-powered shopping assistant embedded directly in an EchoMart Shopify storefront. Customers simply talk to El and she finds products, manages their cart in real time, and answers questions, all through natural voice conversation.
EL.mp4
| Voice Command | What Happens |
|---|---|
| "Show me your snowboards" | El queries Shopify and reads back matching products |
| "What's the cheapest option?" | El re-sorts results by price ascending |
| "Add The Complete Snowboard to my cart" | El signals the browser to call /cart/add.js — the Shopify theme cart updates instantly |
| "What's in my cart?" | El reads out every item, quantity, and price |
| "Update the quantity to 2" | El calls /cart/update.js with the correct variant ID |
| "Remove the snowboard" | El sets quantity to 0 via /cart/update.js |
| "Track my order #12345" | El fetches order status via get_order_status |
Browser (Shopify storefront) Express Server (this repo)
┌─────────────────────────┐ ┌──────────────────────────────┐
│ el-widget.liquid │ HTTP │ server.js │
│ ├─ AgoraRTC client │◄─────────►│ ├─ POST /api/start-el │
│ ├─ Cart polling loop │ │ ├─ POST /api/stop-el │
│ └─ Shopify AJAX cart │ │ ├─ POST /api/shopify-search │◄── Agora MCP
└─────────────────────────┘ │ ├─ GET /api/cart-actions │
│ RTC audio │ ├─ POST /api/cart-state │
▼ │ ├─ POST /api/report-sold-out │
┌───────────────┐ │ └─ GET /api/product-image │
│ Agora Cloud │ └──────────────────────────────┘
│ (voice AI) │ │
└───────────────┘ ▼
│ MCP call ┌──────────────────┐
└─────────────────────────►│ Shopify │
│ Storefront API │
└──────────────────┘
Cart sync via polling, not RTM tokens
El's LLM reformulates MCP tool results in its own words, so [CART_ADD:...] tokens embedded in tool responses never reliably reach the browser via the RTM stream. Instead, the server queues cart actions (pendingActions map) and the widget polls GET /api/cart-actions every 1.5 s to drain and execute them.
Live cart state pushed by the browser
After every AJAX cart operation, the widget POSTs the current /cart.js snapshot to POST /api/cart-state. El's get_cart tool reads from this server-side cache instead of querying Shopify directly, ensuring she always knows what's actually in the shopper's basket.
AI message buffering Agora streams the AI response incrementally — each packet contains the full accumulated text so far. The widget silently buffers incoming packets and only commits the bubble to the chat once the stream goes quiet for 900 ms, so the user always sees the complete message.
Bundled section rendering
Cart AJAX calls include a sections parameter so Shopify returns updated HTML for the cart badge and drawer in the same response. The widget surgically replaces only the inner DOM nodes, preserving existing event listeners.
| Layer | Technology |
|---|---|
| Voice AI | Agora Conversational AI v2 (MCP Streamable-HTTP) |
| LLM | OpenAI GPT-4.1 Mini |
| ASR | Agora Ares |
| TTS | MiniMax Speech 2.8 Turbo |
| Backend | Node.js 22 + Express 4 |
| Product data | Shopify Storefront GraphQL API (2025-04) |
| Public tunnel | ngrok |
| Storefront | Shopify Liquid section |
- Node.js 18+
- An Agora account with a Conversational AI pipeline configured
- A Shopify store with a custom app that has
unauthenticated_read_product_listingsscope - ngrok installed
git clone https://github.com/your-org/El.git
cd El
npm installcp .env.example .env.local
# Fill in every value (see table below)| Variable | Where to get it |
|---|---|
AGORA_APP_ID |
Agora Console → project list |
AGORA_CUSTOMER_ID |
Console → RESTful API → Customer ID |
AGORA_CUSTOMER_SECRET |
Console → RESTful API → Customer Secret |
AGORA_RTC_TOKEN |
Console → Token Builder (UID=123, channel=echo-mart-dev) |
AGORA_PIPELINE_ID |
Agora Studio → your saved agent pipeline |
NGROK_URL |
Output of ngrok http 3000 — update each session |
SHOPIFY_DOMAIN |
your-store.myshopify.com (no https://) |
SHOPIFY_STOREFRONT_TOKEN |
Shopify Admin → Apps → Develop apps → your app → Storefront API access token |
Important: Your Shopify app must have
unauthenticated_read_product_listings(andunauthenticated_write_checkoutsif using cart) enabled under Storefront API scopes.
ngrok http 3000Copy the https://xxxx.ngrok-free.app URL into NGROK_URL in .env.local.
# Production
npm start
# Development (auto-restart on file changes)
npm run devYou should see:
🚀 El prototype server running on http://localhost:3000
Ngrok URL: https://xxxx.ngrok-free.app
Search tool endpoint: https://xxxx.ngrok-free.app/api/shopify-search
- In Shopify Admin → Online Store → Themes → Edit code
- Create a new section:
sections/el-widget.liquid - Paste the full contents of
el-widget.liquid - Update the three config constants near the top of the
<script>block:
const AGORA_APP_ID = "YOUR_AGORA_APP_ID";
const AGORA_TOKEN = "YOUR_RTC_TOKEN"; // regenerate each session
const BACKEND_URL = "https://xxxx.ngrok-free.app";- In Theme Editor, add the El Widget section to your homepage or all pages via
theme.liquid
Wakes El and drops her into the Agora RTC channel. Stops any previously running agent on the same channel first.
// Request
{ "channel": "echo-mart-dev" }
// Success
{ "agent_id": "abc123", "status": "running" }Gracefully removes the Agora agent from the channel.
{ "channel": "echo-mart-dev" }Implements the full MCP JSON-RPC handshake (initialize → tools/list → tools/call). Called automatically by Agora when El invokes any MCP tool.
Tools exposed:
search_catalog— full-text + price-sorted product searchcreate_cart— resolves variant ID, checks inventory, queuesaddactionget_cart— reads live cart state pushed by the browserupdate_cart_item— validates stock, queuesupdateactionremove_from_cart— queuesupdateaction with qty=0
Returns queued cart actions and drains the queue. Polled by the widget every 1.5 s.
{ "actions": [{ "type": "add", "variantId": "12345678", "qty": 1 }] }Browser pushes its live /cart.js snapshot here after every cart mutation.
{ "items": [{ "variant_id": "12345678", "title": "Complete Snowboard", "quantity": 1, "price": "$699.95" }] }Widget calls this when Shopify AJAX returns a "sold out" error. El will remember and block future add attempts for this variant.
{ "variantId": "12345678" }Fetches product image, handle, and price for a given title. Used by the widget to render inline product cards.
{ "status": "ok", "ngrok": "https://xxxx.ngrok-free.app" }Product discovery
You: "What snowboards do you have?"
El: "We have The Complete Snowboard at $699.95, The Videographer Snowboard at $885.95, and The Mammoth Snowboard at $749.95."
Price-based search
You: "Show me the most expensive snowboard"
El: "The most expensive snowboard is The 3p Fulfilled Snowboard at $2,629.95."
Add to cart
You: "Add The Complete Snowboard to my cart"
El: "Done! Added to your cart."
[Cart badge on the page updates immediately]
View cart
You: "What's in my cart?"
El: "Your cart contains: 1 x The Complete Snowboard at $699.95. You can say remove or update quantity for any item."
Update quantity
You: "Change the quantity to 2"
El: "I've updated the quantity to 2 for you."
Remove item
You: "Remove the snowboard"
El: "I've removed that item from your cart."
# Health check
curl http://localhost:3000/health
# Start El manually
curl -X POST http://localhost:3000/api/start-el \
-H "Content-Type: application/json" \
-d '{"channel": "echo-mart-dev"}'
# Search products
curl -X POST http://localhost:3000/api/shopify-search \
-H "Content-Type: application/json" \
-d '{"method":"tools/call","id":1,"params":{"name":"search_catalog","arguments":{"query":"snowboard"}}}'
# Check queued cart actions
curl "http://localhost:3000/api/cart-actions?channel=echo-mart-dev"| Symptom | Fix |
|---|---|
❌ Missing env vars on startup |
Fill in all values in .env.local |
| El can't hear you / no audio | Check browser microphone permissions |
| Cart badge doesn't update | Ensure cart-icon-bubble is the correct section ID for your theme (Dawn assumed) |
| Products not found | Verify SHOPIFY_STOREFRONT_TOKEN has unauthenticated_read_product_listings scope |
401 Unauthorized from Agora |
Regenerate AGORA_RTC_TOKEN — tokens expire |
| ngrok URL changed | Update NGROK_URL in .env.local AND BACKEND_URL in the Liquid widget, then restart server |