Developers
Give your AI assistant real tools with MCP
Give the assistant real tools through MCP, the open standard for connecting AI to your systems. Ship it on web, iOS, Android and desktop. Manage everything from any MCP client.
claude mcp add --transport http busymate-ai https://busymate.ai/mcpWays in
Choose where to start
Add the assistant to your website
One script tag on any page you allow — the launcher and guest chat work right away.
Put AI support inside your iOS app
Load the chat in a WKWebView; a small three-message bridge signs your users in.
Put AI support inside your Android app
Load the chat in a WebView; one JavaScript interface signs your users in.
Connect your MCP server as tools
Your own MCP server becomes the assistant's actions, on each customer's behalf.
Manage every workspace from your terminal
Drive the platform from Claude Code, Cursor or any MCP client over OAuth.
Let AI agents read your site
A plain-text map of your site at /llms.txt, written for AI agents.
01Quickstart
How it fits together
Your app stays the source of truth. The assistant receives a short-lived signed token that says who is present, and calls your tools as a separate, scoped actor.
01
Your app
Keeps identity and account data; signs a two-minute token that says who is signed in.
02
The assistant
Applies your workspace's settings: content, models, tool access levels, confirmation steps, handoff.
03
Your MCP server
Answers tools for one user at a time, taking the user from the signed-in bearer token — never from tool arguments.
7 ways to connect, one assistant: Web embed · Signed-in customers · iOS · Android · Desktop · REST API · Your MCP server
02Signed-in customers
Recognize the customer already signed in
So the assistant can trust who is asking, one authenticated endpoint on your backend signs a short-lived token (an ES256 JWT) with your stable customer id and a few safe display fields.
The token lives at most 120 seconds, carries a one-time nonce and jti, and names your tenant. The private key and your product session never reach the browser or the chat.
import { SignJWT, importJWK } from "jose";
// POST https://YOUR-PRODUCT-DOMAIN/api/bmai/identity — requires YOUR OWN logged-in product session.
app.post("/api/bmai/identity", requireSession, async (req, res) => {
// Fresh on EVERY mint. Never persist the launch token/nonce in localStorage,
// sessionStorage, cookies, React state, or module state: every assistant
// launch consumes this pair exactly once.
const nonce = typeof req.body?.nonce === "string" ? req.body.nonce : "";
if (!/^[A-Za-z0-9_-]{32,200}$/.test(nonce)) return res.status(400).json({ error: "invalid_nonce" });
const key = await importJWK(JSON.parse(process.env.AI_LAUNCH_PRIVATE_JWK), "ES256");
const token = await new SignJWT({
"tenant_id": "00000000-0000-0000-0000-000000000000", // Your product's registered tenant claim
nonce, // equals the sibling field below
name: req.user.displayName, // optional low-sensitivity display claim
})
.setProtectedHeader({ alg: "ES256", kid: process.env.AI_LAUNCH_KEY_ID })
.setIssuer("https://YOUR-PRODUCT-DOMAIN (set when you register the provider)")
.setAudience("busymate-ai")
.setSubject(req.user.id) // IMMUTABLE internal account id; never email/phone/session id
.setJti(crypto.randomUUID()) // one-time (replay-protected)
.setIssuedAt()
.setExpirationTime("120s") // <= registered max age (120s)
.sign(key);
res.set("Cache-Control", "no-store");
res.status(201).json({ token, nonce, expiresIn: 120 });
});The token says who is there, not what they may do. Keep balances, devices, settings and every change behind tools that answer for one user only.
03Web and full page
One script for an embedded launcher
The SDK asks your backend for identity when it needs one, works for guests, refreshes after login and logout, opens external links outside the frame, and needs no third-party cookies.
<!-- Floating "bro" launcher for Your product.
Anonymous chat works immediately on the allowed origins. -->
<script>
function newLaunchNonce() {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
window.BusymateAI = {
getIdentity: async () => {
const accessToken = await getProductAccessToken(); // YOUR existing auth helper
if (!accessToken) return null;
const nonce = newLaunchNonce();
const returnTo = new URL(location.href); returnTo.search = ""; returnTo.hash = "";
const r = await fetch("https://YOUR-PRODUCT-DOMAIN/api/bmai/identity", {
method: "POST", credentials: "include", cache: "no-store",
headers: { "content-type": "application/json", authorization: "Bearer " + accessToken },
body: JSON.stringify({ nonce, returnTo: returnTo.href }),
});
if (r.status === 401) return null; // signed out -> anonymous chat
if (!r.ok) throw new Error("AI identity mint failed (" + r.status + ")");
// MUST be a newly minted { token, nonce } pair on every call. Never
// persist either value in localStorage, sessionStorage, cookies, React
// state, or module state.
const identity = await r.json();
if (identity.nonce !== nonce) throw new Error("AI identity nonce mismatch");
return { token: identity.token, nonce: identity.nonce };
},
};
// Call after YOUR product completes login, logout, access-token/session
// rotation, or account switch. Do not send an identity postMessage directly.
window.refreshAssistantIdentity = () =>
window.BusymateAI?.refreshIdentity?.() ?? Promise.resolve();
</script>
<script
src="https://your-assistant.busymate.ai/embed/v1.js"
data-assistant="your-assistant"
data-label="Ask bro"
async></script>Open the full-page chat at your address
When a signed-in user opens your assistant's address or your custom domain, pass the sign-in token in the URL fragment. The page clears it before the exchange.
// Full-page open with identity in the URL FRAGMENT — never sent in the
// request line, referrer, or logs; the destination strips it before exchange.
function newLaunchNonce() {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
const nonce = newLaunchNonce();
const accessToken = await getProductAccessToken(); // YOUR existing auth helper
if (!accessToken) throw new Error("Sign in before opening an identified assistant");
const returnTo = new URL(location.href); returnTo.search = ""; returnTo.hash = "";
const response = await fetch("https://YOUR-PRODUCT-DOMAIN/api/bmai/identity", {
method: "POST", credentials: "include", cache: "no-store",
headers: { "content-type": "application/json", authorization: "Bearer " + accessToken },
body: JSON.stringify({ nonce, returnTo: returnTo.href }),
});
if (response.status === 401) throw new Error("Sign in before opening an identified assistant");
if (!response.ok) throw new Error("AI identity mint failed (" + response.status + ")");
const identity = await response.json();
if (typeof identity.token !== "string" || typeof identity.nonce !== "string") {
throw new Error("AI identity mint returned an invalid response");
}
if (identity.nonce !== nonce) throw new Error("AI identity nonce mismatch");
const url = new URL("https://your-assistant.busymate.ai/");
url.hash = new URLSearchParams({
bmai_token: identity.token,
bmai_nonce: identity.nonce,
}).toString();
location.assign(url);04iPhone
WKWebView with a narrow message bridge
Allow only the three versioned messages: identity request, identity response and external URL. Mint identity through the app's existing authenticated API client.
// SDK source: https://busymate.ai/sdk/v1/ios/BusymateAI.swift
// Load https://your-assistant.busymate.ai/?channel=ios in a WKWebView.
final class AssistantBridge: NSObject, WKScriptMessageHandler {
let webView: WKWebView
func userContentController(_ controller: WKUserContentController,
didReceive message: WKScriptMessage) {
guard message.name == "BusymateAI",
let body = message.body as? [String: Any],
body["type"] as? String == "busymate.ai.v1.identity_request"
else { return }
Task { // mint through YOUR authenticated API — never a key in the app
let identity = try await api.mintLaunchIdentity()
let payload: [String: Any] = [
"type": "busymate.ai.v1.identity",
"token": identity.token,
"nonce": identity.nonce,
]
let data = try JSONSerialization.data(withJSONObject: payload)
let json = String(decoding: data, as: UTF8.self)
await webView.evaluateJavaScript("window.postMessage(\(json), '*')")
}
}
}05Android
WebView with one allowlisted interface
Keep navigation on your tenant AI origin, send outside links to the system browser and expose no general-purpose native methods.
// SDK source: https://busymate.ai/sdk/v1/android/BusymateAI.kt
// Load https://your-assistant.busymate.ai/?channel=android in a WebView.
class AssistantBridge(private val webView: WebView) {
@JavascriptInterface
fun postMessage(raw: String) {
val message = JSONObject(raw)
if (message.optString("type") != "busymate.ai.v1.identity_request") return
lifecycleScope.launch { // mint through YOUR authenticated API client
val identity = api.mintLaunchIdentity()
val response = JSONObject()
.put("type", "busymate.ai.v1.identity")
.put("token", identity.token)
.put("nonce", identity.nonce)
webView.evaluateJavascript(
"window.postMessage(${JSONObject.quote(response.toString())}, '*')", null
)
}
}
}
webView.settings.javaScriptEnabled = true
webView.addJavascriptInterface(AssistantBridge(webView), "BusymateAINative")
// SupportChatNative + support.chat.v1.* remain accepted for shipped apps.06Desktop
The same protocol fits Electron, Tauri and native shells
Use an isolated WebView, deny new in-view windows, open safe URLs externally and answer identity requests through the privileged host layer.
import { mountBusymateAI } from "https://busymate.ai/sdk/v1/index.js";
// productAuth is a narrow preload/Tauri command bridge. It calls YOUR
// authenticated backend; no cookie, signing key, or refresh token is exposed
// to the renderer.
const assistant = await mountBusymateAI({
assistant: "your-assistant",
origin: "https://your-assistant.busymate.ai",
label: "Ask bro",
getIdentity: () => window.productAuth.mintAssistantIdentity(),
});
window.productAuth.onSessionChanged(() => assistant.refreshIdentity());
assistant.open();
// Electron main process (Tauri: use the equivalent shell/open allowlist):
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
const target = new URL(url);
if (target.protocol === "https:" || target.protocol === "http:") {
void shell.openExternal(target.href);
}
return { action: "deny" };
});07MCP tools
Give the assistant tools without giving it your customer database
Publish complete schemas at discovery. Give every tool one of three access levels, mark the changes that need a confirmation card, and re-check authorization on every call.
Public
Anyone chatting may call it. Prices, status, features.
Identified
Needs a signed-in user. Order status, account answers.
Delegated
Acts on the signed-in user's behalf through a scoped actor token or the user's own OAuth grant.
Every change is confirmed on the server. Tools you list in confirm_tools stop at a confirmation card before they run; so does any tool name the assistant does not recognize. Widget frames never run a change.
MCP endpoint ............ https://YOUR-DOMAIN/mcp
RFC 8707 resource ....... https://YOUR-DOMAIN/mcp (the token is audience-bound to this)
AS metadata (RFC 8414) .. https://YOUR-DOMAIN/.well-known/oauth-authorization-server
Resource meta (RFC 9728) https://YOUR-DOMAIN/.well-known/oauth-protected-resource
Client registration ..... Dynamic (RFC 7591) — public client, no secret
PKCE .................... S256 required (RFC 7636)
Authorization response .. iss parameter checked (RFC 9207)
Grants .................. authorization_code + refresh_token
Delegated tools/call .... no bearer -> 401; user derived from the bearer,
NEVER from an account id in tool arguments
Customer experience ..... one separate Authorize account tools action is expected;
use signed_actor_token instead for automatic SSO// POST https://YOUR-DOMAIN/mcp
// Authorization: Bearer <the per-user OAuth token bro obtained>
{
"jsonrpc": "2.0",
"id": 12,
"method": "tools/call",
"params": { "name": "get_my_account", "arguments": {} }
}
// Your server verifies the bearer, derives the user from its signed subject,
// and returns ONLY that user's data.What the platform speaks
The same protocol set applies whether the platform is the client to your server or the server for your MCP client.
- OAuth 2.1 with PKCE (S256); public clients register dynamically (RFC 7591).
- Discovery through authorization-server metadata (RFC 8414) and protected-resource metadata (RFC 9728).
- Grants: authorization_code and refresh_token; the authorization response carries the issuer (RFC 9207).
- Resource indicators (RFC 8707) when the platform is the OAuth client to your MCP server.
- Token exchange (RFC 8693) where configured.
08Handoff and insights
Set the rules for when a person steps in
When it hands off
A customer asks for a person, a tool fails, a refund is above your limit, a keyword appears, or any event you define.
How your team is told
One Inbox; assignment by hand, in turns or to whoever has the fewest open chats; alerts in the app, by Web Push, APNs and personal Telegram.
Your team takes over
Watch the conversation, join with the context, reply in the same chat; the assistant makes no changes while a person is active.
Insights
Repeat questions, missing knowledge and failed paths, grouped from real conversations with links to the evidence.
09Workspace login
Keep your own user base and authentication
Configure the workspace login and account URLs in the Console. A guest who chooses Sign in leaves the frame, authenticates on your product, and returns to the exact originating URL. Your backend then supplies a one-time sign-in token; Busymate AI never receives the user's password or your signing key.
A change only ever touches the signed-in user's own data. Account tools take the customer from the delegated actor, re-check authorization on every call, and ask for confirmation on the changes you configure.
10Launch checklist
Production launch checklist
- 01Set up branding, SEO, your web address and suggested questions.
- 02Register your sign-in: issuer, JWKS, audience, claims and token age.
- 03Connect MCP, set each tool's access level, prove that reads are self-scoped and writes are confirmed.
- 04Set the handoff rules, staff the Inbox, set hours and response targets.
- 05Test guests, signed-in users, logout and account switch, replayed tokens, and a swapped workspace id.
- 06Check the full-page chat, the embed, iOS, Android, desktop, accessibility and external links.
- 07Publish only the version you checked; watch connection and domain health.
Ready to set up your workspace?
The Console's Integration page generates the code and settings for every surface from your published settings.
Management MCP
Manage every workspace from any MCP client
218 management tools over MCP with OAuth 2.1. The client opens a browser sign-in on first connection; nothing is pasted.
Discovery documents
- MCP endpoint
- https://busymate.ai/mcp
- Authorization-server metadata (RFC 8414)
- https://busymate.ai/.well-known/oauth-authorization-server/mcp
- Protected-resource metadata (RFC 9728)
- https://busymate.ai/.well-known/oauth-protected-resource/mcp
The account you sign in with decides which workspaces the client may manage. Every change asks for confirmation.
Claude Code
claude mcp add --transport http busymate-ai https://busymate.ai/mcpManage the platform from any MCP client
Cursor, Claude Desktop and any client that reads an mcp.json accept this entry; clients that take a remote URL take the endpoint above.
{
"mcpServers": {
"platform-management": {
"type": "http",
"url": "https://busymate.ai/mcp"
}
}
}Latest changes
What shipped
Connect your first tool today
Register your MCP server, choose who may use each tool, publish. The assistant can use it the same minute.