Articles Developers

Embed Widget Setup Guide

Step-by-step guide to integrating the Knowably embed widget on your website, from agent configuration to production deployment.

I spent a long time getting the embed widget right. The goal was simple: one script tag, a floating chat button on your site, streaming answers from your knowledge base. No user accounts for your visitors, no framework dependencies, no style conflicts with your page.

Getting there meant solving real problems around session security, origin validation, and iframe isolation. This guide covers the full integration from agent configuration to production deployment.

Prerequisites: A Knowably workspace with at least one active agent and a connected knowledge base.


Create and Configure an Agent

Before you embed anything, you need an agent worth embedding.

In the Knowably dashboard, navigate to Agents and create a new agent (or open an existing one). The fields that matter for embed:

Name and description. Displayed in the widget header. Keep the name short. The description appears in the agent config response, so the widget can show it as a greeting or subtitle.

Knowledge bases. Connect at least one. The agent searches across all linked knowledge bases when answering questions. No knowledge bases, no answers.

System prompt. This controls how the agent behaves - tone, language, boundaries, formatting rules. A customer-facing widget needs a different prompt than an internal tool.

Suggested queries. Optional, but worth setting. These appear as conversation starters in the widget, giving users a clear starting point instead of a blank input field.

Once the agent is configured, enable embedding by setting Embed enabled to true in the Embed section of the agent editor. An embed token is automatically generated when the agent is created. You will need this token in a later step.


Configure Allowed Origins

I designed this as a deny-by-default system. If the allowed origins list is null or empty, all embedding is blocked. You must explicitly authorize every domain. I have seen too many widgets that default to "allow everything" and rely on developers to remember to lock it down later. They never do.

Allowed origins is a list of domains permitted to embed your widget. Set it in the Embed section of the agent editor. Each origin is a full origin string - scheme plus host, no trailing slash.

https://example.com
https://docs.example.com
https://staging.example.com:3000

