Skip to main content

Operator Widget Embed Guide

The operator widget puts the Gravity Rail operator console inside your own application. Your staff see the same Messaging panel they would see in Gravity Rail — members, chats, and live interactions — without switching tabs, and take over a conversation from the AI in place.

Its widget slug is operator-tools. Everything below refers to that slug.

Not the chat widget. The chat widget is for your customers — an anonymous visitor opens it and starts a conversation with an AI agent. The operator widget is for your staff — an existing Gravity Rail member signs in and works the queue. They are separate widgets, enabled separately, and neither is a configuration of the other.

Which widget slug to use

Gravity Rail's widget registry contains four slugs. Only three are embeddable, and one of the four is retired:

SlugWhat it isEmbed today
chatCustomer-facing AI chat launcherYes — Chat Widget
operator-toolsThe operator console. This guide.Yes
chat-appFull multi-conversation chat app for signed-in membersYes
operatorRetired predecessor of operator-toolsNo

operator is retired. Its document route and loader script have both been removed. It survives in the widget registry, and therefore in generated client types and the domain-settings checkbox list, only for backwards compatibility — enabling it has no effect, and a page that embeds it gets a blocked frame rather than a widget. If you were given an operator snippet, replace it with the operator-tools snippet below.

Prerequisites

  1. A Gravity Rail workspace, and its UUID (or workspace slug) — you will pass it as data-wid.
  2. A verified domain, and the operator widget enabled on it. This is a hard gate; see Authorize your domain below. Nothing renders until it is done.
  3. Members with operator access. The widget is for existing workspace members. It does not create accounts and has no self-serve signup path — an unrecognised person cannot get in through it.
  4. HTTPS on your page. Required for the microphone (live call audio) and for the widget's session storage. It is also the only scheme the domain gate accepts.

Embed snippet

Drop this on any page where your staff should have the console. One tag, anywhere in the document:

<script
async
src="https://app.gravityrail.com/widgets/operator-tools.js"
data-wid="11111111-1111-4111-8111-111111111111"
data-mode="floating"
></script>

Replace data-wid with your workspace UUID (a workspace slug also works). That is the whole required configuration — unlike the chat widget, the operator widget takes no data-workflow.

To open already filtered to a specific member (for example on a patient/customer record page in your app), add either Gravity Rail's member id or your own Member.externalId:

<script
async
src="https://app.gravityrail.com/widgets/operator-tools.js"
data-wid="11111111-1111-4111-8111-111111111111"
data-mode="floating"
data-member-id="12345"
></script>
<script
async
src="https://app.gravityrail.com/widgets/operator-tools.js"
data-wid="11111111-1111-4111-8111-111111111111"
data-mode="floating"
data-member-external-id="crm-contact-987"
></script>

The operator can still switch the Chats filter to another member or view. When both attributes are set, data-member-id wins.

The loader creates the iframe, the launcher button, the badge, the ring tone, and the presence indicator. Prefer the script tag over hand-rolling an <iframe>: a raw iframe gets you the panel but none of the launcher behaviour, and you would have to construct the required hostOrigin and widgetId query parameters yourself or the widget will refuse to render.

You can generate this snippet — pre-filled with your own workspace and settings, in HTML, React, or Next.js form — from Settings → Widgets → Widgets in Gravity Rail, which is the quickest way to get a correct starting point.

Embed modes

data-mode picks how the panel is presented:

ModeBehaviour
floating (default)Fixed launcher button in the corner; clicking slides in a full-height panel
inlineA labelled trigger button you place in your own UI; clicking slides in the same panel
embeddedNo launcher — the panel renders directly into a box in your layout

Embedded, for a console that is always visible in your own layout:

<div id="gr-operator"></div>
<script
async
src="https://app.gravityrail.com/widgets/operator-tools.js"
data-wid="11111111-1111-4111-8111-111111111111"
data-mode="embedded"
data-target="#gr-operator"
data-width="400"
data-height="640"
></script>

With data-target, the widget fills your element and you control the geometry with CSS; data-width / data-height apply only to the fallback box it creates when data-target is absent. The Embed Studio does not emit data-target — add it by hand to the snippet it gives you.

Inline, to hang the panel off a button in your own toolbar:

<span id="gr-operator-trigger"></span>
<script
async
src="https://app.gravityrail.com/widgets/operator-tools.js"
data-wid="11111111-1111-4111-8111-111111111111"
data-mode="inline"
data-launcher-target="#gr-operator-trigger"
data-button-label="Operator"
></script>

data-launcher-target is likewise not emitted by the Embed Studio — add it by hand. An invalid data-mode silently falls back to floating.

React

import { useEffect } from 'react';

