Cairn Widget — Public JS API (ATH-122)
The typed surface lives in clients/widget/src/api/ — every change
there requires updating this document (CLAUDE.md).
The widget runs in your page's origin. The panel's iframe is style isolation, not a sandbox. See Trust boundary before deciding what belongs in a conversation.
Install
Snippet (Doc 04 §2.1):
<script>window.$cairn=[];CAIRN_WEBSITE_ID="ws_pub_…";</script>
<script src="https://{CAIRN_WIDGET_CDN_URL}/l.js" async></script>
{CAIRN_WIDGET_CDN_URL} is not a literal. The install snippet is ISSUED by
GET /widget/install, which substitutes the configured CDN host — and returns
snippet: null while that variable is unset, because a snippet pointing at a
hostname nobody owns is worse than no snippet (PA-298). Copy what the API
gives you, not what is written here.
The same response carries agent_instructions: a Markdown brief that tells an AI
coding agent how to install this snippet in a codebase and how to verify it. It is
shown on Settings → Widget as "AI instructions" (see docs/install/README.md).
Launcher icon. A workspace can replace the launcher's chat bubble with its own
image (Settings → Widget, behind feature.messaging.launcher_icon). The boot config
names it in launcher_icon_url, and the widget draws it at 32 px inside the coloured
circle. If the image fails to load, the widget falls back to the bubble. Pages with a
Content-Security-Policy need the app origin in img-src.
Sites that swap <body> on navigation. Hotwire Turbo Drive, Livewire
wire:navigate, htmx hx-boost, Swup, Barba and pjax move between pages by
replacing <body> (or everything in it) without a page load. The widget follows
them with no extra code. Install the snippet as usual and do not re-run it on a
navigation event. What happens on each swap:
- The launcher, the panel and the widget's other page elements go back into the
new
<body>. They carry adata-cairn-chromeattribute so that copies restored from a page snapshot (Turbo's cache, htmx's history) can be recognised and removed. The attribute is internal: do not style or select on it. - The panel's iframe reloads when it moves, so an open panel re-renders and fetches its conversation again. Nothing in the conversation is lost, but the scroll position and an unsent draft are.
- If the framework runs the snippet again with the new body, that is harmless. The
loader boots once per page load, and
window.$cairn = []hands the new array to the running widget instead of replacing it, so$cairn.push(...)keeps working. - An on-page experience that is running at the moment of the swap (a walkthrough, a tour, the "do it for me" panel, a banner, launcher beacons, a survey) does not follow it. One started after the swap works normally.
- A page that keeps removing the widget's elements (more than 20 times in ten
seconds) is assumed to mean it. The widget stops putting them back and logs one
[cairn]warning.
Internal monorepo package — not published for customer npm installation. External customers use the issued snippet and installation guide. Inside the Cairn repository, the programmatic API is queue-backed until the bundle boots:
import { init } from '@cairn/widget'
const cairn = init({ websiteId: 'ws_pub_…' })
Signing out on a shared computer. The widget keeps its visitor token in
localStorage, and that token is the session: the next person to open a page
in the same browser resumes it. That means the same conversations, the same
onboarding progress, and the last identity user.set named. When a user signs
out of your app, clear it before navigating:
import { forget } from '@cairn/widget'
forget() // removes every `cairn:` key from localStorage and sessionStorage
window.location.href = '/login'
forget() never throws. It doesn't stop a widget already running on the page,
so follow it with a full page load (window.location, or a reload). A
client-side route change is not enough, because the widget would keep the
previous person's session in memory. A snippet install can do the same by
removing every cairn:-prefixed key from both storages.
Internal React wrapper — import { useCairn } from '@cairn/widget/react'
Internal Vue wrapper — app.use(CairnWidget, { websiteId }) from @cairn/widget/vue
Angular — register CairnService from @cairn/widget/angular as a provider
(One package with subpath exports rather than four packages — same DX, a quarter of the release surface.)
Surface
| Call | Effect |
|---|---|
Cairn.open() / close() / toggle() |
Launcher window control |
Cairn.user.set({ email, name?, hmac?, token? }) |
Identify the visitor. hmac = HMAC-SHA256(email, widget identity secret) signed by your backend verifies the identity (ATH-118); without it, verified identities cannot be claimed. token is the same proof as a signed JWT that can also carry the person's attributes for targeting. See Verified attributes |
Cairn.event.push(name) |
Record a custom event; feeds trigger rules (ATH-121). Stays in the browser |
Cairn.track(name, properties?) |
Record a contact event for the identified visitor, stored on their profile, so event-based segments and experience rules match it (PA-394) — see Contact events |
Cairn.helpdesk.query(q?) |
Open help mode, optionally pre-searched (ATH-119) |
Cairn.helpdesk.openArticle(slug) |
Open a public KB article in-widget |
Cairn.walkthrough.start(guideId) |
Start the interactive walkthrough for a published guide (ATH-274, Doc 07 §2.1) — see Walkthroughs |
Cairn.automation.run(guideId, params?) |
Start "do it for me": the assistant executes a published guide's steps on the current page, one at a time behind a consent HUD; optional params pre-fill non-sensitive step values for the user to confirm (PA-218/PA-221, Doc 07 §2.2) — see Do it for me |
Cairn.experiences.start(experienceId) |
Start a published tour by its experience id (Doc 12) — see In-app experiences |
Cairn.on(event, handler) |
Subscribe; returns an unsubscribe function |
Walkthroughs
Cairn.walkthrough.start(guideId) (ATH-274, Doc 07 §2.1) plays a
published guide as an on-page spotlight walkthrough. guideId is the
guide's ULID. Behavior:
- Lazy runtime. The ~25KB walkthrough runtime is a separate chunk loaded on first use — pages that never start one never pay for it.
- Feature-gated server-side. The guide is fetched with the visitor
session (
GET /widget/guides/{guideId}), which 404s while the Pennant flagfeature.walkthrough.playeris off for the workspace — and for unpublished guides. Unavailable guidesconsole.warnand no-op; the host page is never broken. - Cross-page + SPA aware. Player state persists in
sessionStorageand resumes automatically on the next page load; SPA route changes re-anchor (or pause) the active step (ATH-272). - Other entry points. The agent answer's guide card ("Show me")
and the deep link
?cairn_walkthrough=<guideId>funnel into the same command — the deep-link parameter is consumed on load and stripped from the URL viahistory.replaceState.
Surveys (Doc 12 §4.6 R20, feature.experiences.surveys, layered on
the core flag). The decision may hand the single intrusive slot to a
survey instead of an auto-starting tour, delivered as auto_start.survey.
Behaviour:
- Lazy runtime.
@cairn/walkthrough/surveyrides its own chunk (survey-*.js, ~3 KB gz), downloaded the first time a survey is actually chosen. It shares nothing with the tour, launcher and banner chunks — a card fixed to a corner needs no positioning engine. - One question at a time. A survey asks up to ten questions of five
kinds —
nps(0–10),rating(1–5),scale(your own range with end labels),choice(one or several) and freetext— then shows a thank-you. Each answer is posted as it is given, not saved up for the end, so a survey someone abandons halfway still records what it learned. - Two placements.
corneris a card in the bottom corner that your page keeps working behind and that never takes focus.modalis centred over a scrim, takes focus and keeps it until it closes. Both hold the one intrusive slot, so a survey and an auto-starting tour never appear on the same page view; priority decides. - It has a session, unlike launchers, banners and announcements. The
session opens when the card appears — an attempt counts whether or not
it is answered — and ends
completedafter the last answer ordismissedif it is closed or the page changes. The full frequency grammar applies, including "a few times, spaced out". - Answers can become contact attributes. A question may name an attribute to file its answer under, which makes the answer targetable by the next experience. It applies only to identified contacts, only under the key the published version names, and the attribute is never delivered to the browser.
- CSAT is the same system. When an agent asks a resolved conversation
for a rating, the widget's Chat tab now shows the same 1–5 row a
ratingquestion uses. The score goes to the conversation, where your inbox and reports already read it. - Accessibility. The card is a labelled
role="dialog"(aria-modalonly when it really is modal), every scale point is a real button in arole="radiogroup"witharia-checked, choices are real radios or checkboxes and free text is a labelled<textarea>. Escape closes a survey that can be closed.
Like every call, it is queue-backed: safe to invoke before the bundle boots.
Do it for me (execution)
Cairn.automation.run(guideId) (PA-218, Doc 07 §2.2) is the walkthrough's
agent-driven sibling: instead of pointing at the next step and waiting for
the user, the runtime executes the guide's steps on the current page —
one at a time, each behind a floating consent HUD. It runs on the same
engine as the walkthrough (selector resolution + player), so the same
guides work in both. Behavior:
- The server is the security boundary — not the client. Before
anything runs, the runtime opens a run with
POST /widget/automation-runs, and the server enforces, in order: thefeature.automation.executeflag (404 while dark), the workspace's verified domain allowlist (execution only runs on product domains the workspace has proven it owns), and a sensitive-page rail (never on payment or credential pages). A refusal is a4xx; the client cannot talk its way past it, because a client-side check is bypassable. - Consent per step. The HUD shows the next action and does nothing until the user approves it. Pause and Stop are always visible, and Stop halts instantly.
- Sensitive fields are never typed. Any masked field — a password, a card number, anything matching the field heuristics — is handed to the user to fill in themselves. A value the user does type goes straight into the page element and is never sent to the server, the model, or the audit — the step record carries which field and that it happened, never the value.
- Every step is audited. Each executed step is recorded
(
POST /widget/automation-runs/{run}/steps) and the run is closed (PATCH /widget/automation-runs/{run}) as completed, aborted, or failed. If the next element cannot be located unambiguously, the run aborts rather than click the wrong thing. - Lazy + safe. Like the walkthrough, the runtime is a separate chunk
loaded on first use, and an unavailable run (flag dark, unpublished
guide, or a host the workspace has not allowlisted)
console.warns and no-ops — the host page is never broken. - Optional pre-fill (PA-221).
Cairn.automation.run(guideId, params)accepts aRecord<stepId, value>of proposed values for non-sensitive fields. The runtime seeds the consent HUD with them so the user only has to confirm or edit — nothing is auto-submitted. This is what the agent passes when an answer'sexecutionoffer carries aprefillit extracted from the conversation; sensitive fields are never pre-filled (rule 3) and are always entered by hand. When the agent offers execution in an answer, that offer is gated by the workspace'sfeature.automation.agent_offerflag and only appears on answers that cite an executable published guide. - Cross-page resume (PA-283). A navigation step records first, then stores
only the guide id, run id, and next step cursor in
sessionStorage. On the new page the widget resumes the same audit only after the server re-checks the execution flag, verified domain, and sensitive-page rail against the new URL. Proposed pre-fills and anything the user typed are never persisted; a resumed non-sensitive field is intentionally blank for fresh confirmation.
Verified attributes
Tours, banners and checklists target people by custom attributes, such as plan, role or signup date. Your backend can send them signed, alongside the identity, so a visitor can't set their own:
// Your backend, per page render. Any HS256 JWT library works; the secret is
// the same widget identity secret the hmac uses.
$token = JWT::encode([
'email' => strtolower($user->email), // must equal the email you pass to user.set
'name' => $user->name,
'attributes' => ['plan' => 'scale', 'role' => 'owner', 'signed_up_at' => '2026-09-01T00:00:00Z'],
'exp' => time() + 3600, // required; at most 24 hours ahead
], config('services.cairn.identity_secret'), 'HS256');
Cairn.user.set({ email: 'jo@example.com', name: 'Jo', token })
- It verifies the identity, exactly as
hmacdoes. Send either one; withtoken,hmacis optional. - Attributes merge into the contact's custom attributes, the same map the
contacts API writes and
custom_attributetargeting rules read. Keys you don't send are left alone, and anullclears a key. - The limits: at most 30 keys; each value a string (up to 1,000
characters), number, boolean or
null. The token as a whole may be at most 64 KB. That fits 30 keys of 1,000 plain-ASCII characters; non-ASCII text takes more room per character, so very long non-ASCII values can hit the 64 KB cap first (a 422 ontoken). - What it refuses:
- 403: a bad signature, a missing or past
exp, anexpmore than 24 hours ahead, any algorithm other than HS256, or an email different fromuser.set's. - 422: a well-signed token whose attributes break the limits.
- 403: a bad signature, a missing or past
- Attributes are only accepted inside a valid token. There is no unsigned way to set them from the browser.
Contact events (track)
Cairn.track(name, properties?) records something the person did — an order,
an upgrade, a feature used for the first time — on their contact record.
Segments (event_count, event_window) and in-app experience rules
(event_window) match on these events, so "show the upgrade tour to people who
placed their first order this week" is a rule rather than code.
Cairn.user.set({ email: 'jo@example.com', hmac }) // identify first
Cairn.track('first_order_placed', { plan: 'pro', total: 49.5 })
// Snippet installs, before the bundle loads — the same command, queued:
$cairn.push(['track', 'first_order_placed', { plan: 'pro', total: 49.5 }])
- Identified visitors only. The event belongs to the contact
user.setidentified (in this page load or an earlier one); the page cannot name a different person. While the visitor is anonymous the server refuses it (409); the widget logs one[cairn]warning and drops further events untiluser.setsucceeds. Events are never buffered for an anonymous visitor and attached later: on a shared browser that would hand one person's history to the next. - Names:
^[a-z][a-z0-9_.:-]{0,63}$— lower-case, starting with a letter, at most 64 characters (signed_up,checkout.completed,billing:plan-upgraded). Rules match names exactly, so pick one spelling. Names startingcairn.orcairn:are reserved. - Properties: a flat object of at most 20 keys; each value a string (up to
500 characters), number, boolean or
null; at most 4 KB of JSON. Stored as given — never send secrets, payment details or anything typed into a masked field. - Limits: the widget sends at most 30 events a minute and drops the rest
with one warning; the server also throttles each visitor at 60 a minute.
An invalid call logs a
[cairn]warning and sends nothing. - Also a trigger event. A tracked name feeds proactive-message trigger rules
exactly as
Cairn.event.push(name)does.event.pushstays browser-only: use it for trigger rules you do not want stored. - Not on yet? Contact events are behind a workspace rollout flag. Until it
is on,
track()is a silent no-op. - From your back end: the public API takes the same events in batches of up
to 100 —
POST /api/v1/eventswith an API key holdingevents:write, naming each contact bycontact_id,external_idoremail. Use it for what only your server knows (subscription_renewed,invoice_paid). - Retention: contact events are kept for 13 months, then removed by the platform sweep, and are erased with the contact on a deletion request.
In-app experiences
Doc 12 (feature.experiences.core, workspace-gated). With the flag on,
the panel has three tabs — Help, Onboarding, Chat — and the
widget pulls the server's decision for the current page on boot and on
every route change (GET /widget/experiences?url=). With it off, the
widget is the two-tab Chat | Help panel it was before; nothing below
applies and nothing below is requested.
- Help — For this page (what a curator pinned or the page↔knowledge map matched to this URL) and Related (collection / tag neighbours), plus the search box and inline article view that were already there. A guide entry offers Walk me through it (the walkthrough above); an article opens inline.
- Onboarding — the visitor's programs: sections of items (a tour, a
guide walkthrough, an article, a URL, or an event to wait for), each
with its state (
todo | in_progress | done | skipped), a launch control, and progress bars per section and overall. A completed program collapses to a summary with Show again. While a program is incomplete, the launcher button wears a thin progress ring. - Chat — unchanged. An unread badge counts agent replies that arrive while another tab is showing.
Default tab — widget_settings.default_tab is help, onboarding,
chat, or auto. auto opens Onboarding while a program still has
something to do, else Help when the page has pinned or matched
content, else Chat. (Doc 12 R4 words the middle case as "Help, else
Chat"; the widget shows the checklist itself while it is incomplete,
since that is what the visitor came back for.) The workspace logo, when
one is set, renders in the header.
Cairn.experiences.start(experienceId) starts a published tour by its
experience id (there is no start-by-slug: slugs are not unique across
versions, ids are). Behavior:
- Lazy runtime. The tour runtime (
@cairn/walkthrough/tour, ~11 KB gz) rides its own chunk, loaded on first use — the same as the walkthrough. Pages that never start a tour never pay for it. - Feature-gated server-side.
GET /widget/experiences/tours/{id}404s while the flag is dark and for unpublished or foreign ids; unavailable toursconsole.warnand no-op. - Progress is functional, not analytics. A tour opens a session
(
POST /widget/experiences/{version}/sessions), advances it on every step (PATCH … {step_cvid}), ends it with a reason (completed,dismissed,skipped,target_missing) and forwards the end-of-tour feedback. Program items are moved the same way (POST …/sessions/{id}/items/{item}). None of these writes are gated onCairn.consent()(Doc 12 D4): a visitor's checklist works whether or not they accepted analytics. Only beacons are consent-gated. - Auto-start. The server may also start a tour on its own (a welcome
tour on first login); the widget starts it once the page settles and
never restarts a dismissed one on the same page. The client never
decides eligibility — it reports page facts the server asked it to
watch (
POST /widget/experiences/conditions, booleans only) and the server re-decides. - Live updates. With the flag on, the realtime socket connects at boot rather than on first panel open, so an unpublish under a running tour stops it and a program item completed in another tab shows up without a reload.
Launchers (Doc 12 §4.3, feature.experiences.launchers, layered on
the core flag). The server's decision may carry launchers[]: a beacon,
icon or button anchored to an element on the page, activated by hover
(or focus, or a click on touch) or by click, that shows a tooltip or
starts a tour, opens Help, or opens a URL. Behaviour:
- Lazy runtime.
@cairn/walkthrough/launcherrides its own chunk (launcher-*.js, ~3 KB gz over the shared Floating UI), downloaded the first time the server delivers a launcher for a page. A page with no beacons never pays for it. - Anchors resolve like tour targets. The same four-tier element
resolver; a launcher whose element is not on the page (or not visible)
is not rendered, and a
MutationObserveron the element's nearest landmark brings it back when the element returns. It follows the element through scroll and resize. - Activation is functional state. Every activation posts
POST /widget/experiences/{id}/seenwith the page URL and theversion_idthe launcher was delivered as (never consent-gated, Doc 12 D4). The first call is thedismiss_after_first_activationmarker — the server stops delivering that launcher to this person from the next decision on; the beacon stays for the rest of the current page view so the tooltip it opened remains readable — and every call is thelauncher.activatedanalytics event, attributed to the version the person actually saw. A tooltip counts one activation per opening, not once per pointer that crosses an already-open card. - Accessibility. The launcher is a real
<button>with an accessible name (the author's label); the tooltip isrole="tooltip", referenced byaria-describedbywhile open; Escape closes; focus never moves into the tooltip on its own, but a keyboard user may Tab into it and the card stays open while focus is inside;prefers-reduced-motionstops the beacon's pulse. - Non-intrusive. Launchers never take the single tour slot and have no frequency: every eligible one is delivered.
Banners (Doc 12 §4.5, feature.experiences.banners, layered on the
core flag). The decision may also carry banners[]: one bar with a short
message and at most one button. Behaviour:
- Lazy runtime.
@cairn/walkthrough/bannerrides its own chunk (banner-*.js, ~3 KB gz over the shared Floating UI), downloaded the first time the server delivers a banner for a page. - Five placements.
topandbottompin the bar across the viewport —stickykeeps it there while the page scrolls, without it the bar sits at the top (or bottom) of the document and scrolls away.before,afterandinside_topput it immediately above, below or across the top of an element, matched to that element's width, and follow it through scroll and resize. The bar is an overlay above your page, never a node inserted into it: your layout is untouched. - It comes and goes with its anchor. An element-placed banner is not rendered while its element is absent or invisible, and returns when the element does — the same contract launchers honour.
- Singleton per slot. At most one banner per placement survives the server's decision, so two top banners never stack; the higher priority wins.
- Frequency is
onceorunlimited. A banner has no session, so there is nothing for "a few times, spaced out" to count. The widget postsPOST /widget/experiences/{id}/seenonce when the bar renders, which is what aoncebanner reads to stop coming back. - Dismissal is final. A dismissible bar carries a close button; the
widget posts the same endpoint with
dismissed: trueand the server never delivers that banner to that person again, whatever the frequency says. - Accessibility. The bar is a
role="region"landmark with an accessible name; the close button and the CTA are real<button>s with accessible names. Nothing steals focus — a banner is not a dialog.
Announcements (Doc 12 §4.5 R18, feature.experiences.announcements,
layered on the core flag). The decision also carries announcements: {unread, items} — a "What's new" feed that lives in the widget panel's
Help tab rather than on your page. Behaviour:
- Nothing to load. The feed renders inside the panel that is already there: no extra chunk, no overlay, and nothing is drawn on your page.
- Three distributions.
silentis listed and nothing more.badgeis listed and adds an unread count to the Help tab.popupbadges it AND opens the panel on that entry the first time the person is offered it — which is why it competes for the single intrusive slot with auto-starting tours: at most one of them interrupts a page view, and priority decides. A popup that has been read stops competing. - Reading it is the record. Opening an entry's detail posts
POST /widget/experiences/{id}/seenonce; that is what clears the badge, and it is remembered per person (contact when identified, else the visitor token), so the count survives a reload and follows them across devices once they are identified. The entry stays in the list afterwards — "what's new" is a log, not an inbox. - A window, not a frequency. An announcement is offered from its
starts_at(or from publication) until itsends_at(or until it is unpublished). There is no "show it three times": it waits until it is read. - Accessibility. The unread count is real text in the Help tab's
accessible name, each entry is a
<button>, and an unread entry's dot carries an off-screen "Unread". Nothing steals focus.
Like every call, it is queue-backed: safe to invoke before the bundle boots.
Proactive messages
The widget can speak first, from two sources that share one bubble above the launcher — clicking it opens the conversation.
- Trigger rules (ATH-121) are evaluated in the browser from the
boot config: url, time on page, scroll depth, and custom events fed by
Cairn.event.push(name). Each rule fires at most once per browser. - In-app campaigns (ATH-212, Doc 04 §6) are decided on the
server. A campaign targets a segment of your contacts, and because
there is no way to push into a web page, it is delivered when a
matching contact is next present in the widget: the bundle reports
presence on boot and again after
Cairn.user.set(...)identifies the visitor, and the server answers with at most one message. The audience never reaches the browser, and a visitor outside it cannot tell that apart from a workspace running no campaigns at all.
No public API call is involved in either — this section documents behavior you will observe, not a surface you drive. Three properties are worth knowing:
- Exactly once. A campaign message is claimed per (campaign,
contact) server-side, so repeat sessions, extra tabs, a second device,
or a cleared
localStoragewill not replay it. - Capped. A campaign will not stack on a trigger message that just fired, nor arrive while a support conversation is live, nor exceed one interruption per contact per day. Capped messages are deferred, not dropped — they arrive on a later visit.
- Delivered ≠ displayed. The message is written into the visitor's conversation whether or not the bubble gets shown, so it is there when they next open the widget.
campaign.shown and campaign.clicked analytics require
Cairn.consent(true) like every other beacon; delivery itself does not.
Files in chat
Visitors can attach images (PNG, JPEG, GIF, WebP) and PDFs from the chat
composer: the paperclip button, dragging files onto the thread, or pasting an
image (a screenshot on the clipboard). There is no JavaScript API for it and no
code to add. It appears only while the workspace has
feature.messaging.attachments on and the environment runs a malware
scanner; the boot config says so in attachments (enabled, max_bytes,
types), and the upload endpoint enforces the same gates.
What the visitor sees: a progress bar while the file uploads, then "Checking the file for viruses…" until the scan settles. A clean file then appears in the thread for both sides (images as a preview, PDFs as a download). An infected file, or one the scanner could not check, appears as File blocked and can never be downloaded. The team never sees a file before its scan is clean.
Pages with a Content-Security-Policy need the app origin in img-src for image
previews (the panel's frame inherits your page's origin and policy). Uploads use
the same origin as the rest of the widget's API calls, so connect-src already
covers them. Download links are signed and expire; reopening the conversation
mints fresh ones.
Events
| Event | Payload |
|---|---|
open / close |
— |
message:received |
{ body } — an agent message arrived |
experience:completed |
{ experience_id, version_id, slug } — the visitor finished a tour (Doc 12) |
Guarantees
- Calls made before the bundle loads are queued and replayed in order.
- Listener exceptions are swallowed — a buggy handler never breaks the widget or the host page.
init()is idempotent: one loader injection, one shared API instance.- The widget survives navigations that swap
<body>(Turbo Drive,wire:navigate,hx-boostand the like), and$cairn.pushkeeps working after the snippet resetswindow.$cairnon one. See Sites that swap<body>on navigation.
Trust boundary
The widget runs in your page's origin, not in a sandbox. Recorded here because it is a property integrators need when deciding what to put in a conversation, and it is not what the iframe suggests (2026-09-17 review).
The panel is an iframe, but one created with no src — an about:blank
frame, which inherits the origin of the page that made it. The
conversation UI is mounted into that frame's document from the host
page's own script context. The iframe is there for style isolation:
it stops your CSS reaching the widget and the widget's CSS reaching you.
It is not a security boundary, and no same-origin frame can be one.
What follows from that:
- Any script running on the page can read the conversation in the panel, and can read the visitor token the widget holds. That includes third-party tags — analytics, A/B testing, chat overlays, ad scripts — which typically have the same access your own code does.
- The widget can likewise see the page it is embedded in. That is what makes walkthroughs, page maps and co-browse work.
What this means in practice. Treat a support conversation as visible to everything else you load on that page. If your visitors routinely paste card numbers, government identifiers or passwords into support chat, tell them not to — that is good advice regardless of vendor, and here it is load-bearing. Review the third-party scripts on pages where the widget runs with the same care you would apply to a page showing order history.
If you need a hard boundary, do not embed the widget on the page in question. The hosted help centre and the guide-share pages run on Cairn origins and are unaffected by scripts on your site.
This is the common design for a lightweight embedded widget, and it is the reason the widget can do page-aware things at all. A cross-origin panel would contain the conversation but would also end walkthroughs, page maps and co-browse as they currently work. We consider that trade-off open rather than settled; if it matters to your deployment, say so.