Contract: widget element
Version 1.
Purpose
The same messenger as in contracts/widget-host.md, embedded as a component on the partner's own page instead of in an iframe (D-66). A partner chooses it to get what an iframe cannot give: the chat placed inline in its own layout, storage on its own origin, its sign-in passed by a function call, its colours and font, the phone's whole screen, and browser notifications while its site is open. The iframe stays the default.
This contract fixes the element's name, attributes, properties, methods and events, how the loader uses it, and what the partner's page can and cannot see. The generated API schema does not cover it: it is a browser interface, not an HTTP call.
Parties
| Party | Runs on | Code |
|---|---|---|
| Page | The partner's page, at one of the site's allowed origins | The partner's own code, or the loader (loader.js) |
| Element | The same page, loaded as a script from the platform's widget origin | element.js |
The element is a custom element, mp-messenger, that renders the messenger into its own shadow root. The partner's styles do not reach inside it, and its styles do not reach the page.
Loading
The page loads one module from the platform's widget origin:
<script type="module" src="{appOrigin}/element.js"></script>
The module defines mp-messenger once; loading it twice changes nothing. Until the visitor opens the messenger, the element shows nothing and loads nothing else. Content the page puts inside <mp-messenger> shows until the module has defined the element, and is hidden after, so a page should put a short fallback there (for example a sentence and a link to its contact page) for when the script cannot load.
Two ways to place it
- Floating, through the loader.
createWidget({ appUrl, site, embed: "element" })gives the launcher, the panel in the corner, focus return and the unread badge exactly as in contracts/widget-host.md. The only difference: on first open, the loader loadselement.jsand adds<mp-messenger layout="floating">where it would add the iframe, and it listens to the element's events instead ofpostMessage. The loader'sgetUserTokenbecomes the element's, anduserChanged()sets it on the element again (contracts/widget-host.md, "Signing in"). - Inline, in the partner's markup.
<mp-messenger site="localpro" profile="tiago-lp"></mp-messenger>inside the page's own layout, for example on a profile page. It fills the width of its container and has the height the page gives it (at least 360 px).
Attributes and properties
| Attribute | Required | Value |
|---|---|---|
site |
yes | As site in contracts/widget-host.md: the fixture site id in the UI shells, the publishable key with the backend |
profile |
no | A profile on that site. With it, the element opens that conversation; without it, the visitor's chats |
layout |
no | inline (the default) or floating. floating is for the loader: fixed to the bottom right corner, with a Close button |
heading-level |
no | 2 (the default) to 6: the level of the panel's heading inside the page, so the page keeps its own h1. The panel's inner headings follow one level below it |
scenario, state |
development only | As in contracts/widget-host.md |
The element reads site once, when it first connects; changing it later, even after removing the element and adding it back, has no effect, so the page replaces the element instead. Changing profile opens that conversation without moving focus and without making a hidden element visible; open() does both.
| Property | Type | Value |
|---|---|---|
getUserToken |
() => Promise<string | undefined> |
Optional. Returns a fresh user token from the partner's backend for the visitor signed in on the site (W-3, ID-1, contracts/user-token.md), or undefined for a guest. Called before the element starts the visitor's session and when the API asks for a new one. The visitor counts as a guest when the result is not a non-empty string, when the call fails, or when no result comes within 10 seconds. Inert in the UI shells |
Signing in and out. Setting getUserToken again after the element started tells it that the visitor signed in or out on the page: the element calls the new function at once. A token signs the visitor in, and the chats they had as a guest on this site in this browser session move to their account (D-72). No token signs out: the element ends the session the site's sign-in started and continues as a new guest with no chats, so on a shared computer the next person never sees them (design review V-2). A guest stays the same guest. Every mp-messenger of one site on a page shares one visitor session, so the function set last, on any of them, is the one called. A page that loads a new page after its sign-in or sign-out needs nothing more: the element asks when it starts.
Methods
| Method | Does |
|---|---|
open(profile?: string) |
Shows the element if it is hidden and, with a profile on this site, opens that conversation. The loader calls it on every open after the first |
Events
Each event bubbles and crosses the shadow boundary (composed), dispatched on the element:
| Event | detail |
Sent when |
|---|---|---|
mp-ready |
none | As ready in contracts/widget-host.md: once, after the first view |
mp-close |
none | As close: the visitor pressed Close or Escape in a floating element. An inline element has no Close button and never sends it |
mp-unread |
{ count: number } |
As unread: the number of unread messages changed, a whole number of 0 or more |
The loader hides a floating element on mp-close, as it hides the iframe.
Theming
The page may set these custom properties on the element. Anything else stays the platform's.
| Property | Default | Use |
|---|---|---|
--mp-accent |
the platform's accent | Primary buttons, the visitor's own messages, focus rings. Must reach 3:1 against the panel's grounds (#ffffff and #f8f9fa) |
--mp-accent-text |
white | Text on the accent. Must reach 4.5:1 against --mp-accent |
--mp-font |
the platform's font | All text except the AI label and the AI notice |
--mp-radius |
the platform's radius | Panel and bubble corners, up to 24 px: a larger value gives 24 px |
The element checks the accent pair when it starts; if either contrast fails, it uses the platform's accent and warns in the console.
The messenger keeps its light colours whatever colour scheme your page or the visitor's system uses. On a dark page, these properties are how you make it fit.
Theming cannot hide or restyle the AI label or the AI notice (INV-2): they keep a fixed system font, colour, background, line height and a minimum size that no page CSS reaches: not these properties, not the platform's own token names set on the element, not @property registrations, page fonts or inherited text styles. The page's scripts can change anything inside the element, as they can anything on the page; the partner agreement forbids touching the label (see "Security"). Text sizes inside the element follow the browser's text size setting, never the page's root font size, so a page with html { font-size: 62.5% } does not shrink the label.
Keyboard and focus
The same rules as contracts/widget-host.md, "Focus and keyboard", with these differences:
- Escape is handled only when focus is inside the element, never anywhere else on the page. An Escape the element handled reaches the page with
defaultPreventedset. - Clicking text inside the element does not move focus to a control;
focus()on the element moves it to the panel's heading. - On a screen narrower than 480 px a floating element that is shown covers the page and is modal: the page behind is inert and does not scroll until it closes. A floating element that is hidden (by the loader, or by the page with
hidden) holds nothing. - An inline element has no Close button, and nothing it says refers to one.
Storage
The element keeps the visitor's session in the partner origin's session storage, under a key that includes site, so two sites on one domain never share a session. In phase 1 a guest's session lasts as W-2 says, one browser session on one site (D-54), and is renewed by sending it back; P-46 proposes keeping it longer in this mode, where the browser allows it. The element sets no cookies.
Security
What the page can see is different from the iframe, and partners choose this mode knowing it:
- The page sees the conversation. The partner's scripts, and any third-party script on the page, can read what the element shows, including the reply email and report text the visitor types, and the visitor's session token. The token therefore reaches only this visitor's chats on this site (INV-5, INV-10). Nothing of another site can be reached with it. How long it works:
- A guest's token works for 12 hours. Sending it back to the API before then gives a new token for the same guest, for another 12 hours, and ends the old one. The element does this when it starts on a new page. So whoever holds a guest's token can reach that guest's chats on this site for as long as they keep renewing it.
- A signed-in visitor's token works for 1 hour. Sending it back does not renew it: the element asks
getUserTokenfor a fresh user token, which only your backend can sign. A copied token stops working within the hour, and at once when your page signs the visitor out (see "Signing in and out").
- The server scopes by session, never by attribute. The server takes the site from the session and refuses a request for another site's key. Changing
siteorprofilein the page gives no access beyond what the session already has. - The AI label is ours to render (INV-2), and every message the API returns carries the machine-readable AI flag. A page that hides the label breaks the partner agreement, and the platform's operators can then switch the element off for the site, which returns it to the iframe.
- Opt-in, per site. The element is off for every site until a developer or admin of the partner switches it on for that site on the partner console's sites page. The audit records each switch. While the element is off for a site, requests from that site's allowed origins get no CORS headers, no socket and no session.
- Switching it off ends every session on the site. When the element goes off for a site, from the partner console or by the platform's operators, every visitor session on that site ends at once, so a token that a script on the page copied stops working. A visitor your site signed in gets a new session with your next user token. A guest starts again as a new guest, without their earlier chats.
- What the page must allow. Its Content Security Policy must allow the widget origin in
script-srcandfont-src, and the API origin inconnect-src. The element styles itself with constructed stylesheets, so nostyle-srcexception is needed, and it builds no markup from strings, so it works under Trusted Types. - Cross-origin calls. The element calls the API and opens the realtime socket from the partner's page. So the widget's routes answer CORS for the allowed origins (PC-1) of every site that has the element on, and the socket accepts those origins too. Starting a session still checks that the origin is one of the allowed origins of the publishable key's own site, and that this site has the element on. Requests from one network address are rate limited; past the limit the server answers 429.
- Session replay. The element carries the common exclusion markers on its host and again on its inner root, where no page framework can remove them (
class="rr-block fs-exclude ph-no-capture sentry-block",data-hj-suppress,data-clarity-mask="true",data-private,data-dd-privacy="hidden"), so replay tools that honour them do not record the conversation. Tools that ignore them still can, which is part of the trade-off above. - Integrity. In this embed platform code runs in the partner's origin. From go-live, the loader is built with the versioned path and hash of
element.jsand loads it with Subresource Integrity, so a partner that pins the loader pins the element too. - Notifications. With the partner's permission setting on, the visitor can allow browser notifications for the partner's site while it is open. The browser shows the partner's domain in the prompt. A notification says who replied, with the AI label when the AI did, and never the message text.
Versioning
The element's version property holds the contract version. A later version may add attributes, properties, methods and events without changing the ones above. A breaking change raises the version and is served from a new path, so a page that pinned an old version keeps working while a live site uses it.