--- title: "How your page and the widget talk to each other | Busymate AI" description: "The complete host ↔ widget contract: open, preset, ask and identify from the page; the events the widget posts back; page tools both ways; navigation, theming, security and a cookbook." last_updated: "2026-09-16T18:33:33+03:00" --- # How your page and the widget talk to each other | Busymate AI Source: https://busymate.ai/ar/docs/guides/widget-page-api Last modified: 2026-09-16T18:33:33+03:00 The widget your visitors see is one script tag; the page that carries it can drive it, listen to it, and hand it tools. This is the complete contract between the two — every call in one direction, every message in the other — with a runnable copy of each on the [playground](https://busymate.ai/playground). Diagram: Your page calls the widget through window.BusymateAI; the widget posts busymate.ai.v1 messages back; page tools flow both ways through the standard model-context surface Two parts do the work. The **launcher** is the script itself: it draws the button and the panel, owns their geometry, and installs `window.BusymateAI`. The **frame** is the chat, loaded from the assistant's origin inside that panel. Your page only ever talks to the launcher; the launcher talks to the frame over a pinned `postMessage` channel and never trusts a message from anywhere else. ## Mount One tag, before ``. `data-assistant` is your workspace slug (the Console's Integration page prints the real one); the three optional attributes are the launcher's visible label, the frame's title and the button's accessible name. ```html ``` The tag is `async`: nothing on your page waits for it. Every call below is safe to make before it has loaded — commands queue and deliver once the frame is up. ## Open and preset Four calls drive the panel; two more preset what the visitor sees. Nothing is persisted by the frame: your page owns the panel's theme and language only while it embeds it. ```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(); ``` | Call | Does | Returns | | --- | --- | --- | | `open()` / `close()` / `toggle()` | show, hide, flip the panel | — | | `isOpen()` | the current state | `boolean` | | `setTheme(theme)` | `"light"`, `"dark"` or `"system"` (hands the choice back to the visitor's browser) | `false` on any other value | | `setLocale(tag)` | a BCP 47 tag the platform serves; `setLocale(null)` unpins it — the frame follows the visitor again | `false` on a malformed tag | ## Page to chat `ask(text)` opens the panel and delivers the prompt to the frame's own composer path — the same append a typed message takes, never a synthetic key press — so every guard the frame applies to a typed message applies here too. Add `{ submit: false }` to fill the composer and let the visitor press send. ```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 ``` `ask` returns `false` for empty text and `true` otherwise. Calls made before the frame loads queue, eight at most, oldest dropped first. A curated "try this" prompt is the same call — the playground's example prompts are nothing more: ```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."); ``` ## Chat to page The frame posts messages to your page. The launcher already acts on every one of them — collapses the panel, opens a new tab, navigates in place — so your page listens only to observe. Check `event.origin` against the assistant's origin, then read `type`. ```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. window.addEventListener("message", (event) => { if (event.origin !== "https://busymate.ai") 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 } }); ``` | `type` | Payload | When | | --- | --- | --- | | `busymate.ai.v1.ready` | `visitorKind`, `displayClaims` | a session is live — anonymous or recognised | | `busymate.ai.v1.close` | — | the ✕ inside the frame was pressed | | `busymate.ai.v1.navigate` | `href` | a link on your own origin was clicked in a reply | | `busymate.ai.v1.open_url` | `url` | any other link (the launcher opens a new tab) | | `busymate.ai.v1.resize_to` / `resize_end` / `resize_by` | `w, h` / — / `dw, dh` | the visitor is resizing the panel by its corner | | `busymate.ai.v1.identity_request` / `auth_request` | — / `url` | the identity handshake (Identity, below) | ### Same-site navigation A link in a reply that points at your own origin navigates your page in place — same tab, the conversation intact. The launcher pushes the new URL, dispatches `popstate` for any router built on the History API, and dispatches a dedicated `busymate:hostnavigate` event for a page that has no router at all. A page that changes nothing within a short window is treated as static and reloaded normally; the panel's open state and the conversation both survive that. ```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 The panel's state lives on the launcher's DOM — `[data-support-chat][data-open]` on the layer and `aria-expanded` on the button — so a page can react to it without any message at all. ```javascript // The panel's open state lives on the launcher's DOM — observe it directly. const layer = document.querySelector("[data-support-chat]"); new MutationObserver(() => { const open = layer.hasAttribute("data-open"); document.body.classList.toggle("assistant-open", open); }).observe(layer, { attributes: true, attributeFilter: ["data-open"] }); ``` ## Page tools, both ways Your page declares what it can do; the assistant does it in the visitor's own session. One registration reaches both transports — the browser's WebMCP where the engine has it, the assistant's own bridge in every other browser and in apps — and the same tools appear on the **standard surface**, `document.modelContext`, where any agent can find them. ```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", …] ``` A tool marked `readOnlyHint: true` runs when the assistant needs it. Anything else asks the visitor first, as a card inside the chat, before the page changes — your `execute` is not called until they agree. Tools are visible only to the origins you name in `exposedTo`; the default is the assistant's origin. The full guide is [Let the assistant use your page](https://busymate.ai/ar/docs/guides/page-tools); forms you already have can be declared without any JavaScript, and a tool can answer with a [form card](https://busymate.ai/ar/docs/guides/form-cards) instead of a sentence. ## The MCP server behind the chat Behind the assistant sits your workspace's Model Context Protocol server: the tools it calls to answer, and any other agent can discover, over JSON-RPC — its `tools/list` is open before any sign-in. ```javascript // What the assistant on this page can call: the workspace's MCP server, // 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); ``` A prompt from the page that needs one of those tools makes the assistant call it; nothing on the page speaks MCP itself. ```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."); ``` Your own systems join the same server through the Console — [Connect your MCP server](https://busymate.ai/ar/docs/guides/connect-mcp-server) — and every tool you add there is reachable from the widget, the full page and every channel alike. ## Identity A visitor signed in to your product is recognised in the chat automatically. Your backend mints a short-lived, one-time `{ token, nonce }` proof; the launcher asks for it through `getIdentity` when the frame launches, and again on `refreshIdentity()` after your login, logout or account switch. Nothing secret crosses the page: the proof is single-use and the private key never leaves your server. ```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() and refreshIdentity() 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 / logout / account switch: BusymateAI.refreshIdentity(); ``` ```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. window.addEventListener("message", (event) => { if (event.origin !== "https://busymate.ai" || event.data?.type !== "busymate.ai.v1.ready") return; console.log(event.data.visitorKind, event.data.displayClaims); // "anonymous" | "identified", { name?, … } }); ``` `hostedUrl(base)` and `openHosted(base)` carry the same proof into the full-page experience. Keys, the mint endpoint and the acceptance checklist are in [Recognize signed-in customers](https://busymate.ai/ar/docs/guides/identified-visitors). A visitor who is not signed in can sign in inside the chat, on your own accounts, through the `sign_in` widget tool — [Let your users sign in from the chat](https://busymate.ai/ar/docs/guides/in-chat-sign-in) describes that contract; nothing here duplicates it. ## The full page and the apps Every workspace has its own address for the same conversation without the panel — the launcher builds it with the visitor's identity attached. ```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 ``` iOS and Android load that hosted page in a WebView and bridge identity, theme and language over the same message names, through a native handler instead of `postMessage` — [Mobile in-app support](https://busymate.ai/ar/docs/guides/mobile-in-app-support). ## Security - **Origins.** The frame launches only on the origins your workspace allows; a page on any other origin gets a refused launch, not a wrongly branded panel. Every message between launcher and frame is pinned to the frame's window and the assistant's exact origin — never `*`. - **Framing.** The launcher's `