Documentation

Embed the Webchat Widget

Add a floating Akili chat bubble to any website with a script tag and a few lines of configuration.

Overview

The webchat widget (@akili/webchat) is a small, dependency-free script that renders a chat bubble inside a Shadow DOM so it won't clash with your site's styles. Once loaded, it exposes a single global — window.Akili — with an init() method that mounts the widget and connects it to one of your published agents.

1. Include the script

Your Akili instance serves the widget bundle itself, at /embed/webchat.js. Add the script to your page before the closing </body> tag:

<script src="https://app.example.com/embed/webchat.js"></script>

That URL always serves the current build. To pin one, use /embed/webchat.v1.js, which is cached immutably for a year. Hosting the file yourself is supported as an alternative — save the same file and point the script tag at your own CDN.

2. Initialize the widget

After the script loads, call Akili.init() with the agent you want to expose and the base URL of your Akili backend:

<script>
  Akili.init({
    agentId: "YOUR_AGENT_ID",
    apiUrl: "https://app.example.com",
    publicKey: "YOUR_WEBCHAT_PUBLIC_KEY",
    theme: {
      primaryColor: "#7C22F5",
      position: "bottom-right",
      title: "Chat with us",
    },
  });
</script>

Calling Akili.init() again replaces the current widget instance; call Akili.destroy() to remove it entirely (for example, on route change in a single-page app).

Configured on the channel, not in the snippet

Everything below is set once on the channel and served to the widget, so changing it takes effect on every page carrying this snippet without anyone editing HTML. Edits to a published channel are held as a draft until someone publishes them — visitors keep seeing the published version in the meantime.

Appearance
Brand color, light/dark (including follow-the-visitor), font, corner radius, header and message style.
Copy
Bot name and description, welcome message, composer placeholder, and the conversation starters shown before the visitor types.
Contact & legal
Email, phone, website, terms and privacy links, rendered in the panel footer. A channel that keeps a copy of its conversations cannot be published without a privacy link.
Features
File uploads, message feedback, notification sound, unread badge, conversation history and reset scope.
Language
The locale the widget's own text renders in. Leave it unset and each visitor gets their browser's language.
Deploy mode
Floating launcher, or inline — mounted into an element on your own page by CSS selector, with no fixed positioning and no launcher of its own.

The theme fields in the snippet above still win where you set them, for the one case a server-stored channel cannot cover: a page that switches brand at runtime. Leave them out and the channel's own values apply.

A page, when you have no page to embed in

Every channel reserves a URL of its own at https://app.example.com/a/your-channel-slug. Turn on Hosted page for a published channel and that URL serves the same agent as a full page — no site, no snippet, nothing to install. It is a link you can send, put in an email signature, or print on a card.

It runs the identical widget against the identical endpoints, so the origin allowlist, the daily visitor cap and the channel's spend limit apply to it exactly as they do to an embed on your own site. Enabling the hosted page adds one origin — this app's — to that channel's allowlist, and nothing else; every other origin is still refused.

  • The page is not indexed by search engines unless you switch indexing on for that channel. A support agent should not turn up in search results by accident.
  • It is served only while the channel is published. Pausing the channel takes the page down and leaves the slug reserved.
  • Its title, description and link preview image are the channel's own bot name, description and avatar — so what a link preview promises is what the visitor then sees.

Verified visitor identity

The user object in the snippet above is whatever your page says it is. That is fine for greeting someone by name; it is not enough to look up their order history, because anyone reading your page can edit it. Setting an identity secret on the channel turns that assertion into something the server can check.

1. Sign the visitor's id on your server

Compute HMAC_SHA256(secret, visitor.id) and return it as lowercase hexadecimal. Do this where the secret lives — on your server, in the same request that renders the page. Never in the browser: a secret the page can read is a secret the visitor can read, and a signature anyone can produce proves nothing.

Node.js — Built-in crypto — no dependency.

import { createHmac } from "node:crypto";

// The channel's identity secret. Read it from your own secret store; it must
// never reach the browser, and never appear in the page that mounts the widget.
const secret = process.env.AKILI_WEBCHAT_IDENTITY_SECRET;

export function identityHash(visitorId) {
  return createHmac("sha256", secret).update(visitorId, "utf8").digest("hex");
}

