Skip to main content

Embedding the agent app

Mount the ready-made agent app inside your CRM and drive it through the SDK's /embed entry point. For your own interface, start with the TypeScript tutorial.

1. Drop it in

Install @babelforce/babelconnect-sdk and use this helper in a bundled TypeScript application. Pass a real container, the agent-app origin, and a token from your login:

import { BabelconnectEmbed } from "@babelforce/babelconnect-sdk/embed";

export function mountAgent(container: HTMLElement, serverUrl: string, token: string) {
const bc = BabelconnectEmbed.mount({ container, serverUrl, token });
bc.on("agent.loaded", () => {
bc.calls.dial("+15551234567", false); // pre-fill only, after login completes
});
bc.on("cti.error", data => console.error("cti.error", data));
return bc; // call bc.dispose() when this view closes
}

mount injects an iframe. Set dimensions on its container, for example width: 380px; max-width: 100%; height: 640px. The iframe fills it; className and bc.element let you style it. Optional path defaults to /; session/context seed correlation; theme brands the app.

The iframe permits microphone; autoplay. The host must also allow microphone use: if it sets Permissions-Policy or is itself framed, delegate microphone access to the app origin. Browser permission and autoplay rules still apply.

1b. Script tag (no bundler)

Load /embed/babelconnect-embed.iife.js from a current app deployment, or copy dist/web/babelconnect-embed.iife.js from the npm package to your own assets:

<script src="https://agent.example.com/embed/babelconnect-embed.iife.js"></script>

It defines window.BabelconnectEmbed with the same mount API. Use that global instead of the import above; supply the container and token from your host's login code. A browser cannot resolve the npm bare import by itself. The server-served bundle uses Cache-Control: no-cache for revalidation; the npm /embed entry remains ESM. The bundle's origin may differ from the iframe's.

2. Token handoff (security)

The token is handed to the app over postMessage after the app signals ready — never in the iframe URL (a URL token leaks into history/referrer/logs). You just pass token to mount(); the SDK performs the handshake:

app → host: { type: "bcConnect", name: "ready" }
host → app: { type: "connect", module: "auth", name: "auth.set", args: { token, session?, context?, eventsVersion? } }
host → app: { type: "connect", module: "app", name: "app.setTheme", args: { …theme } } ← only if you gave mount() a theme
app → host: { type: "bcConnect", name: "agent.loaded", data: { agentId, … } }

Refreshing the token mid-session: send auth.set again with the new token once the agent is already live — the current app applies it in place, without interrupting the session or any active call: the next request (and the next automatic reconnect of the state stream, if the connection ever drops) uses the new token, and the handshake is not re-run (no second ready / agent.loaded / user.loaded). An auth.set that arrives while the session is still starting (before agent.loaded) is ignored — refresh only once the agent is live. The SDK sends the initial auth.set for you at mount; to refresh, use the SDK's authentication group:

bc.auth.set({ token: newToken });

The SDK checks the app origin, iframe source when present, and any supplied instanceId. The app validates the host message origin. Configure these separately:

SettingPurpose
BC_EMBED_ORIGINSHost origins allowed to exchange messages; falls back to BC_CORS_ORIGINS when unset. A framed app with no message allowlist fails closed.
BC_FRAME_ANCESTORSCSP framing permission; the chart defaults to 'self', so add the host origin. An explicit empty setting permits all framing.
BC_CORS_ORIGINSCross-origin API access; independent of framing permission.

See Authentication for token handling.

3. Drive the app (host → app)

Drive the app after agent.loaded — these calls postMessage straight to the iframe and aren't buffered, so a calls.dial sent before the app has loaded and connected is dropped. (app.setTheme is the one exception: the SDK re-sends the merged theme at every ready, so an early call still lands.)

CallEffect
bc.calls.dial(number, dial = true)place a call (or dial=false to only pre-fill the dialer) with the agent's display-as + your session
bc.session.set({ number?, smsBody?, … })replace session correlation; a number pre-fills a new SMS (to + smsBody/body) and switches to the messaging tab
bc.context.set({ … })merge into the persisted shared context carried onto subsequent calls/SMS
bc.app.setTab("phone" | "messaging" | "phonebook" | "history" | "account" | "outbound")switch the active tab ("chat""messaging" and "contacts""phonebook" are accepted as aliases; an unknown name is ignored, and a tab the deployment has disabled falls back to the first visible tab)
bc.app.setTheme({ … })brand the app with your own tokens, mid-session and without a reload — see 3b. Theme the app

