Use casesAll pages

Use cases

Thirteen things people actually want the widget to do, each one worked out in full. Everything here is built from the commands on the JavaScript API page — nothing private, nothing bespoke.

Your own chat button#

A “Chat with us” link in your header, your pricing table, your error pages, your footer. Delegate the click so it works for buttons rendered later.

anywhere on the page
<button type="button" data-support-chat>Chat with us</button>

<script>
  document.addEventListener("click", function (e) {
    if (!e.target.closest("[data-support-chat]")) return;
    e.preventDefault();
    $connect.push(["do", "chat:open"]);
  });
</script>

Pair it with chat:hide if you would rather have no floating launcher at all and drive the widget entirely from your own interface.

Identify a signed-in customer#

The single highest-value thing you can do. An agent opening the conversation sees a name, an account, a plan, and a link into your own admin instead of “Visitor”.

server-rendered, next to the embed snippet
<!-- rendered by your server, only when someone is signed in -->
<script>
  window.$connect = window.$connect || [];
  $connect.push(["set", "user:nickname", ["{{ user.name }}"]]);
  $connect.push(["set", "user:email",    ["{{ user.email }}", "{{ emailHmac }}"]]);
  $connect.push(["set", "session:data", [[
    ["account",   "{{ org.name }}"],
    ["plan",      "{{ org.plan }}"],
    ["signed_up", "{{ org.createdAt }}"],
    ["admin",     "https://acme.com/staff/orgs/{{ org.id }}"],
  ]]]);
</script>
  • Render it server-side so the data is there on first paint — the queue holds it until the widget boots.
  • The last argument is the HMAC that marks the address verified. See Verifying the email; without it, everything still works and the address shows as unverified.
  • A link into your admin, as a custom-data value, saves your team a search on every single conversation. Do it.

Hide it on some pages#

Checkout flows, embedded views, admin tools, anywhere a floating button is in the way.

a page where it should not appear
// On pages the widget has no business being on.
$connect.push(["do", "chat:hide"]);

In a single-page app the widget outlives the route, so decide again on every navigation.