// identityHash("user-42")
// => "9f2c...", 64 lowercase hex characters

Python — Standard library — hmac + hashlib.

import hashlib
import hmac
import os

# Same rule: server-side only. A secret that reaches the browser signs nothing,
# because anyone who can read it can sign any identifier they like.
SECRET = os.environ["AKILI_WEBCHAT_IDENTITY_SECRET"].encode("utf-8")

def identity_hash(visitor_id: str) -> str:
    return hmac.new(SECRET, visitor_id.encode("utf-8"), hashlib.sha256).hexdigest()

# identity_hash("user-42")
# => "9f2c...", 64 lowercase hex characters

2. Hand it to the widget

Pass the digest alongside the identity it signs. The widget sends it once, when it establishes the session, as the X-Akili-Identity-Hash header on POST /backend/webchat/session. There is deliberately no way to send an identity with an individual message: an identity that could be replaced per message could be replaced after it was checked.

<script>
  Akili.init({
    agentId: "YOUR_AGENT_ID",
    apiUrl: "https://app.example.com",
    publicKey: "YOUR_WEBCHAT_PUBLIC_KEY",
    user: {
      id: "user-42",                 // exactly the value you signed
      name: "Ada Lovelace",
      email: "ada@example.com",
    },
    identityHash: "9f2c…",           // the 64-character hex digest from step 1
  });
</script>

If you are not using the bundled widget, the same digest goes on the session request you make yourself. This is the contract the widget implements, and the one the server checks:

POST https://app.example.com/backend/webchat/session
Content-Type: application/json
X-Akili-Visitor-Id: <your per-browser id>
X-Akili-Identity-Hash: 9f2c…

{ "publicKey": "YOUR_WEBCHAT_PUBLIC_KEY",
  "visitor": { "id": "user-42", "name": "Ada Lovelace", "email": "ada@example.com" } }

If a visitor signs in after the widget has opened, re-initialise it with the new identity and digest rather than trying to update the current session — a session carries one identity for its lifetime, by design.

3. What the server does with it

Channel secretIdentity sentDigestResult
Not setNo—Anonymous session
Not setYesIgnoredAccepted, marked unverified
SetNo—Anonymous session
SetYesMissingRejected — 403
SetYesMalformed or wrongRejected — 403
SetYesCorrectAccepted, marked verified

Once a channel has a secret, a bad or missing digest is rejected, never quietly accepted as unverified — otherwise skipping the signature would be the easiest way past it. A visitor who sends no identity at all is still welcome; the secret governs how a claim is treated, not whether someone may arrive without one.

What it proves: the signature covers the id and nothing else. A verified session means that identifier came from a server holding your secret. The name and email beside it are still your page's claims — look them up by the verified id rather than trusting them as sent.

Configuration reference

FieldTypeDescription
agentIdstring (optional since WC-701)The UUID of the agent the widget should talk to.
apiUrlstring (required)The base URL of your Akili backend, e.g. https://app.example.com.
publicKeystring (required)The webchat config's public key, issued when an org admin configures the widget. Identifies your org and its allowed-origins list to the backend.
theme.primaryColorstring (overrides the channel)Accent color for the chat bubble and header.
theme.position"bottom-right" | "bottom-left" (overrides the channel)Corner of the screen the widget docks to.
theme.title / theme.subtitlestring (overrides the channel)Header text shown inside the chat panel.
theme.avatarUrlstring (overrides the channel)Avatar image shown next to assistant messages.
user.id / user.name / user.emailstringOptional identifying info for the visitor, forwarded with each message.
onOpen / onClose() => voidCalled when the widget panel opens or closes.
onMessage(message: { role: string; content: string }) => voidCalled for each message sent or received.

Controlling the widget from your page

Once the script has loaded, window.Akili is your handle on the running widget. Every method below changes the widget in place: none of them restarts it, and none of them discards the conversation the visitor is in the middle of. Calling any of them before init(), or after destroy(), does nothing rather than throwing into your page.