context persists across interactions; session describes the current interaction and overrides context on key clashes. The merged map accompanies calls and SMS. Numeric tabs 03 mean phone, messaging, history and outbound; out-of-range indices are ignored.

:::note Feature config is per deployment Which surfaces the app shows is server-driven per deployment/account. Read the agent's effective config from GET /v1/agent/stateAgentView.config (calls/messaging/phonebook/history/account/outbound/cti, each with enabled + settings); defaults are all-on. See the AppConfig message in the gRPC reference. :::

3b. Theme the app (your brand inside the iframe)

Pass an EmbedTheme to mount, or call bc.app.setTheme({ mode: "dark" }) later. The SDK sends it after auth.set on every ready, including when set before startup. Subscribe to cti.error for theme_rejected.

Tokens

Every key the app accepts, taken from the code that parses it. Anything else in the payload is ignored, never refused — a token added in a later SDK is always safe to send to an older app.

TokenType · accepted formatWhat it reachesDefault when you don't send it
brandNamestring, 1–64 characters after trimmingthe window/tab title and the accessible label of the logo (sign-in screen and header). It is not painted as text — the header shows the logo alone.the deployment's product name, else babelConnect
logoUrlstring, an absolute https: URL with a hostthe logo on the sign-in screen and in the app header, rendered as-is (never recoloured)the deployment's logo, else the built-in babelconnect mark
accentColorstring, #rrggbb — 6 hex digits, or 8 as #aarrggbb whose alpha is FF; the # optional, case-insensitive. Any other alpha is refused, including the RGBA order a design tool may hand you (#112233FF is alpha 11, not FF).the accent role: primary (filled) buttons and the call actions. Its text/icon colour is derived for contrast, never taken from you.the deployment's accent colour, else #F94721
mode"light" | "dark" | "system" (case-insensitive)light or dark; system follows the viewer's OS setting. Overrides the agent's own choice in the Account tab for as long as you keep it set.the agent's own choice (stored by the app; light until they change it)
primaryColorstring, same format as accentColorthe primary colour role (focused input borders and other primary-role controls); its foreground is derived. The lighter container tints keep their defaults.#05445E in light, #5BCACE in dark
surfaceColorstring, same format as accentColorthe base surface: page background and app bar; its text colour is derived. The container tints — cards, dialogs, popup menus, the navigation bar — are re-derived from your colour as small, contrast-checked tonal steps of it, so the text stays readable (WCAG AA) whichever end of the scale you send, in either mode.#FFFFFF in light, #0F1424 in dark
cornerRadiusnumber, 032 (a JSON number, not a string)the corner radius of the app's containers — cards, dialogs and popup menus — in logical pixels. Controls (buttons, chips, inputs) keep their own shapes.12 for cards; dialogs and popup menus keep the app's own shapes until you send the token

Not a token: the help link on the Account tab stays the deployment's — a host cannot redirect it.

Precedence — one rule, applied per token

Each accepted host token overrides the deployment value, then the built-in default. Deployment branding covers only name, logo and accent; mode falls back to the agent's stored preference. No theme (or {}) leaves the deployment's brand. The Account help link is deployment-owned.

Refused values: cti.error with code: "theme_rejected"

Wrong types or values outside the table produce one theme_rejected event naming every refused token. Accepted siblings still apply; refused tokens fall back to deployment/default values. Unknown keys are silently ignored. A valid logo URL that fails to load shows the built-in mark without an error. The SDK suppresses rejected tokens on later ready re-sends until you set them again.

setTheme() mid-session

setTheme merges: omitted tokens stay, supplied tokens replace, and null resets a token to its fallback. Changes apply to open screens without reconnecting or interrupting audio. They don't change the stored user preference, deployment help link, or iframe size/placement.

Persistence and reloads

