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:
| Slug | What it is | Embed today |
|---|---|---|
chat | Customer-facing AI chat launcher | Yes — Chat Widget |
operator-tools | The operator console. This guide. | Yes |
chat-app | Full multi-conversation chat app for signed-in members | Yes |
operator | Retired predecessor of operator-tools | No |
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
- A Gravity Rail workspace, and its UUID (or workspace slug) — you will pass
it as
data-wid. - 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.
- 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.
- 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:
| Mode | Behaviour |
|---|---|
floating (default) | Fixed launcher button in the corner; clicking slides in a full-height panel |
inline | A labelled trigger button you place in your own UI; clicking slides in the same panel |
embedded | No 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.
| Attribute | Default | Description |
|---|---|---|
data-wid | — required | Workspace UUID or slug |
data-mode | floating | floating, inline, or embedded |
data-open | false | Start expanded. Always true in embedded mode |
data-target | — | CSS selector to mount into (embedded mode) |
data-launcher-target | — | CSS selector to mount the trigger into (inline mode) |
data-button-label | Operator Tools | Trigger button text (inline mode) |
data-title | Operator Tools | iframe title and launcher aria-label |
data-icon | built-in headset icon | Emoji or text to use as the launcher icon |
data-launcher-color | #111827 | Launcher background colour |
data-width | 420 panel / 400 embedded | Panel width, or embedded box width |
data-height | 640 | Embedded box height (ignored for the slide-in panel) |
data-bottom | 24px | Launcher bottom offset (floating mode) |
data-right | 24px | Launcher right offset (floating mode) |
data-z-index | 2147483000 | Stacking order of the widget container |
data-sound | true | Set "false" to silence the incoming-interaction ring tone |
data-video | — | Set "true" to also grant the iframe camera |
data-widget-id | auto-generated | Instance id used to scope postMessage traffic |
data-external-id | — | An opaque context id sent to the widget on load. See the caution |
data-member-id | — | Gravity 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-id | — | Member.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
allowattribute.
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:
- 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.
- 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.comdoes not authorize the operator widget there. TickOperator toolsspecifically. - 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
SameSiteflag, no shared cookie domain, no cross-domain redirect. - Sessions are per browser tab. The token lives in
sessionStorageand 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:
| Event | Payload | Meaning |
|---|---|---|
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
embeddedmode. They are driven by the launcher — whichembeddedmode does not render — and both are additionally suppressed while the panel is open, which inembeddedmode it permanently is. Usefloatingorinlineif you need alerting, or subscribe to thenotifyandbadgeevents 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-widname 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 notOperator? - Does the origin match exactly?
https://app.example.comis not authorized by an entry forexample.comunless 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.comwith no other domains, no origin is authorized for that workspace and widget yet — start with thedata-widcheck. 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
integrityonly 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.
Related
- Chat Widget — the customer-facing chat launcher
- Embeddable Widgets — all embeddable widgets and their status
- Authentication — API keys and OAuth, for server-side integration alongside the widget
- Using Gravity Rail with PHI — requirements that apply before PHI flows through any surface, including an embedded one