The format rules are strict. Every origin must include the scheme (https://). No path, no trailing slash. https://example.com is valid. example.com is not. https://example.com/ is not.

Multiple origins are supported. If your docs live on a different subdomain than your marketing site, add both.


Obtain the Embed Token and Workspace Slug

You need two values to add the widget to your page.

The embed token is the unique identifier for your agent's embed endpoint. Find it in the agent's Embed settings in the dashboard.

The workspace slug identifies your tenant. It appears in your dashboard URL and in the workspace settings.

These two values are the only configuration the script tag needs.


Add the Loader Script

Drop this script tag into your HTML - before the closing </body> tag or anywhere in the document:

<script
  src="https://embed.knowably.ai/embed/loader.js"
  data-agent-token="YOUR_AGENT_TOKEN"
  data-workspace="YOUR_WORKSPACE_SLUG"
  data-position="right"
></script>

Attributes:

  • data-agent-token (required) - the agent's embed token
  • data-workspace (required) - your workspace slug
  • data-position (optional) - "right" (default) or "left" - controls which corner the button and panel appear in

What the loader does under the hood:

The script is a self-executing function. It reads attributes from document.currentScript, derives the base URL from its own src attribute by stripping /embed/loader.js, and builds the widget entirely in JavaScript.

I chose a closed Shadow DOM for the container element. This was a deliberate decision. It isolates all widget styles from your page's CSS and vice versa. Your styles cannot break the widget. The widget cannot break your styles. I tried other approaches early on - scoped CSS, iframe-only - but the Shadow DOM gave the cleanest boundary with the least friction.

Inside the shadow root, the loader renders a floating action button (fixed position, bottom corner, 56px circle) and a hidden iframe container (400x600px with responsive constraints). The iframe src points to {baseUrl}/embed/{workspace}/{agentToken}, which loads the chat application.

No external dependencies. No framework conflicts. One script tag.


The iframe and Widget Lifecycle

The iframe loads the embed page served by the Knowably backend.

In production, the server returns the built React application with an injected window.__EMBED_API_BASE_URL__ so the frontend knows where to send API requests. In development, it redirects to the frontend dev server URL instead.

Widget States

The widget has four visual states:

Collapsed. Only the floating button is visible. A chat icon invites interaction.

Open. The iframe panel appears (400x600px, responsive, max-constrained). The button icon switches to a close icon.

Maximized. The iframe fills the entire viewport. The floating button hides to give the user a full-screen experience.

Mobile. On viewports narrower than 480px, the panel automatically expands to fill the screen when opened.

PostMessage Communication

The iframe and the parent page communicate through window.postMessage. Three messages drive the widget behavior:

  • knowably:close - the widget requests to close (also triggered by the Escape key inside the iframe)
  • knowably:maximize - the widget requests fullscreen mode, hides the floating button
  • knowably:minimize - the widget returns to default size, shows the floating button again

The loader validates that messages originate from the iframe's contentWindow before acting on them. Messages from other sources are ignored.

Accessibility: The button toggles its aria-label between "Open chat" and "Close chat". The iframe has title="Chat widget" and allow="clipboard-write".


Session Creation and Authentication Flow

I designed the session flow to be stateless from the user's perspective but fully tracked on the server. No user accounts, no passwords, no OAuth. The tradeoff is intentional - embed visitors should never hit a login wall. Here is the full lifecycle.

1. Fetch agent config. When the iframe loads, the widget sends GET /api/{workspace}/embed/agents/{embedToken} to retrieve the agent's name, description, and suggested queries. This endpoint is unauthenticated.

2. Extract the parent origin. The widget reads document.referrer to determine which domain is hosting the iframe. This value is set by the browser and cannot be spoofed by JavaScript on the parent page. I chose document.referrer over postMessage-based origin passing specifically because the browser controls it. A malicious parent page can lie about its own origin in a message, but it cannot fake what the browser puts in document.referrer.

3. Create a session. The widget sends POST /api/{workspace}/embed/agents/{embedToken}/sessions with { "origin": "https://example.com" }. The backend validates three things: the agent exists and has embed enabled, the allowed origins list is not empty, and the submitted origin matches one in that list.

4. Token generation. On success, the server generates a random 64-character token using Str::random(64). It stores a SHA-256 hash of that token in the embed_sessions table. The plain token is returned to the client. The server never stores the plain token. Same principle as password hashing - if the database leaks, the tokens are useless.

5. Client persistence. The widget stores the plain token in localStorage under the key knowably-embed-{agentToken}. On subsequent page loads, the widget reuses this token instead of creating a new session.

6. Authenticated requests. All subsequent API calls - creating chats, streaming messages - include the plain token as a Bearer token in the Authorization header.

Session Validation on Every Request

The EmbedAuthenticate middleware runs on every authenticated embed request. It does four things:

  1. Hashes the Bearer token with SHA-256 and looks up the session
  2. Checks that embed_enabled is still true on the agent
  3. Re-validates that the session's stored origin is still in the agent's current embed_allowed_origins
  4. Updates the last_active_at timestamp

That third check matters. If you remove an origin from the allowed list, all existing sessions from that origin are immediately invalidated. No waiting for token expiry. I wanted revocation to be instant - if you pull an origin, it is gone right now, not "eventually."

Rate Limiting

Two rate limiters protect the embed endpoints:

  • Session creation: 10 per minute per IP address
  • Chat streaming: 20 per minute per session

Production Deployment

Three things to get right for production.

Dedicated Subdomain

In production, the embed runs on embed.knowably.ai. This subdomain points to the same Laravel application. Because both the embed HTML page and the embed API are served from the same origin, the iframe avoids cross-origin complications for its own requests.

API Base URL Injection

The EmbedPageController injects a script tag before the first <script> in the built HTML:

<script>window.__EMBED_API_BASE_URL__="https://app.knowably.ai/api";</script>

The frontend uses this value instead of the Vite environment variable. This is how the embed page knows where to send API requests when served from a different domain than the main application.

CSP frame-ancestors

Every embed page response includes a Content-Security-Policy header with a frame-ancestors directive.

When allowed origins are configured: frame-ancestors 'self' https://example.com https://docs.example.com

When the list is empty: frame-ancestors 'self'

This header tells the browser which pages are allowed to frame the widget. It prevents clickjacking by restricting embedding to authorized domains. Browsers enforce this independently of CORS.


Security Model

The three-layer origin check exists because each layer catches something different. One layer alone is not enough.

Origin Validation (Three Layers)

The EmbedCors middleware checks origins against three sources, in order:

  1. Same-origin - the application URL or frontend URL
  2. Embed subdomain - the configured embed URL
  3. Agent's allowed origins - the per-agent origin list

Session creation rejects requests where the origin is not in embed_allowed_origins. The EmbedAuthenticate middleware re-validates origin on every authenticated request, so revoking an origin takes effect immediately.

Layer 1 handles the iframe's own requests. Layer 2 covers the embed subdomain infrastructure. Layer 3 is the per-agent access control you configure in the dashboard. Without all three, there are gaps - a request could pass CORS checks but fail session validation, or vice versa. Together they form a consistent boundary.

CORS (Two Middleware)

Two middleware work together to handle CORS for embed routes:

DisableCorsForEmbed (global middleware) runs on all non-OPTIONS embed API requests matching api/*/embed/*. It temporarily clears Laravel's default CORS config so the global HandleCors middleware does not add wildcard Access-Control-Allow-Origin headers.

I had to build this because Laravel's built-in CORS handling is all-or-nothing. It applies globally with a single allowed origins list. The embed needs per-agent origin control, so I needed to suppress the global behavior first, then apply my own.

EmbedCors (route middleware) applies per-agent, origin-based CORS headers. Only allowed origins receive Access-Control-Allow-Origin in the response. OPTIONS preflight requests from disallowed origins get a 403.

Token Security

The plain session token is never stored server-side. Only the SHA-256 hash is persisted. Token length is 64 random characters. Client-side storage is scoped to localStorage under a key specific to the agent token.

Session Pruning

Stale sessions are cleaned up by the embed:prune-sessions Artisan command. It deletes sessions where last_active_at is older than 30 days by default. Sessions with a null last_active_at fall back to created_at. The command runs across all tenants.

Schedule it as a daily cron job:

php artisan embed:prune-sessions --days=30

Troubleshooting

These are the errors I see most often, in roughly the order you will hit them during integration.

"No allowed origins configured for this agent" (403)

This is the most common error. It means the embed_allowed_origins field on the agent is null or empty. The system blocks everything by default. Add your embedding site's origin in the agent's Embed settings in the dashboard.

"Origin not allowed" (403)

The origin extracted from document.referrer does not match any entry in the allowed origins list. Nine times out of ten this is a scheme mismatch or a trailing slash. Check that the origin includes the correct scheme and host with no trailing slash. https://example.com works. http://example.com does not match https://example.com. I have also seen people add https://example.com/ with a trailing slash - that will not work either.

"Session origin is no longer allowed" (403)

Someone removed an origin from the allowed origins list after sessions were already active. The EmbedAuthenticate middleware re-validates on every request, so existing sessions break immediately. Either re-add the origin, or have the user clear localStorage on the embedding page to force a new session from a now-valid origin.

"Embed session token required" (401)

The API request is missing the Authorization: Bearer {token} header. This usually means the widget failed to create a session - check the browser console for earlier 403 errors. It can also happen if localStorage was cleared between requests.

"Invalid embed session" (401)

The session token does not match any active session. Either the session was pruned (check if it is older than 30 days) or localStorage contains a stale token from a previous setup. Clear localStorage and reload.

"Embed is disabled for this agent" (403)

The agent's embed_enabled flag was set to false after the session was created. Re-enable it in the agent's Embed settings.

Widget Does Not Appear

This one is silent - no errors in the network tab, nothing in the console. Several possible causes:

Missing or incorrect attributes. The data-agent-token or data-workspace attribute on the script tag is missing, empty, or wrong. The loader silently exits if either is absent. I intentionally kept the loader quiet here - a noisy error on someone else's production site felt worse than a missing widget.

Embed not enabled. The agent has embed_enabled set to false. The embed page returns a 404.

Wrong loader URL. The src attribute on the script tag points to the wrong domain or path. It must end with /embed/loader.js.

"Embed page not available" (503)

The production frontend build file (frontend/build/client/index.html) does not exist. Run the frontend build step to generate it.


API Route Reference

Method Path Auth Description
GET /api/{workspace}/embed/agents/{embedToken} None Fetch agent config (name, description, suggested queries)
POST /api/{workspace}/embed/agents/{embedToken}/sessions None (rate-limited) Create embed session, returns plain token
POST /api/{workspace}/embed/chats Bearer (embed session token) Create a new chat
GET /api/{workspace}/embed/chats/{chat} Bearer (embed session token) Fetch chat details
POST /api/{workspace}/embed/chats/{chat}/stream Bearer (embed session token) Stream a chat response
GET /embed/{workspace}/{agentToken} None Serve the embed HTML page (iframe target)

All API routes use the public-tenant middleware group and require the workspace slug in the URL path. Authenticated routes expect the session token as a Bearer token in the Authorization header.


That covers the full integration. One script tag, a handful of settings, and a security model that handles the rest.

If you hit something not covered here, check the browser's developer console first. The embed system returns specific error messages at every failure point.

Agent Configuration Guide | API Reference | Security Overview