flowhelp widget embed: customer guide (contract v1)
English translation of WIDGET.md. The Polish file
docs/WIDGET.mdis the source of truth; if the two ever differ, the Polish one wins.
Version 1.3 · 2026-09-22 · Source of truth:
packages/config/src/widget-embed.tsand DECYZJE.md ADR-026 (embedding), ADR-027 (markdown), ADR-028 (identify), ADR-029 (ratings, contact form, preview in the panel), ADR-031 (source links outside the answer text), ADR-032 (action row, form as a card, one subpage = one link). Everything in this guide is frozen: a snippet pasted today will keep working with every future version of the widget.
1. Snippet
One tag, no inline code, before </body>:
<script async src="https://cdn.flowhelp.ai/loader.js" data-key="pk_live_…"></script>| Attribute | Required | Meaning |
|---|---|---|
data-key | yes | the agent's public key (pk_live_ + 32 hex characters), from the panel |
data-locale | no | forced UI language (pl/en/de/fr/it); if omitted, the agent configuration or the browser decides |
data-api | no | dev/staging only; production uses https://app.flowhelp.ai |
The public key is visible in your HTML by design. It is not protected by secrecy but by the domain allowlist (below), the rate limits and the agent's daily credit cap.
2. Domain allowlist (Origin)
The widget works only on the domains listed in the agent configuration. Semantics:
https://sklep.pl: exactly this domain (default port),*.sklep.pl: any subdomain over https (www.,blog.), without the apex; the apex is added separately,http://localhost:3000: only when listed explicitly, for development;http://never works for any other host.
A page outside the allowlist: the browser blocks the call (the server answers 403 without CORS headers), the widget does not appear and logs an error in the console. It costs nothing.
3. The host page's Content-Security-Policy
If your page has a CSP, exactly two sources are needed:
script-src … https://cdn.flowhelp.ai;
connect-src … https://app.flowhelp.ai;The widget uses no inline JS, no eval, and no external resources beyond these two hosts. Styles live in a Shadow DOM (adoptedStyleSheets), so style-src does not need to change. The loader has a fixed path (5-minute cache); the core is addressed by hash, with integrity (SRI) and crossorigin.
4. Public API
Calls can be queued before the widget loads; they are executed in order:
window.flowhelp = window.flowhelp || [];
window.flowhelp.push(['open']);
window.flowhelp.push(['on', 'ready', () => console.log('widget gotowy')]);| Method | Effect |
|---|---|
open / close / toggle / isOpen | the chat window |
sendMessage(text) | sends a message as the user |
setLocale('pl'|'en'|'de'|'fr'|'it') | UI language |
setContext(obj) | page context (v0: stored, not used yet) |
on(event, cb) / off(event, cb) | events: ready, open, close, message:sent, message:received, error |
grantConsent() / revokeConsent() | consent (see §6) |
reset() | clears the visitor identifier, the current conversation and the identity set by identify |
identify({ userId, hash }) | identity of a signed-in user with an HMAC signature (§5); identify(null) = sign out |
init | no effect (the widget starts by itself) |
An unknown method logs a console warning and does not throw.
5. Signed-in users: identify() with an HMAC signature
If your site knows who is signed in, you can pass that to the widget. Conversations of such a user are labelled with their identifier in the panel, and with the "require verified identity" option turned on, anonymous visitors cannot write.
The widget does not compute the signature and does not know the secret. Your backend computes the signature, using the agent's HMAC secret (panel → Settings → Widget; until Stage 4 we hand it over to you):
hash = hex( HMAC-SHA256( key = sekret_agenta, message = userId ) )Node.js:
const { createHmac } = require('node:crypto');
const hash = createHmac('sha256', process.env.FLOWHELP_HMAC_SECRET).update(user.id).digest('hex');PHP:
$hash = hash_hmac('sha256', $user->id, getenv('FLOWHELP_HMAC_SECRET'));Python:
import hmac, hashlib
hash_ = hmac.new(SECRET.encode(), user_id.encode(), hashlib.sha256).hexdigest()On the page (this can run before the widget loads; it goes into the queue):
window.flowhelp = window.flowhelp || [];
window.flowhelp.push(['identify', { userId: 'u_123', hash: '<64 znaki hex>' }]);
// po wylogowaniu:
window.flowhelp.push(['identify', null]);Rules:
userIdis your internal identifier (or an email): 1-200 printable ASCII characters without spaces. A full name with non-ASCII characters will not pass, because the value travels in an HTTP header. The widget rejects a bad payload with a console warning; the previous identity (if there was one) stays unchanged. Signing out is done only withidentify(null); switching to another user (or signing out) starts a new conversation, and the previous transcript disappears from the window.- The signature is sent with every message and verified on the server in constant time. A mismatched signature returns
403before anything is counted. - Identity does not change the limits or the owner of the conversation; those follow the visitor identifier from the browser. A conversation that started anonymously gets the identity once
identify()is called during it. - The widget sends only
userIdand the signature.email/nameare not passed today. - Keep the secret on the server side. If it leaks, changing the secret invalidates all signatures at once.
6. Consent (GDPR) and the AI disclaimer
consentMode from the agent configuration:
open: the widget works immediately,deferred(the default for the EU): the button is visible, the first message waits for consent (a banner in the window orgrantConsent()),blocked: nothing renders until the page callsgrantConsent().
Consent is not persisted between visits (v0). The visitor identifier is a UUID in localStorage (flowhelp:visitor:<key>; in sessionStorage when localStorage is blocked), with no cookies. The widget writes it only on the first interaction: the first message, a rating of an answer or a form submission. Loading a page with the widget writes nothing to the browser (not even an entry that checks whether storage is available). The first message always includes the AI disclaimer (EU AI Act Art. 50); it is editable in the panel and cannot be turned off.
Link to your privacy policy (ADR-044)
As the controller of your site visitors' data, you inform them about the processing at the moment you collect it (GDPR Art. 13). In the panel (Appearance → "Privacy policy URL") you enter the address of your policy, and the widget shows a "Privacy policy" link in the language of the chat window (for example "Datenschutzerklärung" in German, "Politique de confidentialité" in French, "Informativa sulla privacy" in Italian):
- in the consent banner in the chat window (
deferredmode), above the "I agree" button, - in the contact form and in the "talk to a human" form, above the "Send" button (in every consent mode, including
open).
The link opens in a new tab (target="_blank", rel="noopener noreferrer"). Only https:// addresses are accepted (up to 2000 characters); an empty field means no link. In blocked mode the widget has no consent banner of its own: your banner (CMP) shows the consent request and the link to your policy, and the widget adds the link only in the forms. The link is a plain hyperlink and loads nothing from your site, so it needs no CSP changes.
7. Limits and errors
| Situation | Behavior |
|---|---|
| 20 messages / 5 min from one visitor (default; configurable per agent) | 429, the widget locks the input for Retry-After seconds and shows a message |
| a second message before the previous answer has finished | 429 (Retry-After: 5) |
| the agent's daily credit limit is used up | 429 until midnight UTC |
agent paused / not in the ready status | 403, the widget does not appear |
the agent requires identity and the page did not call identify() | the message is not sent; the message "Sign in on this site to use the chat." is shown |
mismatched identify() signature | 403 before any answer, zero cost |
| question outside the knowledge base | the answer "I don't have that information in my knowledge base." with no guessing |
8. Formatting of answers (markdown)
The assistant's answers are rendered from markdown: paragraphs, bold, italic, ~~strikethrough~~, code and code blocks, lists (nested too), quotes, a horizontal rule, tables, links. # headings are rendered as bold paragraphs, not as <h1>, so they do not affect the structure of your page.
What the widget does not render, because model text never becomes HTML (ADR-027): raw HTML (shown as text), images (the description stays), links with a protocol other than http/https/mailto (they stay as text). Every link opens in a new tab with rel="noopener noreferrer".
Source links do not appear in the body of the answer. Instead of "[1][2]" in the middle of a sentence, a row of clickable links to specific subpages of your site is shown below the answer, preceded by the word "Sources" (ADR-031); each of them also opens in a new tab. One subpage = one link, even if the answer drew on several of its fragments (ADR-032), and at most three links under one answer. Further hits are in the conversation in the panel, but under the answer they would be a wall of pills instead of a footnote. The link between a sentence and its source does not disappear: the same mechanism still computes it, it just does not clutter the text.
9. Appearance, theme, RTL, mobile
- The window header is the assistant's name, an avatar and an "AI" badge. The name comes from the agent name set in the panel (Appearance → "Agent name"; up to 60 characters in the chat window, longer names are truncated). Below the name there is a second line, "AI assistant" (in Polish: "Asystent AI"). Without a name the header has a single line and simply says "Assistant".
- The "AI" badge cannot be turned off or changed. It is a marking required by Art. 50 of the EU AI Act and is rendered by the widget code, not by configuration. The
aiDisclaimersentence in the thread stays unchanged; the badge does not replace it, it repeats the information where it is visible after scrolling the conversation. - The avatar should be an illustration, not a photo of a real person. Giving the assistant the face of a specific employee suggests to the visitor that they are writing to a human, and that is exactly what Art. 50 forbids. Without
avatarUrlthe widget draws the initials of the name on the primary color, and without a name a neutral speech-bubble icon. - The chat button in the corner of the page (panel: Appearance → "Chat button", ADR-037) has three variants. After you pick one, the preview next to the form collapses the window and shows only the button; click it to get back to the window. Bubble: the default, a round button in the primary color; this is what the widget looks like for everyone who changed nothing. flowhelp mascot: our character (a speech bubble with eyes), white, on a circle in the flowhelp gradient, the same one used by the buttons on flowhelp.ai. Once after the page loads it hops slightly and blinks twice (after 5 seconds it stands still), and it rises by 2 px under the cursor; once the chat is open, the same circle shows a white cross. The circle's color does not depend on the primary color; this is the variant with the flowhelp brand. Assistant avatar: your image from the "Avatar URL" field inside the circle, with a small speech-bubble badge in the primary color; without an avatar URL (or if the image fails to load) the button shows the bubble. The label for screen readers is the same in every variant ("Open chat" / "Close chat"), and when the system has reduced motion turned on (
prefers-reduced-motion: reduce) the character stays still. The avatar variant loads the image in the button as soon as the page opens; if your CSP hasimg-src, it must include the avatar's host (the same as for the avatar in the window header). - The widget's texts never use the word "bot": the counterpart is called an AI assistant, never an "employee" or a "consultant".
- Primary color, corner rounding and font come from the agent configuration; the theme is
light/dark/auto(followingprefers-color-scheme). A font outside the configuration means the system stack; the widget does not download fonts. - Your page's styles do not enter the widget (Shadow DOM with a full reset), and the widget's styles do not leak onto the page. Even global rules such as
* { letter-spacing: 2px }have no effect. - A page with
dir="rtl"gets a mirrored layout of the header, the action row and the composer; the text direction of a message follows its content (dir="auto"), so an answer in Arabic on a Polish page, and the other way round, both look right. The screen corner (bottom-right/bottom-left) is a configuration choice and is not mirrored. - A consequence of
dir="auto"that is visible on an RTL page too: an empty field (the composer, the contact form fields) has no content from which a direction could be inferred, so its placeholder sits on the left even though the labels are on the right. It aligns with the first character typed. This is intentional: the direction comes from what the visitor writes, not from the language of your page. - The contact form is a card above the conversation: centered in the window on desktop, and below 640 px a sheet sliding up from the bottom edge. With the full set of eight fields the card scrolls, and the submit button stays stuck to its bottom.
- Below 640 px of width the chat window fills the screen and follows the on-screen keyboard (
visualViewport), so the input field does not hide under the keyboard. - The host's
z-indexis 2,147,483,000; sticky headers and footers with a highz-indexdo not cover the widget.
10. Compatibility with frameworks and CMSs (verified 2026-09-14)
A style leak test in both directions (docs/qa/etap3-zamkniecie-2026-09-14/): two runs of the same page (without the widget and with it), comparing probes of the host and of the widget elements, z-index, mobile at 390 px, zero console errors.
| Environment | Result |
|---|---|
Bootstrap 5.3 (navbar fixed-top, footer z-1050, aggressive global stylesheet) | 0 differences in the host probes, widget styles untouched |
| Tailwind (Play CDN, preflight) | as above |
| WordPress 7.1 + Elementor 4.2.4 + Hello Elementor 3.5.1 | the widget loads through a snippet in wp_footer, Elementor and host styles unchanged, zero console errors |
A dir="rtl" page | header and composer mirrored, screen corner unchanged |
If your theme has global rules such as * { letter-spacing: 2px }, they have no effect (the widget has a full reset with !important on the host). The only things the widget needs from your page: two hosts in the CSP (§3); your page's X-Frame-Options does not matter, because the widget is not an iframe.
11. Embed test
QA page: https://flowhelp.ai/qa/widget-embed-test.html (code: docs/qa/). It checks that the loader loads the core, the window opens from the queue, the answer has a citation, and the host styles stay untouched. The same file served from a different origin does not start the widget.
12. Answer ratings and the contact form (ADR-029)
You turn both on in the panel; in the snippet nothing changes, and there is no API here to call from your page.
Thumbs under an answer
When the agent has rating collection turned on, every assistant answer has two buttons under it (👍/👎). Clicking the same one a second time undoes the rating. Ratings appear in the panel in Conversations (the "thumbs down" filter) and in Insights; this is the only quality signal that comes from your visitors.
Turning ratings off in the panel really turns them off: the buttons disappear, and a request sent anyway gets 403. A thumb requires neither cookie consent nor signing in. We attach it to the same conversation in which the answer was produced, and only that browser can rate it.
Contact form (lead) and "talk to a human"
The form appears according to the trigger set in the panel:
| Trigger | When the widget shows the form |
|---|---|
after_messages | after N assistant answers (N from the panel) |
on_fallback | right after an "I don't have that information" answer, that is, where the assistant did not help |
manual | never on its own; there is a button with the form's title above the input field |
The fields (labels, types, required flags; max 8) come from the panel. The form opens as a card above the conversation (ADR-032): it dims what is under it and takes over the keyboard; you close it with the cross or the Esc key. Closing it turns off the automatic trigger until the end of that conversation, so it does not come back after every message. After submission the visitor sees your thank-you text in the same card, with a "Back to the chat" button (then tapping the backdrop also closes the card).
Escalation is the same form with an extra "Message" field and its own button in the action row (you set its text in the panel; the button wraps a long label, so you do not need to shorten it). The conversation then gets an "escalated" mark, which feeds the deflection metric in Insights.
If you entered the address of your privacy policy in the panel, both forms show a "Privacy policy" link above the "Send" button (§ 6).
Every submitted form goes to the panel and to email: the address from the action settings, and if there is none, the workspace notification address, and if there is none of those, the owner's email. The message contains the form fields and the last 10 messages of the conversation, so you can reply without opening the panel.
Limit: 5 forms per visitor per day (a UTC day). This is a safeguard for your inbox and for our sender reputation; it cannot be raised in the panel. A rejected form gets 429 with a message, and the content stays in the fields.
13. Preview in the panel (widget-preview.js)
The "Appearance" section of the panel shows the same widget that runs on your site; it is not a separate mockup. The preview also carries the name from the "Agent name" field: change the name and you immediately see the header that the visitor will see (together with the "AI" badge). Step 3 of onboarding shows the same name. The panel loads https://cdn.flowhelp.ai/widget-preview.js, which is a second entry point of the same code, and mounts the widget in the form's frame instead of in the screen corner.
What differs from the widget on your site:
- the transport is a stub: the preview does not send questions to the model and costs no credits; after ~0.6 s it shows a fixed answer with a sample citation. To talk to your own agent, use the Playground, which counts credits;
- the thumb and the form in the preview save nothing; they are meant to show the layout, not to create leads from the panel;
- the host gets the
data-previewattribute and is positioned inside the frame (position: absolute; inset: 0), so the container must have its own height.
The preview is not part of the embed contract: it is a panel tool, and you should not paste it onto your site, because it does not talk to your agent. If the preview script does not load within 5 s (panel CSP, CDN), the panel shows a static card with the color and the greeting, and the form keeps working.
14. Widget languages and agent texts in other languages (ADR-041)
The widget speaks English, Polish, German, French and Italian. The language is chosen in this order: the data-locale attribute in the snippet (or setLocale() from the page), then the "Widget language" setting in the panel (Appearance), and with "Automatic", the visitor's browser language. If none of them matches, the widget uses English. This language applies to the buttons, the messages and the assistant's answers, regardless of the language of your knowledge base.
The texts you write yourself, that is the welcome message, the AI notice and the suggested questions, are in one language: the one you typed them in. In the panel (Appearance, the "Texts in other languages" section) you can give each of them a version for a specific language:
- the widget takes the version in the visitor's language field by field: if you translate only the welcome message, the AI notice and the questions stay in their main version. The questions are one field: a filled list in a language replaces all the main questions, and empty rows are skipped;
- an empty field means "use the main text", so you do not have to fill in every language or every field;
- the AI notice is never empty (EU AI Act, Art. 50): an empty language version shows the main text;
- the preview in the panel has a "Preview language" switch, and clicking into the fields of a language switches the preview to that language by itself;
- language versions work on every plan and need no change to the snippet.
The contact form and the talk-to-a-human button (section 12) have a single set of texts for now, in the language you typed them in the panel.