Embed the widget
Install snippet
The widget is a single <script> tag. Drop it on any page,
pass an agent ID, and you're live. The bundle is ≤50KB gzipped, runs in
a Shadow DOM so it can't be styled by your site, and never blocks page
load (it's async).
The snippet
Paste this just before </body>:
<script
src="https://your-app.test/widget/widget.js?v=ab12cd34"
data-agent-id="01HXY..."
async></script>
The full snippet (with your agent ID and the current cache-bust hash) is on every agent's Settings page next to a copy button.
Two optional attributes
data-mount="manual"— load but do nothing untilwindow.Pitchbar.mount()is called; how a consent banner gates the chat.data-context-global="blengiContext"— the name of awindowglobal your pages fill with their own context (page type, category, the product on screen).data-launcher="none"— you have your own chat button; render no floating launcher of ours. Open the panel withwindow.Pitchbar.open().
Both, the JavaScript API (open, send,
close, setContext) and the list of what the
widget stores are on the Widget
API page.
The inline block
Besides the floating launcher, the same script renders the agent inside a page wherever this block is pasted — a WordPress Custom HTML block, a template, anywhere:
<div data-blengi-inline></div>
Nothing else goes on the site. Copy, layout and colours are managed under Customize → Inline block; the snippet with a copy button is on the agent's Overview page. Details, per-block attributes and the CSS hooks for agencies: Inline embed.
Deploying through Google Tag Manager
The widget can be added as a Custom HTML tag in
Google Tag Manager instead of in the site's template — a common ask
when a marketing agency owns the container and the site itself is
hard to edit. In a tag manager, put the agent id in the
script URL, not in a data- attribute:
<script src="https://blengi.com/widget/widget.js?agent=YOUR-AGENT-ID" async></script>
Use an All Pages (Page View) trigger. Nothing else needs configuring. The agent's Overview page carries this exact snippet, with the id filled in, under Google Tag Manager next to the normal one.
Why the URL form: when GTM fires a Custom HTML tag it does not insert
your markup as written. It creates a new script element and copies
only the src; every data- attribute is
dropped, so a tag pasted with data-agent-id loads the
file, mounts nothing, and stays silent with no error anywhere. The
URL is the one thing that survives, and the loader reads
?agent= from its own src. In your own
template either form works; a data-agent-id attribute
wins when both are present.
- Allowed origins still apply — add the domain the
container runs on (the agency's test URL now, the production
domain later) under Allowed
origins, or the widget boots and is refused at
/init. - Consent Mode. If the tag is gated behind
analytics or marketing consent, the widget only appears after
the visitor consents. The widget sets one functional identifier
(
pb_anon_id, to resume the conversation) and no tracking cookies, so most sites fire it as a functional tag; that classification is the site owner's call. - Do not load the bundle from an inline script
that fetches it, and do not strip the
data-agent-idattribute — with no attribute on any script tag the loader has no agent to talk to and stays silent. - With GTM on the page, chat
events push straight into
dataLayeronce switched on for the agent.
Two agents on one site, chosen by URL path
When different sections of a site should talk to different agents, keep one Custom HTML tag and let a variable pick the agent:
- Create a RegEx Table variable. Input variable:
Page Path. One row per section, pattern → agent id,
for example
^/waterhardheid/→ the agent's id. Default value:none. - Leave "Full Match" unchecked. With it on, the
pattern has to match the whole path, so
^/waterhardheid/matches/waterhardheid/and nothing beneath it: every deeper page such as/waterhardheid/gulpen-wittem/falls back tonone, and the widget appears on the section's index page and nowhere else. If you prefer Full Match on, end each pattern with.*instead. - In the tag, put the variable in the URL:
<script src="https://blengi.com/widget/widget.js?agent={{Blengi agent}}" async></script>, with an All Pages trigger and an exception (blocking) trigger for Blengi agent equals none. Not adata-agent-idattribute: GTM drops those when it injects the script. - Verify in Preview on a deep page of the section, not on its index page. The index page passes with Full Match on as well, which is exactly how this misconfiguration hides.
Leave any ?v= off the URL in a tag manager: a version
pinned in a GTM constant goes stale, and the bundle is served current
regardless. ?agent= is the only query the URL needs.
The agent must be allowed on your domain
The widget script is public, so an agent only runs on the domains you list under Settings → Allowed origins. An empty list allows nothing: the script loads, the first request is refused, and the page simply shows no chat with no error on screen.
You rarely have to do this by hand. The first web page you add as a knowledge source sets the origin for you, and the onboarding wizard does the same. If an agent is published with an empty list, its overview page says so and links straight to the field. One entry per site is enough, because https, www and non-www all match.
What's in the URL
src— points at/widget/widget.json your Blengi deployment. The?v=<hash>suffix is the bundle's content hash; it changes whenever the widget is rebuilt, so customers can't get stuck on stale versions cached by a CDN.data-agent-id— the published agent's ULID. The widget's loader reads this attribute and uses it on every/v1/widget/initcall.async— non-blocking. The widget appears once the bundle finishes downloading; your page's load metrics are unaffected.data-locale(optional) — force the widget's language (e.g.data-locale="nl"). Omit it and the widget follows your page's<html lang>automatically. See Language below.
Language
The widget renders its UI — launcher label, starter chips, buttons, and
system messages — in the visitor's language, resolved at init
in this order:
data-localeon the script tag, if set — an explicit override (e.g.data-locale="de").- The host page's
<html lang>— so the widget matches whatever language your site is currently rendering. On our own marketing site this is what makes the demo follow the language switcher. - The agent's default language (Settings → the agent's language) when the page declares none.
- The browser's
Accept-Language, then English.
Regional tags degrade gracefully: en-GB → en,
pt-BR stays pt-BR when that file ships. The page
language wins over the agent default, so a single agent embedded on a
multilingual site speaks each page's language. Translate the launcher
label and starter chips per language in
Translations; anything
without a translation falls back to the original text.
What it injects
On boot, the loader:
- Creates a
<div>at the bottom of<body>and attaches a Shadow DOM to it. - Renders the launcher (small button) inside the shadow root.
- Calls
POST /v1/widget/initto get an agent config + JWT + recent history. - Wires up trigger listeners (scroll, idle, exit-intent) per the agent's behavior rules.
- Persists a small
anon_idinlocalStorageso the same browser keeps the same conversation across reloads.
Customizing the launcher
Theme is fully agent-driven — see Persona, theme & prompts.
The widget reads theme.position, theme.primary,
theme.accent, theme.radius, and
theme.launcher_label from the init response and renders
accordingly.
There's no per-page customization — the launcher always reads from the agent. If you need a different look on different pages, embed two different agents.
Programmatic control
The widget exposes a single global, window.Pitchbar.mount(),
used by the loader to boot. The script tag's async
attribute and auto-mount handle the common case for you, so you don't
typically call this directly.
// Mount the widget into a custom host element (rare).
window.Pitchbar.mount(document.getElementById('chat-host'));
There's no public open / close /
send / on API yet — those are deferred. If
you need to fire conversion analytics on lead capture today, subscribe
to the lead.captured webhook instead (see
Outgoing webhooks).
Single-page apps
The widget loads once per page-load, but the conversation persists across in-app navigations as long as the script tag stays in the DOM. You don't need to re-mount it when your router changes routes — the Shadow DOM and JWT survive.
If your SPA fully re-mounts on route changes (e.g. you tear down the body), the widget will re-init and resume the visitor's conversation from the last 24 hours of history.
What gets sent on every init
A POST /v1/widget/init includes:
agent_id— the value fromdata-agent-id.page_url— the currentlocation.href, used for the "current page" boost in retrieval.anon_id— the visitor's persistent ID fromlocalStorage, generated on first visit.locale— the host page's language (data-locale, else<html lang>), so the widget UI follows the page. Omitted when neither is present.
The server also reads the Origin, Referer, and
Accept-Language headers — Origin for the
allowed-origin check,
Accept-Language for default language detection.
Versioning & caching
The path component /widget/widget.js is stable across
every deploy. Customers paste the snippet once and never need to
touch it again — bundle updates ship transparently. The
?v=<hash> query mutates on every build, which busts
browser caches on existing pages without changing the URL the customer
pasted on their site.
Internally /widget/widget.js is served by
WidgetBundleController, which streams the latest hashed
bundle named in public/widget/manifest.json with
Cache-Control: no-cache, must-revalidate and a strong
ETag. The hashed file (widget.<hash>.js) is the on-
disk artifact; the stable URL is the public contract.
Pre-fix (2026-05-29) the embed snippet exposed the hashed filename
directly (e.g. widget.abc123.js); every new build rotated
the URL and customers' pasted <script> tags
404'd. The fix landed in AgentController::widgetUrl()
and OnboardingController::buildWidgetSrc().