@askelo/browser
v0.1.1
Published
Framework-agnostic browser SDK for the Askelo support widget. Loads the widget and returns a handle you can open, close and subscribe to.
Maintainers
Readme
@askelo/browser
The Askelo support widget, for any browser app. Loads the widget and hands back something you can open, close and subscribe to.
Using React? @askelo/react wraps
this in components and a hook.
npm install @askelo/browserUsage
import { loadAskeloWidget } from "@askelo/browser";
const widget = await loadAskeloWidget({ widgetId: "your-widget-id" });
widget.open();
widget.on("unread", (count) => updateYourBadge(count));Your widget id is in Channels → Widget in the dashboard. Add the origin your app runs on to that widget's allow-list, or it will refuse to start.
Everything the widget looks like — colour, position, greeting, handoff labels — is resolved from the widget id on the server, so there is nothing else to configure and a settings change never needs a redeploy.
Your own launcher
const widget = await loadAskeloWidget({ widgetId: "…", hideLauncher: true });
document.querySelector("#support")!.addEventListener("click", () => widget.toggle());
widget.on("unread", (count) => { badge.textContent = count > 0 ? String(count) : ""; });hideLauncher also suppresses the greeting bubble and the unread badge, since
both are drawn against the launcher's corner. The unread event still fires, so
your own trigger can carry the count.
Styling
There are two surfaces, and they are customized in two different ways because they live in two different places.
The chrome — CSS on your own page
The launcher, the floating card and the greeting bubble are drawn in your
DOM, so you style them with ordinary CSS. Set any of these custom properties
anywhere they cascade — :root, a wrapper, a media query, your dark-mode class:
:root {
--askelo-launcher-size: 72px;
--askelo-launcher-offset-y: 120px; /* clear of your cookie banner */
--askelo-panel-radius: 4px;
}| Property | Default | What it does |
| --- | --- | --- |
| --askelo-launcher-size | 60px (54px under 768px) | Launcher width and height |
| --askelo-launcher-offset-x | 24px (20px under 768px) | Distance from the left/right edge |
| --askelo-launcher-offset-y | 24px (20px under 768px) | Distance from the top/bottom edge |
| --askelo-launcher-radius | 50% | Launcher corner radius |
| --askelo-launcher-background | gradient from your brand colour | Launcher fill |
| --askelo-launcher-shadow | two-layer soft shadow | Launcher shadow |
| --askelo-panel-width | 420px (fills the screen under 768px) | Floating panel width |
| --askelo-panel-height | 640px (fills the screen under 768px) | Floating panel height |
| --askelo-panel-offset | 104px | Gap between the panel and the viewport edge |
| --askelo-panel-radius | 20px (16px under 768px) | Panel corner radius |
| --askelo-panel-shadow | two-layer deep shadow | Panel shadow |
| --askelo-greeting-width | 320px | Greeting bubble width |
| --askelo-greeting-background | white | Greeting bubble fill |
| --askelo-greeting-radius | 16px | Greeting bubble corner radius |
| --askelo-greeting-shadow | soft shadow + hairline | Greeting bubble shadow |
| --askelo-z-index | 2147483000 | Base of the widget's stack — the launcher sits at +2, the panel at +1, the greeting at -1. Lower it to put your own modal on top. |
Set a property once and it applies at every breakpoint: the responsive defaults above only apply when you have not set one. That is deliberate — a launcher you sized at 72px should stay 72px on a phone, because that is what you asked for.
The panel — the theme option
The panel is a cross-origin iframe, so no CSS of yours reaches inside it. Three values cross the boundary instead:
const widget = await loadAskeloWidget({
widgetId: "your-widget-id",
theme: {
accent: "#ff0055",
fontFamily: '"Söhne", system-ui, sans-serif',
colorScheme: "dark",
},
});
widget.setTheme({ colorScheme: "light" }); // later, e.g. your own theme toggle
widget.setTheme(null); // back to CSS properties, then the dashboard| Key | Default | Notes |
| --- | --- | --- |
| accent | your dashboard setting | Overrides it for this page only |
| fontFamily | the platform UI stack | Nothing is downloaded — name a face the page already loads |
| colorScheme | "auto" | "light" / "dark" force it; "auto" follows the visitor's OS |
You can also set these from CSS, which is what a plain <script> tag install
uses — they are read off your root element when the widget loads:
:root {
--askelo-accent: #ff0055;
--askelo-font-family: "Söhne", system-ui, sans-serif;
--askelo-color-scheme: dark;
}CSS is read once, at load; setTheme() applies at any time and wins over it.
If your page has a live theme toggle, drive it with setTheme().
The two accents are separate on purpose.
--askelo-accentthemes the panel's interior;--askelo-launcher-backgroundthemes the launcher. Setting your brand colour in the dashboard drives both, which is what nearly everyone wants — reach for these only when one surface has to differ from the other on a particular page.
What is not themeable, and why
Spacing, radii and per-element colours inside the panel are deliberately not exposed. Everything that crosses the iframe boundary is an API we then have to keep working, and the panel's interior is already inside a box whose outside you control completely. If you need something specific there, ask — it is a better conversation than a token we can never change.
A chat on your own page
For a support page, a help sidebar, or anywhere the corner bubble is the wrong shape:
import { mountAskeloChat } from "@askelo/browser";
const { chat } = await mountAskeloChat("#support-chat", { widgetId: "your-widget-id" });The container needs a height — the chat fills it and brings no size of its own.
The floating launcher is hidden by default (pass showLauncher: true to keep
it), since a page that has embedded the conversation already has a way in.
It is the same widget and the same conversation as the launcher, so a visitor who starts here and later opens the bubble elsewhere on your site is in one conversation.
chat.isOnScreen() reports whether the visitor can actually see it. That is
also what the widget reports as presence, and presence decides whether an
agent's reply is also emailed — a chat scrolled off the page reads as away, so
the reply reaches their inbox too.
If you already have a widget handle, widget.mountInline(element) does the same
thing without loading a second one.
Telling the widget who your visitor is
If your app already knows who is signed in, say so — the widget stops asking them to type their own email address to reach a human, and the agent who picks the conversation up sees their name.
const widget = await loadAskeloWidget({ widgetId: "…" });
widget.identify({
externalId: user.id,
email: user.email,
name: user.name,
});Call it again when they sign in, or when the account they are acting as
changes. Passing user to loadAskeloWidget does the same thing at load, so
the first identify costs you no extra round trip.
An identity is a prefill and a label. It fills in the contact details on a support case and names the person to your agent. It is not authentication and it never widens what the widget can see.
Verified identities
Because the call above happens in a browser, by itself it is a claim: anyone who can open the page can make it. Turn on identity verification for your widget (Channels → Widget → your widget → Identity verification) and Askelo will only accept a claim your server has signed.
// on your server
import { createHmac } from "node:crypto";
const subject = user.id ?? user.email; // externalId first, else email
const signature = createHmac("sha256", process.env.ASKELO_IDENTITY_SECRET)
.update(subject)
.digest("hex");// in the browser, with the signature your server sent down
widget.identify({ externalId: user.id, email: user.email, name: user.name, signature });The contract is HMAC-SHA256(key = your widget's secret, message = subject),
hex-encoded and lowercase, where subject is the externalId — or the email
when there is no externalId. Compute it on the server: a secret your frontend
can read is a secret any visitor can read.
Only that one field is proven. With an externalId, email rides beside
it unsigned — it prefills the case, but it is not trusted for anything an
action reads, like a Stripe or Shopify lookup scoped to the visitor's verified
email.
Proving both fields at once (v2)
If you need externalId and email trusted together, sign the versioned
message instead and send signatureVersion: 2 alongside:
// on your server
import { createHmac } from "node:crypto";
function v2Message(externalId: string, email: string) {
const normalizedEmail = email.trim().toLowerCase();
return `v2\nuid:${externalId.length}:${externalId}\nemail:${normalizedEmail.length}:${normalizedEmail}`;
}
const signature = createHmac("sha256", process.env.ASKELO_IDENTITY_SECRET)
.update(v2Message(user.id, user.email))
.digest("hex");// in the browser — identical shape, signatureVersion just rides along
widget.identify({
externalId: user.id,
email: user.email,
name: user.name,
signature,
signatureVersion: 2,
});Both fields are length-prefixed in the message so that no value either one can
contain — a newline, a colon — can shift where uid ends and email begins.
email is trimmed and lowercased before it enters the message; sign it the
same way on your server. Existing integrations that sign only externalId or
only email keep working unchanged — v2 is opt-in per identify() call, not
a widget-wide setting, and the same secret verifies both.
Once a secret is set, an unsigned or wrongly signed identity is refused, not recorded as unverified. So deploy your signing code first, or turn verification on after your integration is live. Until you set one, identities are accepted and your agents see them marked as unverified.
API
loadAskeloWidget(options)
| Option | Type | Description |
| --- | --- | --- |
| widgetId | string | Required. |
| cdnUrl | string | Only if you were given a dedicated CDN origin. |
| hideLauncher | boolean | Suppress the built-in launcher. |
| timeoutMs | number | Default 20000. 0 waits indefinitely. |
| user | AskeloVisitor | Identify the visitor as part of loading. |
mountAskeloChat(target, options) takes the same options plus showLauncher,
and resolves with { widget, chat }.
Resolves with a handle:
interface AskeloWidgetHandle {
widgetId: string;
version: string;
open(): void;
close(): void;
toggle(): void;
isOpen(): boolean;
unreadCount(): number;
setLauncherHidden(hidden: boolean): void;
identify(user: AskeloVisitor): Promise<void>;
mountInline(container: HTMLElement): AskeloInlineChat;
on(event: "open" | "close" | "unread" | "destroy", handler): () => void;
destroy(): void;
}
interface AskeloVisitor {
externalId?: string;
email?: string;
name?: string;
signature?: string;
/** Absent or 1 is the default contract above; 2 is the versioned one that proves externalId and email together. */
signatureVersion?: number;
}identify() resolves once the identity has reached the widget, not once the
server has accepted it — the widget's iframe does not exist until the visitor
first opens the panel, so for a call made at login there is nothing yet to
round-trip to. The widget holds the identity and sends it when there is a
conversation to attach it to. A signature the server refuses is reported in the
browser console.
Calling loadAskeloWidget twice for the same widget returns two handles on one
widget, not two widgets. destroy() releases a handle; the widget itself is torn
down once the last one is released. A widget that was already running when you
called — because the page also carries the <script> snippet — is adopted and
never destroyed by this package.
interface AskeloInlineChat {
isOnScreen(): boolean;
unreadCount(): number;
onUnread(handler: (count: number) => void): () => void;
identify(user: AskeloVisitor): void;
destroy(): void;
}Errors
Rejections are an AskeloError with a code: invalid_widget_id,
invalid_cdn_url, script_load_failed, bootstrap_failed, timeout,
no_browser, invalid_container, or inline_unsupported (the widget runtime
this browser cached predates inline chat — redeploy, or wait for the cached
loader to expire).
import { AskeloError, loadAskeloWidget } from "@askelo/browser";
try {
await loadAskeloWidget({ widgetId });
} catch (error) {
if (error instanceof AskeloError && error.code === "bootstrap_failed") {
// wrong widget id, or this origin is not on the widget's allow-list
}
}destroyAllAskeloWidgets()
Removes every widget this package loaded, immediately. An escape hatch for a "log out and clear everything" path; prefer destroying handles otherwise.
Notes
- Server-safe to import, not to call:
loadAskeloWidgetrejects withno_browseroutside a browser rather than touchingdocument. Call it from an effect, or after atypeof window !== "undefined"check. - ESM and CJS builds are both published, with types for each.
- Assets are fetched from Askelo's CDN at runtime and their integrity is verified there (signed manifest, SRI-pinned bundle), so this package stays a few KB and gets fixes without an npm upgrade.
