diff --git a/assistant/widget-preview.mdx b/assistant/widget-preview.mdx
new file mode 100644
index 0000000000..a4f8a2de12
--- /dev/null
+++ b/assistant/widget-preview.mdx
@@ -0,0 +1,11 @@
+---
+title: "Widget preview"
+description: "Host page for the live preview in the assistant widget playground."
+keywords: ["assistant", "widget", "preview"]
+mode: "custom"
+noindex: true
+---
+
+import { AssistantWidgetPreviewHost } from "/snippets/assistant-widget-preview-host.jsx";
+
+
diff --git a/assistant/widget.mdx b/assistant/widget.mdx
new file mode 100644
index 0000000000..6189963192
--- /dev/null
+++ b/assistant/widget.mdx
@@ -0,0 +1,260 @@
+---
+title: "Mintlify widget"
+sidebarTitle: "Widget"
+description: "Install and configure the Mintlify widget to embed the AI assistant trained on your content in any website or web application."
+keywords: ["assistant", "chat", "embed"]
+mode: "wide"
+---
+
+import { AssistantWidgetPlayground } from "/snippets/assistant-widget-playground.jsx";
+
+export const WidgetCodeBlock = ({ children, ...props }) => (
+ {children}
+);
+
+The [assistant](/assistant) answers questions on your Mintlify site. To embed the same capability on another site or web app, use the widget. With the widget, you can give your users access to AI chat trained on your content in your product dashboard, marketing site, support portal, or elsewhere.
+
+Use the [`@mintlify/assistant-widget`](https://www.npmjs.com/package/@mintlify/assistant-widget) package to add your Mintlify assistant to any website or web application. The hosted package owns its trigger and renders inside a closed Shadow DOM, which prevents your application styles from affecting the widget.
+
+The only required browser option is the public widget ID. Manage the enabled state, allowed origins, attachments, and bot protection in your dashboard. Set embed-specific starter questions and a support email in the browser configuration.
+
+## Prerequisites
+
+- A [Pro or Enterprise plan](https://mintlify.com/pricing?ref=assistant). The widget uses the same credits as the assistant.
+
+## Enable the widget
+
+1. Navigate to your deployment's [Widget](https://app.mintlify.com/settings/deployment/widget) page.
+2. Enable the widget.
+3. Add allowed origins where you embed the widget.
+4. Copy the widget ID.
+
+## Install and configure
+
+Use the playground to configure the presentation, visual options, and observer hooks for your widget. The installation code block updates as you change each option.
+
+
+ Replace `YOUR_WIDGET_ID` in the generated code with the widget ID from the [Widget](https://app.mintlify.com/settings/deployment/widget) page of your dashboard.
+
+
+After you add the generated code to your site, reload the page. Confirm the trigger appears, then click it and send a test question to verify the widget is connected.
+
+
+
+
+ Module scripts defer and run in document order. Keep the hosted loader before the initialization block when you install the widget with HTML, or the widget fails to mount.
+
+
+## Open on initialization
+
+Set `defaultOpen` to `true` to open the widget immediately after its first mount:
+
+```js
+await window.MintlifyAssistant.init({
+ id: "YOUR_WIDGET_ID",
+ defaultOpen: true,
+});
+```
+
+`defaultOpen` defaults to `false` and only applies to the first initialization. Calling `init()` again with the same widget ID and API endpoint does not reopen a widget that a visitor closed. Use `open()` and `close()` to control it after initialization.
+
+## Use a custom trigger
+
+Await `init()` before calling other methods. Keep the built-in trigger or open the configured presentation from any button in your application.
+
+```js
+await window.MintlifyAssistant.init({
+ id: "YOUR_WIDGET_ID",
+ supportEmail: "hi@mintlify.com",
+ starterQuestions: [
+ "How do I get started with Mintlify?",
+ "How do I customize my docs?",
+ "How do I deploy my docs?",
+ ],
+});
+
+document.querySelector("#help-button").addEventListener("click", () => {
+ void window.MintlifyAssistant.open({
+ source: "help-button",
+ focus: true,
+ });
+});
+```
+
+To open the widget and immediately send a question, call `ask()`:
+
+```js
+await window.MintlifyAssistant.ask("How do I authenticate?", {
+ source: "authentication-guide",
+ open: true,
+ focus: true,
+});
+```
+
+Event metadata and requests include the `source` value, which lets you distinguish built-in interactions from your custom entry points.
+
+## Update a mounted widget
+
+Use `update()` to change appearance, labels, support email, starter questions, or hooks without clearing the current conversation. Only the supplied fields change.
+
+```js
+await window.MintlifyAssistant.update({
+ appearance: {
+ theme: "dark",
+ accent: "#7c3aed",
+ },
+ labels: {
+ title: "Docs copilot",
+ trigger: "Ask docs",
+ },
+ supportEmail: "support@example.com",
+ starterQuestions: [
+ "How do I get started?",
+ "How do I manage my account?",
+ ],
+});
+```
+
+Pass `null` to restore a field or group to its default, remove the support email, or restore an empty starter-question list:
+
+```js
+await window.MintlifyAssistant.update({
+ appearance: {
+ accent: null,
+ },
+ supportEmail: null,
+ starterQuestions: null,
+ hooks: null,
+});
+```
+
+Changing `identity` starts a new conversation. Changing the widget ID or API endpoint requires calling `destroy()` before a new `init()`.
+
+You can supply `supportEmail` and `starterQuestions` during initialization and change them later with `update()`. These values apply to the current embed and do not inherit from your Mintlify dashboard.
+
+## Configuration reference
+
+### `AssistantConfig`
+
+Pass this object to `init()`.
+
+| Option | Type | Description |
+| ------------------ | ----------------------------------------------- | ---------------------------------------------------------------------------- |
+| `id` | string | Public widget ID from the Mintlify dashboard. |
+| `endpoint` | string | Overrides the hosted widget API endpoint. |
+| `identity` | string | Signed end-user identity token. Omit for anonymous visitors. |
+| `nonce` | string | CSP nonce copied to resources created by the widget. |
+| `defaultOpen` | boolean | Opens the widget on its first initialization. The default is `false`. |
+| `appearance` | [`AssistantAppearance`](#assistantappearance) | Visual and presentation overrides. |
+| `labels` | [`AssistantLabels`](#assistantlabels) | Customer-facing text overrides. |
+| `supportEmail` | string | Sets the support address shown in the widget toolbar for this embed. |
+| `starterQuestions` | string[] | Sets up to **three** empty-state prompts for this embed. |
+| `hooks` | [`AssistantHooks`](#assistanthooks) | Event and error observers. |
+
+### `AssistantAppearance`
+
+| Option | Values | Description |
+| -------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
+| `variant` | `widget`, `modal`, `panel` | Controls whether the assistant opens as an anchored popover, centered dialog, or responsive side panel. |
+| `theme` | `light`, `dark`, `system` | Sets the widget color scheme. The default is `system`. |
+| `accent` | CSS color | Sets the color of primary controls. |
+| `radius` | CSS border radius | Sets the panel radius, such as `18px`. |
+| `font` | CSS font family | Uses a font already loaded by your application. The default is bundled Inter. |
+| `side` | `top`, `bottom`, `left`, `right`, `inline-start`, `inline-end` | Positions the built-in trigger on a screen edge. |
+| `align` | `start`, `center`, `end` | Aligns the trigger along its selected edge. |
+| `dismissOnInteractOutside` | boolean | Controls whether pointer or focus interactions outside close the assistant. |
+| `logo` | URL or `{ light, dark }` | Replaces the default Mintlify mark. |
+| `zIndex` | number | Changes the stacking order of the widget host. |
+
+Arbitrary CSS and neutral-palette overrides are not supported. The closed Shadow DOM protects both your application and the widget from cross-site style regressions.
+
+### `AssistantLabels`
+
+| Option | Values | Description |
+| ------------- | ----------------- | ------------------------------------------------------------------- |
+| `title` | string or `null` | Sets the panel header. The default is `Assistant`. |
+| `trigger` | string or `null` | Sets the compact widget and panel trigger text. |
+| `placeholder` | string or `null` | Sets the composer and modal trigger placeholder. |
+| `disclaimer` | string, `false`, or `null` | Sets the empty-state disclaimer. Pass `false` to hide it. |
+| `suggestions` | string or `null` | Sets the heading above starter questions. The default is `Suggestions`. |
+
+### `AssistantHooks`
+
+```js
+hooks: {
+ event(event) {
+ console.log(event.type, event.actor, event.source);
+ },
+ error(error) {
+ console.error(error.code, error.retryable, error.status);
+ },
+}
+```
+
+The `event` hook receives lifecycle and interaction metadata for `init`, `open`, `close`, `ask`, `update`, `reset`, `navigate`, and `destroy`. Events do not include question text, identity, session, or CAPTCHA tokens.
+
+The `error` hook receives a stable `code`, a `retryable` boolean, and an optional HTTP `status`. Exceptions thrown by either hook do not interrupt the widget.
+
+### `AssistantOpenOptions`
+
+Pass this optional object to `open()`.
+
+| Option | Type | Description |
+| -------- | ------- | -------------------------------------------------------------------- |
+| `source` | string | Customer-defined attribution included in events and requests. |
+| `focus` | boolean | Focuses the composer after opening. The default is `true`. |
+
+### `AssistantAskOptions`
+
+Pass this optional object after the question string in `ask()`.
+
+| Option | Type | Description |
+| -------- | ------- | -------------------------------------------------------------------- |
+| `source` | string | Customer-defined attribution included in events and requests. |
+| `open` | boolean | Opens the panel before sending. The default is `true`. |
+| `focus` | boolean | Focuses the composer when opening. The default is `true`. |
+
+### `AssistantUpdate`
+
+Pass this object to `update()`. Every field is optional, and `null` restores its default.
+
+| Option | Type | Description |
+| ------------------ | ------------------------------------------------------- | ----------------------------------------------------------------- |
+| `identity` | string or `null` | Changes the signed identity and starts a new conversation. |
+| `appearance` | [`AssistantAppearance`](#assistantappearance) or `null` | Deep-patches appearance settings. |
+| `labels` | [`AssistantLabels`](#assistantlabels) or `null` | Deep-patches customer-facing text. |
+| `supportEmail` | string or `null` | Changes the support address. Pass `null` to remove it. |
+| `starterQuestions` | string[] or `null` | Changes up to three prompts. Pass `null` to restore an empty list. |
+| `hooks` | [`AssistantHooks`](#assistanthooks) or `null` | Deep-patches event and error observers. |
+
+## Browser API
+
+| Method | Parameter types | Description |
+| ------------------------ | ----------------------------------------------------------- | ---------------------------------------------------------------------------------- |
+| `init(config)` | [`AssistantConfig`](#assistantconfig) | Loads and mounts the widget. This is the readiness promise for every other method. |
+| `open(options)` | [`AssistantOpenOptions`](#assistantopenoptions) | Opens the configured presentation. |
+| `close()` | — | Closes the widget. |
+| `ask(question, options)` | string, [`AssistantAskOptions`](#assistantaskoptions) | Opens the widget if requested and sends a question. |
+| `update(config)` | [`AssistantUpdate`](#assistantupdate) | Deep-patches mutable identity, appearance, copy, and observer settings. |
+| `reset()` | — | Starts a fresh conversation. |
+| `destroy()` | — | Removes the widget and releases its browser resources. |
+
+Conversation snapshots remain private to the widget. Each method resolves to `void`.
+
+## Content Security Policy
+
+If your site uses a Content Security Policy, allow the origins required by your enabled widget features:
+
+| Directive | Source | Required for |
+| -------------------------------------------- | ----------------------------------- | --------------------------- |
+| `script-src` | `https://cdn.jsdelivr.net` | Widget loader and runtime |
+| `connect-src` | `https://api.mintlify.com` | Widget API |
+| `style-src` | `https://cdn.jsdelivr.net` | Widget style sheet |
+| `font-src` | `https://cdn.jsdelivr.net` | Optional bundled Inter font |
+| `script-src`, `connect-src`, and `frame-src` | `https://challenges.cloudflare.com` | Turnstile bot protection |
+| `script-src` | `https://js.hcaptcha.com` | hCaptcha bot protection |
+| `connect-src` and `frame-src` | `https://*.hcaptcha.com` | hCaptcha bot protection |
+
+A strict `script-src` policy must still authorize both the loader and initialization script. Passing `nonce` to `init()` propagates it only to resources the widget creates after initialization.
+
+
diff --git a/docs.json b/docs.json
index cc2689cbfb..eb2b1c4869 100644
--- a/docs.json
+++ b/docs.json
@@ -234,6 +234,7 @@
"pages": [
"assistant/configure",
"assistant/customize",
+ "assistant/widget",
"assistant/skills",
"assistant/use"
]
diff --git a/playground.css b/playground.css
new file mode 100644
index 0000000000..127553fc27
--- /dev/null
+++ b/playground.css
@@ -0,0 +1,56 @@
+@media (min-width: 1024px) {
+ html[data-current-path="/assistant/widget"] #content-area {
+ position: relative;
+ max-width: 1800px !important;
+ margin-inline: auto;
+ /* Percentage padding resolves against the parent, so cap it at half of the 1800px max-width */
+ padding-right: calc(min(50%, 900px) + 1rem);
+ }
+
+ html[data-current-path="/assistant/widget"] #content {
+ position: static;
+ }
+
+ html[data-current-path="/assistant/widget"] [data-assistant-preview] {
+ position: absolute;
+ inset: 0 1.5rem 0 auto;
+ width: calc(50% - 2.5rem);
+ }
+}
+
+html[data-current-path="/assistant/widget"]
+ div:has(> iframe[title="Live Assistant Widget preview"]) {
+ min-height: 0;
+ flex: 1 1 0%;
+}
+
+html[data-current-path="/assistant/widget"]
+ iframe[title="Live Assistant Widget preview"] {
+ height: 100%;
+}
+
+/* The widget playground embeds /assistant/widget-preview in its preview
+ iframe. The suffix match keeps localized paths covered. Hide every piece of
+ docs chrome so only the widget renders, and keep the document transparent
+ so the playground card provides the visible frame. */
+html[data-current-path$="/assistant/widget-preview"] #navbar,
+html[data-current-path$="/assistant/widget-preview"] #navbar-transition,
+html[data-current-path$="/assistant/widget-preview"] #mobile-nav,
+html[data-current-path$="/assistant/widget-preview"] #banner,
+html[data-current-path$="/assistant/widget-preview"] #sidebar,
+html[data-current-path$="/assistant/widget-preview"] #footer,
+html[data-current-path$="/assistant/widget-preview"] #assistant-entry,
+html[data-current-path$="/assistant/widget-preview"] #assistant-entry-mobile,
+html[data-current-path$="/assistant/widget-preview"] [aria-label="Preview Widget"],
+html[data-current-path$="/assistant/widget-preview"] [aria-label="Preview Widget Menu"] {
+ display: none !important;
+}
+
+html[data-current-path$="/assistant/widget-preview"],
+html[data-current-path$="/assistant/widget-preview"] body {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ overflow: hidden !important;
+ background: transparent !important;
+}
diff --git a/snippets/assistant-widget-playground.jsx b/snippets/assistant-widget-playground.jsx
new file mode 100644
index 0000000000..48ca536fa0
--- /dev/null
+++ b/snippets/assistant-widget-playground.jsx
@@ -0,0 +1,550 @@
+export const AssistantWidgetPlayground = ({ children, CodeBlockComponent }) => {
+ // Mintlify evaluates snippet exports independently, so shared values must stay in this scope.
+ const EXAMPLE_WIDGET_ID = "YOUR_WIDGET_ID";
+ const EMBED_URL =
+ "https://cdn.jsdelivr.net/npm/@mintlify/assistant-widget@0.0/dist/browser/embed.js";
+ // The preview loads the hidden /assistant/widget-preview page through the
+ // chromeless `/_minimal/` renderer instead of a srcdoc iframe: captcha
+ // providers reject documents without a hostname, and srcdoc documents have
+ // none. Message names must stay in sync with
+ // snippets/assistant-widget-preview-host.jsx.
+ const PREVIEW_READY_MESSAGE = "mintlify-assistant-playground:ready";
+ const PREVIEW_UPDATE_MESSAGE = "mintlify-assistant-playground:update";
+ const PREVIEW_STATE_MESSAGE = "mintlify-assistant-playground:state";
+ const SUPPORT_EMAIL = "hi@mintlify.com";
+ const STARTER_QUESTIONS = [
+ "How do I get started with Mintlify?",
+ "How do I customize my docs?",
+ "How do I deploy my docs?",
+ ];
+ const VARIANT_OPTIONS = [
+ { value: "widget", label: "Widget" },
+ { value: "modal", label: "Modal" },
+ { value: "panel", label: "Panel" },
+ ];
+ const THEME_OPTIONS = [
+ { value: "system", label: "System" },
+ { value: "light", label: "Light" },
+ { value: "dark", label: "Dark" },
+ ];
+ const SIDE_OPTIONS = [
+ { value: "top", label: "Top" },
+ { value: "bottom", label: "Bottom" },
+ { value: "left", label: "Left" },
+ { value: "right", label: "Right" },
+ { value: "inline-start", label: "Inline start" },
+ { value: "inline-end", label: "Inline end" },
+ ];
+ const ALIGN_OPTIONS = [
+ { value: "start", label: "Start" },
+ { value: "center", label: "Center" },
+ { value: "end", label: "End" },
+ ];
+ const INSTALL_OPTIONS = [
+ { value: "html", label: "HTML" },
+ { value: "next", label: "Next.js" },
+ ];
+
+ const [installTarget, setInstallTarget] = useState("html");
+ const [variant, setVariant] = useState("widget");
+ const [theme, setTheme] = useState("system");
+ const [accent, setAccent] = useState("#16a34a");
+ const [radius, setRadius] = useState(18);
+ const [side, setSide] = useState("bottom");
+ const [align, setAlign] = useState("end");
+ const [trackEvents, setTrackEvents] = useState(false);
+ const [reportErrors, setReportErrors] = useState(false);
+ const [previewHostReady, setPreviewHostReady] = useState(false);
+ const [previewUrl, setPreviewUrl] = useState(null);
+ const [previewStatus, setPreviewStatus] = useState("loading");
+ const previewRef = useRef(null);
+ const previewHostRef = useRef(null);
+
+ useEffect(() => {
+ // Resolve the preview page against the deployment base path (for example
+ // /docs on mintlify.com). Translated pages keep their locale segment
+ // after the /_minimal/ renderer prefix.
+ const pageMatch = window.location.pathname
+ .replace(/\/$/, "")
+ .match(/^(.*?)(\/[a-z]{2}(?:-[A-Za-z]{2,4})?)?\/assistant\/widget$/);
+ const basePath = pageMatch?.[1] ?? "";
+ const locale = pageMatch?.[2] ?? "";
+ const mode = document.documentElement.classList.contains("dark")
+ ? "dark"
+ : "light";
+ setPreviewUrl(
+ `${basePath}/_minimal${locale}/assistant/widget-preview?mode=${mode}`,
+ );
+ }, []);
+
+ useEffect(() => {
+ // A deleted custom script can leave its parent-page widget mounted during local hot reloads.
+ const removeRootWidget = () => {
+ const rootWidget = document.querySelector("body > mintlify-assistant");
+ if (!rootWidget) return false;
+
+ const destroyPromise = window.MintlifyAssistant?.destroy();
+ void destroyPromise?.catch(() => {});
+ rootWidget.remove();
+ return true;
+ };
+ const rootWidgetObserver = new MutationObserver(removeRootWidget);
+
+ removeRootWidget();
+ rootWidgetObserver.observe(document.body, { childList: true });
+
+ return () => rootWidgetObserver.disconnect();
+ }, []);
+
+ useEffect(() => {
+ // SPA navigations can mount this page before the sticky/absolute preview
+ // host has a laid-out box. Delay the iframe until the host has size.
+ const host = previewHostRef.current;
+ if (!host) return undefined;
+
+ const markReady = (height) => {
+ if (height > 0) setPreviewHostReady(true);
+ };
+
+ markReady(host.getBoundingClientRect().height);
+
+ if (typeof ResizeObserver === "undefined") {
+ setPreviewHostReady(true);
+ return undefined;
+ }
+
+ const observer = new ResizeObserver((entries) => {
+ markReady(entries[0]?.contentRect.height ?? 0);
+ });
+ observer.observe(host);
+ return () => observer.disconnect();
+ }, []);
+
+ const classNames = (...classes) => classes.filter(Boolean).join(" ");
+
+ const renderSegmentedControl = ({
+ ariaLabel,
+ onChange,
+ options,
+ threeColumns = false,
+ value,
+ }) => (
+