Skip to content
AI Assistant

Guides

How your page and the widget talk to each other

The host and widget contract: open, preset, ask and identify from your page, listen to the widget's events, and share page tools both ways.

On this page

AI Assistant puts your assistant on a page with one script tag, and the page that carries it can drive it, listen to it and hand it tools. This is that contract, every call in one direction and every message in the other, with a runnable copy of each on the playground.

Your page window.BusymateAI registerPageTools The widget the launcher + the frame busymate.ai.v1.* open · ask · setTheme · identify ready · close · navigate · resize page tools, both transports

The launcher is the script: it draws the button and the panel and installs window.BusymateAI. The frame is the chat inside the panel, loaded from the assistant's origin. Your page talks only to the launcher, which talks to the frame over a pinned postMessage channel and trusts no other sender.

1. Mount the widget

One tag, before </body>. data-assistant is your workspace slug, which the Console's Integration page prints; the other attributes are optional labels.

html
<!-- ONE tag mounts the launcher + the panel on any page. -->
<script
  src="https://busymate.ai/embed/v1.js"
  data-assistant="your-workspace-slug"
  data-label="Ask us"
  data-title="Assistant"
  data-aria-label="Open the assistant"
  async></script>

The tag is async, and every call below is safe before it loads: commands queue until the frame is up. A launcher look set in the Console arrives a moment after the launcher. data-launcher="custom" holds the button, invisible, until that look loads (0.8 seconds at most); data-launcher="hidden" never shows it, for a page that opens the chat from its own control.

2. Open and preset

javascript
// The loader installs window.BusymateAI. Every call is safe before the frame
// has loaded — commands queue and deliver on load.
BusymateAI.open();
BusymateAI.close();
BusymateAI.toggle();
BusymateAI.isOpen(); // → true | false
javascript
// Preset the panel from the page: colour scheme + language.
// Neither is persisted by the frame — your page owns them while it embeds it.
BusymateAI.setTheme("dark");     // "light" | "dark" | "system"
BusymateAI.setLocale("de");      // any BCP 47 tag the platform serves
BusymateAI.setLocale(null);      // unpin — the frame follows the visitor again
BusymateAI.open();

setTheme and setLocale return false for a value they do not accept. The frame persists neither: your page owns the panel's theme and language while it embeds it.

Make room for the panel

By default the panel floats over your page. data-layout="push" makes the page yield instead: while the panel is open, above 1100 pixels wide, the launcher reserves the panel's real width as padding on <html> and hands it back on close. Phones never move, and reduced motion snaps instead of animating.

html
<!-- Opt in to the page making room for the panel instead of being
     covered by it. Above 1100px only; phones and tablets never move. -->
<script
  src="https://busymate.ai/embed/v1.js"
  data-assistant="your-workspace-slug"
  data-layout="push"
  async></script>

<style>
  /* A FULL-BLEED block is anchored to the viewport, so root padding cannot
     narrow it. Give it the exact page width the loader publishes instead. */
  html[data-bmai-chat-reserve] .full-bleed {
    width: var(--bmai-chat-page-width);
    margin-left: calc(50% - var(--bmai-chat-page-width) / 2);
  }
</style>

In this mode the launcher owns the padding and overflow-x of <html>, so move any root padding of your theme to a wrapper. A full-bleed block is anchored to the viewport, so give it the page width the loader publishes, as the snippet does. A launcher docked bottom-left reserves the left side instead.

3. Page to chat

ask(text) opens the panel and sends the prompt through the frame's own composer, under every guard a typed message gets. { submit: false } only fills the composer. It returns false for empty text; calls made before the frame loads queue, eight at most.

javascript
// Open the panel and send a prompt — the same append path a typed message takes.
BusymateAI.ask("What can you do on this page?");
javascript
// Fill the composer only; the visitor reads, edits and presses send.
BusymateAI.ask("Book a table for two on Friday at 19:00", { submit: false });
html
<!-- "Open in chat" buttons: one call each, no synthetic key presses. -->
<button onclick="BusymateAI.ask('How do I connect my own MCP server?')">
  How do I connect my own MCP server?
</button>
<button onclick="BusymateAI.ask('Show me the pricing')">
  Show me the pricing
</button>
javascript
// Every example prompt on this page is one call — the same call your
// own "try it" buttons make.
BusymateAI.ask("Compare the plans and recommend one for a two-person shop.");

