@askelo/angular
v0.1.1
Published
Angular components, a directive and a signal-based service for the Askelo support widget.
Maintainers
Readme
@askelo/angular
The Askelo support widget for Angular: a floating chat launcher, an embedded chat for a support page of your own, and a signal-based service to drive both.
Standalone components and a directive — no NgModule. SSR-safe: nothing loads on
the server, so an Angular Universal / @angular/ssr app needs no guard of its
own.
npm install @askelo/angularQuick start
Configure it once, in app.config.ts:
import { provideAskelo } from "@askelo/angular";
export const appConfig: ApplicationConfig = {
providers: [provideAskelo({ widgetId: "your-widget-id" })],
};That is the whole installation — the launcher appears in the corner of every route. Colour, position, greeting and handoff labels are resolved from the widget id on our side, so changing them in the dashboard never means changing your app.
Prefer to scope it to part of the UI? Skip provideAskelo() and drop the
component in a template instead:
<askelo-widget widgetId="your-widget-id" />import { AskeloWidgetComponent } from "@askelo/angular";
@Component({ imports: [AskeloWidgetComponent], /* ... */ })Mounted in one route's template, the widget leaves with that route. Mounted in
app.html, it is everywhere — the same as provideAskelo().
Controlling the widget
Inject AskeloService anywhere. Everything it exposes is a signal, so a
template reads it directly:
import { AskeloService } from "@askelo/angular";
@Component({
template: `
<button (click)="askelo.open()" [disabled]="!askelo.isReady()">
Support
@if (askelo.unreadCount() > 0) {
<span class="badge">{{ askelo.unreadCount() }}</span>
}
</button>
`,
})
export class HelpButton {
protected readonly askelo = inject(AskeloService);
}| Signal | |
| --- | --- |
| status() | "idle" · "loading" · "ready" · "error" |
| isReady() | shorthand for status() === "ready" |
| error() | the AskeloError, in the "error" status |
| isOpen() | whether the panel is on screen |
| unreadCount() | agent replies since the visitor last had it open |
| widget() | the underlying @askelo/browser handle, or null |
Methods: open(), close(), toggle(), identify(user), setTheme(theme).
open() before the widget is ready is dropped rather than queued — a visitor
who clicks Support during a slow load, gives up and navigates away should not
have a panel open itself on the next page.
Your own launcher
Turn the built-in one off and wire up a button of your own:
provideAskelo({ widgetId: "your-widget-id", hideLauncher: true });<button askeloLauncher class="my-support-button">
Support
@if (askelo.unreadCount() > 0) {
<span class="badge">{{ askelo.unreadCount() }}</span>
}
</button>[askeloLauncher] brings no styles and no content — what a support trigger
should look like is your decision. What it does bring is the part that is easy
to get wrong: aria-expanded tracking the panel, aria-haspopup="dialog", an
accessible name that mentions unread replies without overriding the button's
own visible text, and disabled until the widget is ready (set
[disableUntilReady]="false" to style the pending state yourself).
hideLauncher also suppresses the greeting bubble and the unread badge, since
both are positioned against the launcher's corner. The unread count still
arrives — that is how your own button carries its own badge.
A chat on your own page
<askelo-chat />The same widget the launcher opens, drawn as a block instead of a card. That is the point rather than an implementation detail: a visitor who asks here and later opens the bubble on another page is in one conversation, and the agent who picks it up sees all of it.
Inside an app that called provideAskelo() it needs no attributes at all, and
leaves the floating launcher exactly as that app configured it. Give it a
widgetId of its own and it becomes the thing configuring the widget on that
page — and then turns the launcher off, since a page that has embedded the
conversation already has a way in:
<askelo-chat widgetId="your-widget-id" />
<!-- or keep the bubble too -->
<askelo-chat widgetId="your-widget-id" [showLauncher]="true" />Sizing. The chat fills the element and brings no height of its own, so the element ships with one — 640px, the height of the floating panel. Override it with the custom property, or with any rule of yours that beats a component style:
askelo-chat {
--askelo-chat-height: 100%;
}A container of zero height renders a loaded, bootstrapped, invisible chat, which
looks exactly like a widget that failed to load. The element carries
data-askelo-chat="idle | loading | ready | error" if you want to style the
wait.
Styling
Two mechanisms, split by which document the pixels are in.
The chrome is CSS. The launcher, the floating card and the greeting bubble
are drawn in your page's DOM, so they read --askelo-* custom properties you
set anywhere:
:root {
--askelo-accent: #ff4f00;
--askelo-launcher-size: 64px;
--askelo-z-index: 900;
}Media queries, dark-mode classes and scoped overrides all work, because it is ordinary CSS.
The panel is a message. It is a cross-origin iframe, so no cascade reaches it. Three values travel across:
<askelo-widget
widgetId="your-widget-id"
[theme]="{ accent: '#ff4f00', fontFamily: 'Inter, sans-serif', colorScheme: 'dark' }"
/>colorScheme defaults to "auto" (the visitor's OS), which is the wrong answer
for a dark-designed site whose visitor is set to light. Nothing is downloaded
for fontFamily — name a face your page has already loaded.
Re-binding the theme re-themes in place; it never reloads the widget, and an inline object literal is safe (only its contents are compared).
Telling the widget who your visitor is
<askelo-widget widgetId="your-widget-id" [user]="{ externalId: user.id, email: user.email, name: user.name }" />or, from a sign-in callback:
await this.askelo.identify({ externalId: user.id, email: user.email });It prefills the contact details on a support case and names the visitor to the agent who picks it up. It is not authentication and it never widens what the widget can see.
Changing it re-identifies and never closes an open conversation — which matters
most at exactly the moment it changes, since somebody logging in mid-chat is the
ordinary case. An empty claim ({}, while your session loads) is a no-op rather
than "this visitor is now anonymous".
If the widget has identity verification switched on, send a signature
alongside — HMAC-SHA256(key = your widget's secret, message = externalId || email),
hex, lowercase, computed on your server. A browser that can read the secret
can claim to be any of your customers. Only that one field is proven: with an
externalId, email rides beside it unsigned.
To prove both fields together, sign the versioned message instead and send
signatureVersion: 2:
// 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");await this.askelo.identify({ externalId: user.id, email: user.email, 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.
Existing integrations that sign only externalId or only email keep working
unchanged.
Server-side rendering
Nothing to do. The service checks PLATFORM_ID and does not load on the server,
so provideAskelo() in a shared app.config.ts is correct as written and
<askelo-chat> renders an empty, correctly-sized box during hydration.
What your origin has to be
Your app's origin must be on the widget's allowlist in the dashboard — including
http://localhost:4200 while you are developing. This is the same rule as the
<script> tag; installing from npm does not exempt you from it.
API
provideAskelo(config)
widgetId · cdnUrl · hideLauncher · timeoutMs · theme · user ·
enabled.
<askelo-widget>
The same fields as inputs, all optional; anything you bind wins over
provideAskelo(). Rendering the component opens a standing contribution and
destroying it withdraws that one — so two surfaces on a page (a shell and a
support route, say) merge field by field instead of erasing each other, and the
one that leaves takes only its own settings with it.
<askelo-chat>
widgetId · cdnUrl · showLauncher · timeoutMs · theme · user ·
enabled.
[askeloLauncher]
On a <button>. One input: disableUntilReady (default true).
AskeloService
The signals and methods above.
Errors
error() holds an AskeloError with a machine-readable code:
invalid_widget_id, invalid_cdn_url, script_load_failed, bootstrap_failed,
timeout, no_browser, invalid_container, inline_unsupported, destroyed.
A rejected origin arrives as bootstrap_failed.
Notes
- One widget per (CDN origin, widget id), ref-counted. Two surfaces asking for the same widget get the same one, and neither can tear it out from under the other.
- A widget already running on the page — from the pasted
<script>snippet — is adopted rather than replaced, so a migration from the tag to this package is not a moment with two launchers or none. - Reloading only happens for
widgetId,cdnUrlandtimeoutMs. Everything else is applied to the running widget, because a reload closes the visitor's open conversation. - For a framework this package does not cover,
@askelo/browserloads the same widget from any browser app.
MIT
