DokumentasjonAdmin i egen appMarkdown
Admin UI in your own app
Put the full Snakkbar inbox inside a customer's existing admin/back office. Their staff stay in their own system, logged in with their own accounts; Snakkbar never shows a login.
Browser (your admin page) Your backend Snakkbar
────────────────────────── ───────────── ────────
mountAdmin(el, { token }) ──► GET /snakkbar/operator-token
(checks your own session)
POST /api/v1/server/operator-tokens ──► upserts operator,
Authorization: Bearer sk_snakkbar_… returns JWT (1 h)
◄── { token }
admin UI calls /api/v1/operator/* with the token, refreshes it before expiry1. Create a secret API key
Admin → Innstillinger → API-nøkler → Ny nøkkel (or POST /api/v1/operator/api-keys as an admin).
The key (sk_snakkbar_…) is shown once. Store it as a server-side secret (e.g. SNAKKBAR_SECRET_KEY).
Never send it to a browser.
2. Add a token endpoint to your backend
It must only answer for your logged-in staff, and map each of your users to a stable externalId.
// Example: Next.js route handler — app/api/snakkbar/operator-token/route.ts
export async function POST() {
const user = await requireStaffUser(); // your own auth; throw 401 if not staff
const res = await fetch(`${process.env.SNAKKBAR_HOST}/api/v1/server/operator-tokens`, {
method: "POST",
headers: {
authorization: `Bearer ${process.env.SNAKKBAR_SECRET_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({
externalId: user.id, // stable id in your system
name: user.displayName, // shown to visitors
email: user.email, // optional
avatarUrl: user.avatarUrl, // optional, https URL
role: user.isAdmin ? "admin" : "agent",
ttlSeconds: 3600, // optional, max 86400
}),
});
if (!res.ok) return new Response("Snakkbar token failed", { status: 502 });
const { token, expiresAt } = await res.json();
return Response.json({ token, expiresAt });
}The first exchange for an externalId creates the operator; later exchanges update name, email,
avatar and role. Operators can still change their own greeting, language and avatar in the UI.
A token request for a removed operator is refused with 403 (see below).
2b. Keep the team in sync with your user system (recommended)
Token exchange alone creates staff the first time they open the inbox, but never removes anyone. Let your user system be the source of truth and push changes to Snakkbar:
| When, in your system | Call |
|---|---|
| A user becomes staff (created, added to the staff group, re-enabled) | PUT /api/v1/server/operators/:externalId { name, email?, avatarUrl?, role?, invite? } (invite: true mails a sign-in link to the hosted panel) |
| Name, e-mail, photo or role changes | the same PUT (idempotent) |
| A user stops being staff (deleted, removed from the group, suspended) | DELETE /api/v1/server/operators/:externalId |
| Nightly, to repair missed events | GET /api/v1/server/operators, then PUT / DELETE the differences |
PUT provisions staff before they ever log in, so colleagues can @-mention them and the assistant
can hand conversations to them. DELETE deactivates rather than erases: their tokens stop working
immediately, they leave the team and every open conversation (ownership passes to the next person
or back to the assistant), and their e-mail is cleared. Their name and photo stay on messages they
wrote. A later PUT with the same externalId brings them back with their history. Both calls are
safe to repeat; DELETE answers 204 for unknown ids.
The hosted panel too. Staff you provision can also answer in Snakkbar's hosted panel
(<host>/panel) and the mobile app, with no separate Snakkbar account to manage. Send their work
e-mail and invite: true on the PUT (or call POST /api/v1/server/operators/:externalId/invite):
they get a one-time sign-in link, no password. They can also ask for a link or a code at
<host>/panel/login. The first sign-in with that verified address links the panel login to the
same operator your tokens use: same id, history, departments and notification settings, and
the button in alert e-mails works for them. Your system stays in charge: DELETE ends panel access
at once, and changing the e-mail or reactivating someone unlinks the login until the address signs
in again. role is always yours; it cannot be changed in the panel. Details:
api.md.
Integration accounts. If your backend needs an operator of its own (for example to manage
departments with an operator token), mark it system: true on the PUT: it is hidden from the
team, @-mentions, round-robin, alerts and the dashboard. Departments can also be managed directly
with the secret key: /api/v1/server/departments.
Example: Craft CMS module
A module that treats members of the user group kundeservice (and Craft admins) as staff. Calls go
through Craft's queue, so a Snakkbar outage is retried instead of blocking the save.
<?php
// modules/snakkbar/Module.php — register in config/app.php: 'modules' => ['snakkbar' => modules\snakkbar\Module::class], 'bootstrap' => ['snakkbar']
namespace modules\snakkbar;
use Craft;
use craft\elements\User;
use craft\events\ElementEvent;
use craft\events\UserEvent;
use craft\events\UserGroupsAssignEvent;
use craft\services\Elements;
use craft\services\Users;
use yii\base\Event;
class Module extends \yii\base\Module
{
public function init(): void
{
parent::init();
Craft::setAlias('@modules/snakkbar', __DIR__);
$this->controllerNamespace = Craft::$app->request->isConsoleRequest ? 'modules\snakkbar\console' : 'modules\snakkbar\controllers';
$sync = fn(?User $user) => $user && Craft::$app->queue->push(new SyncOperator(['userId' => $user->id, 'uid' => $user->uid]));
foreach ([Elements::EVENT_AFTER_SAVE_ELEMENT, Elements::EVENT_AFTER_DELETE_ELEMENT, Elements::EVENT_AFTER_RESTORE_ELEMENT] as $name) {
Event::on(Elements::class, $name, fn(ElementEvent $e) => $e->element instanceof User && $sync($e->element));
}
foreach ([Users::EVENT_AFTER_SUSPEND_USER, Users::EVENT_AFTER_UNSUSPEND_USER] as $name) {
Event::on(Users::class, $name, fn(UserEvent $e) => $sync($e->user));
}
Event::on(Users::class, Users::EVENT_AFTER_ASSIGN_USER_TO_GROUPS, fn(UserGroupsAssignEvent $e) => $sync(User::find()->id($e->userId)->status(null)->trashed(null)->one()));
}
public static function isStaff(?User $user): bool
{
return $user && !$user->trashed && $user->getStatus() === User::STATUS_ACTIVE && ($user->admin || $user->isInGroup('kundeservice'));
}
public static function client(): \GuzzleHttp\Client
{
return Craft::createGuzzleClient([
'base_uri' => rtrim(getenv('SNAKKBAR_HOST'), '/') . '/api/v1/server/',
'headers' => ['Authorization' => 'Bearer ' . getenv('SNAKKBAR_SECRET_KEY')],
]);
}
public static function remove(string $uid): void
{
self::client()->delete('operators/' . rawurlencode($uid));
}
/** PUT for staff, DELETE for everyone else. */
public static function push(User $user): void
{
if (!self::isStaff($user)) {
self::remove($user->uid);
return;
}
$body = ['name' => $user->getFriendlyName(), 'email' => $user->email, 'role' => $user->admin ? 'admin' : 'agent'];
$photo = $user->getPhoto()?->getUrl();
if ($photo && str_starts_with($photo, 'https://')) {
$body['avatarUrl'] = $photo; // left out otherwise, so a photo set in Snakkbar survives
}
self::client()->put('operators/' . rawurlencode($user->uid), ['json' => $body]);
}
}<?php
// modules/snakkbar/SyncOperator.php
namespace modules\snakkbar;
use craft\elements\User;
use craft\queue\BaseJob;
class SyncOperator extends BaseJob
{
public int $userId;
public string $uid;
public function execute($queue): void
{
$user = User::find()->id($this->userId)->status(null)->trashed(null)->one();
// Hard-deleted users are gone from Craft by now; remove them by uid.
$user ? Module::push($user) : Module::remove($this->uid);
}
protected function defaultDescription(): string
{
return 'Snakkbar: synkroniser bruker';
}
}<?php
// modules/snakkbar/console/SyncController.php — run nightly: php craft snakkbar/sync
namespace modules\snakkbar\console;
use craft\console\Controller;
use craft\elements\User;
use modules\snakkbar\Module;
class SyncController extends Controller
{
public function actionIndex(): int
{
$staff = [];
foreach (User::find()->status(null)->each() as $user) {
if (Module::isStaff($user)) {
Module::push($user);
$staff[$user->uid] = true;
}
}
$list = json_decode((string) Module::client()->get('operators')->getBody(), true);
foreach ($list['operators'] as $operator) {
if (!$operator['deactivatedAt'] && !isset($staff[$operator['externalId']])) {
Module::remove($operator['externalId']);
}
}
return 0;
}
}The token endpoint from step 2 must use the same rule (Module::isStaff(Craft::$app->user->identity))
and the same externalId ($user->uid).
3. Mount the inbox
<div id="snakkbar-admin" style="height: calc(100vh - 64px)"></div>
<script src="https://<host>/admin.js"></script>
<script>
let cached = null;
async function token() {
if (cached && Date.parse(cached.expiresAt) - Date.now() > 60_000) return cached.token;
cached = await (await fetch("/api/snakkbar/operator-token", { method: "POST" })).json();
return cached.token;
}
const admin = Snakkbar.mountAdmin(document.getElementById("snakkbar-admin"), {
host: "https://<host>",
token, // string or (async) function; called before every request
language: "no", // "en" for English UI
theme: "auto", // "light" | "dark" | "auto": until the person picks one themselves
// onThemeChange: (theme) => …, // optional: told when they pick (e.g. to match your page)
// look: "snakkbar", // optional: Snakkbar's own look (as in the hosted panel) instead of the
// // neutral default in the tenant's brand colour
});
// admin.unmount() when leaving the page
</script>The admin renders in a shadow root and fills its container; give the container a height. It needs the page origin in the tenant's allowed origins, like the widget.
By default it is neutral and uses the tenant's brand colour, so it sits quietly in your own system.
look: "snakkbar" switches to Snakkbar's own look (sand and ink, Snakkbar pink, artwork from
<host>/brand/). That look uses the fonts in the CSS variables --font-display and --font-body
when your page defines them on or above the container (e.g. Bricolage Grotesque and Figtree), and
system fonts otherwise. In that look the navigation rail sits on the left and opens to icons with
labels from the Snakkbar logo at its top (remembered in the browser).
Staff can pick Lys / Mørk / Automatisk themselves (the profile menu at the bottom of the rail, or
Settings); the pick is kept in their browser's localStorage and wins over theme from then on.
What staff get
Inbox with filters (Alle / Åpne / Mine), several conversations side by side (drag a conversation or tab to the edge of a pane to split; up to 6 panes with 6 tabs each), join / take over / give back / leave / close, typing indicators and read receipts, alerts (title blink, sound, desktop notifications), «Spør etter deg» when a visitor asks for them, visitor details and move visitor to page, translation in both directions, «Legg i kunnskap» on replies, knowledge base and suggestion review, callbacks, own profile (name, photo, greeting, language) and — for admins — tenant settings, assistant portrait and API keys.
Also «Oversikt» (dashboard: visitors and conversations now and over a period, response time to requests for a person, busy hours, top pages) and «Besøkende» (live visitors: who is on the site now, their page, device, location and navigation history, and «Se live» to follow one visitor's cursor, clicks and scroll over the page they are on).
For «Se live» to show the real page, the tenant's public site must allow being framed by your admin page's origin; see Framing the customer's site below. Otherwise it shows a schematic view.
Only want part of it?
Use the SDK (createOperatorClient) and build your own screens; see sdk.md. A common
split: stock widget for visitors, own inbox for staff, or the other way round.
Build your own live view
A headless admin can show the same live view as «Se live». The parts:
- The feed.
staff.visitors.watch(visitorId)(renews every 10 s) makes the visitor's browser sendpointerevents onstaff.subscribe(...):url,viewport,scroll,documentHeight, and pointersamples/clicksas[t, x, y]in the visitor's viewport coordinates. See realtime.md. - The page. An iframe named
snakkbar-mirrorloading the visitor's current URL (currentPage.urlfromvisitors.get(), then theurlof each frame). Snakkbar's widget (orstartTracking()) on that page sees the name, renders nothing, tracks nothing, and runs the mirror: it answers yourpostMessages and scrolls the page where you tell it. - The overlay. Your own cursor and click marks drawn on top of the iframe.
<div class="live" style="position: relative; overflow: hidden">
<iframe
name="snakkbar-mirror"
sandbox="allow-scripts allow-same-origin"
referrerpolicy="no-referrer"
style="position: absolute; top: 0; left: 0; border: 0; transform-origin: 0 0; pointer-events: none"
></iframe>
<div class="cursor" style="position: absolute; pointer-events: none"></div>
</div>import { isMirrorMessage, type MirrorMessage } from "@snakkbar/sdk";
// `staff` is your createOperatorClient(...) from sdk.md; `visitorId` the visitor to watch.
const frame = document.querySelector<HTMLIFrameElement>("iframe[name=snakkbar-mirror]")!;
const box = frame.parentElement!;
const cursor = box.querySelector<HTMLElement>(".cursor")!;
let mirrorReady = false;
let fallback: ReturnType<typeof setTimeout> | undefined;
// No `ready` from the mirror within a few seconds: the site refused framing (or has no widget).
function load(url: string) {
mirrorReady = false;
frame.src = url;
clearTimeout(fallback);
fallback = setTimeout(() => !mirrorReady && showSchematicView(), 6000);
}
window.addEventListener("message", (event) => {
if (event.source !== frame.contentWindow || !isMirrorMessage(event.data)) return;
if (event.data.type === "ready") { mirrorReady = true; clearTimeout(fallback); hideSchematicView(); }
});
const visitor = await staff.visitors.get(visitorId);
if (visitor.currentPage) load(visitor.currentPage.url);
const send = (message: MirrorMessage, url: string) =>
frame.contentWindow?.postMessage(message, new URL(url).origin);
const stopWatching = staff.visitors.watch(visitorId);
const sub = staff.subscribe((event) => {
if (event.type !== "pointer" || event.visitorId !== visitorId || !event.visible) return;
if (event.url !== frame.src) load(event.url); // the visitor moved on
if (event.viewport) {
// Render the page at the visitor's viewport size, scaled to fit the box.
const scale = box.clientWidth / event.viewport.width;
frame.style.width = `${event.viewport.width}px`;
frame.style.height = `${event.viewport.height}px`;
frame.style.transform = `scale(${scale})`;
box.style.height = `${event.viewport.height * scale}px`;
const last = event.samples.at(-1);
if (last) cursor.style.transform = `translate(${last[1] * scale}px, ${last[2] * scale}px)`;
// event.clicks: draw a short ripple at each [t, x, y] (scaled the same way)
}
if (event.scroll) send({ snakkbar: "mirror", type: "scroll", x: event.scroll.x, y: event.scroll.y }, event.url);
});
// leaving: stopWatching(); sub.close();For smooth motion, replay samples over the batch using their t offsets instead of jumping to
the last one.
The MirrorMessage protocol
All messages are objects with snakkbar: "mirror" (check with isMirrorMessage):
| Direction | type | Fields | |
|---|---|---|---|
| Live view → page | hello | Asks the mirror to send ready again (e.g. when your view mounts after the page loaded). | |
| Live view → page | scroll | x, y | Scroll the page to the visitor's position (instant). |
| Page → live view | ready | url, width, height, documentHeight | The mirror runs. Sent on load, on hello, and after the page's URL changes. |
The mirror only accepts messages from its parent window, and only sends layout numbers and the
page's own URL. It runs only when the tenant has live visitors on and the page has the widget or
calls startTracking().
Framing the customer's site
The iframe loads the public page from the customer's site, so that site decides whether it may be framed:
- It must allow the origin of the page that contains the iframe in its CSP, e.g.
Content-Security-Policy: frame-ancestors 'self' https://admin.example.no. That ishttps://<host>for the hosted panel, and the origin of your own admin page formountAdminor your own live view. List both if staff use both. - It must not send
X-Frame-Options: DENY, orSAMEORIGINwhen the admin is on another origin. Browsers that supportframe-ancestorsignoreX-Frame-Optionswhen both are present, but older ones don't; drop it onceframe-ancestorsis in place. - Cookies set with
SameSite=Lax/Strict(the default in most browsers) are not sent to a cross-site iframe, so pages that need a login show their logged-out version.
Sandbox. allow-scripts is required (the mirror is a script) and allow-same-origin keeps the
page working (storage, its own requests) as it does for visitors. Leave out allow-forms,
allow-popups and allow-top-navigation*, so nothing in the page can submit, open windows or
navigate your admin, and set pointer-events: none so the operator can't click in the copy by
accident. If your admin is served from the same origin as the customer's site, the framed page has
full access to the admin page either way; that is the customer's own code, but keep it in mind.
Fallback. When the site refuses framing (or has no widget), the browser shows an error page in
the iframe and you can't tell from the outside. Treat "no ready within a few seconds" as refused
and switch to a schematic view: a page outline sized viewport × documentHeight with the scroll
position, cursor and clicks from the pointer events, and the URL and title.
The operator's browser loads the page with its own session, never the visitor's; see security.md.
Hosted alternative
Staff can always use <host>/panel with Snakkbar accounts instead (created by Snakkbar), or with
the sign-in your backend invites them to (section 2b). Hosted and embedded operators can work side
by side in the same tenant, and one person can use both with the same operator.