4. Chat to page

The frame posts messages to your page, and the launcher already acts on each one, so your page listens only to observe. Compare event.origin with the assistant's origin, derived from the loaded script rather than typed, because a workspace on its own domain serves the frame from there.

javascript
// The frame posts busymate.ai.v1.* messages to your page. The loader already
// acts on every one of them; your page only listens to OBSERVE.
// audit docs-and-developer-path-08 (2026-09-18): derived, never a hardcoded
// literal — a workspace on its own custom domain serves the frame from a
// different origin than the one this doc happens to render on.
const ORIGIN = window.BusymateAI?.origin
  ?? new URL(document.currentScript?.src ?? document.querySelector('script[data-assistant]').src).origin;
window.addEventListener("message", (event) => {
  if (event.origin !== ORIGIN) return;
  const { type, ...data } = event.data ?? {};
  if (typeof type !== "string" || !type.startsWith("busymate.ai.v1.")) return;
  console.log(type, data);
  // busymate.ai.v1.ready      { visitorKind, displayClaims }  a session is live
  // busymate.ai.v1.close      —                               ✕ pressed inside the frame
  // busymate.ai.v1.navigate   { href }                        a same-origin link was clicked
  // busymate.ai.v1.open_url   { url }                         any other link (new tab)
  // busymate.ai.v1.resize_to  { w, h }  resize_end  resize_by { dw, dh }
});

Same-site navigation

A reply's link to your own origin navigates your page in place, conversation intact. The launcher pushes the URL, dispatches popstate for History-API routers, and a busymate:hostnavigate event for a page with no router. A page that does not change is reloaded normally.

javascript
// A same-origin link in the chat navigates YOUR page in place (same tab).
// SPA routers already listening for popstate resync on their own; this
// dedicated event needs no router at all.
window.addEventListener("busymate:hostnavigate", (event) => {
  const { href } = event.detail;
  myRouter.push(new URL(href).pathname);
});

Open state

<html> carries an open or closed attribute from the first frame, for CSS, and window receives an open and a close event, for scripts, each only on a real change. Do not watch for the panel's <iframe>: it exists from the first paint, open or not.

javascript
// The panel announces itself: an event for scripts, an attribute for CSS.
// <html data-bmai-chat="open" | "closed"> is stamped from the first frame.
window.addEventListener("bmai:chat-open", (event) => {
  console.log("open", event.detail.assistant, event.detail.reserved);
});
window.addEventListener("bmai:chat-close", () => {});

5. Page tools, both ways

Declare what your page can do, and the assistant does it in the visitor's own session. One registration reaches the browser's WebMCP where it exists, the assistant's bridge everywhere else, and the standard document.modelContext surface any agent can read. A tool without readOnlyHint: true asks the visitor first, as a card in the chat. See Let the assistant use your page and form cards.

javascript
// Declare what THIS page can do. One call, both transports: the browser's
// own WebMCP where it exists, the assistant's bridge everywhere else.
BusymateAI.registerPageTools([
  {
    name: "set_page_theme",
    description: "Switch this page between light and dark.",
    inputSchema: {
      type: "object",
      properties: { theme: { type: "string", enum: ["light", "dark"], description: "The scheme to apply" } },
      required: ["theme"],
      additionalProperties: false,
    },
    annotations: { readOnlyHint: false, consequentialHint: true },
    execute: async ({ theme }) => {
      // Flip through your OWN theme mechanism (whatever sets color-scheme /
      // your CSS variables) so every other themed control on the page agrees
      // with what this tool just did — never write the DOM attribute alone.
      const applied = theme === "light" || theme === "dark";
      if (applied) myApplyTheme(theme);
      // Return the RESULTING state, not just "ok": a caller with no applied
      // flag to check against will report success it never confirmed.
      return { ok: applied, applied, theme };
    },
  },
  {
    name: "get_cart",
    description: "Read what is in the visitor's cart right now.",
    inputSchema: { type: "object", properties: {}, additionalProperties: false },
    annotations: { readOnlyHint: true },
    execute: async () => ({ items: cart.items, total: cart.total }),
  },
]);
javascript
// The same tools are on the STANDARD surface — any agent, not only ours.
const tools = await document.modelContext.getTools();
tools.map((t) => t.name); // → ["set_page_theme", "get_cart", …]

6. The MCP server behind the chat

