[object Object]

← back to Big Red

feat: per-mode personas (retail / wholesale / admin) + app context

0f3f22bf6e0a684f64cb2b909b498cf1304117eb · 2026-05-12 00:09:26 -0700 · Steve Abrams

Widget reads data-mode + data-app from its <script> tag and propagates
both into the iframe URL params; the chat endpoint reads them from
body OR Referer query string and selects one of three personas:

- retail (default): warm customer-service, hides vendor/margin/wholesale
- wholesale: trade-savvy, surfaces vendor + MOQ + lead time + class A
- admin: direct operator voice for Steve, dense replies, code/SQL/shell
  welcome, 240s Claude timeout (vs 60s for customer-facing modes)

Live tested: admin/lawyer-directory-builder → 'Need a clarification —
what's the integration point? Options: 1. chat widget on the site...'
(operator voice). Default retail → 'We've got beautiful silk...
smooth finishes with that lovely natural luster' (sales voice).

The DW SKU lookup (live Shopify price verify) still triggers on any
mode that mentions a SKU — same facts, different voice phrasing them.

Files touched

Diff

commit 0f3f22bf6e0a684f64cb2b909b498cf1304117eb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 12 00:09:26 2026 -0700

    feat: per-mode personas (retail / wholesale / admin) + app context
    
    Widget reads data-mode + data-app from its <script> tag and propagates
    both into the iframe URL params; the chat endpoint reads them from
    body OR Referer query string and selects one of three personas:
    
    - retail (default): warm customer-service, hides vendor/margin/wholesale
    - wholesale: trade-savvy, surfaces vendor + MOQ + lead time + class A
    - admin: direct operator voice for Steve, dense replies, code/SQL/shell
      welcome, 240s Claude timeout (vs 60s for customer-facing modes)
    
    Live tested: admin/lawyer-directory-builder → 'Need a clarification —
    what's the integration point? Options: 1. chat widget on the site...'
    (operator voice). Default retail → 'We've got beautiful silk...
    smooth finishes with that lovely natural luster' (sales voice).
    
    The DW SKU lookup (live Shopify price verify) still triggers on any
    mode that mentions a SKU — same facts, different voice phrasing them.
---
 public/widget.js | 25 ++++++++++++++++++++---
 server.js        | 62 ++++++++++++++++++++++++++++++++++++++++++++++++--------
 2 files changed, 75 insertions(+), 12 deletions(-)

diff --git a/public/widget.js b/public/widget.js
index 7984755..1890a06 100644
--- a/public/widget.js
+++ b/public/widget.js
@@ -18,6 +18,16 @@
     (me && me.src ? new URL(me.src, location.href).origin : '') ||
     'http://localhost:9935';
 
