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.
<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”.
<!-- 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.
// 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.
// 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);Open the chat from a link#
Put “reply to this email and we will pick it up in chat” behind a URL. Useful from status pages, receipts, and onboarding emails.
// 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.
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.
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.
$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.
$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.
// 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.
$connect.push(["set", "user:email", ["jane@acme.com", emailHmac]]);Wait for cookie consent#
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.
// 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.
// 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.