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).
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.
npm — the programmatic equivalent; safe to call anywhere, the API is queue-backed until the bundle boots:
import { init } from '@cairn/widget'
const cairn = init({ websiteId: 'ws_pub_…' })
React — import { useCairn } from '@cairn/widget/react'
Vue — 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? }) |
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 |
Cairn.event.push(name) |
Record a custom event; feeds trigger rules (ATH-121) |
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.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.
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.
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.
Events
| Event | Payload |
|---|---|
open / close |
— |
message:received |
{ body } — an agent message arrived |
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.