Go-live
Embedding checklist.
Putting an agent inside an existing product (a server-rendered app, an admin/desk UI, or a SPA) is three real steps and a handful of settings that are easy to miss. This page is the recipe plus the pre-flight list — work top to bottom and the widget loads the first time.
Before you start
What you need
- Tenant ID and Embed Secret — Settings → Profile.
- Copilot ID — any agent's Publish tab.
- A place on your server to sign a JWT (the secret never reaches the browser).
The flow
Mint on the server, render on the page
Works the same whether your app is Rails, Django, Laravel, Frappe/ERPNext, or a Node app: sign a per-user token from the logged-in session, then render the embed on every page from your base layout/template.
1 — Sign a per-user token (server)
// One endpoint, called from your page render or via fetch.
import { OnPilot } from "@onpilot/node";
const onpilot = new OnPilot({
tenantId: process.env.ONPILOT_TENANT_ID!,
embedSecret: process.env.ONPILOT_EMBED_SECRET!, // server-only
});
export function mintToken(user) {
return onpilot.signIdentityToken({
copilotId: process.env.ONPILOT_COPILOT_ID!,
user: { id: user.id, name: user.name, email: user.email,
role: user.isAdmin ? "admin" : "user" },
expiresIn: "1h",
});
}No Node backend? Sign the same HS256 claim shape in any language — see the server recipes.
2 — Render the widget on every page (base layout)
<!-- Inject this into your app's shared layout so it appears on every page.
In Rails/Django/Laravel this is the base template; in Frappe/ERPNext
it's an `app_include_js` hook. Render the token from the session. -->
<script
src="https://chat.onpilot.ai/embed.js"
data-identity-token="<%= mintToken(currentUser) %>"
data-position="bottom-right"
></script>Prefer React? Use @onpilot/react with an identityTokenProvider (client recipes). Pick a surface — Bubble, Sidebar, Inline, or headless Panel — on the components page.
Do all of these
Pre-flight checklist
- Sign the token server-side — Claims: iss=tenant, sub=user, copilot_id, role, exp. The Embed Secret stays on the server.
- Add the embedding origin to Allowed Domains — Publish → Allowed Domains on the agent. Include your real domain AND localhost during dev (e.g. localhost:3000). This is the #1 missed step — see below.
- Pick a mount surface — bottom-right / bottom-left / sidebar / full-page, or render-target for inline.
- Mounting after page load? Don't use inline render-target — Floating surfaces are safe; inline render-target only mounts at DOMContentLoaded. See “Mounting after load”.
- Long-lived pages → identityTokenProvider — A static 1-hour token silently breaks at minute 60.
- Bust cache when you redeploy the host page — The chat host registers a service worker; a normal refresh can serve a stale widget.
- Send page context (optional, high-value) — postMessage onpilot:page_context on onpilot:ready and on each navigation, so the agent knows the current record. See the Page context page.
The #1 gotcha
Allowed Domains gates two things
Allowed Domains is not just a resolve allow-list — it also drives the chat iframe's Content-Security-Policy. Miss it and you get a blank panel that says the chat host “refused to connect.”
frame-ancestors 'self' *.onpilot.ai — so it refuses to be framed by your site even though token resolution succeeds. Add your embedding origin (the URL in the browser bar) to Publish → Allowed Domains on the agent.- Entries are bare
host:port(e.g.app.acme.com,localhost:3000) — protocol is ignored. - Loopback hosts (
localhost,127.0.0.1,*.local) auto-expand to bothhttp://andhttps://. - Draft agents still resolve — publish state does not gate the embed. Allowed Domains does.
Gotcha
Mounting after the page has loaded
Floating surfaces (Bubble/Sidebar) mount safely whenever the script runs. The inline render-target path attaches on DOMContentLoaded — so if you inject the script after the page is interactive (a SPA route change, or an admin/desk app that boots its own shell), it can silently no-op.
If you must mount inline into a late-rendered container, drive the handshake yourself: POST the identity token to /api/v1/embed/resolve, create the iframe at <chatUrl>?copilotId=…, and post the session token on onpilot:ready — the full contract is on the postMessage protocol page.
When it's blank
Troubleshooting
| Symptom | Cause & fix |
|---|---|
| “host refused to connect” in the panel | Frame blocked by CSP. Add the embedding origin to Allowed Domains. |
| Panel opens but never loads / changes don't show after a deploy | Stale cache or service worker. Hard-refresh, or clear site data for the chat host — a normal reload won't bust the iframe's cache. |
| Works, then breaks ~1 hour later | Static token expired. Switch to identityTokenProvider. |
RESOLVE_ERROR / 401 | Wrong Embed Secret, expired token, or iss doesn't match the tenant. |
Connect / OAuth link comes back without ?ctok | sub isn't a UUID — per-user OAuth (connect flows) rejects non-UUID ids. Map the login to a stable UUID (e.g. uuidv5 of the user). |
| Inline widget never appears (no errors) | Script injected after load with inline render-target. Use a floating surface or the postMessage handshake. |