ガイド
ページとウィジェットがやり取りする方法
完全なホスト ↔ ウィジェット契約:ページから開く、プリセット、質問、識別;ウィジェットが投稿し返すイベント;両方向のページツール;ナビゲーション、テーマ、セキュリティ、およびクックブック。
このページの内容
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.
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 </body>. 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.
<!-- 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: 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.
<!-- snippet:open-close -->// 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// 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.
// Open the panel and send a prompt — the same append path a typed message takes.
BusymateAI.ask("What can you do on this page?");// 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 });<!-- "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>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:
// 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.
// 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.
// 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.
// 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.
// 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 }),
},
]);// 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; forms you already have can be declared without any JavaScript, and a tool can answer with a form card 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.
// 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.
<!-- snippet:mcp-ask -->// 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 — 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.
// 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();// Auth that hydrates AFTER the chat launched anonymously — hand it over late.
BusymateAI.identify({ token, nonce }); // remounts as an identified launch// 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. 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 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.
<!-- snippet:hosted -->// 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 visitoriOS 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.
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
<iframe>carriessandbox="allow-scripts allow-forms allow-same-origin"andallow="tools; microphone; autoplay"— the last so voice and page tools work; leave it as the loader sets it. - Content Security Policy. Allow the assistant's origin in
script-srcandframe-src; pass anonceon the tag if your policy requires one and the launcher copies it onto the style it injects. - Links. A
navigatemessage is re-checked against your page's own origin before anything moves; anopen_urlopens withnoopener,noreferrer. - Nothing secret on the wire. Identity is a one-time proof, never a session; a password typed into an in-chat sign-in card goes to a same-origin server action, never into the conversation.
Cookbook
Plain HTML. The one tag, plus any of the calls above in a <script> after it — the playground's snippets are exactly that.
<script src="https://busymate.ai/embed/v1.js" data-assistant="your-workspace-slug" async></script>React. Mount the tag once (a useEffect that appends the script, or the typed mountBusymateAI from https://busymate.ai/sdk/v1/index.js), keep the returned controller in a ref, and call it from event handlers. Listen for busymate:hostnavigate and push its href into your router.
WordPress. Install the plugin — Install the real WordPress plugin — and everything above is already on every page; window.BusymateAI is the same object.
Verify
Open the playground and press every control: the stage on the right logs each message your page would receive. On your own site, open the browser console and run BusymateAI.isOpen() — a boolean means the launcher is installed; undefined means the tag has not run.
Troubleshooting
BusymateAI.askis not a function. The tag has not executed yet, or an older copy of the page's own code definedwindow.BusymateAIafter the loader ran. Define yourgetIdentityobject before the tag and call methods after it loads.- The panel opens but
setThemeseems to do nothing. The frame applies the pin on load; a call made before the panel first opens is queued and applied then. - A same-site link reloads the page instead of routing. Your router did not change the document within the launcher's window; listen for
busymate:hostnavigateand route from it. - No
readymessage. The session did not launch — most often the page's origin is not in the workspace's allowed list. - The assistant cannot see my page tools. Register them after the loader has run, name the assistant's origin in
exposedTo(or leave the default), and checkdocument.modelContext.getTools()lists them.