The app does not persist a theme. The SDK remembers the merged theme and re-sends it on iframe reload; a host-page reload must supply it again. If you use raw postMessage, send the full set after every ready:

{ type: "connect", module: "app", name: "app.setTheme",
args: { brandName?, logoUrl?, accentColor?, mode?, primaryColor?, surfaceColor?, cornerRadius? } }

4. React to the app (app → host)

bc.on("cti.call", data => crm.logCall(data)); // {call:{id,state,type,from,to,ownsMedia}}
bc.on("cti.error", data => crm.showError(data)); // {code,message}: e.g. missing caller ID
bc.on("cti.message", (data) => crm.screenPop(data)); // screen-pop forwarded from the platform
bc.on("cti.iframe", (data) => crm.openIframe(data));
bc.on("cti.outbound.lead", (data) => crm.showLead(data));
bc.on("agent.loaded", (data) => {/* … */}); // fired once when the agent is ready ({ agentId })
bc.on("user.loaded", () => {/* … */}); // companion lifecycle event, also once on ready

Any CTI push whose kind the app doesn't recognize is forwarded as cti.message too (rather than under its own name), with the original kind carried at data.kind — an unknown kind is never silently dropped; it degrades to a generic screen-pop until the app adds first-class support for it. A recognized screen-pop carries no data.kind, so its presence on a cti.message event tells you it was a forward-compat fallback.

Event data has TypeScript type unknown: narrow it before reading fields, according to the deployed app's schema. The crm functions above are host-provided handlers.

on() returns an unsubscribe function. bc.element is the injected <iframe> (reach for it to style or resize it), and bc.dispose() removes it and stops listening — tearing down the embedded app and releasing its microphone, so call it when your component unmounts.

:::note Which events fire depends on the app The SDK relays whatever the embedded app posts, so the forwarded events above (cti.error / cti.message / cti.iframe / cti.outbound.lead) only arrive if the deployed agent-app version emits them. After authentication, agent.loaded waits for a nonempty agent ID; user.loaded is emitted without waiting for that snapshot. cti.call is gated by config.cti.emitCallEvents — don't assume the rest are present without confirming against your deployment. :::

cti.call carries { call: { id, state, type, from, to, ownsMedia } }. State is a lowercase lifecycle (ringing, in_progress, bridged, completed, failed); type is inbound/outbound. Emission follows state or media-ownership changes and requires config.cti.emitCallEvents.

Loaded event schema version

Unset and unknown eventsVersion use the current app's minimal shape: agent.loaded carries { agentId }; user.loaded has no payload. The legacy adapter below normalizes both to { agentId }. Opt into eventsVersion: "v1" only when your host needs the older agent/user data: agent.loaded adds agent: { id, name, email, number }; user.loaded adds user: { email }. Set it at mount. Changing it through auth.set takes effect on the next ready handshake, not on already-emitted live-session events. The agent email belongs to the agent; the user email identifies the login, and they can differ. The opt-in does not add account details or user roles.

Multiple tabs and multiple embeds

Each mount has an instanceId (generated if omitted, at most 128 characters when supplied). It is correlation, not authorization. The listener's second argument carries envelope metadata; older apps may omit its ID. cti.call.ownsMedia identifies the instance with audio, and call.media_owner reports { callId, instanceId, ownsMedia } when ownership changes. Shared call visibility does not grant each mount an audio leg.

The current web app guards one live session per agent across instances on the app's origin. A second instance shows Use here instead before connecting. session.guard reports { state, instanceId }, with holder, blocked or released; its guard ID is distinct from the SDK mount ID. This coordination does not span browser profiles, different browsers or native apps. Taking over closes the previous media leg: it is not seamless audio transfer.

While an instance owns a call, it registers a browser close guard. A prompt needs a prior user gesture inside that iframe; a click only in your CRM does not arm it. The browser chooses the text, and crashes/force-quits bypass it. A host can also register its own guard from call.media_owner. The app's desktop ring notifications are disabled inside an iframe; the host handles attention.

Resizing

resized reports app dimensions. Avoid feedback loops: update container dimensions only when they change, bound them to your layout, and don't add the same padding on every event. You can also keep a fixed-height container. bc.dispose() removes the iframe and listeners when your view closes.

