# Headless with the SDK

`@snakkbar/sdk` is a small, dependency-free TypeScript client for the [HTTP API](api.md), plus
optional React hooks. Use it to build your own chat UI, your own staff inbox, or both.

## Install

```ts
// From the Snakkbar host (no install):
import { createVisitorClient, createOperatorClient } from "https://<host>/sdk/snakkbar.mjs";
import { useVisitorChat } from "https://<host>/sdk/react.mjs"; // needs `react` in your import map/bundle

// Or vendor packages/sdk from the Snakkbar repo (npm package `@snakkbar/sdk`, ESM + .d.ts in dist/).
```

All types (`Message`, `TurnEvent`, `OperatorConversation`, …) are exported. The full surface is
`packages/sdk/src/interface.ts`; the wire types are `packages/sdk/src/types.ts`.

## Visitor side

```ts
const chat = createVisitorClient({
  host: "https://<host>",
  tenantId: "amatec",
  // token: identifiedVisitorToken,  // optional, see widget.md → Identified visitors
  // language: "en",                  // default: browser language
});

const config = await chat.config();            // assistant name/portrait, welcome, team, presence
const conversation = await chat.createConversation({ pageUrl: location.href, pageTitle: document.title });

let draft = "";
const result = await chat.send(conversation.id, { body: "Har dere sveisemaskiner?" }, {
  onEvent(event) {
    switch (event.type) {
      case "message":          render(event.message); break;          // the visitor's stored message
      case "assistant.status": showStatus(event.label); break;         // "Søker etter produkter"
      case "assistant.delta":  draft += event.text; renderDraft(draft); break;
      case "assistant.done":   draft = ""; event.messages.forEach(render); break; // text, product cards, suggestions
      case "silent":           break; // a person owns the chat or was asked for; their reply arrives via subscribe()
      case "error":            showError(event.message); break;
    }
  },
});

// Live updates: staff replies, typing, read receipts, presence.
const sub = chat.subscribe(async (event) => {
  if (event.type === "message" || event.type === "conversation") rerender(await chat.getConversation(conversation.id));
  if (event.type === "typing" && event.side === "operator") showTyping(event.operator?.name, event.typing);
  if (event.type === "presence") updateTeam(await chat.config({ fresh: true }));
}, { conversationId: conversation.id });
```

Other visitor calls: `listConversations()`, `getConversation(id)`, `upload(id, file)` then
`send(id, { body, attachmentIds })`, `markRead(id, isoTime)`, `setTyping(id, bool)` (throttled for
you), `requestHuman(id, handles?)`, `requestCallback(id, { name, phone | email, message })`,
`reset()`.

### Visitor profile and live visitors

On a headless site (no stock widget), start tracking once per page load and tell Snakkbar who the
visitor is when you know:

```ts
const stopTracking = chat.startTracking(); // heartbeats, page changes (also SPA routes), live view

// After login on your site (or whenever you learn more):
await chat.updateProfile({
  name: user.name,
  email: user.email,
  phone: user.phone,
  company: user.company,
  attributes: { customerNo: user.customerNo, plan: "pro", newsletter: true }, // null removes a key
});
const me = await chat.me(); // VisitorProfile: what Snakkbar knows, `verified` for identified visitors
```

