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:

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:

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:

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:

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 })

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 }])

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.

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:

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:

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:

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:

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.

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:

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

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:

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.