Embedding a Web Chat Widget

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written on 2026-10-01 against the browser platform as documented by the WHATWG HTML and Fetch standards and MDN on that date, React (createRoot, React 18 or later) and Vue 3 (defineCustomElement), with the DocsAssistant backend on FastAPI 0.142.2 or Spring AI 2.0.1 on Spring Boot 4.1.1 (see the version baseline). The snippets are illustrative: they are written from the official documentation of each API and are not compiled or run by this repository, and no browser testing was done for this page. Browser support and policy details are stated with the caveat that they must be confirmed in the browsers you target.

The RAG API page already ends with a small widget: a fetch call that reads the SSE stream of POST /v1/ask and a reminder to render text as text (see the minimal browser widget). That snippet is a function you call from your own page. This page does not repeat it; it extends it into something a different team can put on its site with one line of HTML, without a build step and without its CSS or JavaScript colliding with yours. The server side of the stream (event types, the contract) is in Streaming Chat APIs; the protocol-level alternatives to the hand-written client are in Chat UI Protocols, and the off-the-shelf front ends in Ready-Made Chat UIs.

What "embeddable" adds

An in-app chat component runs in a page you control. An embeddable widget runs in a page you do not control, so every assumption changes.

Requirement Consequence for the design

One-line install

A single <script> tag, configured with data-* attributes, that works from any HTML page, CMS template or tag manager.

Style isolation both ways

The host’s CSS must not restyle the widget and the widget’s CSS must not leak out: Shadow DOM, not global selectors.

No framework assumptions

The host may already ship React 17, Vue 2, jQuery or nothing. The default bundle is plain JavaScript with no dependencies.

Cross-origin by definition

The page is on www.example.com and the API on api.example.org: CORS preflight, an origin allowlist and a token that the host page can obtain are part of the product.

Strict host policies

Hosts often run a Content Security Policy. The widget must need only a documented, minimal set of directives.

Accessible by default

The host’s accessibility statement now includes your widget: keyboard, screen reader and reduced-motion behaviour are not optional extras.

Versioned delivery

The host pins a version, or accepts a moving URL. Either way the contract with /v1/ask must stay additive.

Three ways to embed

flowchart LR subgraph Host["Host page (www.example.com)"] TAG["script tag with data-api"] EL["docs-assistant element"] subgraph SR["Shadow root"] UI["launcher, panel, transcript, form"] end TAG --> EL --> UI end TOK["Host token endpoint (same origin)"] API["DocsAssistant API (api.example.org)"] CDN["Widget script (widget.example.org)"] CDN -. "loaded by" .-> TAG UI -- "1. get a short-lived token" --> TOK UI -- "2. POST /v1/ask (CORS, SSE)" --> API
Variant Strengths Weaknesses Use when

Custom element with Shadow DOM, loaded by a script tag (this page’s main example)

One line to install, no iframe sizing problems, inherits fonts and colours through CSS custom properties, can read page context (title, URL) if the host allows it.

Runs with the host page’s privileges: a compromised widget script is a compromised host page. Needs host CSP changes.

The host trusts you (same organisation or a contract) and wants a tight visual fit.

<iframe> pointing at a page you serve

A real security boundary: your origin, your CSP, your cookies. The host cannot read the conversation and vice versa.

Fixed-size box, resize handshakes over postMessage, third-party cookie and storage partitioning, harder theming.

Unknown or untrusted hosts, or authenticated chat that must keep its session private.

Framework component (React or Vue package)

Natural for apps that already use the framework, typed props, tree-shakable.

Version coupling with the host’s framework, bundler required, no script-tag install.

The host is a React or Vue application and you control or advise its code (see the variants below).

Shadow DOM is a styling and DOM-encapsulation tool, not a security boundary: scripts on the page can reach an open shadow root and the widget’s script executes with the host page’s origin. Only an iframe from another origin gives isolation of that kind. Choose accordingly, and for the iframe variant jump to the iframe alternative.

The stream client

The first piece is the part that talks to the API. It extends the minimal widget in four ways: it tolerates CRLF line endings and comments, it turns a non-2xx response into an error using the problem-details type (RFC 9457), it aborts a stalled stream, and it asks the host for a token instead of hard-coding one. EventSource is not an option because POST /v1/ask has a body and an Authorization header; the stream is read with fetch and a ReadableStream instead.