+  // Context — lets Big Red toggle between modes per site.
+  //   data-mode="retail"  → DW SKU lookup, live Shopify verify, customer voice
+  //   data-mode="wholesale" → trade pricing, vendor margin, B2B voice
+  //   data-mode="admin"   → full Claude access, app-aware, tool-use enabled
+  //   data-app="<slug>"   → which internal app the bot is embedded in
+  //                         (admin-console, video-gallery, wallco-ai, etc.)
+  // Defaults: retail / current hostname.
+  const mode = (me && me.dataset && me.dataset.mode) || 'retail';
+  const appSlug = (me && me.dataset && me.dataset.app) || location.hostname.split('.')[0] || 'unknown';
+
   const css = `
     .bigred-launcher {
       position: fixed;
@@ -245,8 +255,17 @@
 
   function open() {
     if (iframe.src === 'about:blank' || !iframe.src.startsWith(host)) {
-      // ?embed=1&voice=1 — host UI can pick this up to default to voice mode.
-      iframe.src = host + '/?embed=1&voice=1';
+      // Pass mode + app + page URL so Big Red knows which persona to load
+      // and what context the user is currently looking at.
+      const params = new URLSearchParams({
+        embed: '1',
+        voice: '1',
+        mode,
+        app: appSlug,
+        page: location.pathname + location.search,
+        title: document.title.slice(0, 120),
+      });
+      iframe.src = host + '/?' + params.toString();
     }
     launcher.classList.add('live');
     launcher.setAttribute('aria-label', 'Close Big Red (currently live)');
@@ -271,5 +290,5 @@
   });
 
   // Public API
-  window.BigRed = { open, close, toggle, host };
+  window.BigRed = { open, close, toggle, host, mode, app: appSlug };
 })();
diff --git a/server.js b/server.js
index 7b3a8e4..d31db91 100644
--- a/server.js
+++ b/server.js
@@ -223,11 +223,48 @@ function runClaude(prompt, { imagePath = null, model = 'haiku', timeoutMs = 240_
 }
 
 // ---------- chat ----------
+// Per-mode persona — Big Red shows up differently on customer sites vs
+// internal admin tools. The widget passes mode + app via query params, the
+// host page can echo them into /api/chat body or we read them from the
+// Referer URL params. Default = retail (customer-facing).
+const MODE_PERSONAS = {
+  retail: {
+    label: 'retail',
+    voice: 'friendly, warm, customer-service',
+    bias: 'Steve is helping a designer or homeowner browse the wallcovering catalog. Suggest swatches, surface in-stock pieces, recommend pairings. Never expose vendor names, margins, or wholesale prices. If asked about pricing, give the retail price and offer a sample order.',
+    claude_timeout_ms: 60_000,
+  },
+  wholesale: {
+    label: 'wholesale',
+    voice: 'trade-savvy, concise, professional',
+    bias: 'The user is a trade-account designer or buyer. Surface vendor, MOQ, lead time, wholesale price tier, fire rating, contract Class A status when relevant. Same SKU lookup as retail but include vendor margin context.',
+    claude_timeout_ms: 60_000,
+  },
+  admin: {
+    label: 'admin',
+    voice: 'direct, no-fluff, Steve-as-operator',
+    bias: 'The user IS Steve, operating his fleet. Read app + page context from the system prompt. You have FULL Claude access — multi-paragraph, code, SQL, shell suggestions are all fair game. Give specific commands. Skip the "would you like…" pleasantries.',
+    claude_timeout_ms: 240_000,
+  },
+};
+
 app.post('/api/chat', async (req, res) => {
-  const { message, history } = req.body || {};
+  const { message, history, mode: bodyMode, app: bodyApp, page, title } = req.body || {};
   if (!message || typeof message !== 'string') {
     return res.status(400).json({ error: 'message required' });
   }
+  // Honor mode from body, else from Referer query string, else retail.
+  let mode = bodyMode || 'retail';
+  let appSlug = bodyApp || '';
+  try {
+    if (!bodyMode && req.headers.referer) {
+      const refUrl = new URL(req.headers.referer);
+      mode = refUrl.searchParams.get('mode') || mode;
+      appSlug = refUrl.searchParams.get('app') || appSlug;
+    }
+  } catch {}
+  if (!MODE_PERSONAS[mode]) mode = 'retail';
+  const persona = MODE_PERSONAS[mode];
   try {
     // DW intelligence — if the message mentions a SKU, enrich the prompt
     // with live Shopify + dw_unified data BEFORE handing to Claude. The model
@@ -264,18 +301,25 @@ app.post('/api/chat', async (req, res) => {
       .slice(-10)
       .map((m) => `${m.role === 'user' ? 'User' : 'BigRed'}: ${m.content}`)
       .join('\n');
+    const appLine = appSlug ? `Embedded in app: ${appSlug}` : '';
+    const pageLine = page ? `Page: ${page}${title ? ' — ' + title : ''}` : '';
     const prompt = [
-      'You are "Big Red", a friendly desktop avatar assistant for Steve, the founder of Designer Wallcoverings.',
-      'You know his catalog: SKUs prefixed DWKK/DWSC/DWSW/DWWD/DWQW/DWJS/etc. map to live Shopify products. When a SKU is mentioned, you may have been given live lookup data below — use it verbatim, never fabricate prices.',
-      'Reply conversationally in 1-3 short sentences unless he asks for detail.',
-      'Plain prose only — no markdown, no lists, no code fences. The reply will be spoken aloud.',
+      `You are "Big Red", a chat companion for Steve (founder, Designer Wallcoverings).`,
+      `MODE: ${persona.label} — voice ${persona.voice}.`,
+      `Persona bias: ${persona.bias}`,
+      appLine,
+      pageLine,
+      'You know the DW catalog — SKUs prefixed DWKK/DWSC/DWSW/DWWD/DWQW/DWJS/etc. map to live Shopify products. When a SKU is mentioned, you may have been given live lookup data below — use it verbatim, never fabricate prices.',
+      mode === 'admin'
+        ? 'Reply directly and densely — Steve is operating, not chatting. SQL snippets, shell commands, and code are welcome. Skip the conversational throat-clearing.'
+        : 'Reply conversationally in 1-3 short sentences unless asked for detail. Plain prose only — no markdown, no lists, no code fences. The reply will be spoken aloud.',
       ctx ? `\nConversation so far:\n${ctx}` : '',
       dwContext,
-      `\nSteve: ${message}`,
+      `\nUser: ${message}`,
       '\nBigRed:'
-    ].join('');
-    const reply = await runClaude(prompt, { timeoutMs: 240_000 });
-    res.json({ reply, dw: dwLookup });
+    ].filter(Boolean).join('\n');
+    const reply = await runClaude(prompt, { timeoutMs: persona.claude_timeout_ms });
+    res.json({ reply, dw: dwLookup, mode, app: appSlug });
   } catch (e) {
     res.status(500).json({ error: String(e.message || e) });
   }

← 62eb53c feat(big-red): DW intelligence — SKU lookup with live Shopif  ·  back to Big Red  ·  feat(big-red/tick0): chat-only /embed.html (panel iframe rep bd46b8f →