# @olotalk/assistant

The [Olotalk](https://olotalk.com) AI chat assistant, a Svelte 5 component that mounts a RAG-powered, multilingual chat assistant into any web page via Shadow DOM.

> **Most users should use [`@olotalk/assistant-loader`](https://www.npmjs.com/package/@olotalk/assistant-loader) instead.** It handles config fetching, browser compatibility, and dynamic loading with a single `<script>` tag. Use this package directly only if you need low-level control over assistant instantiation.

---

## Install

```bash
npm install @olotalk/assistant
```

---

## Usage

```ts
import { createAssistant } from '@olotalk/assistant'

const assistant = createAssistant(document.body, {
  assistantId: 'YOUR_ASSISTANT_ID',
  config: assistantConfig,   // fetched from /public/assistants/:id/config
  theme: 'light',
  placement: 'floating',
  cssUrl: 'https://cdn.jsdelivr.net/npm/@olotalk/assistant/dist/olotalk-assistant.css',
  // origin: 'https://your-bff.example.com',  // self-hosters only —
  // cloud builds bake in https://api.olotalk.com; dev falls back to
  // window.location.origin
})
```

### CSS

The assistant loads its styles via a `<link>` injected inside the Shadow DOM. The CSS URL will be resolved automatically from the assistant config in a future release. Until then, pass it explicitly via `cssUrl` or import the stylesheet manually:

```ts
import '@olotalk/assistant/style.css'
```

When self-hosting the bundle for use with `@olotalk/assistant-loader`, publish `dist/version.json` alongside the JS and CSS files so the loader can cache-bust asset URLs automatically.

---

## `createAssistant(mount, options)`

Mounts the assistant into the given element and returns a `AssistantInstance`.

### Options

| Option | Type | Required | Description |
|---|---|---|---|
| `assistantId` | `string` | Yes | Your assistant ID. |
| `origin` | `string` | No | Base URL of your Olotalk API. Cloud builds bake in `https://api.olotalk.com` at build time; dev/self-host omits and the assistant falls back to `window.location.origin`. Set this only when self-hosting the BFF on a different origin. |
| `config` | `AssistantConfig` | Yes | Assistant configuration object from the API. |
| `theme` | `"light"` \| `"dark"` \| `object` | No | Color scheme or custom theme overrides. |
| `mode` | `AssistantMode` | No | Which shape to mount (default: `"bubble"`). See [Shapes](#shapes). |
| `reach` | `string` | No | Additive triggers over the mounted shape (ADR 0226): a comma-separated list of trigger-capable shape ids, `"selection"`, `"commandk"`, whose triggers open **this** mount instead of conjuring their own panel. `mode: "bubble", reach: "selection,commandk"` gives a corner bubble that also opens from a text highlight and from ⌘K. Unknown ids are dropped with a console warning. See [Combining shapes](#combining-shapes). |
| `selectionAnswers` | `string` | No | Where a **selection** opens the panel: `"beside"` (default) flies it to the highlighted words, `"in-place"` opens it where the assistant lives. Only affects the selection trigger, a host calling `open({ anchor, anchorPresentation })` is untouched. Unrecognised values warn and fall back to `"beside"`. |
| `placement` | `"floating"` \| `"embedded"` | No | **Deprecated** alias for `mode`, `floating` → `bubble`, `embedded` → `inline`. Prefer `mode`. |
| `cssUrl` | `string` | No | URL of the assistant stylesheet. Will be resolved automatically from the assistant config in a future release. |
| `locale` | `string` | No | Force a display language. Accepts any well-formed BCP-47 tag (e.g. `"en"`, `"fr"`, `"en-CA"`, `"vi"`, `"ja"`). When omitted, the assistant resolves from a stored visitor preference, the page's `<html lang>`, the visitor's browser, then the operator's `defaultLocale`, bounded by the assistant's `supportedLocales`. |

### `AssistantConfig` shape

This is returned by the Olotalk API at `/public/assistants/:id/config`.

```ts
type AssistantConfig = {
  tenantId: string
  assistantId: string
  displayName?: string             // shown in the assistant header; when logoUrl is set the visible label is capped at 32 chars, while the full name remains available to assistive tech/tooltips
  welcomeMessage?: string          // greeting shown on the welcome screen
  theme?: Record<string, any>
  launcher?: {
    text?: string                  // label on the closed launcher; omit or "" for the localized default ("Ask AI")
    logoUrl?: string               // when set, the header shows the logo image + displayName side by side
    position?: 'bottom-right' | 'bottom-left'
    // Starter bubbles: the stack of starter questions that rises off the
    // launcher on hover. Named `teaser` for history — it used to drive a canned
    // tooltip. ONLY `enabled` is read.
    teaser?: {
      enabled?: boolean
      // The four below are accepted and IGNORED, kept so stored configs keep
      // parsing. They budgeted an uninvited interruption; a hover needs no
      // delay, cooldown or per-session cap, and the bubbles carry the
      // operator's `starters` questions rather than canned copy.
      messages?: string[]           // @deprecated — read by nothing
      delayMs?: number              // @deprecated — read by nothing
      durationMs?: number           // @deprecated — read by nothing
      cooldownMs?: number           // @deprecated — read by nothing
      maxShowsPerSession?: number   // @deprecated — read by nothing
    }
  }
  // Suggested starter questions shown before the first message.
  // Auto-generated from ingested content when not manually configured.
  starters?: {
    topics?: Array<{ label: string; questions?: string[] }>
    questions?: string[]
  }
  allowedDomains?: string[]
  features?: {
    domRagEnabled?: boolean
    siteRagEnabled?: boolean
    citationsEnabled?: boolean
    streamingEnabled?: boolean
  }
  security?: {
    rateLimitPer5Min?: number
    captchaMode?: boolean
  }
  privacy?: {
    piiRedaction?: boolean
    retentionDays?: number
  }
}
```

---

## `AssistantInstance` API

```ts
assistant.open()                          // Open the chat panel
assistant.close()                         // Close the chat panel
assistant.isOpen()                        // true while the panel is showing
assistant.setTheme(theme)                 // Change theme at runtime
assistant.setLocale('fr')                 // Change language, keeping the conversation
assistant.destroy(opts?: { outro?: boolean }) // Unmount and remove from DOM

// Subscribe to events
assistant.on('ready', ({ assistantId }) => { ... })
assistant.on('open',  () => { ... })
assistant.on('close', () => { ... })
assistant.on('send',  ({ text }) => { ... })
assistant.on('cta_click', ({ id, action, intent }) => { ... })
assistant.on('lead_submit', ({ leadId, status, score }) => { ... })
```

---

## Shapes

The same bundle mounts fourteen ways. One `mode` apart, same conversation, same
grounding, same citations; only the mount differs.

`shape-spec.ts` is the source of truth for this list, and a test fails if a live
shape is missing from this table, so it cannot quietly fall behind the code.

| `mode` | Where it lands | How it opens |
|---|---|---|
| `bubble` | Corner launcher (default) | Its own launcher |
| `starters` | Corner launcher, questions on hover | Its own launcher |
| `corner` | The same corner panel, with nothing at rest | A trigger you place, or `reach` |
| `inline` | Inside a container in your markup | Always open |
| `drawer` | Full height, flush to an edge, modal | A button you place |
| `commandk` | Centred over a dimmed page, modal | <kbd>⌘</kbd><kbd>K</kbd> / <kbd>Ctrl</kbd><kbd>K</kbd> |
| `center` | The same centred panel, without the ⌘K claim | A trigger you place, or `reach` |
| `expand` | A line in your copy that unfolds | A trigger you place |
| `anchored` | Beside one element, never covering it | A trigger you place |
| `fullpage` | The assistant is the page | Always open |
| `sidebar` | A fixed rail down one edge | Always open |
| `navfield` | Your own nav field | Your markup submits to it |
| `askbar` | Your own bar at the foot of the page | Your markup submits to it |
| `selection` | Nothing at rest; appears where text is highlighted | The visitor selecting text |

**`mobile` is not a shape you pick.** It is the presentation `bubble`, `drawer`,
`commandk` and `anchored` collapse into at phone width, so one embed covers both.
Passing it as a `mode` is not supported.

Four of these attach to something only you can point at (`drawer`, `anchored`,
`navfield` and `askbar`) so they need `open()` wired to your own trigger. The
rest mount from config alone. See the
[docs](https://docs.olotalk.com/assistant-api#shapes) for the full contract.

## Combining shapes

A page may mount **several** shapes, and they share **one conversation** (ADR
0245). The first one listed is the *residence*, the panel a bare
`data-olotalk-open` and `getAssistant(id)` resolve:

```html
<script src="…/loader.iife.js"
  data-olotalk-assistant-id="ast_…"
  data-olotalk-mode="bubble,drawer" async></script>

<button data-olotalk-open="drawer">Ask AI</button>
```

A corner launcher AND a header button that opens a drawer, on one transcript.
Only one panel is open at a time: opening one closes the others, because two
panels showing the same conversation is a duplicated transcript competing for the
same screen. A shape whose sibling is open also hides its own resting affordance
for as long as that lasts, while the drawer is out, the corner launcher goes
away rather than floating on top of it inviting you to open what is already
open (ADR 0246).

Adding a *shape* is how you add another panel. Adding a **way in** to the panel
you already have is cheaper, and is what `reach` and doors are for (ADR 0226):

- **`data-olotalk-open`, your own affordances, no JavaScript.** Put the
  attribute on any element you already have and it becomes a door:

  ```html
  <button data-olotalk-open>Ask AI</button>
  <button data-olotalk-open="drawer">Ask AI</button>
  <button data-olotalk-open data-olotalk-prefill="How does billing work?">…</button>
  ```

  One delegated listener handles the page, so a button rendered later by a
  framework works with no re-scan. The value names *which* mounted shape to
  open; empty means the one panel on the page. `data-olotalk-prefill` opens it
  with a question, which the shape then sends or leaves editable according to
  its own `sendOnPrefill` (a corner bubble sends; select-to-ask holds it for
  amending). A door that names a shape nothing has mounted warns and does
  nothing rather than failing silently.

  A plain door **toggles**, press it again while the panel is open and it
  closes, which is what a header control that says "Ask AI" is read as. A door
  carrying a prefill always opens instead: those mean "ask *this*", so a second
  press re-asks rather than dismissing. `getAssistant(id).isOpen()` is the same
  state, if you want to drive your own affordance from it.

  `data-olotalk-door="branded"` opts a door into the assistant's own look, a
  quiet chip at rest, the launcher's gradient rim turning around it on hover.
  Opt-in on purpose: without it a door keeps *your* styles and we inject nothing,
  which is the point of putting the attribute on an element you already have.

  This is what makes the **drawer** installable by pasting: it has no launcher
  of its own by design, and its snippet now ships the button with it,
  branded, with the robot mark inline so it can blink.
- **`getAssistant(id).open({ prefill })`, the same door, in JavaScript.** For a
  trigger that is not a click: a nav field's submit, a router event, your own
  component. This has always been the contract: the instance registry exists so
  a trigger resolves the panel that already exists rather than conjuring a
  second one.
- **`reach`, our triggers, any residence.** `reach: "selection,commandk"`
  raises select-to-ask and ⌘K over whatever is mounted. ⌘K toggles the
  residence; it is claimed only when you ask for it, and a reach naming the
  residence's own trigger is simply redundant, not an error.

**`mode: "selection"` and `reach: "selection"` are not the same thing.** The
word does double duty, and this is the easiest place to misread it:

| | at rest | ways in |
|---|---|---|
| `mode: "selection"` | nothing on the page, no button anywhere | highlighting text, and only that |
| `mode: "bubble", reach: "selection"` | the corner launcher | the launcher **and** highlighting |

Select-to-ask with no button on the page is the first one, mounted on its own:
it is a shape, not a modifier.

## The open panel is a window

Visitors can **resize** almost any panel that floats over the page, and **drag**
the anchored one. Neither is configurable: there is nothing to switch on, and
nothing an embed can turn off.

`drawer` opts out via `resizable: false` on its shape record: its width is the
amount of page it takes away, and it is the one shape whose opening already
moves the host layout (ADR 0244).

| | resize | drag |
|---|---|---|
| `bubble` · `starters` · `corner` · `commandk` · `center` | ✅ | — |
| `drawer` | — |, |
| `anchored` (select-to-ask, `mode: "anchored"`) | ✅ | ✅ |
| `inline` · `expand` · `fullpage` · `sidebar` | — |, |
| any shape at phone width | — |, |

Eight handles: 10px edge bands with 20px corners. Floor 320×360, ceiling the
viewport less a margin.

**Drag is narrower than resize on purpose.** A corner bubble keeps its launcher
on screen while open (it morphs into the close control) so moving the panel
away would orphan that button. The anchored panel has no such tether and
deliberately covers the passage it is about, so moving it is the only way to
read the original words.

The chosen **size** survives a close; the **position** never does. Press the
launcher and the assistant comes back to its corner, at the size the visitor
picked. Both gestures are pointer-only.

The in-flow shapes are your layout, so they are left alone entirely.

### Select-to-ask

Highlighting a passage raises a pill above it. Taking that offer opens the
assistant **beside the words** (even when it normally lives in the corner)
and the passage arrives as a removable quote above the composer, which stays
empty for the question the visitor actually wants to ask. Send combines the
two. Nothing is ever asked on their behalf.

**If you would rather it stayed put**, set `selectionAnswers: "in-place"`
(`data-olotalk-selection-answers="in-place"`) and the panel opens where the
assistant lives instead of travelling to the highlight. Worth it when the
assistant is a fixture your visitors have already located and the flight across
the page costs more than the distance saves; the default is `"beside"`, because
usually the answer belongs where the question was asked.

**A second highlight while the panel is open seeds the conversation already
running**: new quote, composer focused, panel not moved, anything typed kept.
The passage replaces the previous quote rather than stacking.

On touch devices the pill is deliberately absent: the OS owns that gesture,
and the only way to suppress its own Copy / Look Up menu is to disable text
selection entirely.

Reach adds entry points, never panels. Nothing starts at page load, and
opening via reach counts exactly like opening via the shape's own launcher,
a session when the panel opens, a metered message when one is sent. One
pairing is refused with a warning: `commandk` reach over the `anchored`
shape, because a keystroke names no element for an anchored panel to position
from. Try an ensemble locally:
`demo/shapes.html?mode=bubble&reach=selection,commandk`.

---

## Distribution files

| File | Format | Use case |
|---|---|---|
| `dist/olotalk-assistant.js` | ESM | Dynamic `import()` from CDN or bundler |
| `dist/olotalk-assistant.iife.js` | IIFE | Legacy browsers (`<script>` tag, no module support) |
| `dist/olotalk-assistant.css` | CSS | Styles loaded inside the Shadow DOM |
| `dist/version.json` | JSON | Build manifest used by the loader for cache-busting |
| `dist/index.d.ts` | TypeScript | Type definitions |

---

## Features

- **RAG-powered answers**: responses grounded in your website's knowledge base, not generic LLM output
- **Suggested starters**: topic chips and question buttons auto-generated from ingested content; shown before the first message to guide visitors
- **Human escalation**: amber banner notifies visitors when the conversation is handed off to your team
- **Multilingual**: any BCP-47 locale supported. `en`, `fr`, `de` ship hand-translated dictionaries; other languages (e.g. `vi`, `ja`, `ar`, `sw`) get LLM-translated UI strings cached on the BFF. Operators declare their site's `supportedLocales` once; the assistant auto-detects from the embedding page or browser. Visitors who type in a different supported language see an inline "Continue in {language}?" toast that swaps the entire chrome on accept (ADR 0050)
- **Streaming** (real-time text generation for a conversational feel
- **Citations**) optionally surfaces source URLs alongside answers
- **Twelve shapes**: one bundle, one `mode` apart: corner bubble, inline, drawer, ⌘K, expand-in-place, anchored, full page, docked sidebar, nav field, ask bar, select-to-ask (see [Shapes](#shapes))
- **Starter bubbles**: the launcher raises the operator's own starter questions on hover; no canned copy, and nothing uninvited (it replaced the delay-based teaser, ADR 0219)
- **UTM + referrer attribution**: first-touch UTM params and `document.referrer` forwarded at session creation
- **Shadow DOM isolation**: assistant styles never bleed into the host page
- **Anonymous by default** (no visitor login required
- **Domain whitelisting**) control which sites can embed the assistant
- **Privacy controls**: optional PII redaction and configurable data retention

---

## License

MIT © [Olotalk](https://olotalk.com)