The assistant's tools come from the platform's MCP server, whose tools/list is open before any sign-in. Your own systems join it through the Console: Connect your MCP server.

javascript
// What the assistant on this page can call: the platform's public tools,
// discoverable before any sign-in (JSON-RPC over HTTP).
const res = await fetch("https://busymate.ai/mcp", {
  method: "POST",
  headers: { "content-type": "application/json", accept: "application/json" },
  body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
});
const { result } = await res.json();
result.tools.map((t) => t.name);
javascript
// From the page, a prompt that makes the assistant call one of those tools.
BusymateAI.ask("Is the platform up right now? Check the status.");

7. Identity

A visitor signed in to your product is recognised automatically: your backend mints a one-time { token, nonce } proof, the launcher asks for it through getIdentity, and again on identityChanged() or signedOut(). See Recognize signed-in customers.

javascript
// Auto-connect: when the visitor is signed in to YOUR product, the chat
// knows who they are. Your backend mints a short-lived, one-time proof.
// Add getIdentity ONTO the object — never assign a fresh one to
// window.BusymateAI: the loader installs every call on THAT object, so
// replacing it takes open(), ask(), identityChanged() and signedOut() with it.
window.BusymateAI = window.BusymateAI || {};
window.BusymateAI.getIdentity = async () => {
  const r = await fetch("/api/assistant-identity", { method: "POST", credentials: "include" });
  return r.status === 401 ? null : r.json(); // { token, nonce } or anonymous
};
// Declaring it after the tag already loaded? Hand it over instead:
BusymateAI.configure({ getIdentity: window.BusymateAI.getIdentity });
// After YOUR login, token rotation or account switch — upgrades the live
// conversation in place, same thread:
BusymateAI.identityChanged();
// After YOUR logout — without this the visitor keeps a verified chat and the
// previous transcript until the tab closes:
BusymateAI.signedOut();
javascript
// Auth that hydrates AFTER the chat launched anonymously — hand it over late.
BusymateAI.identify({ token, nonce }); // remounts as an identified launch
javascript
// The frame tells the page which kind of visitor the session is for.
// (ORIGIN derived the same way as the "chat to page" listener above.)
window.addEventListener("message", (event) => {
  if (event.origin !== ORIGIN || event.data?.type !== "busymate.ai.v1.ready") return;
  console.log(event.data.visitorKind, event.data.displayClaims); // "anonymous" | "identified", { name?, … }
});

8. The full page and the apps

The same conversation also runs as a full page at your workspace address, and iOS and Android load that page in a WebView (Mobile in-app support).

javascript
// The same assistant as a full page. For an ANONYMOUS visitor that is a
// plain link — the workspace address is public.
window.open("https://your-workspace-slug.busymate.ai/", "_blank", "noopener,noreferrer");

// To carry an IDENTIFIED visitor across (a different top-level site cannot
// read this page's storage), ask the loader to mint the hand-off. Both calls
// REJECT unless the getIdentity provider above is configured.
const url = await BusymateAI.hostedUrl("https://your-workspace-slug.busymate.ai/");
BusymateAI.openHosted("https://your-workspace-slug.busymate.ai/"); // new tab, same visitor

Security

  • Origins. The frame launches only on the origins your workspace allows, and every message is pinned to the frame's window and the assistant's exact origin, never *.
  • Framing. Leave the frame's sandbox and allow="tools; microphone; autoplay" as the loader sets them.
  • Content Security Policy. Allow the assistant's origin in script-src and frame-src, and pass a nonce on the tag if your policy needs one.
  • Links. A navigate message is re-checked against your origin; other links open with noopener,noreferrer.

On plain HTML the tag is all you need; in React, mount it once and call it from event handlers; on WordPress the plugin installs it.

html
<script src="https://busymate.ai/embed/v1.js" data-assistant="your-workspace-slug" async></script>

Verify

Open the playground and press every control: the stage logs each message your page would receive. On your own site, run BusymateAI.isOpen() in the console: a boolean means the launcher is installed.

Next

Questions

Why is BusymateAI.ask not a function?

The tag has not run yet, or your own code replaced window.BusymateAI after the loader. Add to the object instead of replacing it, and call methods after the tag loads.

Your router did not change the document in time. Listen for busymate:hostnavigate and route from it.

Why is there no ready message?

The session did not launch, most often because the page's origin is not in the workspace's allowed list.