/** Gravity Rail operator-tools widget for any React app (Vite, CRA, Remix, …). */
export function GravityOperatorToolsWidget() {
useEffect(() => {
const script = document.createElement('script');
script.async = true;
script.src = 'https://app.gravityrail.com/widgets/operator-tools.js';
script.dataset.wid = '11111111-1111-4111-8111-111111111111';
script.dataset.mode = 'floating';
document.body.appendChild(script);
return () => script.remove();
}, []);
return null;
}

Next.js

import Script from 'next/script';

/** Gravity Rail operator-tools widget — render once in a layout or page. */
export function GravityOperatorToolsWidget() {
return (
<Script
src="https://app.gravityrail.com/widgets/operator-tools.js"
strategy="afterInteractive"
data-wid="11111111-1111-4111-8111-111111111111"
data-mode="floating"
/>
);
}

Mount it once, high in the tree (a root layout), not per route. See Single-page apps for why.

Configuration reference

Every attribute is optional except data-wid.

AttributeDefaultDescription
data-widrequiredWorkspace UUID or slug
data-modefloatingfloating, inline, or embedded
data-openfalseStart expanded. Always true in embedded mode
data-targetCSS selector to mount into (embedded mode)
data-launcher-targetCSS selector to mount the trigger into (inline mode)
data-button-labelOperator ToolsTrigger button text (inline mode)
data-titleOperator Toolsiframe title and launcher aria-label
data-iconbuilt-in headset iconEmoji or text to use as the launcher icon
data-launcher-color#111827Launcher background colour
data-width420 panel / 400 embeddedPanel width, or embedded box width
data-height640Embedded box height (ignored for the slide-in panel)
data-bottom24pxLauncher bottom offset (floating mode)
data-right24pxLauncher right offset (floating mode)
data-z-index2147483000Stacking order of the widget container
data-soundtrueSet "false" to silence the incoming-interaction ring tone
data-videoSet "true" to also grant the iframe camera
data-widget-idauto-generatedInstance id used to scope postMessage traffic
data-external-idAn opaque context id sent to the widget on load. See the caution
data-member-idGravity Rail member id. Defaults the Messaging Chats filter to that member (operator can still change it). Prefer this when you already know the id. Appears in the iframe URL — see URL exposure.
data-member-external-idMember.externalId in the workspace. Resolved to a member and applied the same way as data-member-id. Ignored when data-member-id is set. Appears in the iframe URL — see URL exposure.

When either member attribute is set, the Chats list opens scoped to that person and Create Chat prefills them as the recipient (still changeable). This matches opening Messaging on a member page inside Gravity Rail.

The operator widget does not accept the chat widget's data-workflow, data-locale, data-voice, data-subtitle, data-button, or data-storage-key. Setting them has no effect.

iframe permissions

The loader sets the iframe's allow attribute for you:

microphone; autoplay; clipboard-read; clipboard-write; publickey-credentials-get;

Adding data-video="true" appends camera.

  • microphone + autoplay — live call audio, which is the point of the widget.
  • publickey-credentials-get — lets a member complete passkey two-factor authentication inside the frame.
  • Your page must be HTTPS or the browser blocks microphone access regardless of the allow attribute.

Disclose microphone use in your own privacy notice: this widget requests it unconditionally, because an operator answering a live call needs it.

Authorize your domain

This is the step that makes the difference between a working embed and a blank frame, and it is fail-closed — until it is done, every parent origin is denied. Authorization is per (workspace, domain, widget), and it is self-service in Gravity Rail:

  1. Verify the domain at the organization level. Prove ownership with a DNS TXT record, the same verification email and site domains use. An unverified domain cannot be enabled for any widget.
  2. Enable the operator widget on that domain. In the embedding workspace, go to Settings → Widgets → Settings, find the verified domain, and tick Operator tools. Optionally allow subdomains, or pin a non-default port.

Three things to get right:

  • Each widget is enabled separately. Having the chat widget working on app.example.com does not authorize the operator widget there. Tick Operator tools specifically.
  • Do not tick Operator. That is the retired slug. It appears in the list for backwards compatibility and does nothing.
  • Ports are exact. A domain entry with no port pinned authorizes only the standard HTTPS port. If your page is served on, say, :8443, pin that port on the domain entry or the embed is denied.

allow_subdomains authorizes both the apex domain and its subdomains. It is set per domain entry and applies to every widget enabled on that entry.

Verification is checked live on every request. If a domain loses its verified status, embedding stops immediately — Gravity Rail shows a warning on the domain in Settings → Widgets when that has happened.

Content Security Policy on your page

Only needed if you run a CSP. Allow the loader script and the iframe:

Content-Security-Policy: script-src https://app.gravityrail.com; frame-src https://app.gravityrail.com;

Cross-origin isolation is not supported. A page serving Cross-Origin-Embedder-Policy: require-corp cannot embed the widget. If your app needs cross-origin isolation, host the operator widget on a page that does not.