MethodWhat it does
Akili.open()Opens the chat panel.
Akili.close()Closes the chat panel.
Akili.toggle()Opens the panel if it is closed, closes it if it is open.
Akili.sendMessage(text)Sends text as though the visitor had typed it. Does not open the panel, and does not clear whatever the visitor was halfway through writing. Safe to call immediately after init(): it is queued until the widget has mounted.
Akili.sendEvent(payload)Attaches context about your page — the route, the plan, a cart total — to the next message. It does not send a turn of its own, because there is nothing there for the agent to answer. Payloads merge; the merged object is capped at 2 KB.
Akili.updateUser(user)Adds visitor detail your page learned after the widget opened. It can only fill in fields that are still empty — a name or email already asserted is never replaced — and adding one costs a new session, which counts against that visitor's daily allowance.
Akili.getUser()Returns a copy of the identity currently asserted for this visitor.
Akili.config(partial)Applies part of a configuration to the running widget — brand color, corner position, header title and subtitle, and your callbacks — without restarting it, so the conversation survives. theme merges into the existing theme rather than replacing it, and user goes through updateUser's rules. The header avatar and the channel the widget points at still need destroy() and a fresh init().
Akili.setUnreadMessageCount(n)Puts a badge on the closed launcher. The widget sets this itself when a reply arrives while the panel is shut, and clears it when the visitor opens the panel.
Akili.on(type, handler)Subscribes to one event type and returns a function that unsubscribes. Additive: onEvent in your init() config still receives everything, whether or not you ever call this.
Akili.restartConversation()Clears the transcript and starts a new conversation.
Akili.showLauncher() / hideLauncher()Shows or hides the floating launcher bubble.
Akili.requestHandoff(reason?)Asks for a human. resolveHandoff() ends it.
Akili.destroy()Removes the widget from the page entirely.
<script>
  Akili.init({
    apiUrl: "https://app.example.com",
    publicKey: "YOUR_WEBCHAT_PUBLIC_KEY",
  });

  // Rebrand without restarting: the visitor keeps their conversation.
  Akili.config({ theme: { primaryColor: "#0F766E", title: "Enterprise support" } });

  // Tell the agent where the visitor is. Merged, and sent with the next message.
  Akili.sendEvent({ page: location.pathname, plan: "enterprise" });

  // Fill in what you learn later. It adds; it never overwrites.
  Akili.updateUser({ email: "ada@example.com" });

  // Open the panel with a question already asked.
  document.querySelector("#help").addEventListener("click", () => {
    Akili.open();
    Akili.sendMessage("How do I add a teammate?");
  });

  const off = Akili.on("message", (event) => analytics.track("chat_message", event.payload));
  // off() unsubscribes. Your init()'s onEvent keeps firing either way.
</script>
Asserted by your page, not verified: Everything you send through sendEvent() and updateUser() is labeled as asserted by your page and unverified, and the agent is instructed to treat it that way. It is context, not proof of identity: do not build a flow that reveals account, order or billing detail on the strength of it.

Language

The widget's own text — the composer placeholder, the header subtitle, the launcher's accessible labels and every error a visitor can be shown — is translated into the ten languages Akili ships: Arabic, Chinese, English, French, German, Italian, Japanese, Portuguese, Spanish and Swahili.

  • Set Language on the channel to pin one, or leave it unset and each visitor gets the language their browser asks for.
  • A regional tag resolves to its language: a channel set to pt-BR renders Portuguese, not English.
  • Anything you wrote yourself — bot name, description, welcome message, placeholder, starters — is shown exactly as you wrote it. Only the widget's built-in text is translated.
  • Catalogs are fetched from this app after the widget has already painted in English, so a slow or blocked request costs the visitor nothing but the translation.

How it works

  • DocsWebchatWidgetEmbedPage.howItWorks.items.0
  • Conversation history is kept in the visitor's browser storage, scoped to the agent, so a returning visitor resumes where they left off.
  • The agent must be reachable from the embedding site — make sure your Akili backend's CORS configuration allows the domains you plan to embed on.
  • Before rendering, the widget checks your publicKey's configured allowed origins and requests a short-lived session token scoped to that origin. Requests from a site not on your allowlist are rejected — add every domain you embed on in your org's webchat settings first.
  • Each visitor is capped to a configurable number of sessions per day (a per-visitor identifier is stored in browser storage) — once reached, the widget shows a "come back tomorrow" message instead of sending further messages.
Note: The widget only talks to the agent you configure — it does not expose your account, other agents, or organization data.