docs-assistant-client.js — a POST-capable SSE client (JavaScript)
export async function askStream({ api, question, conversationId, token, signal, onEvent }) {
  const headers = { "Content-Type": "application/json", Accept: "text/event-stream" };
  if (token) headers.Authorization = `Bearer ${token}`;

  const res = await fetch(api, {
    method: "POST",
    headers,
    body: JSON.stringify({ question, conversationId }),
    signal,
  });
  if (!res.ok) {
    // Problem details (application/problem+json): show a stable message, never the raw body.
    const problem = await res.json().catch(() => ({}));
    throw Object.assign(new Error(problem.type ?? `http-${res.status}`), { status: res.status });
  }
  if (!res.body) throw new Error("streaming-unsupported");

  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = "";
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += value;
    let end;
    // A blank line ends an event; accept both LF LF and CRLF CRLF (lone CR is not handled).
    while ((end = buffer.search(/\r?\n\r?\n/)) >= 0) {
      const block = buffer.slice(0, end);
      buffer = buffer.slice(end).replace(/^\r?\n\r?\n/, "");
      let event = "message";
      const data = [];
      for (const line of block.split(/\r?\n/)) {
        if (line.startsWith(":")) continue;                       // comment / keep-alive
        const i = line.indexOf(":");
        const field = i < 0 ? line : line.slice(0, i);
        const val = i < 0 ? "" : line.slice(i + 1).replace(/^ /, "");
        if (field === "event") event = val;
        else if (field === "data") data.push(val);
      }
      if (data.length) onEvent(event, JSON.parse(data.join("\n")));
    }
  }
}

Two behaviours differ from EventSource and have to be provided by the widget:

  • No automatic reconnect. If the connection drops mid-answer, the widget shows the partial answer with a "retry" button that sends the question again (a new turn). Resuming a stream with Last-Event-ID needs server support that the DocsAssistant core does not implement, so the widget does not pretend to.

  • No built-in timeout. An idle timer that aborts the request when no byte (including a keep-alive comment) arrives for, say, 30 seconds keeps a dead proxy from leaving the UI in a permanent "typing" state. AbortController is the tool for both the timer and the user’s "stop" button.

The same module is imported unchanged by the framework variants below; only the rendering differs.

The widget as a custom element

The widget is one classic script (not an ES module) that registers a <docs-assistant> element, attaches a Shadow DOM to it and builds the UI with createElement and textContent only. A classic script is chosen on purpose: document.currentScript is the way a script tag reads its own data-* attributes, and it is null inside module scripts and after any await, so it is read once, synchronously, at the top. The code below inlines the client of the previous section for brevity.