Sign-in and session behaviour

The widget renders in a third-party iframe on your domain, so it does not rely on cross-site cookies — those are blocked by default in Safari and increasingly elsewhere.

Instead, on first load the member signs in inside the widget: they enter their email or phone, receive a one-time code, and enter it. Two-factor challenges are completed inline too — authenticator codes and recovery codes work everywhere, and passkeys work where the browser permits them in an embedded frame. The resulting session is held as a bearer token in the iframe's own partitioned storage and is sent as an Authorization: Bearer header on the widget's own API calls.

What this means for you as an embedder:

  • No cookie configuration on your side, and none on ours. There is nothing to set — no SameSite flag, no shared cookie domain, no cross-domain redirect.
  • Sessions are per browser tab. The token lives in sessionStorage and is cleared when the tab closes, so a member signs in again in a new tab. This is deliberate: an operator console on a shared machine should not persist.
  • You cannot pass a session in from your app. There is no SSO handoff, no token parameter, and no way to pre-authenticate the frame from the host page. The member signs in in the widget. (If you embed the widget on a page served from a Gravity Rail origin, the first-party session is used automatically — but that is not the third-party embed case.)
  • Your page never sees the session. The token is inside the iframe, on Gravity Rail's origin. Your JavaScript cannot read it.
  • Expiry is handled. If the session goes stale, the widget drops it and shows the sign-in prompt again rather than wedging.

Sign-in happens client-side inside the frame. The widget never navigates the frame to a login page, so it cannot break out of your layout or redirect your page.

Controlling the widget from your page

The loader exposes a small API on window.GravityRailOperator once it has mounted:

GravityRailOperator.open();
GravityRailOperator.close();
GravityRailOperator.toggle();

// Subscribe to widget state. Returns an unsubscribe function.
const off = GravityRailOperator.on('badge', ({ count }) => {
document.title = count > 0 ? `(${count}) Support` : 'Support';
});

Subscribable events:

EventPayloadMeaning
badge{ count: number }Conversations waiting on a human, plus any pending incoming interaction
state{ open: boolean }The panel opened or closed
presence{ status: string }The operator went live or offline
notify{ kind: 'incoming_call' | 'incoming_chat' }A new interaction arrived

Use badge to mirror the count into your own navigation, and notify to add your own alerting on top of the widget's.

Payloads carry counts, booleans, and event kinds only — never names, phone numbers, or message content. No conversation data crosses the frame boundary, so your page cannot read what the operator is reading, and neither can an XSS on your page.

Notifications and the ring tone

Desktop notifications and the ring tone are raised by the loader, in your origin, because browsers block a cross-origin iframe from requesting notification permission or starting audio.

Both are unlocked by the first click on the launcher, which is the user gesture the browser requires. So:

  • The permission prompt appears the first time a member clicks the launcher, not on page load.
  • Notification bodies are generic ("incoming call") — no member data.
  • Set data-sound="false" to suppress the ring tone while keeping the badge.

Desktop notifications and the ring tone are not available in embedded mode. They are driven by the launcher — which embedded mode does not render — and both are additionally suppressed while the panel is open, which in embedded mode it permanently is. Use floating or inline if you need alerting, or subscribe to the notify and badge events below and raise your own.

Troubleshooting

The frame shows {"error":"Embed origin is not allowed for this widget"} (HTTP 403)

The domain gate denied your origin. Work through, in order:

  • Does data-wid name the workspace you enabled the domain on? Domains are verified once for your whole organization, but widgets are enabled per workspace. If your organization has more than one workspace, it is easy to verify the domain, tick the widget on workspace A, and embed workspace B — every screen looks correctly configured and every embed is still denied. Open Settings → Widgets in the workspace whose UUID is in your snippet and confirm the domain is listed there. This is the single most common cause.
  • Is the domain verified at the organization level, and still verified? Check for the "no longer verified" warning in Settings → Widgets.
  • Is Operator tools ticked for that domain — not just Chat, and not Operator?
  • Does the origin match exactly? https://app.example.com is not authorized by an entry for example.com unless Allow subdomains is on.
  • Is your page on a non-default port? Pin the port on the domain entry.
  • Is your page HTTPS? Only HTTPS origins can be authorized.

Allow subdomains only ever adds coverage: an entry for example.com with it switched on authorizes https://example.com and https://*.example.com. Turning it on cannot stop your apex domain working, so it is safe to enable when you need preview hosts like pr-123.example.com. (Note the wildcard covers one label — https://*.example.com matches pr-123.example.com, not a.b.example.com.)

Note that the gate result is cached briefly, so a settings change takes a few seconds to take effect. Wait a moment, then hard-reload. If it is still blocked a minute later, something else is wrong — keep working the list rather than waiting longer.