react router, vue router, whatever you have
// In a single-page app, re-evaluate on every route change.
const HIDDEN = [/^\/checkout/, /^\/admin/, /^\/embed\//];

function syncWidget(pathname) {
  const hide = HIDDEN.some((re) => re.test(pathname));
  $connect.push(["do", hide ? "chat:hide" : "chat:show"]);
}

router.afterEach((to) => syncWidget(to.path));
syncWidget(location.pathname);

Put “reply to this email and we will pick it up in chat” behind a URL. Useful from status pages, receipts, and onboarding emails.

honouring ?chat=open
// https://acme.com/pricing?chat=open  — from an email, an ad, a status page.
if (new URLSearchParams(location.search).get("chat") === "open") {
  $connect.push(["do", "chat:open"]);
}

Ecommerce context#

“My discount is not applying” is a two-minute conversation when the agent can see the cart, and a ten-minute one when they cannot.

cart state and checkout moments
function pushCart(cart) {
  $connect.push(["set", "session:data", [[
    ["cart_items", cart.lines.length],
    ["cart_total", cart.formattedTotal],
    ["currency",   cart.currency],
    ["last_order", customer.lastOrderNumber || "none"],
  ]]]);
}

shop.on("cart:updated", pushCart);

shop.on("checkout:started", (checkout) => {
  $connect.push(["set", "session:event", [[
    ["checkout_started", { total: checkout.total, items: checkout.count }, "orange"],
  ]]]);
});

shop.on("payment:failed", (err) => {
  $connect.push(["set", "session:event", [[
    ["payment_failed", { reason: err.code }, "red"],
  ]]]);
});
  • Custom data is state — the cart as it is right now, overwritten as it changes.
  • Events are moments — checkout started, payment failed — and they stay on the timeline.
  • Give failures a colour so an agent scanning the sidebar sees the problem before reading a word.

SaaS context#

Plan, revenue, seats, and where they are in a trial. Segments make the same information filterable across the visitors list.

plan data and derived segments
const daysLeft = Math.ceil((org.trialEndsAt - Date.now()) / 86400000);

$connect.push(["set", "session:data", [[
  ["plan",       org.plan],
  ["mrr",        org.mrr],
  ["seats",      org.seatCount],
  ["trial_days", daysLeft],
]]]);

$connect.push(["set", "session:segments", [[
  org.plan,
  org.mrr > 500 ? "high-value" : "standard",
  daysLeft > 0 && daysLeft <= 3 ? "trial-ending" : "active",
]]]);

Remember that segments replace the whole set on every call, so compute the complete list each time.

Let agents see inside your app#

Register functions your support team can run in the visitor’s browser. The diagnostics one ends most “it is broken” threads immediately.

two functions worth having on day one
$connect.push(["register", "function", [
  {
    name: "get_client_state",
    description: "App version, active flags, and the last client error.",
    parameters: { type: "object", properties: {} },
    handler: function () {
      return {
        version: window.__APP_VERSION__,
        flags: Object.keys(window.__FLAGS__ || {}).join(", ") || "none",
        lastError: window.__LAST_ERROR__ || "none",
        online: navigator.onLine,
      };
    },
  },
  {
    name: "resend_receipt",
    description: "Email the receipt for an order to the signed-in customer.",
    parameters: {
      type: "object",
      properties: { orderId: { type: "string", description: "Order number" } },
      required: ["orderId"],
    },
    handler: async function (args) {
      const res = await fetch("/api/orders/" + args.orderId + "/receipt", {
        method: "POST",
      });
      if (!res.ok) throw new Error("Order not found or not yours");
      return { sent: true, to: currentUser.email };
    },
  },
]]);

Read Client functions before registering anything that changes state — the ten-second timeout and the “an agent may click twice” rule both matter.

Measure engagement#

The two events the widget fires are enough to answer “which pages make people ask for help”, which is usually the question worth asking.

into whatever analytics you run
$connect.push(["on", "chat:opened", function () {
  analytics.track("Support chat opened", { path: location.pathname });
}]);

$connect.push(["on", "chat:closed", function () {
  analytics.track("Support chat closed", { path: location.pathname });
}]);

Nudge the right pages only#

The first-visit nudge is configured per website and has no page filter. Hiding the widget on pages you do not want it on hides the nudge card too.

pricing pages get the nudge, nothing else does
// The nudge is a per-WEBSITE setting; it has no page filter.
// Hiding the widget hides the nudge card along with it.
const NUDGE_PAGES = [/^\/pricing/, /^\/plans/];
if (!NUDGE_PAGES.some((re) => re.test(location.pathname))) {
  $connect.push(["do", "chat:hide"]);
}

Deflect with your knowledge base#

Nothing to write. Publish articles in the dashboard’s knowledge base and the widget opens on a home view with search and suggestions above the “start a conversation” button. Visitors who can answer their own question do, and the ones who cannot are one tap from a person.

  • The surface hides itself until you publish at least one article.
  • Articles are also served at /help/<your-slug>, which is what to link from emails and macros.
  • The AI assistant answers from the same articles, so writing one improves both surfaces.

Skip the email question#

When your team is offline, the widget asks the visitor for an email address so you can reply later. For a signed-in customer that question is noise — push the address and it never appears.

one line, on every authenticated page
$connect.push(["set", "user:email", ["jane@acme.com", emailHmac]]);

Connect sets no cookies, but it does keep a little browser storage, and some consent regimes cover that. Injecting the loader on acceptance keeps the decision in your consent tool.

load on acceptance, never twice
// Inject the loader only once the visitor has accepted.
function loadConnect() {
  if (window.__CONNECT_LOADED__) return;
  window.$connect = window.$connect || [];
  window.CONNECT_WEBSITE_KEY = "wk_xxxxxxxxxxxxxxxxxxxxxxxx";
  const s = document.createElement("script");
  s.src = "https://connect.example.com/widget/v1.js";
  s.async = 1;
  document.head.appendChild(s);
}

if (consent.has("support")) loadConnect();
consent.on("accepted", (categories) => {
  if (categories.includes("support")) loadConnect();
});

The __CONNECT_LOADED__ guard makes a second call harmless, so wire it to both the initial check and the acceptance event without worrying about order.

Several brands, one team#

A workspace can hold several websites. Each gets its own key, its own colour, its own origin allowlist, its own knowledge base audience — and they all land in one inbox where conversations are labelled by site.

pick the key by hostname
// One workspace, one inbox, a separate website record per brand.
const KEYS = {
  "acme.com":       "wk_aaaaaaaaaaaaaaaaaaaaaaaa",
  "shop.acme.com":  "wk_bbbbbbbbbbbbbbbbbbbbbbbb",
  "northwind.com":  "wk_cccccccccccccccccccccccc",
};
window.CONNECT_WEBSITE_KEY = KEYS[location.hostname];
  • Each site’s allowlist must contain its own origins. A shared snippet with the wrong key is the most common cause of a silent 403.
  • Appearance is per website, so each brand keeps its own accent colour and launcher icon.
  • Your plan’s website limit applies — check it before splitting a brand out.