5. Use the v2 API with a legacy app

The SDK includes a legacy iframe bridge in both its /embed module and its browser bundle. Select it for one mount with legacyBridge: true. Set serverUrl to the legacy app's origin and, if necessary, path to its entry path. Omitting legacyBridge selects the current app protocol.

You must provide legacyAuth. Its set function hands credentials to the legacy app using your application's existing authentication integration. The SDK does not define a legacy token, cookie or storage mechanism. getAgentToken() and handOffLegacyCredentials() below are functions your application supplies; the latter must actually authenticate the selected app.

// legacy-runtime-example
import { BabelconnectEmbed } from "@babelforce/babelconnect-sdk/embed";

const bc = BabelconnectEmbed.mount({
container: document.getElementById("bc")!,
serverUrl: "https://legacy-agent.example.com",
token: await getAgentToken(),
legacyBridge: true,
legacyAuth: {
set: (args) => handOffLegacyCredentials(args),
},
session: { ticketId: "ticket-123" },
context: { source: "crm" },
});

bc.on("agent.loaded", () => {
bc.calls.dial("+49301234567", false); // pre-fill after the app is connected
bc.app.setTab("phone");
});
bc.on("cti.error", (error) => console.error(error));

// Once your token has been refreshed:
bc.auth.set({ token: await getAgentToken() });

// When the containing view is closed:
// bc.dispose();

The hook receives the full current AuthSetArgs payload: token and any session, context, or eventsVersion you set. It runs on iframe load and on bc.auth.set(), including after a reload. A thrown error or rejected promise emits cti.error with code: "legacy_auth_failed"; other bridge failures use legacy_bridge_failed.

Legacy ready is synthesized from iframe load. It does not wait for authentication to finish. Wait for agent.loaded before dialing or switching tabs. Calls return void; they do not acknowledge that the app completed an operation.

OperationLegacy behavior
bc.calls.dial(number, dial)Dial or pre-fill, carrying the current session.
bc.session.set(values)Replace session correlation.
bc.context.set(values)Merge shared context.
bc.app.setTab(name)phone, messaging (chat), history, and outbound map to legacy indices 03. Numeric indices pass through.
bc.on(name, handler)Forward app events. Loaded events default to { agentId }; eventsVersion: "v1" opts into the legacy agent/user fields.
bc.dispose()Remove the iframe, subscriptions and connection's message listener.

phonebook, contacts, account and unknown named tabs throw UnsupportedEmbedFeatureError. Legacy mode also rejects theme at mount and bc.app.setTheme() with that error. The theme section above applies to the current app. Supplying only one of legacyBridge and legacyAuth throws LegacyEmbedConfigurationError.

For script-tag usage, load babelconnect-embed.iife.js from a current SDK deployment or copy dist/web/babelconnect-embed.iife.js from this npm package to your host's assets. Then use window.BabelconnectEmbed.mount() with the same options above. The SDK bundle and the legacy iframe may be served from different origins; serverUrl always identifies the iframe's origin. No additional legacy script or global is required. The legacy bridge accepts messages only from that origin and that iframe window.

Here is the script-tag version in plain JavaScript. Copy the package's browser bundle to /assets/babelconnect-embed.iife.js and supply the two authentication functions described above:

<!-- legacy-script-example -->
<div id="bc" style="width: 380px; height: 640px"></div>
<script src="/assets/babelconnect-embed.iife.js"></script>
<script>
(async () => {
const bc = window.BabelconnectEmbed.mount({
container: document.getElementById("bc"),
serverUrl: "https://legacy-agent.example.com",
token: await getAgentToken(),
legacyBridge: true,
legacyAuth: { set: (args) => handOffLegacyCredentials(args) },
session: { ticketId: "ticket-123" },
context: { source: "crm" },
});
bc.on("agent.loaded", () => {
bc.calls.dial("+49301234567", false);
bc.app.setTab("phone");
});
bc.on("cti.error", (error) => console.error(error));
// Later: bc.auth.set({ token: refreshedToken });
// When this view closes: bc.dispose();
})();
</script>

An application with its own lifecycle-capable bridge can supply a LegacyBridge object instead of true.

Reference