Checking it yourself. You can ask the gate what it will allow, without a browser and without signing in:

curl "https://app.gravityrail.com/api/v2/s/widget-embed-policy?wid=YOUR_WID&slug=operator-tools"
# {"frameAncestors":["https://example.com","https://*.example.com"]}

An empty list means nothing is authorized for that workspace and widget yet. If your origin is missing from a non-empty list, compare it character for character against your page's window.location.origin.

The frame shows {"error":"Missing host origin for widget embed"} (HTTP 400)

The request arrived with no usable parent origin. Almost always a hand-built <iframe> missing the hostOrigin query parameter — use the loader script. It can also happen behind a proxy that strips both Origin and Referer.

The frame is blank, and the console says the ancestor violates frame-ancestors

Same root cause as the 403 — an unauthorized origin — reaching you through the CSP rather than the status code. Work the 403 checklist above.

Two things about that message are worth knowing, because both mislead:

  • The URL it names is always https://app.gravityrail.com/. Browsers report only the origin of the blocked frame, never the path, so the message looks the same whichever widget you embedded. It does not mean your page is trying to frame our home page.
  • Read the policy it prints. If it ends at frame-ancestors 'self' https://app.gravityrail.com with no other domains, no origin is authorized for that workspace and widget yet — start with the data-wid check. If it lists domains but not yours, the authorization exists and it is your specific origin that does not match.

"Missing or invalid host origin parameter." / "Missing or invalid widget id parameter."

The widget document loaded but its required parameters were absent or malformed. Again, a hand-built iframe. Use the loader script.

Nothing at all appears, and the console says data-wid is a required attribute

data-wid is missing or empty. On the operator widget that is the only required attribute.

Two launchers appear

Two copies of the loader mounted. See Single-page apps.

The widget sits on "Authenticating..."

It is waiting on the session check. If it does not resolve, the member's browser is likely blocking the iframe's storage entirely — check for an extension or an enterprise policy blocking third-party storage on your domain.

Microphone does not work

  • Your page must be HTTPS.
  • Check the browser's site permission for your domain, not Gravity Rail's — a cross-origin iframe inherits the top-level page's permission decision.
  • Chrome and Edge handle embedded WebRTC more reliably than Safari.

Mixed-content errors

Both your page and the widget must be HTTPS. Do not embed over http://.

Single-page apps

The loader mounts once per script element and ignores a script tag that has already been discarded from the document. Two consequences:

  • Mount once, high in the tree. A root layout, not a route component.
  • If you inject the tag from an effect, guard against double-mounting — an effect that re-runs (a dependency resolving after mount, React strict mode in development) can leave two launchers. Mount at a stable point in your tree, or keep your own "already injected" flag.

Security notes

  • The verified-domain gate is the boundary. Keep the enabled set tight, and remove domains you no longer embed from. Enable and disable are recorded in the security audit log.
  • Subresource integrity. The loader is served from our origin and changes as the widget ships; pin it with integrity only if you are prepared to update the hash on release.
  • No PHI crosses the frame. The cross-frame protocol is counts, booleans, and event kinds. Conversation content stays inside the iframe on Gravity Rail's origin.
  • Origin checks run both ways. The widget drops messages that do not come from your registered host origin, and the loader drops messages that do not come from the widget's origin. Neither side ever posts to a wildcard target.
  • A compromised host page cannot read the conversation, but it is not harmless. Browser cross-origin isolation stops your page from reading the frame's pixels, DOM, or session, so an XSS on your page cannot exfiltrate conversation content. What it can do is drive the widget's public API (open, close, toggle, setContext), observe the badge, presence, and notify events, and reposition, overlay, or clickjack the frame. Harden the pages you embed on accordingly.

Do not put identifiers in data-external-id

data-external-id (and its setContext equivalent) is an opaque context handle passed to the widget. Do not put patient or customer identifiers in it. It is supplied by your page, so it can end up in URLs, browser history, proxy logs, and your own front-end error reports — none of which are appropriate for patient identifiers. If you need the widget scoped to a person, use data-member-id (or data-member-external-id for your workspace's Member.externalId). For any other correlation, use an opaque value that is meaningless outside your own system.

Member attributes and URL exposure

data-member-id and data-member-external-id are copied into the iframe's query string so the widget can apply the default filter. That means the values can appear in:

  • the browser address bar of the iframe document (and thus history for that origin)
  • Gravity Rail access logs and reverse-proxy logs for the widget document
  • any network tooling that records the iframe src

Prefer stable internal ids over free-text labels that themselves contain patient or customer identifiers. If your Member.externalId is a patient account number in your system, treat it as an identifier under your BAA and only pass it on pages where that exposure is already acceptable. Operators can still clear or change the filter after load; the attribute only sets the default.