DokumentasjonSDK (headless)Markdown
Headless with the SDK
@snakkbar/sdk is a small, dependency-free TypeScript client for the HTTP API, plus
optional React hooks. Use it to build your own chat UI, your own staff inbox, or both.
Install
// 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
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:
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 visitorsstartTracking(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().liveVisitorsfirst 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). So call it on every page, also if you only want the live view to work. - Options:
url: () => stringreports another URL thanlocation.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.
- Checks
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.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.bodywhen present, with a way to seebody. kind: "products"→ cards frommessage.products;kind: "suggestions"→ chips frommessage.suggestionsthat send the chip text when tapped.kind: "navigation"→ move the browser tomessage.navigation.urlif 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≥ itscreatedAt. - Send
setTyping(id, true)while the visitor types andfalseon send/blur.
React
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
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
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 for every field.visitors.list({ minutes }): visitors seen in the lastminutes(default 30, max 1440).onlinemeans a heartbeat in the last 35 s; there is no event when someone goes offline, so re-evaluatelastSeenAton a timer (or refetch every 15–30 s) as well as onvisitorevents.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).pointerevents go to every operator's stream; filter byvisitorId.- To show the visitor's page behind the cursor, load
currentPage.url(or the frame'surl) in an iframe namedsnakkbar-mirrorand keep it scrolled withMirrorMessages;MIRROR_NAME,isMirrorMessageand theMirrorMessagetype are exported. Recipe in admin-embed.md.
React: useOperatorInbox(client, { filter }), useOperatorConversation(client, id),
usePresence(client).
Utilities
ALL_CONVERSATIONS("*"): theconversationIdthe operatorsubscribe()uses while polling, meaning "refetch everything you show".prepareImage(file): whatupload()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.