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)

ts
// 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)

html
<!-- 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-sideClaims: iss=tenant, sub=user, copilot_id, role, exp. The Embed Secret stays on the server.
  • Add the embedding origin to Allowed DomainsPublish → 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 surfacebottom-right / bottom-left / sidebar / full-page, or render-target for inline.
  • Mounting after page load? Don't use inline render-targetFloating surfaces are safe; inline render-target only mounts at DOMContentLoaded. See “Mounting after load”.
  • Long-lived pages → identityTokenProviderA static 1-hour token silently breaks at minute 60.
  • Bust cache when you redeploy the host pageThe 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.”

An empty Allowed Domains list does not mean “open.” With no domains set, the iframe ships 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 both http:// and https://.
  • 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

SymptomCause & fix
host refused to connect” in the panelFrame blocked by CSP. Add the embedding origin to Allowed Domains.
Panel opens but never loads / changes don't show after a deployStale 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 laterStatic token expired. Switch to identityTokenProvider.
RESOLVE_ERROR / 401Wrong Embed Secret, expired token, or iss doesn't match the tenant.
Connect / OAuth link comes back without ?ctoksub 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.