Widget Embed Guide
Single-line summary: widget.js is a zero-dependency, Shadow DOM-isolated chat widget that embeds with a single <script> tag — no attributes required when serving from the same origin. It auto-discovers available agents (Schema.org JSON-LD → data-agents attr → /.well-known/mentionable-agents.json), renders a FAB + panel, and communicates via A2A transport when available (auto-detected from the agent card) with REST as the fallback. Agent replies are rendered as rich HTML — markdown is parsed inline (headings, bold, italic, code blocks, links) and tool calls are shown as collapsible cards with in-flight / success / error states.
See also: transport-module-guide#rest, webfinger-agent-card-guide, deployment-patterns, glossary
Implementation: examples/llm-agent-vercel/public/widget.js. Runtime spec: docs/spec/transport-rest-v0.1.md.
Installation
Zero-config (same-origin)
When the widget is served from the same domain as the agent host, no attributes are needed:
<script src="/widget.js"></script>
The widget uses window.location.hostname as the domain, fetches /.well-known/mentionable-agents.json to discover all agents, and shows a <select> dropdown automatically when two or more agents are available.
Cross-origin embed
When embedding on an external site, specify the agent address:
<script src="https://your-agent-domain/widget.js" data-agent="@lean@your-agent-domain"></script>
No build step, no npm install. The script mounts a floating action button (FAB) and a chat panel into a Shadow DOM container on document.body. It is safe to include the same script multiple times — duplicate mounts on the same domain are silently skipped.
Attribute Reference
| Attribute | Required | Default | Description |
|---|---|---|---|
data-agent | No | auto-discovered from current domain | @handle@domain address of the primary agent. Required only for cross-origin embeds. Must match WebFinger grammar ([a-z0-9_.-]{1,64}@[a-z0-9.-]{1,253}). |
data-agents | No | auto-fetched from /.well-known/… | Comma-separated list of @handle@domain addresses. When 2+ are provided, a <select> dropdown appears at the top of the panel. Overrides auto-fetch when present. |
data-theme | No | "auto" | "light" | "dark" | "auto". "auto" follows prefers-color-scheme. |
data-position | No | "bottom-right" | "bottom-right" | "bottom-left" — corner where the FAB and panel are anchored. |
data-title | No | agent’s displayName | Panel header title shown for the primary agent. |
data-endpoint | No | https://<domain>/~<handle>/ | Full REST endpoint URL override. Useful when embedding behind a CORS proxy or when the endpoint path differs from the default. |
data-base-url | No | window.location.origin | Base URL prefix. The widget derives the domain from this when data-agent is absent. Ignored when data-endpoint is set. |
CSS Customisation
The widget uses Shadow DOM, so host-page CSS cannot bleed in. All visual tokens are exposed as CSS custom properties (--mw-*) on the :host element. Override them by targeting the widget’s host element ID from the outside:
#mentionable-widget-your-domain {
--mw-accent: #6366f1; /* brand colour (FAB, focus rings, pay links) */
--mw-radius: 16px; /* panel + FAB border-radius */
--mw-fab-bg: #6366f1; /* FAB background */
--mw-fab-icon: #ffffff; /* FAB icon colour */
--mw-bg: #ffffff; /* panel transcript background */
--mw-surface: #f8f8f8; /* panel header + input row background */
--mw-surface2: #efefef; /* avatar + close button hover background */
--mw-text: #0f0f0f; /* primary text */
--mw-text-muted: #555555; /* secondary text */
--mw-text-dim: #aaaaaa; /* placeholder, domain labels */
--mw-border: #e0e0e0; /* panel + bubble border */
--mw-border-soft: #eeeeee; /* input row top border */
--mw-user-bubble-bg: #0f0f0f; /* user message background */
--mw-user-bubble-text: #fff; /* user message text */
--mw-err-bg: #fef2f2; /* error bubble background */
--mw-err-border: #fca5a5; /* error bubble border */
--mw-err-text: #dc2626; /* error bubble text */
--mw-font: system-ui, sans-serif; /* font stack */
}
The host element ID follows the pattern mentionable-widget-<domain>, e.g. mentionable-widget-example.com.
Dark-mode defaults are already built in via @media (prefers-color-scheme: dark). Override only the tokens you want to change — unset tokens fall back to the built-in defaults.
Multi-Agent Support
The widget supports multiple agents on the same domain and shows a <select> dropdown when two or more agents are available. Switching agents clears the current chat history and reconnects to the new agent’s endpoint.
Agent discovery priority order
On every mount the widget resolves the agent list using the following priority — stopping at the first successful source:
-
Schema.org JSON-LD on the page — parses
<script type="application/ld+json">looking forcontactPoint[].emailfields inhandle@domainform. Zero network cost; works on same-origin pages that already embed the JSON-LD block (e.g.examples/llm-agent-vercellanding page). Takes precedence over all other sources. -
data-agentsattribute — comma-separated list of@handle@domainaddresses evaluated synchronously. Used when JSON-LD yields no agents.<script src="https://your-domain/widget.js" data-agents="@lean@your-domain,@ops@your-domain,@docs@your-domain" ></script> -
/.well-known/mentionable-agents.json— falls back to a singleGETrequest if neither JSON-LD nordata-agentsproduces a list.Expected response shape:
{ "agents": [ { "handle": "lean", "displayName": "Lean", "description": "LLM assistant" }, { "handle": "ops", "displayName": "Ops", "description": "DevOps agent" } ] }If the request fails for any reason (CORS error, 404, parse error, network timeout) the widget silently stays with the initial single-agent state. The fetch runs after mount and does not block the initial render.
Transport Selection
The widget auto-detects which transport to use per agent. No configuration is needed.
Priority order
- A2A — if the agent card at
/.well-known/agent-card/<handle>exposes ana2a.endpoint, the widget uses A2A JSON-RPC.a2a.capabilities.streaming: true→message/stream(SSE, token-by-token)a2a.capabilities.streamingabsent/false →message/send+tasks/getpolling (2 s interval)
- REST — fallback when no agent card is found or the card has no
a2a.endpoint.- Server responds
Content-Type: text/event-stream→ SSE streaming - Server responds
Content-Type: application/json→ full JSON response - Any other
Content-Type→ treated as plain text
- Server responds
The agent card fetch result is cached for the page lifetime. Switching agents (multi-agent dropdown) clears the A2A contextId and starts a fresh conversation.
Zero-config transport
When the widget runs in zero-config mode (no data-agent, HANDLE is unknown), agent card lookup is skipped and REST is used directly. Once the agent list is resolved (via JSON-LD or well-known fetch) and the user switches to a named agent, A2A detection runs on the next send.
CORS Considerations
The widget POSTs multipart/form-data to the REST endpoint from the embedding page’s origin. This triggers a CORS preflight.
The agent host must serve the following headers on the REST endpoint:
Access-Control-Allow-Origin: * (or the specific embedding origin)
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Accept, Content-Type
examples/llm-agent-vercel serves these headers automatically on /~<handle>/ routes.
When you cannot configure CORS on the agent host (e.g. third-party deployment), set data-endpoint to a same-origin proxy:
<script
src="https://your-site.com/widget.js"
data-agent="@lean@agent-host.example"
data-endpoint="https://your-site.com/api/agent-proxy/"
></script>
The /.well-known/mentionable-agents.json discovery fetch is a simple GET and also requires CORS headers if the widget is embedded cross-origin.
Message Rendering
Agent replies are rendered as structured HTML rather than plain text. The widget includes a zero-dependency inline parser — no external libraries, no innerHTML assignment.
Markdown
renderMarkdown(text) converts the text part of each reply into DOM nodes:
| Syntax | Rendered as |
|---|---|
# H1 / ## H2 / ### H3 | <h1>–<h3> with .wgt-md-h1/h2/h3 |
**bold** | <strong> |
*italic* | <em> |
`inline code` | <code> with .wgt-md-code |
``` fenced ``` | <pre><code> with .wgt-md-pre |
https://… URLs | <a> with .wgt-md-a (HTTPS-only; bare http:// is left as text) |
| everything else | <p> with .wgt-md-p |
Tool calls
renderToolCall(part) renders each tool_call part as a card (.wgt-tool-card) with three states:
- In-flight — spinner label
⋯ <tool name>while the call is pending - Success —
✓ <tool name>summary; full input/output JSON in a collapsible<details>(.wgt-tool-detail) - Error —
✗ <tool name>with the error message inline
CSS tokens for rendering
The rendering helpers use scoped class names prefixed with .wgt-. You can target them from outside the Shadow DOM using ::part() if you expose the elements, or by overriding the --mw-* tokens that the classes reference internally.
unmount() API
To programmatically tear down a widget, call unmount() on its registry entry:
const id = 'mentionable-widget-your-domain'
window.MentionableWidgets?.[id]?.unmount()
unmount() removes the host element from document.body, removes the keydown listener used for Escape-to-close, and removes the prefers-color-scheme media listener (when data-theme="auto"). The registry entry is deleted so the same address can be re-mounted later.
window.MentionableWidgets is a plain object keyed by widget ID. It is created on first mount and persists for the page lifetime.