docs-assistant.js — embeddable widget, framework-free (JavaScript)
(() => {
  const script = document.currentScript;                 // read synchronously; null for modules
  const cfg = {
    api: script.dataset.api,                             // https://api.example.org/v1/ask
    tokenUrl: script.dataset.tokenUrl,                   // same-origin endpoint of the host, optional
    title: script.dataset.title ?? "Docs assistant",
    linkHosts: (script.dataset.linkHosts ?? "").split(",").filter(Boolean), // allowed citation hosts
  };

  const sheet = new CSSStyleSheet();
  sheet.replaceSync(`
    :host { all: initial; font: 16px/1.4 system-ui, sans-serif; color-scheme: light dark;
            --da-accent: #1a56db; --da-bg: Canvas; --da-fg: CanvasText; --da-radius: 12px; }
    * { box-sizing: border-box; }
    .launcher { position: fixed; right: 16px; bottom: 16px; min-width: 48px; min-height: 48px; border-radius: 24px;
                border: 0; background: var(--da-accent); color: #fff; font: inherit; padding: 0 16px; cursor: pointer; }
    .panel { position: fixed; right: 16px; bottom: 80px; width: min(380px, calc(100vw - 32px)); max-height: 70vh;
             display: flex; flex-direction: column; background: var(--da-bg); color: var(--da-fg);
             border: 1px solid GrayText; border-radius: var(--da-radius); }
    .panel[hidden] { display: none; }
    h2 { font-size: 1rem; margin: 0; padding: 12px; border-bottom: 1px solid GrayText; }
    ol.transcript { list-style: none; margin: 0; padding: 12px; overflow: auto; flex: 1; }
    li.msg { margin: 0 0 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
    li.user { text-align: right; }
    form { display: flex; gap: 8px; padding: 12px; border-top: 1px solid GrayText; }
    input { flex: 1; min-height: 44px; font: inherit; padding: 0 8px; }
    button { min-height: 44px; font: inherit; }
    :focus-visible { outline: 3px solid var(--da-accent); outline-offset: 2px; }
    .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
    @media (prefers-reduced-motion: no-preference) { ol.transcript { scroll-behavior: smooth; } }
  `);

  const h = (tag, props = {}, ...kids) => {
    const el = document.createElement(tag);
    for (const [k, v] of Object.entries(props)) {
      if (k === "onclick" || k === "onsubmit" || k === "onkeydown") el.addEventListener(k.slice(2), v);
      else el.setAttribute(k, v);
    }
    el.append(...kids);                                  // strings become Text nodes, never HTML
    return el;
  };

  class DocsAssistant extends HTMLElement {
    #abort; #conversationId;

    connectedCallback() {
      if (this.shadowRoot) return;
      const root = this.attachShadow({ mode: "open" });
      root.adoptedStyleSheets = [sheet];

      this.launcher = h("button", { class: "launcher", type: "button", "aria-expanded": "false",
        "aria-controls": "panel" }, cfg.title);
      this.status = h("div", { class: "sr-only", role: "status" });           // polite live region
      this.transcript = h("ol", { class: "transcript", "aria-label": "Conversation" });
      this.input = h("input", { type: "text", name: "q", maxlength: "4000", autocomplete: "off",
        "aria-label": "Your question" });
      this.stop = h("button", { type: "button", hidden: "", onclick: () => this.#abort?.abort() }, "Stop");
      const form = h("form", { onsubmit: (e) => { e.preventDefault(); this.#send(); } },
        this.input, h("button", { type: "submit" }, "Send"), this.stop);
      this.panel = h("div", { id: "panel", role: "dialog", "aria-labelledby": "title", hidden: "",
        class: "panel", onkeydown: (e) => { if (e.key === "Escape") this.#toggle(false); } },
        h("h2", { id: "title" }, cfg.title), this.transcript, form);

      this.launcher.addEventListener("click", () => this.#toggle(this.panel.hidden));
      root.append(this.launcher, this.panel, this.status);
    }

    disconnectedCallback() { this.#abort?.abort(); }

    #toggle(open) {
      this.panel.hidden = !open;
      this.launcher.setAttribute("aria-expanded", String(open));
      (open ? this.input : this.launcher).focus();       // move focus in on open, back out on close
    }

    async #token() {
      if (!cfg.tokenUrl) return null;
      const res = await fetch(cfg.tokenUrl, { credentials: "same-origin" });   // host's own origin
      return res.ok ? (await res.json()).token : null;
    }

    async #send() {
      const question = this.input.value.trim();
      if (!question || this.#abort) return;
      this.input.value = "";
      this.transcript.append(h("li", { class: "msg user" }, question));
      const answer = h("li", { class: "msg bot" });
      this.transcript.append(answer);
      let text = "";
      this.#abort = new AbortController();
      this.stop.hidden = false;
      this.status.textContent = "Searching the documentation";
      try {
        await askStream({
          api: cfg.api, question, conversationId: this.#conversationId, token: await this.#token(),
          signal: this.#abort.signal,
          onEvent: (event, p) => {
            if (event === "token") { text += p.text; answer.textContent = text; }
            else if (event === "citations") answer.replaceChildren(...renderAnswer(text, p, cfg.linkHosts));
            else if (event === "done") this.#conversationId = p.conversationId;
            else if (event === "error") throw new Error(p.type);
          },
        });
        this.status.textContent = text;                  // announce the finished answer once
      } catch (err) {
        if (err.name !== "AbortError") answer.textContent = "Sorry, the assistant is unavailable. Please try again.";
        this.status.textContent = err.name === "AbortError" ? "Stopped" : "The assistant is unavailable";
      } finally {
        this.#abort = undefined;
        this.stop.hidden = true;
        this.transcript.scrollTop = this.transcript.scrollHeight;
      }
    }
  }

  if (!customElements.get("docs-assistant")) customElements.define("docs-assistant", DocsAssistant);
  document.body.append(document.createElement("docs-assistant"));
})();

The host installs it with one tag. defer keeps the script from blocking parsing and guarantees document.body exists when it runs; integrity pins the exact file for a versioned URL (see delivery and versioning):

Host page — the whole installation (HTML)
<script src="https://widget.example.org/1.4.0/docs-assistant.js"
        integrity="sha384-REPLACE_WITH_THE_PUBLISHED_HASH" crossorigin="anonymous" defer
        data-api="https://api.example.org/v1/ask"
        data-token-url="/assistant/token"
        data-title="Ask the docs"
        data-link-hosts="docs.example.org"></script>
Attribute Meaning

data-api

Absolute URL of POST /v1/ask. The widget never guesses it from the script URL.

data-token-url

A same-origin endpoint of the host that returns { "token": "…​" } for the current visitor. Omit it for a public assistant that relies on origin allowlisting and rate limits only.

data-title

Launcher label and dialog heading.

data-link-hosts

Comma-separated hostnames citation links may point to; anything else is rendered as plain text.

Shadow DOM details that matter

  • Styling. Selectors in the shadow root do not match outside it and the page’s selectors do not match inside, with two intended exceptions: inherited properties (font, colour) and CSS custom properties cross the boundary. :host { all: initial } resets the inherited properties so the host page’s body styles do not leak in, and --da-accent and friends are the documented theming surface: the host sets them on the docs-assistant element. For finer control, expose pieces with part and exportparts.

  • Fonts. Declare @font-face rules in the host document, or use system fonts, as above; font declarations inside a shadow tree are not reliable across browsers, so confirm before depending on them.

  • Identifiers. id references (aria-labelledby, aria-controls, <label for>) resolve only within one shadow tree. That is why the panel, its heading and the launcher all live in the same root, and why citation markers use buttons with click handlers rather than href="#source-2": fragment navigation does not look inside shadow roots.

  • Events. Events that cross the boundary are retargeted to the host element, so the page’s analytics sees a click on docs-assistant, not on the inner button. Dispatch explicit CustomEvent`s (`composed: true) for the things the host may want to observe, such as assistant-answer-complete.

  • Forms. An <input> inside a shadow root does not take part in a surrounding host <form>, which is what you want here; password managers and autofill tools may also skip it.

Rendering answers and citations

Model output is untrusted text. A retrieved document can carry a prompt injection that makes the model emit <img src=x onerror=…​> or a javascript: link, and innerHTML turns that into script execution in the host page’s origin. Three rules keep the widget safe, and they are also what makes it work under a strict CSP:

  1. Set content with textContent or append(string), never innerHTML, outerHTML or insertAdjacentHTML.

  2. Build links from the server’s resolved citations payload only, never by parsing URLs out of the answer text, and validate each URL before it becomes an href.

  3. If you must show Markdown, convert it with a library that outputs a DOM tree or sanitise its HTML with a maintained sanitiser (DOMPurify is the common one) before inserting it, and enable Trusted Types in your own test pages so any forgotten sink throws.

The renderer used by the widget above splits the answer on the [n] markers the server inserts (the contract is in Citations & Grounding) and appends a numbered source list:

Rendering the answer text and citations without HTML (JavaScript)
function safeUrl(raw, linkHosts = []) {
  try {
    const url = new URL(raw, location.href);
    const okProtocol = url.protocol === "https:";               // http: only if you really need it
    const okHost = linkHosts.length === 0 || linkHosts.includes(url.hostname);
    return okProtocol && okHost ? url.href : null;
  } catch {
    return null;
  }
}

function renderAnswer(text, citations, linkHosts) {
  const byMarker = new Map(citations.map((c) => [String(c.marker), c]));
  const nodes = [];
  const list = h("ol", { class: "sources", "aria-label": "Sources" });

  for (const part of text.split(/(\[\d+\])/)) {
    const marker = /^\[(\d+)\]$/.exec(part)?.[1];
    if (!marker || !byMarker.has(marker)) { nodes.push(document.createTextNode(part)); continue; }
    const target = h("li", { value: marker, tabindex: "-1" });
    const c = byMarker.get(marker);
    const href = safeUrl(c.url, linkHosts);
    target.append(href
      ? h("a", { href, target: "_blank", rel: "noopener noreferrer" }, c.title)
      : document.createTextNode(c.title));
    if (c.quote) target.append(h("blockquote", {}, c.quote));
    list.append(target);
    nodes.push(h("sup", {}, h("button", {
      type: "button", "aria-label": `Source ${marker}: ${c.title}`,
      onclick: () => target.focus(),                       // focus the source item inside the same shadow tree
    }, marker)));
  }
  return list.children.length ? [...nodes, list] : nodes;
}

Design decisions worth keeping:

  • An unverifiable or disallowed citation is shown as text, not dropped, so the answer stays honest about its sources and the failure is visible in testing.

  • target="_blank" always comes with rel="noopener noreferrer", and the link text is the document title, never a URL that could be mistaken for a different site.

  • A marker without a matching citation stays as plain text instead of becoming a dead button.

  • Grounding verdicts (the grounding event) can add a visible "not fully supported by sources" note; how that verdict is computed is in Hallucination Detection & Correction, and the guardrails against prompt injection in Guardrails & Prompt Injection.

Accessibility

A chat widget concentrates the hard parts of dynamic-content accessibility: content that changes while the user is doing something else, a panel that appears over the page, and a stream that would otherwise be read out token by token. The general principles, standards and validation tools are in Accessibility; the points specific to this widget follow. The conformance target is WCAG 2.2 level AA.

Concern What the widget does

Launcher

A real <button> with a visible label, aria-expanded reflecting the panel state and aria-controls pointing at it (in the same shadow tree). Target size well above the 24 by 24 CSS pixel minimum of WCAG 2.2 criterion 2.5.8.

Opening the panel

The panel has role="dialog" and aria-labelledby pointing at its heading. Focus moves to the input when it opens, because focus left on the launcher would leave keyboard and screen-reader users outside the new content.

Closing

Escape closes the panel and returns focus to the launcher. It is a non-modal dialog (the page behind stays usable), so it does not trap focus or set aria-modal; if you make it modal, follow the full modal pattern with a focus trap and an inert background.

Streaming text

Appending every token to a live region makes screen readers announce fragments, interrupt each other and repeat. The transcript is therefore not a live region. A separate visually hidden role="status" element (implicitly polite) announces milestones ("Searching the documentation") and then the finished answer once. Whether to read the whole answer or only a summary such as "Answer ready, 3 sources" is a product decision; test it with real screen readers, as live-region behaviour differs between browser and reader combinations.

New messages

Focus is never moved to a new answer automatically: that would steal the cursor from the input where the user is about to type the follow-up. Status messages are announced without moving focus, as WCAG 2.2 criterion 4.1.3 requires.

Stop and retry

Both are buttons that appear in the tab order; "Stop" is hidden when nothing is streaming and is a keyboard-operable equivalent of cancelling the request.

Citations

Marker buttons have accessible names that include the source title. The source list is an ordered list with an aria-label, and links open in a new tab, so say so in the accessible name where the host’s guidelines require it.

Colour and motion

color-scheme: light dark and system colours (Canvas, CanvasText, GrayText) follow the user’s theme and forced colours mode; focus indicators are 3 pixels; scrolling animation is limited to prefers-reduced-motion: no-preference.

Zoom and reflow

The panel width is min(380px, 100vw - 32px) and its height is viewport-relative, so 400% zoom and small screens do not produce horizontal scrolling or a panel that covers the launcher (WCAG 2.2 criterion 2.4.11 asks that focus is not fully hidden by author content).

Automated checkers find missing names and contrast problems but not whether the stream is bearable to listen to; manual keyboard and screen-reader passes are part of the release checklist, as explained in Accessibility. For the framework variants, the Vue guide has its own notes on Security and Accessibility, and automated browser tests that exercise the widget in a real browser are covered in End-to-End Testing in Real Browsers.

The React variant

When the host is a React application, ship the widget as a component, and reuse the stream client unchanged. The state machine lives in a custom hook (see Custom Hooks); cancellation is tied to the component’s lifetime with an effect cleanup (see Effects).

DocsAssistant.jsx — hook and component (JavaScript with JSX)
import { useCallback, useEffect, useReducer, useRef } from "react";
import { askStream } from "./docs-assistant-client.js";

function reducer(state, action) {
  switch (action.type) {
    case "ask":       return { ...state, busy: true, messages: [...state.messages,
                        { role: "user", text: action.text }, { role: "assistant", text: "", citations: [] }] };
    case "token":     return { ...state, messages: state.messages.map((m, i) =>
                        i === state.messages.length - 1 ? { ...m, text: m.text + action.text } : m) };
    case "citations": return { ...state, messages: state.messages.map((m, i) =>
                        i === state.messages.length - 1 ? { ...m, citations: action.citations } : m) };
    case "done":      return { ...state, busy: false, conversationId: action.conversationId };
    case "error":     return { ...state, busy: false, error: action.message };
    default:          return state;
  }
}

export function useDocsAssistant({ api, getToken }) {
  const [state, dispatch] = useReducer(reducer, { messages: [], busy: false });
  const controller = useRef(null);

  useEffect(() => () => controller.current?.abort(), []);       // cancel on unmount

  const send = useCallback(async (question) => {
    controller.current = new AbortController();
    dispatch({ type: "ask", text: question });
    try {
      await askStream({
        api, question, conversationId: state.conversationId, token: await getToken?.(),
        signal: controller.current.signal,
        onEvent: (event, p) => {
          if (event === "token") dispatch({ type: "token", text: p.text });
          else if (event === "citations") dispatch({ type: "citations", citations: p });
          else if (event === "done") dispatch({ type: "done", conversationId: p.conversationId });
          else if (event === "error") throw new Error(p.type);
        },
      });
    } catch (e) {
      if (e.name !== "AbortError") dispatch({ type: "error", message: "The assistant is unavailable." });
      else dispatch({ type: "done", conversationId: state.conversationId });
    }
  }, [api, getToken, state.conversationId]);

  return { ...state, send, stop: () => controller.current?.abort() };
}

export function DocsAssistant({ api, getToken, linkHosts = [] }) {
  const { messages, busy, error, send, stop } = useDocsAssistant({ api, getToken });
  return (
    <section aria-labelledby="da-title">
      <h2 id="da-title">Ask the docs</h2>
      <ol aria-label="Conversation">
        {messages.map((m, i) => (
          <li key={i}>
            {m.text /* JSX escapes text: no HTML is ever parsed */}
            {m.citations?.map((c) => (
              <a key={c.marker} href={safeUrl(c.url, linkHosts)} rel="noopener noreferrer" target="_blank">{c.title}</a>
            ))}
          </li>
        ))}
      </ol>
      {error && <p role="alert">{error}</p>}
      <form onSubmit={(e) => { e.preventDefault(); const q = new FormData(e.currentTarget).get("q"); e.currentTarget.reset(); send(String(q)); }}>
        <input name="q" aria-label="Your question" />
        <button type="submit" disabled={busy}>Send</button>
        {busy && <button type="button" onClick={stop}>Stop</button>}
      </form>
    </section>
  );
}

safeUrl is the same helper as above, given the same citation-host allow-list as the framework-free widget (here a linkHosts prop, read from a link-hosts attribute when mounted as a custom element); React escapes text children but does not stop a javascript: URL in href. To offer the React version through the script tag too, mount it inside the same kind of custom element, so the styling isolation is kept (React attaches its event listeners to the root container, which here is inside the shadow tree):

Mounting a React root inside a shadow root (JavaScript with JSX)
import { createRoot } from "react-dom/client";

class ReactDocsAssistant extends HTMLElement {
  #root;
  connectedCallback() {
    const shadow = this.attachShadow({ mode: "open" });
    const style = document.createElement("style");
    style.textContent = WIDGET_CSS;                  // a bundler's global CSS would land in <head>, outside the shadow tree
    const mount = document.createElement("div");
    shadow.append(style, mount);
    this.#root = createRoot(mount);
    const linkHosts = (this.getAttribute("link-hosts") ?? "").split(",").filter(Boolean);
    this.#root.render(<DocsAssistant api={this.getAttribute("api")} linkHosts={linkHosts} />);
  }
  disconnectedCallback() { this.#root?.unmount(); }
}
customElements.define("react-docs-assistant", ReactDocsAssistant);

A <style> element created by script needs a matching CSP style-src allowance (nonce or hash), unlike the constructed stylesheet of the framework-free version; that is a real cost of the framework route. Fetching data and cancellation patterns in general are in Data Fetching, and project setup for a bundled build in Getting Started. Teams on Angular can implement the same client behind a service (RxJS and Async); Angular’s HttpClient is not the right tool for a streamed POST body, so the fetch client is wrapped in an Observable instead.

The Vue variant

In Vue the same split is a composable (see Composables) and an SFC, and Vue can compile a component to a custom element with its own Shadow DOM, which makes the script-tag distribution almost free.

useDocsAssistant.js — composable (JavaScript)
import { onScopeDispose, ref } from "vue";
import { askStream } from "./docs-assistant-client.js";

export function useDocsAssistant({ api, getToken }) {
  const messages = ref([]);
  const busy = ref(false);
  let conversationId;
  let controller;

  async function send(question) {
    controller = new AbortController();
    busy.value = true;
    messages.value.push({ role: "user", text: question });
    const answer = { role: "assistant", text: "", citations: [] };
    messages.value.push(answer);
    const reactive = messages.value.at(-1);                  // the reactive proxy of the object just pushed
    try {
      await askStream({
        api, question, conversationId, token: await getToken?.(), signal: controller.signal,
        onEvent: (event, p) => {
          if (event === "token") reactive.text += p.text;
          else if (event === "citations") reactive.citations = p;
          else if (event === "done") conversationId = p.conversationId;
          else if (event === "error") throw new Error(p.type);
        },
      });
    } catch (e) {
      if (e.name !== "AbortError") reactive.text = "The assistant is unavailable.";
    } finally {
      busy.value = false;
    }
  }

  onScopeDispose(() => controller?.abort());                 // cancel when the component unmounts
  return { messages, busy, send, stop: () => controller?.abort() };
}
DocsAssistant.ce.vue — component compiled to a custom element (Vue SFC)
<script setup>
import { ref } from "vue";
import { useDocsAssistant } from "./useDocsAssistant.js";
import { safeUrl } from "./safe-url.js";

const props = defineProps({ api: String, linkHosts: { type: String, default: "" } });
const linkHosts = props.linkHosts.split(",").filter(Boolean);   // allowed citation hosts, as in the script-tag widget
const { messages, busy, send, stop } = useDocsAssistant({ api: props.api });
const question = ref("");
function submit() { const q = question.value.trim(); if (q) { question.value = ""; send(q); } }
</script>

<template>
  <section aria-labelledby="da-title">
    <h2 id="da-title">Ask the docs</h2>
    <ol aria-label="Conversation">
      <li v-for="(m, i) in messages" :key="i">
        {{ m.text }}
        <a v-for="c in m.citations" :key="c.marker" :href="safeUrl(c.url, linkHosts)" target="_blank" rel="noopener noreferrer">{{ c.title }}</a>
      </li>
    </ol>
    <form @submit.prevent="submit">
      <input v-model="question" aria-label="Your question" />
      <button type="submit" :disabled="busy">Send</button>
      <button v-if="busy" type="button" @click="stop">Stop</button>
    </form>
  </section>
</template>

<style>
section { font: 16px/1.4 system-ui, sans-serif; }
</style>
main.js — registering the element (JavaScript)
import { defineCustomElement } from "vue";
import DocsAssistant from "./DocsAssistant.ce.vue";

customElements.define("vue-docs-assistant", defineCustomElement(DocsAssistant));

Files named *.ce.vue have their styles inlined into the element’s shadow root, which solves the CSS placement problem the React route has to handle by hand. Mustache interpolation ({{ }}) escapes text, and v-html is the Vue equivalent of innerHTML: do not use it on model output. Vue does not sanitise URL bindings either, so safeUrl is still needed, with the allow-list passed as the link-hosts attribute of the custom element (the Vue security guide describes this injection class). More background in Vue.js, Getting Started and Rendering, Render Functions and Web Components.

Content Security Policy

The host page, not the widget, decides the policy, so the widget documents what it needs and works with a strict policy. For the script-tag custom element above the host’s policy needs only the following.

Directive Needed value Why

script-src

https://widget.example.org, or a nonce or hash for the tag

The widget script itself. With 'strict-dynamic' the host’s source allowlist is ignored by supporting browsers, so the tag must carry the nonce.

connect-src

https://api.example.org (and the host’s own origin for the token endpoint)

The fetch calls to /v1/ask and to data-token-url. A missing entry shows up as a console violation and a failed fetch, easily mistaken for a CORS error.

style-src

Nothing for the constructed stylesheet; a nonce or hash for a <style> element

Stylesheets built with new CSSStyleSheet() and replaceSync are created from script rather than parsed from markup; confirm in the browsers you support that your host policy does not need 'unsafe-inline'.

img-src, font-src

Only if the theme loads avatars or icons or custom fonts

The default widget uses none.

require-trusted-types-for 'script'

Optional

The widget has no innerHTML sinks, so it should run under this policy: that is a design goal of the rendering rules above. Test it.

frame-src

Only for the iframe variant

The host must allow the widget’s origin to be framed by itself.

Practical guidance: ship the widget’s required directives in its documentation as a copy-paste block, roll a new policy out with Content-Security-Policy-Report-Only first, and send violation reports to a place someone reads. Avoid designs that need 'unsafe-inline' or 'unsafe-eval' (no template compilers at runtime, no eval, no inline event-handler attributes in generated markup), because hosts that care about CSP will refuse the widget otherwise. The Vue runtime-only build and React’s production build both satisfy that, but a Vue build that compiles templates at runtime does not.

CORS

The widget runs on www.example.com and calls api.example.org, so every request is cross-origin. A POST with a JSON Content-Type and an Authorization header is not a "simple" request, so the browser first sends an OPTIONS preflight and only sends the real request if the answer allows it. The mechanics, the headers and the common mistakes are in CORS; the DocsAssistant specifics follow.

sequenceDiagram participant W as Widget (www.example.com) participant A as API (api.example.org) W->>A: OPTIONS /v1/ask (Origin, Access-Control-Request-Method: POST, Access-Control-Request-Headers: authorization, content-type) A-->>W: 204 (Access-Control-Allow-Origin: www.example.com, Allow-Methods: POST, Allow-Headers: authorization, content-type, Max-Age: 3600, Vary: Origin) W->>A: POST /v1/ask (Authorization: Bearer ..., Accept: text/event-stream) A-->>W: 200 text/event-stream (Access-Control-Allow-Origin: www.example.com, Vary: Origin) A-->>W: event: token ... event: citations ... event: done

Rules for the API:

  • Allowlist origins, do not reflect them. Keep a list of origins registered by hosts (per tenant if you have tenants), answer with the exact matching origin in Access-Control-Allow-Origin, and add Vary: Origin so caches do not serve one host’s answer to another. Echoing back whatever Origin arrives is equivalent to * and defeats the point.

  • Prefer tokens to cookies. The wildcard * is not accepted together with credentials, and a cookie-authenticated third-party API runs into third-party cookie restrictions. The design above gets a short-lived bearer token from the host’s backend (same origin, so the host’s session cookie is used there) and sends it in Authorization, which needs no Allow-Credentials. The token flows are in Browser-Based Apps.

  • Allow what the widget sends, nothing more: POST (and OPTIONS), headers Authorization and Content-Type (plus Idempotency-Key if you use it).

  • Expose what the widget reads. Only a few response headers are readable by cross-origin scripts by default; if the client reads X-Trace-Id for a "report a problem" link, list it in Access-Control-Expose-Headers.

  • Cache the preflight. Access-Control-Max-Age avoids one extra round trip per question; browsers cap the value (MDN documents different upper limits for Chromium and Firefox), so a value of a few thousand seconds is enough.

  • CORS is not authentication. It stops other sites' scripts from reading responses in a visitor’s browser; it does not stop a script or curl from calling the API. Authentication, authorisation and rate limits still apply (see Access Control & Privacy and Serving RAG as an API).

FastAPI — CORS for the widget’s origins (Python)
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://www.example.com", "https://docs.example.org"],   # loaded from configuration
    allow_methods=["POST"],
    allow_headers=["Authorization", "Content-Type", "Idempotency-Key"],
    expose_headers=["X-Trace-Id"],
    max_age=3600,
)
Spring Security — CORS for the widget’s origins (Java)
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
    return http
        .cors(Customizer.withDefaults())                       // uses the CorsConfigurationSource bean below
        .authorizeHttpRequests(a -> a.anyRequest().authenticated())
        .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
        .build();
}

@Bean
CorsConfigurationSource corsConfigurationSource(WidgetProperties props) {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(props.allowedOrigins());            // exact origins from configuration
    config.setAllowedMethods(List.of("POST", "OPTIONS"));
    config.setAllowedHeaders(List.of("Authorization", "Content-Type", "Idempotency-Key"));
    config.setExposedHeaders(List.of("X-Trace-Id"));
    config.setMaxAge(Duration.ofHours(1));
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/v1/**", config);
    return source;
}

If the response is streamed through a reverse proxy or CDN, also check that it does not buffer text/event-stream and that the CORS headers are added to the real response and the preflight alike; a proxy that answers OPTIONS itself must agree with the application’s allowlist.

The iframe alternative

When the host is not trusted, or the conversation is authenticated with your own cookies, serve the chat as a page on your origin and embed that page. The host-facing snippet shrinks to an iframe, the CSP that matters is the one your chat page sends, and the cross-origin problems become postMessage problems:

Host page and chat page — iframe embedding (HTML)
<iframe src="https://chat.example.org/embed?site=docs" title="Ask the docs"
        sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
        allow="clipboard-write" loading="lazy"
        style="border:0; width:380px; height:560px"></iframe>

<!-- chat.example.org response header: only these sites may frame it -->
<!-- Content-Security-Policy: frame-ancestors https://www.example.com https://docs.example.org -->
  • The title is required for an accessible frame; focus has to be managed across the frame boundary (the launcher can live in the host page and move focus into the frame on open).

  • frame-ancestors is the modern replacement for X-Frame-Options and is controlled by the framed page; list only registered host origins.

  • Send messages with postMessage(data, "https://chat.example.org") using the exact target origin, and on receipt check event.origin before acting on event.data; never use "*" for messages that carry tokens.

  • Browsers partition or block third-party cookies and storage in frames. If the chat page depends on cookies set when the visitor logged in on your site directly, test it in the browsers you support, or pass a short-lived token in the URL fragment or a postMessage instead of relying on ambient cookies.

  • Resizing needs a message handshake (height events from the frame), or a fixed size with internal scrolling, as above.

Delivery and versioning

  • Immutable, versioned URLs. Publish /1.4.0/docs-assistant.js with a long cache lifetime and a hash for SRI; offer a /1/docs-assistant.js channel for hosts that accept automatic minor updates, without integrity.

  • Additive API contract. The widget ignores unknown SSE events and unknown fields, so the server can add event types within /v1, as described under versioning in Serving RAG as an API.

  • Hosting. Static files from object storage plus a CDN; the API runs where the core runs (Deployment Patterns).

  • Observability. Send the traceId from the done event with user feedback so a "this answer was wrong" click links to a trace (Observability & Tracing); the widget itself should report only coarse client errors, not conversation text, to respect privacy.

  • Testing. Run the widget in a real browser against a fixture page that has hostile CSS (* { all: unset }, a button { display: none } rule), a strict CSP and a different origin from the API; the point of the tests is the host environment, not the widget’s own logic.

  • Where the conversation lives. Keep the conversationId in memory (as above) or in sessionStorage if the host wants the chat to survive navigation; do not store tokens or transcripts in localStorage on a page you do not own.

Checklist

Area Check

Install

One script tag with defer, data-* configuration, integrity on versioned URLs.

Isolation

Shadow DOM, theming through custom properties, no global selectors, no globals other than the custom element name.

Rendering

No innerHTML anywhere, citation URLs validated, rel="noopener noreferrer", works under Trusted Types.

Streaming

fetch plus ReadableStream for POST, abort on stop, unmount and idle timeout, no reliance on automatic reconnect.

Accessibility

Focus in on open and back on close, Escape, a status region instead of a live transcript, 24 pixel minimum targets, reduced motion, forced colours, manual screen-reader pass.

CSP

Documented script-src and connect-src requirements, nothing needing 'unsafe-inline' or 'unsafe-eval'.

CORS

Origin allowlist with Vary: Origin, preflight cached, bearer token instead of cookies, CORS not mistaken for auth.

Operations

Versioned immutable files, additive contract, traceId on feedback, hostile-page browser tests.