- `startTracking(options?)` keeps the visitor visible to staff while the page is open: a heartbeat
  about every 10 s with URL, title, visibility, viewport and scroll; an immediate report on page
  loads, single-page-app URL changes and tab switches; and, while an operator watches, pointer and
  click samples every ~500 ms. It never sends page content. It returns a stop function; call it
  once per client (a second call is a no-op until you stop the first).
  - Checks `config().liveVisitors` first and does nothing when the tenant has it off — and then it
    doesn't create an anonymous visitor either.
  - Inside an operator's live view (an iframe named `snakkbar-mirror`) it reports nothing and runs
    the mirror instead (see [admin-embed.md](admin-embed.md#build-your-own-live-view)). So call it
    on every page, also if you only want the live view to work.
  - Options: `url: () => string` reports another URL than `location.href` (e.g. without private
    path segments); `onWatch(watched)` is called when an operator starts or stops watching.
  - With an open `subscribe()` the tracker hears about a watch at once; otherwise at its next
    heartbeat (≤ 10 s).
  - Pages that run the stock widget already track; don't start a second tracker there.
- `updateProfile(input)` → `PATCH /visitor/me`. Calls within 250 ms are merged into one request.
  Unverified: for an identified visitor it only fills fields that are still empty and adds
  attribute keys that don't exist yet (send trusted
  values from your backend with the visitor token instead). Rules in
  [api.md](api.md#patch-visitorme--visitorprofile).
- `activity(input)` sends one raw heartbeat/batch; you only need it if you write your own tracker.

**Rendering rules the stock widget follows** (match them for a consistent experience):
- `role: "assistant"` bodies are Markdown; everything else is plain text.
- Show `message.translation.body` when present, with a way to see `body`.
- `kind: "products"` → cards from `message.products`; `kind: "suggestions"` → chips from
  `message.suggestions` that send the chip text when tapped.
- `kind: "navigation"` → move the browser to `message.navigation.url` if it is same-origin
  (once per message id).
- System kinds (`joined`, `left`, `requested`, `callback`, `closed`) render as small notices.
- Show «Sett» under the visitor's latest message when `conversation.teamReadAt` ≥ its `createdAt`.
- Send `setTyping(id, true)` while the visitor types and `false` on send/blur.

### React

```tsx
import { createVisitorClient } from "@snakkbar/sdk";
import { useVisitorChat } from "@snakkbar/sdk/react";

const client = createVisitorClient({ host: "https://<host>", tenantId: "amatec" });

export function Chat() {
  const { config, messages, streamingText, status, send, startNew } = useVisitorChat(client);
  // render messages, streamingText (assistant reply in progress), status ("idle" | "sending" | …)
}
```

## Staff side

```ts
const staff = createOperatorClient({
  host: "https://<host>",
  token: async () => (await fetch("/api/snakkbar/operator-token", { method: "POST" }).then((r) => r.json())).token,
});

const { operator, tenant } = await staff.me();
const stopPresence = staff.startPresence();          // heartbeats while the page is visible
await staff.updateMe({ available: true });           // check in

const { conversations } = await staff.listConversations({ filter: "open" });
const detail = await staff.getConversation(conversations[0].id);
await staff.act(detail.id, "join");
await staff.send(detail.id, { body: "Hei! Jeg ser på dette nå." });
await staff.send(detail.id, { body: "@amanda hva er leveringstiden på X200?" }); // ask the assistant

staff.subscribe((event) => {
  // "conversation" / "message" → refetch that conversation (conversationId "*" = refetch everything,
  // sent while the SDK is polling because streaming is unavailable)
  // "typing" (side "visitor") → typing indicator; "presence" → refetch team()
});
```

Also: `act(id, "takeover" | "release" | "leave" | "close" | "reopen")`, `navigate(id, url)`,
`upload`, `markRead`, `setTyping`, `deleteConversation` (admin), `requests()`, `team()`,
`updateMe({ greeting, language, displayName })`, `uploadAvatar(file)`, `knowledge.*`,
`callbacks.*`, `tenant.*` (admin), `apiKeys.*` (admin), `dashboard()` and `visitors.*` (below).

### Overview and live visitors

```ts
const stats = await staff.dashboard({ days: 30 });  // Dashboard: online now, conversations, response time, top pages…

const visitors = await staff.visitors.list({ minutes: 30 }); // LiveVisitor[], online first
const visitor = await staff.visitors.get(visitors[0].id);    // + pageviews (navigation history) and conversations

// Live view: while watched, the visitor's browser streams cursor, clicks and scroll.
const stopWatching = staff.visitors.watch(visitor.id);       // renews every 10 s until stopped
const sub = staff.subscribe((event) => {
  if (event.type === "visitor") refreshVisitorList();        // came online, new page, profile changed (debounce)
  if (event.type === "pointer" && event.visitorId === visitor.id) {
    // LiveFrame: url, title, visible, viewport, scroll, documentHeight,
    // samples/clicks as [t ms since batch start, x, y] in the visitor's viewport coordinates.
    drawFrame(event);
  }
});
// leaving the live view:
stopWatching();
sub.close();
```

- `dashboard({ days })`: 1–90, default 7; see [api.md](api.md#get-operatordashboarddays7--dashboard)
  for every field.
- `visitors.list({ minutes })`: visitors seen in the last `minutes` (default 30, max 1440).
  `online` means a heartbeat in the last 35 s; there is no event when someone goes offline, so
  re-evaluate `lastSeenAt` on a timer (or refetch every 15–30 s) as well as on `visitor` events.
- `visitors.get(id)`: **LiveVisitorDetail** with up to 200 pageviews and 30 conversations.
- `visitors.watch(id)` → stop function. Renews the watch every 10 s (it expires 25 s after the
  last renewal, so a closed tab stops it by itself). `pointer` events go to every operator's
  stream; filter by `visitorId`.
- To show the visitor's page behind the cursor, load `currentPage.url` (or the frame's `url`) in
  an iframe named `snakkbar-mirror` and keep it scrolled with `MirrorMessage`s; `MIRROR_NAME`,
  `isMirrorMessage` and the `MirrorMessage` type are exported. Recipe in
  [admin-embed.md](admin-embed.md#build-your-own-live-view).

React: `useOperatorInbox(client, { filter })`, `useOperatorConversation(client, id)`,
`usePresence(client)`.

## Utilities

- `ALL_CONVERSATIONS` (`"*"`): the `conversationId` the operator `subscribe()` uses while polling,
  meaning "refetch everything you show".
- `prepareImage(file)`: what `upload()` does before sending — photos over 1.5 MB or wider than
  1600 px are re-encoded as JPEG (GIFs untouched). Use it if you upload through your own code.
- `knowledge.upload(file)` sends the file straight to storage through a signed URL, so documents up
  to 50 MB work.

## Errors

Every failed call throws `SnakkbarError` with `status`, a stable `code` (`unauthorized`,
`forbidden`, `not_found`, `rate_limited`, `origin_not_allowed`, …) and a Norwegian `message`
suitable for showing to the user.
